diff --git a/src/LogExpert.UI.Tests/Services/LedIndicatorServiceTests.cs b/src/LogExpert.UI.Tests/Services/LedIndicatorServiceTests.cs index 0adfd816..9dc9447b 100644 --- a/src/LogExpert.UI.Tests/Services/LedIndicatorServiceTests.cs +++ b/src/LogExpert.UI.Tests/Services/LedIndicatorServiceTests.cs @@ -451,6 +451,34 @@ public void ClearDirty_ClearsStateAndRaisesCleanIcon () Assert.That(raisedIcon, Is.SameAs(_service.GetIcon(50, new LedState()))); } + [TestCase(false, 100)] + [TestCase(true, 170)] + public void GetInactiveLedColor_FollowsColorMode (bool darkMode, int expectedGray) + { + var color = LedIndicatorService.GetInactiveLedColor(darkMode); + + Assert.Multiple(() => + { + Assert.That(color.R, Is.EqualTo(expectedGray)); + Assert.That(color.G, Is.EqualTo(expectedGray)); + Assert.That(color.B, Is.EqualTo(expectedGray)); + }); + } + + [TestCase(false, 160)] + [TestCase(true, 175)] + public void GetTailOffLedColor_FollowsColorMode (bool darkMode, int expectedGray) + { + var color = LedIndicatorService.GetTailOffLedColor(darkMode); + + Assert.Multiple(() => + { + Assert.That(color.R, Is.EqualTo(expectedGray)); + Assert.That(color.G, Is.EqualTo(expectedGray)); + Assert.That(color.B, Is.EqualTo(expectedGray)); + }); + } + private static LogWindow CreateLogWindow () { var configManagerMock = new Mock(); diff --git a/src/LogExpert.UI.Tests/Theming/DarkToolStripRendererTests.cs b/src/LogExpert.UI.Tests/Theming/DarkToolStripRendererTests.cs new file mode 100644 index 00000000..5492d168 --- /dev/null +++ b/src/LogExpert.UI.Tests/Theming/DarkToolStripRendererTests.cs @@ -0,0 +1,90 @@ +using System.Drawing.Imaging; +using System.Runtime.Versioning; + +using LogExpert.UI.Theming; + +using NUnit.Framework; + +using WeifenLuo.WinFormsUI.Docking; + +namespace LogExpert.UI.Tests.Theming; + +[TestFixture] +[Apartment(ApartmentState.STA)] // Required for WinForms components +[SupportedOSPlatform("windows")] +public class DarkToolStripRendererTests +{ + [Test] + public void Constructor_WithTheVS2015DarkPalette_Succeeds () + { + // Guards the wiring in ToolStripTheme: the theme has to expose its palette before any + // window exists, otherwise the renderer cannot be built at start-up. + var theme = new VS2015DarkTheme(); + + var renderer = new DarkToolStripRenderer(theme.ColorPalette); + + Assert.That(renderer, Is.Not.Null); + } + + [Test] + public void ShouldInvertIcon_DarkMonochromeGlyph_ReturnsTrue () + { + // The resource glyphs are black-on-transparent PNGs. + using var glyph = CreateIcon(Color.Black); + + Assert.That(DarkToolStripRenderer.ShouldInvertIcon(glyph), Is.True); + } + + [Test] + public void ShouldInvertIcon_ColouredIcon_ReturnsFalse () + { + // Inverting a blue icon turned it orange; coloured icons must keep their colours. + using var icon = CreateIcon(Color.FromArgb(0, 120, 215)); + + Assert.That(DarkToolStripRenderer.ShouldInvertIcon(icon), Is.False); + } + + [Test] + public void ShouldInvertIcon_LightMonochromeGlyph_ReturnsFalse () + { + // A light glyph is already visible on the dark strip; inverting would make it invisible. + using var glyph = CreateIcon(Color.White); + + Assert.That(DarkToolStripRenderer.ShouldInvertIcon(glyph), Is.False); + } + + [Test] + public void ShouldInvertIcon_EmptyImage_ReturnsFalse () + { + using var bitmap = new Bitmap(16, 16, PixelFormat.Format32bppArgb); + + Assert.That(DarkToolStripRenderer.ShouldInvertIcon(bitmap), Is.False); + } + + [Test] + public void ShouldInvertIcon_GreyscaleGlyphWithAntiAliasing_ReturnsTrue () + { + // Real glyphs are anti-aliased, so their edge pixels are grey rather than black. The + // tolerance has to accept them; otherwise half the toolbar would stay dark. + using var bitmap = new Bitmap(16, 16, PixelFormat.Format32bppArgb); + + using (var graphics = Graphics.FromImage(bitmap)) + using (var brush = new SolidBrush(Color.FromArgb(90, 90, 90))) + { + graphics.FillRectangle(brush, 2, 2, 12, 12); + } + + Assert.That(DarkToolStripRenderer.ShouldInvertIcon(bitmap), Is.True); + } + + private static Bitmap CreateIcon (Color color) + { + var bitmap = new Bitmap(16, 16, PixelFormat.Format32bppArgb); + + using var graphics = Graphics.FromImage(bitmap); + using var brush = new SolidBrush(color); + graphics.FillRectangle(brush, 2, 2, 12, 12); + + return bitmap; + } +} diff --git a/src/LogExpert.UI.Tests/UI/PaintHelperTests.cs b/src/LogExpert.UI.Tests/UI/PaintHelperTests.cs index 12ceb081..cb1985a2 100644 --- a/src/LogExpert.UI.Tests/UI/PaintHelperTests.cs +++ b/src/LogExpert.UI.Tests/UI/PaintHelperTests.cs @@ -72,6 +72,73 @@ public void GetForeColorFromHighlightEntry_NoEntryInDarkMode_FallsBackToLightCol Assert.That(IsLight(foreColor), Is.True, $"expected a light fallback color but got {foreColor}"); } + [Test] + public void GetDefaultForeColor_DarkMode_UsesTheSoftOffWhite () + { + // 220,220,220 rather than pure white; the grid, the color combo box and the highlight + // preview have to agree on it. + var foreColor = PaintHelper.GetDefaultForeColor(darkMode: true); + + Assert.That(foreColor.ToArgb(), Is.EqualTo(Color.FromArgb(220, 220, 220).ToArgb())); + } + + [Test] + public void GetDefaultForeColor_LightMode_UsesBlack () + { + var foreColor = PaintHelper.GetDefaultForeColor(darkMode: false); + + Assert.That(foreColor, Is.EqualTo(Color.Black)); + } + + [Test] + public void GetDefaultBackColor_MatchesTheWindowBackgroundOfEachColorMode () + { + // 32,32,32 is the surface the grid paints cells with in dark mode; the highlight + // preview has to use the same one instead of SystemColors.Window. + Assert.Multiple(() => + { + Assert.That(PaintHelper.GetDefaultBackColor(darkMode: true), Is.EqualTo(Color.FromArgb(32, 32, 32))); + Assert.That(PaintHelper.GetDefaultBackColor(darkMode: false), Is.EqualTo(Color.White)); + }); + } + + [Test] + public void GetForeColorFromHighlightEntry_EntryWithoutForeColor_UsesTheDefaultForeColor () + { + // HighlightEntry.ForegroundColor defaults to Color.Empty ("not set"). Both callers of + // a highlight without a color must end up with exactly the same result. + Assert.Multiple(() => + { + Assert.That(PaintHelper.GetForeColorFromHighlightEntry(null, darkMode: true), + Is.EqualTo(PaintHelper.GetDefaultForeColor(darkMode: true))); + Assert.That(PaintHelper.GetForeColorFromHighlightEntry(new HighlightEntry(), darkMode: false), + Is.EqualTo(PaintHelper.GetDefaultForeColor(darkMode: false))); + }); + } + + [Test] + public void GetBookmarkMarkerTextColor_LightMode_KeepsDarkOrange () + { + var markerColor = PaintHelper.GetBookmarkMarkerTextColor(darkMode: false); + + Assert.That(markerColor.ToArgb(), Is.EqualTo(Color.FromArgb(255, 190, 100, 0).ToArgb())); + } + + [Test] + public void GetBookmarkMarkerTextColor_DarkMode_IsBrighterAndStaysOrange () + { + var dark = PaintHelper.GetBookmarkMarkerTextColor(darkMode: true); + var light = PaintHelper.GetBookmarkMarkerTextColor(darkMode: false); + + Assert.Multiple(() => + { + Assert.That(dark.GetBrightness(), Is.GreaterThan(light.GetBrightness()), + $"expected a brighter dark-mode marker but got {dark} vs {light}"); + Assert.That(dark.R, Is.GreaterThan(dark.G), "marker is expected to stay orange"); + Assert.That(dark.G, Is.GreaterThan(dark.B), "marker is expected to stay orange"); + }); + } + [Test] public void GetForeColorFromHighlightEntry_NoEntryInLightMode_FallsBackToBlack () { diff --git a/src/LogExpert.UI/Controls/ColorComboBox.cs b/src/LogExpert.UI/Controls/ColorComboBox.cs index 68d1a4f0..77ef5ca0 100644 --- a/src/LogExpert.UI/Controls/ColorComboBox.cs +++ b/src/LogExpert.UI/Controls/ColorComboBox.cs @@ -2,6 +2,8 @@ using System.Drawing.Drawing2D; using System.Runtime.Versioning; +using LogExpert.UI.Entities; + namespace LogExpert.UI.Controls; [SupportedOSPlatform("windows")] @@ -67,23 +69,32 @@ private void OnColorComboBoxDrawItem (object sender, DrawItemEventArgs e) e.DrawBackground(); if (e.Index >= 0) { + var darkMode = Application.IsDarkModeEnabled; + + // Same off-white the grid uses, so the swatch labels match the log view. + var textColor = PaintHelper.GetDefaultForeColor(darkMode); + using var textBrush = new SolidBrush(textColor); + using var borderPen = new Pen(textColor); + Rectangle rectangle = new(4, e.Bounds.Top + 2, 30, e.Bounds.Height - 4); var rectColor = (Color)Items[e.Index]; - e.Graphics.FillRectangle(new SolidBrush(rectColor), rectangle); - e.Graphics.DrawRectangle(Pens.Black, rectangle); + using var swatchBrush = new SolidBrush(rectColor); + e.Graphics.FillRectangle(swatchBrush, rectangle); + e.Graphics.DrawRectangle(borderPen, rectangle); if (e.Index == 0) { - e.Graphics.DrawString(Resources.ColorComboBox_UI_ColorComboBox_Text_Custom, e.Font, Brushes.Black, new PointF(42, e.Bounds.Top + 2)); + e.Graphics.DrawString(Resources.ColorComboBox_UI_ColorComboBox_Text_Custom, e.Font, textBrush, new PointF(42, e.Bounds.Top + 2)); } else { - e.Graphics.DrawString(((Color)Items[e.Index]).Name, e.Font, Brushes.Black, new PointF(42, e.Bounds.Top + 2)); + e.Graphics.DrawString(((Color)Items[e.Index]).Name, e.Font, textBrush, new PointF(42, e.Bounds.Top + 2)); } if (!Enabled) { - HatchBrush brush = new(HatchStyle.Percent50, Color.LightGray, Color.FromArgb(10, Color.LightGray)); + var hatchLight = darkMode ? Color.DarkGray : Color.LightGray; + HatchBrush brush = new(HatchStyle.Percent50, hatchLight, Color.FromArgb(10, hatchLight)); rectangle.Inflate(1, 1); e.Graphics.FillRectangle(brush, rectangle); brush.Dispose(); diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 63d42349..a86d94d2 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -58,6 +58,13 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private const string FONT_VERDANA = "Verdana"; private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + // Bookmark marker colors: the dark variants are more saturated so the small square keeps + // enough contrast against the dark grid. + private static readonly Color _bookmarkColor = Color.FromArgb(165, 200, 225); + private static readonly Color _autoBookmarkColor = Color.FromArgb(180, 210, 180); + private static readonly Color _bookmarkColorDark = Color.FromArgb(140, 195, 240); + private static readonly Color _autoBookmarkColorDark = Color.FromArgb(150, 215, 150); + private readonly Image _advancedButtonImage; private readonly BookmarkDataProvider _bookmarkProvider = new(); @@ -352,14 +359,15 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b #region Properties [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] - public Color BookmarkColor { get; set; } = Color.FromArgb(165, 200, 225); + public Color BookmarkColor { get; set; } = _bookmarkColor; /// /// Color used to paint the bookmark marker in column 0 for auto-generated (highlight-triggered) bookmarks. Uses a - /// lighter/more desaturated shade to distinguish from manual bookmarks. + /// slightly different shade to distinguish from manual bookmarks; the dark variant is brighter, not dimmer, + /// because the marker has to stand out against the dark grid. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] - public Color AutoBookmarkColor { get; set; } = Color.FromArgb(180, 210, 180); + public Color AutoBookmarkColor { get; set; } = _autoBookmarkColor; public ILogLineMemoryColumnizer CurrentColumnizer { @@ -2741,6 +2749,13 @@ private void CreateDefaultViewStyle () filterGridView.RowsDefaultCellStyle = PaintHelper.GetDataGridDefaultRowStyle(darkMode); PaintHelper.ApplyGridViewTheme(dataGridView, darkMode); PaintHelper.ApplyGridViewTheme(filterGridView, darkMode); + + // Bookmark marker colors need more saturation against dark backgrounds. Both variants + // are assigned explicitly: the color mode is fixed for the lifetime of the process + // (Program.SetColorMode runs before the first window is created), so this never has to + // be re-applied at runtime. + BookmarkColor = darkMode ? _bookmarkColorDark : _bookmarkColor; + AutoBookmarkColor = darkMode ? _autoBookmarkColorDark : _autoBookmarkColor; } [SupportedOSPlatform("windows")] @@ -6635,7 +6650,7 @@ public void CellPainting (bool focused, int rowIndex, int columnIndex, bool isFi var fontName = isFilteredGridView ? FONT_VERDANA : FONT_COURIER_NEW; var stringToDraw = isFilteredGridView ? "!" : "i"; - using var brush2 = new SolidBrush(Color.FromArgb(255, 190, 100, 0)); //dark orange + using var brush2 = new SolidBrush(PaintHelper.GetBookmarkMarkerTextColor(Application.IsDarkModeEnabled)); using var font = new Font(fontName, Preferences.Font.Size, FontStyle.Bold); e.Graphics.DrawString(stringToDraw, font, brush2, new RectangleF(rect.Left, rect.Top, rect.Width, rect.Height), _format); } diff --git a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs index ae7fb5c3..7d4d8ca3 100644 --- a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs +++ b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs @@ -9,6 +9,7 @@ using LogExpert.Core.Helpers; using LogExpert.Dialogs; using LogExpert.UI.Controls; +using LogExpert.UI.Entities; namespace LogExpert.UI.Dialogs.Highlight; @@ -404,11 +405,16 @@ private void UpdatePreview () ? Resources.HighlightEntryEditDialog_UI_Preview_Placeholder : text; - _previewLabel.ForeColor = GetSelectedColor(_colorBoxForeground, Color.Black); + // Preview with the same defaults the grid paints with. SystemColors.Window stays white + // in dark mode, which would put the off-white preview text on a white background. + var darkMode = Application.IsDarkModeEnabled; + var defaultBackColor = PaintHelper.GetDefaultBackColor(darkMode); + + _previewLabel.ForeColor = GetSelectedColor(_colorBoxForeground, PaintHelper.GetDefaultForeColor(darkMode)); _previewLabel.BackColor = _checkBoxNoBackground != null && _checkBoxNoBackground.Checked - ? SystemColors.Window - : GetSelectedColor(_colorBoxBackground, SystemColors.Window); + ? defaultBackColor + : GetSelectedColor(_colorBoxBackground, defaultBackColor); var bold = _checkBoxBold != null && _checkBoxBold.Checked; var baseFont = Font; diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 5ec992ae..e4d1b15b 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -29,6 +29,7 @@ using LogExpert.UI.Services.TabControllerService; using LogExpert.UI.Services.ToolLaunchService; using LogExpert.UI.Services.ToolWindowCoordinatorService; +using LogExpert.UI.Theming; using NLog; @@ -93,6 +94,7 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu InitializeComponent(); ConfigureDockPanel(); + ConfigureToolStripDark(); _tabController = new TabController(dockPanel); InitializeTabControllerEvents(); @@ -384,6 +386,31 @@ private void ConfigureDockPanel () dockPanel.DocumentStyle = DocumentStyle.DockingWindow; } + /// + /// Switches this window's ToolStrip family to the dark renderer. WinForms ignores + /// Application.SetColorMode(Dark) for ToolStrips, so the renderer is installed + /// process-wide by ; see there for the global scope. + /// + /// ToolStripContainer forces RenderMode = System on the strips it hosts, which overrides + /// the manager renderer. Putting the strips and their panels back to ManagerRenderMode is + /// what makes the container stop fighting us. + /// + private void ConfigureToolStripDark () + { + if (!Application.IsDarkModeEnabled) + { + return; + } + + ToolStripTheme.Apply(); + + buttonToolStrip.RenderMode = ToolStripRenderMode.ManagerRenderMode; + externalToolsToolStrip.RenderMode = ToolStripRenderMode.ManagerRenderMode; + mainMenuStrip.RenderMode = ToolStripRenderMode.ManagerRenderMode; + statusStrip.RenderMode = ToolStripRenderMode.ManagerRenderMode; + toolStripContainer.TopToolStripPanel.RenderMode = ToolStripRenderMode.ManagerRenderMode; + } + private void ApplyTextResources () { mainMenuStrip.Text = Resources.LogTabWindow_UI_MenuStrip_MainMenu; diff --git a/src/LogExpert.UI/Entities/PaintHelper.cs b/src/LogExpert.UI/Entities/PaintHelper.cs index 6cbcb46d..cdbad4d0 100644 --- a/src/LogExpert.UI/Entities/PaintHelper.cs +++ b/src/LogExpert.UI/Entities/PaintHelper.cs @@ -75,7 +75,7 @@ public static void CellPainting (ILogPaintContextUI logPaintCtx, bool focused, i if (bookmark.Text.Length > 0) { - using var brush2 = new SolidBrush(Color.FromArgb(255, 190, 100, 0)); //DarkOrange + using var brush2 = new SolidBrush(GetBookmarkMarkerTextColor(Application.IsDarkModeEnabled)); using var font = logPaintCtx.MonospacedFont; e.Graphics.DrawString("i", font, brush2, new RectangleF(r.Left, r.Top, r.Width, r.Height), _format); } @@ -90,9 +90,36 @@ public static void CellPainting (ILogPaintContextUI logPaintCtx, bool focused, i // Matches what SystemColors.Window resolves to under Application.SetColorMode(Dark). private static readonly Color _darkModeWindowBackColor = Color.FromArgb(32, 32, 32); + /// + /// Default foreground color on dark surfaces — a soft off-white that keeps contrast high + /// (≈12:1 against the dark window background) but avoids the harsh glare of pure #FFFFFF. + /// + private static readonly Color _darkModeForeColor = Color.FromArgb(220, 220, 220); + public static Color GetBackColorFromHighlightEntry (HighlightEntry? entry, bool darkMode) { - return entry?.BackgroundColor ?? (darkMode ? _darkModeWindowBackColor : Color.White); + return entry?.BackgroundColor ?? GetDefaultBackColor(darkMode); + } + + /// + /// Background of a cell that has no background of its own: the dark window surface in dark + /// mode, white otherwise. Callers that preview log output outside the grid - the highlight + /// dialog, for example - have to use this instead of . + /// + public static Color GetDefaultBackColor (bool darkMode) + { + return darkMode ? _darkModeWindowBackColor : Color.White; + } + + /// + /// Default foreground color for text that has no explicitly configured color: the soft + /// off-white in dark mode, black otherwise. Everything that paints text on a themed + /// surface should use this instead of , so the grid, the color + /// combo box and the highlight preview cannot drift apart. + /// + public static Color GetDefaultForeColor (bool darkMode) + { + return darkMode ? _darkModeForeColor : Color.Black; } public static Color GetForeColorFromHighlightEntry (HighlightEntry? entry, bool darkMode) @@ -102,7 +129,18 @@ public static Color GetForeColorFromHighlightEntry (HighlightEntry? entry, bool return entry.ForegroundColor; } - return darkMode ? Color.White : Color.Black; + return GetDefaultForeColor(darkMode); + } + + /// + /// Color of the "i" / "!" marker text drawn on bookmark / filter squares in column 0. + /// Dark mode uses a brighter orange so the marker stays visible against the dark background. + /// + public static Color GetBookmarkMarkerTextColor (bool darkMode) + { + return darkMode + ? Color.FromArgb(255, 220, 130, 20) // brighter orange on dark + : Color.FromArgb(255, 190, 100, 0); // DarkOrange baseline } [SupportedOSPlatform("windows")] @@ -250,7 +288,7 @@ public static DataGridViewCellStyle GetDataGridViewCellStyle (bool darkMode) Alignment = DataGridViewContentAlignment.MiddleLeft, BackColor = SystemColors.Window, Font = new Font("Courier New", 8.25F, FontStyle.Regular, GraphicsUnit.Point, 0), - ForeColor = darkMode ? Color.White : Color.Black, + ForeColor = darkMode ? _darkModeForeColor : Color.Black, SelectionBackColor = SystemColors.Highlight, SelectionForeColor = GetForeColorBasedOnBackColor(SystemColors.Highlight), WrapMode = DataGridViewTriState.False @@ -265,7 +303,7 @@ public static DataGridViewCellStyle GetDataGridDefaultRowStyle (bool darkMode) { Alignment = DataGridViewContentAlignment.MiddleLeft, BackColor = SystemColors.Window, - ForeColor = darkMode ? Color.White : Color.Black, + ForeColor = darkMode ? _darkModeForeColor : Color.Black, SelectionBackColor = SystemColors.Highlight, SelectionForeColor = GetForeColorBasedOnBackColor(SystemColors.Highlight), WrapMode = DataGridViewTriState.False diff --git a/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs b/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs index 718b53d1..51bd3181 100644 --- a/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs +++ b/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs @@ -254,6 +254,9 @@ public void Initialize (Color tailColor) /// private void CreateBrushes (Color tailColor) { + var darkMode = Application.IsDarkModeEnabled; + + // Signal-color LEDs (red/orange/yellow/green) are theme-independent — they carry semantic meaning. _ledBrushes = new Brush[5]; _ledBrushes[0] = new SolidBrush(Color.FromArgb(255, 0, 0)); // Red (highest activity) _ledBrushes[1] = new SolidBrush(Color.FromArgb(255, 100, 0)); // Orange @@ -263,12 +266,29 @@ private void CreateBrushes (Color tailColor) _tailLedBrush = new Brush[3]; _tailLedBrush[0] = new SolidBrush(tailColor); // Tail on - _tailLedBrush[1] = new SolidBrush(Color.FromArgb(160, 160, 160)); // Tail off + _tailLedBrush[1] = new SolidBrush(GetTailOffLedColor(darkMode)); // Tail off _tailLedBrush[2] = new SolidBrush(Color.FromArgb(220, 220, 0)); // Tail trigger - _offLedBrush = new SolidBrush(Color.FromArgb(100, 100, 100)); - _dirtyLedBrush = new SolidBrush(Color.FromArgb(220, 0, 220)); // Magenta for dirty - _syncLedBrush = new SolidBrush(Color.FromArgb(0, 100, 220)); // Blue for synced + _offLedBrush = new SolidBrush(GetInactiveLedColor(darkMode)); + _dirtyLedBrush = new SolidBrush(Color.FromArgb(220, 0, 220)); // Magenta for dirty — semantic color, theme-independent + _syncLedBrush = new SolidBrush(Color.FromArgb(0, 100, 220)); // Blue for synced — semantic color, theme-independent + } + + /// + /// Grey of an inactive activity LED. The light-mode grey disappears against the dark + /// toolbar and tab strip, so dark mode uses a brighter one. + /// + internal static Color GetInactiveLedColor (bool darkMode) + { + return darkMode ? Color.FromArgb(170, 170, 170) : Color.FromArgb(100, 100, 100); + } + + /// + /// Grey of the "tail off" row. Same reasoning as . + /// + internal static Color GetTailOffLedColor (bool darkMode) + { + return darkMode ? Color.FromArgb(175, 175, 175) : Color.FromArgb(160, 160, 160); } /// diff --git a/src/LogExpert.UI/Theming/DarkToolStripRenderer.cs b/src/LogExpert.UI/Theming/DarkToolStripRenderer.cs new file mode 100644 index 00000000..a99d3210 --- /dev/null +++ b/src/LogExpert.UI/Theming/DarkToolStripRenderer.cs @@ -0,0 +1,211 @@ +using System.Drawing.Imaging; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +using WeifenLuo.WinFormsUI.Docking; + +namespace LogExpert.UI.Theming; + +/// +/// DockPanel Suite's VS2015 renderer with one adjustment: dark monochrome icons are inverted +/// while painting. Backgrounds, hover, arrows, separators, check marks and text colours all +/// come from the palette. +/// +/// The glyphs in LogExpert.Resources are black-on-transparent PNGs; drawing them +/// unchanged on the dark toolbar makes them invisible. Coloured icons must keep their colours, +/// though - the tail button and the favourite icons loaded from an external .exe are coloured, +/// and inverting them turned blue into orange. Only images that are actually dark and +/// monochrome are inverted. +/// +/// Text is deliberately not overridden. picks the +/// colour from the palette per strip type and item state and never reads +/// , so a per-item colour - the dimmed +/// bookmark entries, for example - cannot be honoured by a renderer subclass at all. Overriding +/// it only means re-deciding every item's colour here, which is what the palette already does. +/// +[SupportedOSPlatform("windows")] +internal sealed class DarkToolStripRenderer : VisualStudioToolStripRenderer +{ + // Pixels below this alpha belong to the anti-aliased edge and are ignored when sampling. + private const int ALPHA_CUTOFF = 32; + + // Largest spread between the R, G and B channels of a pixel that still counts as grey. + private const int MONOCHROME_TOLERANCE = 24; + + // A monochrome glyph darker than this would disappear on the dark strip and is inverted. + // Icons that are already light are left alone, otherwise they would turn dark. + private const int DARK_GLYPH_LUMINANCE = 140; + + // Inverting a 192x192 PNG on every paint would be wasteful, so each distinct image is + // converted at most once. The table keeps the key (the original image) weak, so icons + // that get replaced - external tool favourites, for example - are still collected. + private static readonly ConditionalWeakTable _invertedGlyphs = new(); + + // Invert the RGB channels (black <-> white) and keep alpha, so the transparent + // background stays transparent instead of getting a white halo. + private static readonly ColorMatrix _invertMatrix = new( + [ + [ -1f, 0f, 0f, 0f, 0f ], + [ 0f, -1f, 0f, 0f, 0f ], + [ 0f, 0f, -1f, 0f, 0f ], + [ 0f, 0f, 0f, 1f, 0f ], + [ 1f, 1f, 1f, 0f, 1f ], + ]); + + public DarkToolStripRenderer (DockPanelColorPalette palette) : base(palette) + { + } + + /// + /// Tells whether the icon is a dark monochrome glyph that has to be inverted to stay + /// visible on the dark strip. Coloured icons - including the ones that are already light - + /// return false and are drawn unchanged. + /// + internal static bool ShouldInvertIcon (Image image) + { + return GetInvertedGlyph(image) != null; + } + + protected override void OnRenderItemImage (ToolStripItemImageRenderEventArgs e) + { + var inverted = e.Image == null ? null : GetInvertedGlyph(e.Image); + + if (inverted == null) + { + // Coloured icons and items without an image keep the stock behaviour: disabled + // items are greyed out, ImageTransparentColor and ImageScaling still apply. + base.OnRenderItemImage(e); + return; + } + + // Draw the inverted copy the same way ToolStripRenderer draws a normal image, so a + // disabled item is still greyed out and the ImageRectangle / scaling are honoured. + // The copy already carries the alpha channel, so no transparent-color key is needed. + Image image = inverted; + Image? disabledImage = null; + + try + { + if (!e.Item.Enabled) + { + disabledImage = CreateDisabledImage(inverted); + image = disabledImage; + } + + // Rectangle.Empty happens for items that are too small to show their image; the + // stock renderer skips those, and drawing into an empty rectangle is not allowed. + if (e.ImageRectangle.Width > 0 && e.ImageRectangle.Height > 0) + { + e.Graphics.DrawImage(image, e.ImageRectangle); + } + } + finally + { + disabledImage?.Dispose(); + } + } + + private static Bitmap? GetInvertedGlyph (Image image) + { + if (image.Width <= 0 || image.Height <= 0) + { + return null; + } + + return _invertedGlyphs.GetValue(image, static source => new InvertedGlyph(CreateInvertedGlyph(source))).Image; + } + + /// + /// Returns an inverted copy of if it is a dark monochrome glyph, + /// or null if the image has to be drawn unchanged. + /// + private static Bitmap? CreateInvertedGlyph (Image source) + { + var width = source.Width; + var height = source.Height; + var bounds = new Rectangle(0, 0, width, height); + + // Normalise to 32bppArgb first: the resources are palette or 24bpp PNGs, which + // LockBits cannot hand out as 32bpp. + using var copy = new Bitmap(width, height, PixelFormat.Format32bppArgb); + + using (var graphics = Graphics.FromImage(copy)) + { + graphics.DrawImage(source, bounds, 0, 0, width, height, GraphicsUnit.Pixel); + } + + if (!IsDarkMonochrome(copy)) + { + return null; + } + + var inverted = new Bitmap(width, height, PixelFormat.Format32bppArgb); + + using (var graphics = Graphics.FromImage(inverted)) + using (var attributes = new ImageAttributes()) + { + attributes.SetColorMatrix(_invertMatrix); + graphics.DrawImage(copy, bounds, 0, 0, width, height, GraphicsUnit.Pixel, attributes); + } + + return inverted; + } + + private static bool IsDarkMonochrome (Bitmap bitmap) + { + var data = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + + try + { + var stride = Math.Abs(data.Stride); + var pixels = new byte[stride * bitmap.Height]; + Marshal.Copy(data.Scan0, pixels, 0, pixels.Length); + + var luminanceSum = 0; + var pixelCount = 0; + + for (var offset = 0; offset <= pixels.Length - 4; offset += 4) + { + var alpha = pixels[offset + 3]; + + if (alpha < ALPHA_CUTOFF) + { + continue; + } + + var blue = pixels[offset]; + var green = pixels[offset + 1]; + var red = pixels[offset + 2]; + + var max = Math.Max(red, Math.Max(green, blue)); + var min = Math.Min(red, Math.Min(green, blue)); + + if (max - min > MONOCHROME_TOLERANCE) + { + // A single saturated pixel is enough to call this a coloured icon. + return false; + } + + luminanceSum += ((red * 299) + (green * 587) + (blue * 114)) / 1000; + pixelCount++; + } + + return pixelCount > 0 && luminanceSum / pixelCount < DARK_GLYPH_LUMINANCE; + } + finally + { + bitmap.UnlockBits(data); + } + } + + /// + /// Cache entry for one image. Wraps the result so images that must not be inverted can be + /// remembered too - does not accept a null + /// value. + /// + private sealed class InvertedGlyph (Bitmap? image) + { + public Bitmap? Image { get; } = image; + } +} diff --git a/src/LogExpert.UI/Theming/ToolStripTheme.cs b/src/LogExpert.UI/Theming/ToolStripTheme.cs new file mode 100644 index 00000000..01c30b33 --- /dev/null +++ b/src/LogExpert.UI/Theming/ToolStripTheme.cs @@ -0,0 +1,51 @@ +using System.Runtime.Versioning; + +using WeifenLuo.WinFormsUI.Docking; + +namespace LogExpert.UI.Theming; + +/// +/// Installs the dark ToolStrip renderer for the whole process. +/// +[SupportedOSPlatform("windows")] +internal static class ToolStripTheme +{ + private static readonly Lock _lock = new(); + + private static bool _installed; + + /// + /// Installs , built from the same VS2015 palette the + /// dock panel uses, so toolbar, status bar and menus share one set of colours. + /// + /// The renderer is deliberately process-wide: every ToolStrip, MenuStrip, StatusStrip and + /// ContextMenuStrip that uses picks it + /// up, which is what makes the tab menu, the LogWindow context menus and plugin menus dark + /// as well. Strips inside a ToolStripContainer additionally have to be switched to + /// ManagerRenderMode by their owner, because the container forces RenderMode = System. + /// + /// The setting is not reverted when the user switches back to light mode. The colour mode + /// is read once by Program.SetColorMode() before the first window is created and + /// changing it requires an application restart, so there is no supported runtime switch + /// that would need to undo this. + /// + public static void Apply () + { + if (!Application.IsDarkModeEnabled) + { + return; + } + + lock (_lock) + { + if (_installed) + { + return; + } + + var theme = new VS2015DarkTheme(); + ToolStripManager.Renderer = new DarkToolStripRenderer(theme.ColorPalette); + _installed = true; + } + } +}