From 12c0630053e95c387fb75aeaa5d350d7a313854e Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Thu, 22 Oct 2026 18:20:38 +0800
Subject: [PATCH 1/2] feat(ui): tune dark theme colors and make ToolStrip icons
visible
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary of changes
### ToolStrip dark adaptation (LogTabWindow.cs)
- Add ConfigureToolStripDark(): installs a ToolStripManager global renderer
with a VS Dark-derived color palette. Bypasses ToolStripContainer's
TopToolStripPanel which forces RenderMode=System on every child bar.
- Add DarkToolStripRenderer: OnRenderItemImage inverts dark-mode PNG
glyphs via ColorMatrix so toolbar icons render as light-on-transparent
against the dark bar; OnRenderItemText forces light foreground text.
- Add DarkToolStripColorTable: override ProfessionalColorTable with
VS2015 Dark Theme surface/highlight/accent colors.
### Hard-coded color fixes
- PaintHelper.cs: soft off-white foreground (220 220 220) instead of
pure #FFFFFF to reduce eye strain; centralize bookmark marker color
as BookmarkMarkerTextColor property (brighter orange on dark).
- LogWindow.cs: bookmark column colors get more saturation on dark.
- LedIndicatorService.cs: off/neutral LEDs brightened on dark; signal
colors (red/orange/yellow/green) kept theme-independent.
- ColorComboBox.cs: border / text / hatch brush adapt to Application.IsDarkModeEnabled.
- HighlightEntryDialog.cs: preview label foreground fallback chooses
Color.White on dark instead of always falling back to Color.Black.
## Test plan
- [x] Build passes with 0 errors
- [x] OnRenderItemImage called and e.Image non-null at runtime
- [ ] Run dark mode and verify: toolbar icons visible, colors readable,
dropdowns, hover/pressed states, dialog previews
## Risk
- ToolStripManager.VisualStylesEnabled = false is app-global (only set
in dark mode). Other dialogs with ToolStrips not yet verified.
- ColorMatrix inverts ALL RGB channels — correct for monochrome glyphs
but would flip semantic colored icons if any are added later.
---
src/LogExpert.UI/Controls/ColorComboBox.cs | 13 +-
.../Controls/LogWindow/LogWindow.cs | 9 +-
.../Dialogs/Highlight/HighlightEntryDialog.cs | 3 +-
.../Dialogs/LogTabWindow/LogTabWindow.cs | 149 ++++++++++++++++++
src/LogExpert.UI/Entities/PaintHelper.cs | 23 ++-
.../LedService/LedIndicatorService.cs | 13 +-
6 files changed, 196 insertions(+), 14 deletions(-)
diff --git a/src/LogExpert.UI/Controls/ColorComboBox.cs b/src/LogExpert.UI/Controls/ColorComboBox.cs
index 68d1a4f0..4b9f2af5 100644
--- a/src/LogExpert.UI/Controls/ColorComboBox.cs
+++ b/src/LogExpert.UI/Controls/ColorComboBox.cs
@@ -67,23 +67,28 @@ private void OnColorComboBoxDrawItem (object sender, DrawItemEventArgs e)
e.DrawBackground();
if (e.Index >= 0)
{
+ var darkMode = Application.IsDarkModeEnabled;
+ var textBrush = darkMode ? Brushes.White : Brushes.Black;
+ var borderPen = darkMode ? Pens.White : Pens.Black;
+
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);
+ 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..0d6eb335 100644
--- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
+++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
@@ -2741,6 +2741,13 @@ private void CreateDefaultViewStyle ()
filterGridView.RowsDefaultCellStyle = PaintHelper.GetDataGridDefaultRowStyle(darkMode);
PaintHelper.ApplyGridViewTheme(dataGridView, darkMode);
PaintHelper.ApplyGridViewTheme(filterGridView, darkMode);
+
+ // Bookmark marker column colors need more saturation against dark backgrounds.
+ if (darkMode)
+ {
+ BookmarkColor = Color.FromArgb(140, 195, 240); // brighter blue than light-mode #A5C8E1
+ AutoBookmarkColor = Color.FromArgb(150, 215, 150); // brighter green than light-mode #B4D2B4
+ }
}
[SupportedOSPlatform("windows")]
@@ -6635,7 +6642,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.BookmarkMarkerTextColor);
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..93b4a62b 100644
--- a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs
+++ b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs
@@ -2,6 +2,7 @@
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
+using System.Windows.Forms;
using ColumnizerLib;
@@ -404,7 +405,7 @@ private void UpdatePreview ()
? Resources.HighlightEntryEditDialog_UI_Preview_Placeholder
: text;
- _previewLabel.ForeColor = GetSelectedColor(_colorBoxForeground, Color.Black);
+ _previewLabel.ForeColor = GetSelectedColor(_colorBoxForeground, Application.IsDarkModeEnabled ? Color.White : Color.Black);
_previewLabel.BackColor = _checkBoxNoBackground != null && _checkBoxNoBackground.Checked
? SystemColors.Window
diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
index 5ec992ae..bc318449 100644
--- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
+++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
@@ -93,6 +93,7 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu
InitializeComponent();
ConfigureDockPanel();
+ ConfigureToolStripDark();
_tabController = new TabController(dockPanel);
InitializeTabControllerEvents();
@@ -384,6 +385,38 @@ private void ConfigureDockPanel ()
dockPanel.DocumentStyle = DocumentStyle.DockingWindow;
}
+ ///
+ /// Switches the main ToolStrip and MenuStrip family to a dark ProfessionalColorTable.
+ /// WinForms ToolStrip does not honour Application.SetColorMode(Dark); the default
+ /// ToolStripProfessionalRenderer keeps drawing light gradients regardless.
+ /// Icons are re-coloured on-the-fly by DarkToolStripRenderer (see below).
+ ///
+ /// ToolStripContainer.TopToolStripPanel forces RenderMode = System on every child
+ /// ToolStrip, which overrides any per-toolstrip Renderer we set here. We bypass that
+ /// by installing the renderer globally via ToolStripManager.Renderer and switching
+ /// every ToolStrip / MenuStrip to ManagerRenderMode — then each parent panel stops
+ /// fighting us.
+ ///
+ private void ConfigureToolStripDark ()
+ {
+ if (!Application.IsDarkModeEnabled)
+ {
+ return;
+ }
+
+ var renderer = new DarkToolStripRenderer(new DarkToolStripColorTable());
+
+ // Global override — every ToolStrip that opts into ManagerRenderMode uses this.
+ ToolStripManager.Renderer = renderer;
+ ToolStripManager.VisualStylesEnabled = false;
+
+ 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;
@@ -2781,4 +2814,120 @@ private class LogWindowData
#endregion
}
+
+ ///
+ /// ToolStripProfessionalRenderer subclass that inverts icon colours at paint time.
+ /// All icons on the main ToolStrip family are light-mode PNGs with dark strokes —
+ /// painting them as-is on a dark ToolStrip background renders them invisible.
+ /// This renderer applies a ColorMatrix in OnRenderItemImage so glyphs go from
+ /// near-black → near-white during paint, regardless of how or when each item's
+ /// Image property was assigned.
+ ///
+ [SupportedOSPlatform("windows")]
+ private sealed class DarkToolStripRenderer : ToolStripProfessionalRenderer
+ {
+ // Invert RGB channels (black↔white) while preserving alpha.
+ private static readonly System.Drawing.Imaging.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 ],
+ ]);
+
+ private static readonly System.Drawing.Imaging.ImageAttributes _imageAttrs = new();
+
+ static DarkToolStripRenderer ()
+ {
+ _imageAttrs.SetColorMatrix(_invertMatrix);
+ }
+
+ public DarkToolStripRenderer (ProfessionalColorTable colorTable) : base(colorTable)
+ {
+ }
+
+ protected override void OnRenderItemImage (ToolStripItemImageRenderEventArgs e)
+ {
+ if (e.Image == null)
+ {
+ return;
+ }
+
+ // Original icons are light-mode PNGs with dark strokes on transparent background.
+ // Invert RGB channels so glyphs become light-on-transparent against the dark bar,
+ // while preserving alpha (so transparency stays clean — no opaque white halo).
+ e.Graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
+ e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
+ e.Graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
+ e.Graphics.DrawImage(e.Image, e.ImageRectangle, 0, 0, e.Image.Width, e.Image.Height, GraphicsUnit.Pixel, _imageAttrs);
+ }
+
+ // Force text to use a light foreground so it stays readable on the dark background.
+ protected override void OnRenderItemText (ToolStripItemTextRenderEventArgs e)
+ {
+ var tc = e.TextColor;
+ if (tc.GetBrightness() <= 0.5f)
+ {
+ tc = e.Item.Enabled ? Color.FromArgb(220, 220, 220) : Color.FromArgb(140, 140, 140);
+ }
+
+ var rect = e.TextRectangle;
+ TextRenderer.DrawText(e.Graphics, e.Text, e.TextFont, rect, tc, e.TextFormat);
+ }
+ }
+
+ ///
+ /// Minimal dark palette for ToolStripProfessionalRenderer. WinForms does not switch the
+ /// default ProfessionalColorTable to dark under Application.SetColorMode(Dark), so we
+ /// override the essential surface/accent colors here.
+ ///
+ private sealed class DarkToolStripColorTable : ProfessionalColorTable
+ {
+ private static readonly Color _bg = Color.FromArgb(37, 37, 38); // Visual Studio dark
+ private static readonly Color _border = Color.FromArgb(70, 70, 70);
+ private static readonly Color _hover = Color.FromArgb(62, 62, 66);
+ private static readonly Color _press = Color.FromArgb(0, 122, 204); // VS blue accent
+ private static readonly Color _menuBorder = Color.FromArgb(100, 100, 100);
+ private static readonly Color _sep = Color.FromArgb(80, 80, 80);
+
+ public override Color ToolStripPanelGradientBegin => _bg;
+ public override Color ToolStripPanelGradientEnd => _bg;
+ public override Color ToolStripBorder => _border;
+ public override Color ToolStripDropDownBackground => _bg;
+
+ public override Color MenuItemSelected => _hover;
+ public override Color MenuItemSelectedGradientBegin => _hover;
+ public override Color MenuItemSelectedGradientEnd => _hover;
+ public override Color MenuItemPressedGradientBegin => _press;
+ public override Color MenuItemPressedGradientEnd => _press;
+ public override Color MenuBorder => _menuBorder;
+ public override Color MenuItemBorder => _menuBorder;
+
+ public override Color SeparatorDark => _sep;
+ public override Color SeparatorLight => _border;
+
+ public override Color ImageMarginGradientBegin => _bg;
+ public override Color ImageMarginGradientMiddle => _bg;
+ public override Color ImageMarginGradientEnd => _bg;
+ public override Color ImageMarginRevealedGradientBegin => _hover;
+ public override Color ImageMarginRevealedGradientMiddle => _hover;
+ public override Color ImageMarginRevealedGradientEnd => _hover;
+
+ public override Color ButtonSelectedBorder => _border;
+ public override Color ButtonSelectedGradientBegin => _hover;
+ public override Color ButtonSelectedGradientMiddle => _hover;
+ public override Color ButtonSelectedGradientEnd => _hover;
+ public override Color ButtonPressedBorder => _press;
+ public override Color ButtonPressedGradientBegin => _press;
+ public override Color ButtonPressedGradientMiddle => _press;
+ public override Color ButtonPressedGradientEnd => _press;
+
+ public override Color GripDark => _sep;
+ public override Color GripLight => _border;
+
+ public override Color CheckBackground => _bg;
+ public override Color CheckSelectedBackground => _hover;
+ public override Color CheckPressedBackground => _press;
+ }
}
diff --git a/src/LogExpert.UI/Entities/PaintHelper.cs b/src/LogExpert.UI/Entities/PaintHelper.cs
index 6cbcb46d..910cefe4 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(BookmarkMarkerTextColor);
using var font = logPaintCtx.MonospacedFont;
e.Graphics.DrawString("i", font, brush2, new RectangleF(r.Left, r.Top, r.Width, r.Height), _format);
}
@@ -90,6 +90,12 @@ 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);
@@ -102,9 +108,18 @@ public static Color GetForeColorFromHighlightEntry (HighlightEntry? entry, bool
return entry.ForegroundColor;
}
- return darkMode ? Color.White : Color.Black;
+ return darkMode ? _darkModeForeColor : Color.Black;
}
+ ///
+ /// 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 BookmarkMarkerTextColor =>
+ Application.IsDarkModeEnabled
+ ? Color.FromArgb(255, 220, 130, 20) // brighter orange on dark
+ : Color.FromArgb(255, 190, 100, 0); // DarkOrange baseline
+
[SupportedOSPlatform("windows")]
public static Brush GetBrushForFocusedControl (bool focused, Color selectionColor, bool darkMode)
{
@@ -250,7 +265,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 +280,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..5380bb89 100644
--- a/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs
+++ b/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs
@@ -2,6 +2,7 @@
using System.Drawing.Imaging;
using System.Globalization;
using System.Runtime.Versioning;
+using System.Windows.Forms;
using LogExpert.UI.Controls.LogWindow;
using LogExpert.UI.Interface;
@@ -254,6 +255,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 +267,13 @@ 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(darkMode ? Color.FromArgb(175, 175, 175) : Color.FromArgb(160, 160, 160)); // 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
+ // Off / neutral LEDs must be visible against the surrounding UI background.
+ _offLedBrush = new SolidBrush(darkMode ? Color.FromArgb(170, 170, 170) : Color.FromArgb(100, 100, 100));
+ _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
}
///
From d54ea9dd9aa4c45151d0269ace380bd401ef83fc Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Fri, 23 Oct 2026 00:27:28 +0800
Subject: [PATCH 2/2] Theme tool strips from the dock palette and share marker
colors
The renderer and colour table lived inside LogTabWindow and carried a palette
of their own, so the toolbars drifted from the dock panel colours. Move both
into LogExpert.UI/Theming, build them on the VS2015DarkTheme palette the dock
panel already uses, and install them process-wide from ToolStripTheme so every
strip in ManagerRenderMode picks them up.
PaintHelper gains GetDefaultBackColor, GetDefaultForeColor and
GetBookmarkMarkerTextColor(bool). The grid, the colour combo box, the highlight
dialog and the LED indicator now take their colours from those instead of
repeating the dark-mode ternaries, which is what let the highlight preview and
the grid disagree.
Adds tests for the renderer, the marker colour and the dark LED lookup.
---
.../Services/LedIndicatorServiceTests.cs | 28 +++
.../Theming/DarkToolStripRendererTests.cs | 90 ++++++++
src/LogExpert.UI.Tests/UI/PaintHelperTests.cs | 67 ++++++
src/LogExpert.UI/Controls/ColorComboBox.cs | 12 +-
.../Controls/LogWindow/LogWindow.cs | 28 ++-
.../Dialogs/Highlight/HighlightEntryDialog.cs | 13 +-
.../Dialogs/LogTabWindow/LogTabWindow.cs | 138 +-----------
src/LogExpert.UI/Entities/PaintHelper.cs | 35 ++-
.../LedService/LedIndicatorService.cs | 25 ++-
.../Theming/DarkToolStripRenderer.cs | 211 ++++++++++++++++++
src/LogExpert.UI/Theming/ToolStripTheme.cs | 51 +++++
11 files changed, 540 insertions(+), 158 deletions(-)
create mode 100644 src/LogExpert.UI.Tests/Theming/DarkToolStripRendererTests.cs
create mode 100644 src/LogExpert.UI/Theming/DarkToolStripRenderer.cs
create mode 100644 src/LogExpert.UI/Theming/ToolStripTheme.cs
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 4b9f2af5..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")]
@@ -68,12 +70,16 @@ private void OnColorComboBoxDrawItem (object sender, DrawItemEventArgs e)
if (e.Index >= 0)
{
var darkMode = Application.IsDarkModeEnabled;
- var textBrush = darkMode ? Brushes.White : Brushes.Black;
- var borderPen = darkMode ? Pens.White : Pens.Black;
+
+ // 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);
+ using var swatchBrush = new SolidBrush(rectColor);
+ e.Graphics.FillRectangle(swatchBrush, rectangle);
e.Graphics.DrawRectangle(borderPen, rectangle);
if (e.Index == 0)
diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs
index 0d6eb335..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
{
@@ -2742,12 +2750,12 @@ private void CreateDefaultViewStyle ()
PaintHelper.ApplyGridViewTheme(dataGridView, darkMode);
PaintHelper.ApplyGridViewTheme(filterGridView, darkMode);
- // Bookmark marker column colors need more saturation against dark backgrounds.
- if (darkMode)
- {
- BookmarkColor = Color.FromArgb(140, 195, 240); // brighter blue than light-mode #A5C8E1
- AutoBookmarkColor = Color.FromArgb(150, 215, 150); // brighter green than light-mode #B4D2B4
- }
+ // 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")]
@@ -6642,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(PaintHelper.BookmarkMarkerTextColor);
+ 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 93b4a62b..7d4d8ca3 100644
--- a/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs
+++ b/src/LogExpert.UI/Dialogs/Highlight/HighlightEntryDialog.cs
@@ -2,7 +2,6 @@
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
-using System.Windows.Forms;
using ColumnizerLib;
@@ -10,6 +9,7 @@
using LogExpert.Core.Helpers;
using LogExpert.Dialogs;
using LogExpert.UI.Controls;
+using LogExpert.UI.Entities;
namespace LogExpert.UI.Dialogs.Highlight;
@@ -405,11 +405,16 @@ private void UpdatePreview ()
? Resources.HighlightEntryEditDialog_UI_Preview_Placeholder
: text;
- _previewLabel.ForeColor = GetSelectedColor(_colorBoxForeground, Application.IsDarkModeEnabled ? Color.White : 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 bc318449..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;
@@ -386,16 +387,13 @@ private void ConfigureDockPanel ()
}
///
- /// Switches the main ToolStrip and MenuStrip family to a dark ProfessionalColorTable.
- /// WinForms ToolStrip does not honour Application.SetColorMode(Dark); the default
- /// ToolStripProfessionalRenderer keeps drawing light gradients regardless.
- /// Icons are re-coloured on-the-fly by DarkToolStripRenderer (see below).
+ /// 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.TopToolStripPanel forces RenderMode = System on every child
- /// ToolStrip, which overrides any per-toolstrip Renderer we set here. We bypass that
- /// by installing the renderer globally via ToolStripManager.Renderer and switching
- /// every ToolStrip / MenuStrip to ManagerRenderMode — then each parent panel stops
- /// fighting us.
+ /// 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 ()
{
@@ -404,11 +402,7 @@ private void ConfigureToolStripDark ()
return;
}
- var renderer = new DarkToolStripRenderer(new DarkToolStripColorTable());
-
- // Global override — every ToolStrip that opts into ManagerRenderMode uses this.
- ToolStripManager.Renderer = renderer;
- ToolStripManager.VisualStylesEnabled = false;
+ ToolStripTheme.Apply();
buttonToolStrip.RenderMode = ToolStripRenderMode.ManagerRenderMode;
externalToolsToolStrip.RenderMode = ToolStripRenderMode.ManagerRenderMode;
@@ -2814,120 +2808,4 @@ private class LogWindowData
#endregion
}
-
- ///
- /// ToolStripProfessionalRenderer subclass that inverts icon colours at paint time.
- /// All icons on the main ToolStrip family are light-mode PNGs with dark strokes —
- /// painting them as-is on a dark ToolStrip background renders them invisible.
- /// This renderer applies a ColorMatrix in OnRenderItemImage so glyphs go from
- /// near-black → near-white during paint, regardless of how or when each item's
- /// Image property was assigned.
- ///
- [SupportedOSPlatform("windows")]
- private sealed class DarkToolStripRenderer : ToolStripProfessionalRenderer
- {
- // Invert RGB channels (black↔white) while preserving alpha.
- private static readonly System.Drawing.Imaging.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 ],
- ]);
-
- private static readonly System.Drawing.Imaging.ImageAttributes _imageAttrs = new();
-
- static DarkToolStripRenderer ()
- {
- _imageAttrs.SetColorMatrix(_invertMatrix);
- }
-
- public DarkToolStripRenderer (ProfessionalColorTable colorTable) : base(colorTable)
- {
- }
-
- protected override void OnRenderItemImage (ToolStripItemImageRenderEventArgs e)
- {
- if (e.Image == null)
- {
- return;
- }
-
- // Original icons are light-mode PNGs with dark strokes on transparent background.
- // Invert RGB channels so glyphs become light-on-transparent against the dark bar,
- // while preserving alpha (so transparency stays clean — no opaque white halo).
- e.Graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
- e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
- e.Graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
- e.Graphics.DrawImage(e.Image, e.ImageRectangle, 0, 0, e.Image.Width, e.Image.Height, GraphicsUnit.Pixel, _imageAttrs);
- }
-
- // Force text to use a light foreground so it stays readable on the dark background.
- protected override void OnRenderItemText (ToolStripItemTextRenderEventArgs e)
- {
- var tc = e.TextColor;
- if (tc.GetBrightness() <= 0.5f)
- {
- tc = e.Item.Enabled ? Color.FromArgb(220, 220, 220) : Color.FromArgb(140, 140, 140);
- }
-
- var rect = e.TextRectangle;
- TextRenderer.DrawText(e.Graphics, e.Text, e.TextFont, rect, tc, e.TextFormat);
- }
- }
-
- ///
- /// Minimal dark palette for ToolStripProfessionalRenderer. WinForms does not switch the
- /// default ProfessionalColorTable to dark under Application.SetColorMode(Dark), so we
- /// override the essential surface/accent colors here.
- ///
- private sealed class DarkToolStripColorTable : ProfessionalColorTable
- {
- private static readonly Color _bg = Color.FromArgb(37, 37, 38); // Visual Studio dark
- private static readonly Color _border = Color.FromArgb(70, 70, 70);
- private static readonly Color _hover = Color.FromArgb(62, 62, 66);
- private static readonly Color _press = Color.FromArgb(0, 122, 204); // VS blue accent
- private static readonly Color _menuBorder = Color.FromArgb(100, 100, 100);
- private static readonly Color _sep = Color.FromArgb(80, 80, 80);
-
- public override Color ToolStripPanelGradientBegin => _bg;
- public override Color ToolStripPanelGradientEnd => _bg;
- public override Color ToolStripBorder => _border;
- public override Color ToolStripDropDownBackground => _bg;
-
- public override Color MenuItemSelected => _hover;
- public override Color MenuItemSelectedGradientBegin => _hover;
- public override Color MenuItemSelectedGradientEnd => _hover;
- public override Color MenuItemPressedGradientBegin => _press;
- public override Color MenuItemPressedGradientEnd => _press;
- public override Color MenuBorder => _menuBorder;
- public override Color MenuItemBorder => _menuBorder;
-
- public override Color SeparatorDark => _sep;
- public override Color SeparatorLight => _border;
-
- public override Color ImageMarginGradientBegin => _bg;
- public override Color ImageMarginGradientMiddle => _bg;
- public override Color ImageMarginGradientEnd => _bg;
- public override Color ImageMarginRevealedGradientBegin => _hover;
- public override Color ImageMarginRevealedGradientMiddle => _hover;
- public override Color ImageMarginRevealedGradientEnd => _hover;
-
- public override Color ButtonSelectedBorder => _border;
- public override Color ButtonSelectedGradientBegin => _hover;
- public override Color ButtonSelectedGradientMiddle => _hover;
- public override Color ButtonSelectedGradientEnd => _hover;
- public override Color ButtonPressedBorder => _press;
- public override Color ButtonPressedGradientBegin => _press;
- public override Color ButtonPressedGradientMiddle => _press;
- public override Color ButtonPressedGradientEnd => _press;
-
- public override Color GripDark => _sep;
- public override Color GripLight => _border;
-
- public override Color CheckBackground => _bg;
- public override Color CheckSelectedBackground => _hover;
- public override Color CheckPressedBackground => _press;
- }
}
diff --git a/src/LogExpert.UI/Entities/PaintHelper.cs b/src/LogExpert.UI/Entities/PaintHelper.cs
index 910cefe4..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(BookmarkMarkerTextColor);
+ 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);
}
@@ -98,7 +98,28 @@ public static void CellPainting (ILogPaintContextUI logPaintCtx, bool focused, i
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)
@@ -108,17 +129,19 @@ public static Color GetForeColorFromHighlightEntry (HighlightEntry? entry, bool
return entry.ForegroundColor;
}
- return darkMode ? _darkModeForeColor : 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 BookmarkMarkerTextColor =>
- Application.IsDarkModeEnabled
+ 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
+ : Color.FromArgb(255, 190, 100, 0); // DarkOrange baseline
+ }
[SupportedOSPlatform("windows")]
public static Brush GetBrushForFocusedControl (bool focused, Color selectionColor, bool darkMode)
diff --git a/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs b/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs
index 5380bb89..51bd3181 100644
--- a/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs
+++ b/src/LogExpert.UI/Services/LedService/LedIndicatorService.cs
@@ -2,7 +2,6 @@
using System.Drawing.Imaging;
using System.Globalization;
using System.Runtime.Versioning;
-using System.Windows.Forms;
using LogExpert.UI.Controls.LogWindow;
using LogExpert.UI.Interface;
@@ -267,13 +266,29 @@ private void CreateBrushes (Color tailColor)
_tailLedBrush = new Brush[3];
_tailLedBrush[0] = new SolidBrush(tailColor); // Tail on
- _tailLedBrush[1] = new SolidBrush(darkMode ? Color.FromArgb(175, 175, 175) : 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
- // Off / neutral LEDs must be visible against the surrounding UI background.
- _offLedBrush = new SolidBrush(darkMode ? Color.FromArgb(170, 170, 170) : Color.FromArgb(100, 100, 100));
+ _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
+ _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;
+ }
+ }
+}