diff --git a/ILSpy.Tests/Themes/ThemeAwareHighlightingColorizerTests.cs b/ILSpy.Tests/Themes/ThemeAwareHighlightingColorizerTests.cs
new file mode 100644
index 000000000..8b7db112a
--- /dev/null
+++ b/ILSpy.Tests/Themes/ThemeAwareHighlightingColorizerTests.cs
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ILSpy.TextView;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Themes;
+
+///
+/// Verifies the editor → colorizer wiring:
+/// returns a , and the AvaloniaEdit
+/// pipeline installs it into the text view's LineTransformers on
+/// SyntaxHighlighting assignment. Without this seam the theme-aware colour math
+/// in ThemeManager never reaches the rendered output.
+///
+[TestFixture]
+public class ThemeAwareHighlightingColorizerTests
+{
+ [AvaloniaTest]
+ public void DecompilerTextEditor_Installs_ThemeAwareHighlightingColorizer_For_CSharp_Highlighting()
+ {
+ var editor = new DecompilerTextEditor();
+ editor.SyntaxHighlighting = HighlightingService.GetByExtension(".cs");
+
+ editor.TextArea.TextView.LineTransformers
+ .Any(t => t is ThemeAwareHighlightingColorizer)
+ .Should().BeTrue(
+ "setting SyntaxHighlighting must route through CreateColorizer, which produces the theme-aware variant");
+ }
+
+ [AvaloniaTest]
+ public void DecompilerTextEditor_Replaces_Old_Colorizer_When_SyntaxHighlighting_Changes()
+ {
+ // AvaloniaEdit's setter removes the previous CreateColorizer-produced transformer
+ // before installing the new one. If that contract regresses (older theme-aware
+ // colorizers leaking onto every navigation), the editor would slowly accumulate
+ // colorizers and render colour decisions from stale highlighting definitions.
+ var editor = new DecompilerTextEditor();
+ editor.SyntaxHighlighting = HighlightingService.GetByExtension(".cs");
+ editor.SyntaxHighlighting = HighlightingService.GetByExtension(".xml");
+
+ editor.TextArea.TextView.LineTransformers
+ .Count(t => t is ThemeAwareHighlightingColorizer)
+ .Should().Be(1, "exactly one theme-aware colorizer is alive at a time");
+ }
+}
diff --git a/ILSpy.Tests/Themes/ThemeManagerTests.cs b/ILSpy.Tests/Themes/ThemeManagerTests.cs
new file mode 100644
index 000000000..c73e01911
--- /dev/null
+++ b/ILSpy.Tests/Themes/ThemeManagerTests.cs
@@ -0,0 +1,152 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+
+using AvaloniaEdit.Highlighting;
+
+using AwesomeAssertions;
+
+using ILSpy.Themes;
+
+using NSubstitute;
+
+using NUnit.Framework;
+
+// Bare `Avalonia` would resolve to ICSharpCode.ILSpy.Tests.* via lexical namespace
+// lookup. global:: keeps the references unambiguous.
+using AvColor = global::Avalonia.Media.Color;
+using AvColors = global::Avalonia.Media.Colors;
+using AvFontStyle = global::Avalonia.Media.FontStyle;
+using AvFontWeight = global::Avalonia.Media.FontWeight;
+
+namespace ICSharpCode.ILSpy.Tests.Themes;
+
+///
+/// Unit coverage for the theme-color machinery that backs
+/// ThemeAwareHighlightingColorizer: the XSHD-marker check and the light→dark
+/// brush remapper. Live colorizer behaviour against a real editor is covered separately
+/// in the integration tests.
+///
+[TestFixture]
+public class ThemeManagerTests
+{
+ [Test]
+ public void IsThemeAware_Returns_True_When_Definition_Carries_The_ILSpy_Marker_Property()
+ {
+ // XSHD authors mark a definition theme-aware by emitting
+ //
+ // near the document root. The marker tells the colorizer to leave colours alone
+ // even under a dark theme — "this definition already ships theme-correct".
+ var def = StubDefinition(("ILSpy.IsThemeAware", "True"));
+
+ ThemeManager.Current.IsThemeAware(def).Should().BeTrue();
+ }
+
+ [Test]
+ public void IsThemeAware_Returns_False_When_Marker_Property_Is_Absent()
+ {
+ var def = StubDefinition();
+
+ ThemeManager.Current.IsThemeAware(def).Should().BeFalse();
+ }
+
+ [Test]
+ public void IsThemeAware_Returns_False_When_Marker_Value_Is_Not_True()
+ {
+ // Case-sensitive: only the exact string "True" enables the marker; anything else
+ // (including "true" or "1") behaves like an absent marker.
+ var def = StubDefinition(("ILSpy.IsThemeAware", "false"));
+
+ ThemeManager.Current.IsThemeAware(def).Should().BeFalse();
+ }
+
+ [Test]
+ public void GetColorForDarkTheme_Returns_Identity_When_Both_Brushes_Are_Null()
+ {
+ // Some highlighting colours describe italic/bold-only spans with no colour swap —
+ // the dark remapper has nothing to flip, so the same instance comes back.
+ var color = new HighlightingColor { FontWeight = AvFontWeight.Bold };
+
+ var remapped = ThemeManager.GetColorForDarkTheme(color);
+
+ remapped.Should().BeSameAs(color);
+ }
+
+ [Test]
+ public void GetColorForDarkTheme_Inverts_Lightness_Of_A_Black_Foreground_To_Near_White()
+ {
+ // Default C# keyword colour ~= black on the light theme. Under the dark remapper
+ // it must become a light colour so it's actually readable against a dark editor
+ // background. We allow a wide tolerance: the exact value depends on the HSL
+ // curve constants; what matters is that the perceived brightness has flipped.
+ var black = new HighlightingColor { Foreground = new SimpleHighlightingBrush(AvColors.Black) };
+
+ var remapped = ThemeManager.GetColorForDarkTheme(black);
+
+ var fg = ColorOf(remapped.Foreground!);
+ fg.R.Should().BeGreaterThan(200, "black-on-light must remap to near-white on dark");
+ fg.G.Should().BeGreaterThan(200);
+ fg.B.Should().BeGreaterThan(200);
+ }
+
+ [Test]
+ public void GetColorForDarkTheme_Returns_A_Clone_Not_The_Original_Instance()
+ {
+ // The light + dark colour caches in the colorizer rely on the remapper not
+ // mutating the source HighlightingColor — flipping a shared instance in place
+ // would corrupt every other colorizer that already cached the light value.
+ var color = new HighlightingColor { Foreground = new SimpleHighlightingBrush(AvColors.Red) };
+
+ var remapped = ThemeManager.GetColorForDarkTheme(color);
+
+ remapped.Should().NotBeSameAs(color);
+ ColorOf(color.Foreground!).Should().Be(AvColors.Red, "original must be untouched");
+ }
+
+ [Test]
+ public void GetColorForDarkTheme_Preserves_Non_Colour_Style_Attributes()
+ {
+ // Bold / italic / underline are theme-neutral — the dark remapper must carry them
+ // across into the cloned colour unchanged.
+ var color = new HighlightingColor {
+ Foreground = new SimpleHighlightingBrush(AvColors.Green),
+ FontWeight = AvFontWeight.Bold,
+ FontStyle = AvFontStyle.Italic,
+ };
+
+ var remapped = ThemeManager.GetColorForDarkTheme(color);
+
+ remapped.FontWeight.Should().Be((AvFontWeight?)AvFontWeight.Bold);
+ remapped.FontStyle.Should().Be((AvFontStyle?)AvFontStyle.Italic);
+ }
+
+ static AvColor ColorOf(HighlightingBrush brush)
+ => brush.GetColor(null!) ?? throw new InvalidOperationException("brush has no color");
+
+ static IHighlightingDefinition StubDefinition(params (string Key, string Value)[] properties)
+ {
+ var def = Substitute.For();
+ var dict = new Dictionary();
+ foreach (var (k, v) in properties)
+ dict[k] = v;
+ def.Properties.Returns(dict);
+ return def;
+ }
+}
diff --git a/ILSpy/TextView/DecompilerTextEditor.cs b/ILSpy/TextView/DecompilerTextEditor.cs
new file mode 100644
index 000000000..bd098099a
--- /dev/null
+++ b/ILSpy/TextView/DecompilerTextEditor.cs
@@ -0,0 +1,70 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+
+using AvaloniaEdit;
+using AvaloniaEdit.Highlighting;
+using AvaloniaEdit.Rendering;
+
+using ILSpy.Themes;
+
+namespace ILSpy.TextView
+{
+ ///
+ /// subclass that hooks two theme concerns:
+ /// (a) overrides so syntax highlighting goes
+ /// through and adapts to the active theme;
+ /// (b) listens for and forces a TextView redraw
+ /// so an already-rendered editor picks up the new palette without needing the user
+ /// to scroll or reselect.
+ ///
+ public class DecompilerTextEditor : TextEditor
+ {
+ public DecompilerTextEditor()
+ {
+ ThemeManager.Current.ThemeChanged += OnThemeChanged;
+ }
+
+ // Avalonia resolves the control template via the runtime type; subclasses of a
+ // templated control inherit the base template only when StyleKeyOverride is
+ // pointed at the base. Without this override AvaloniaEdit's template doesn't
+ // apply to us — meaning no ScrollViewer is installed, scroll offsets stay 0,
+ // and Copy can't reach the editor's TextArea via the template lookup chain.
+ protected override System.Type StyleKeyOverride => typeof(TextEditor);
+
+ protected override IVisualLineTransformer CreateColorizer(IHighlightingDefinition highlightingDefinition)
+ {
+ ArgumentNullException.ThrowIfNull(highlightingDefinition);
+ return new ThemeAwareHighlightingColorizer(highlightingDefinition);
+ }
+
+ void OnThemeChanged(object? sender, EventArgs e)
+ {
+ // Already-painted lines cache their colour decisions; a Redraw discards those
+ // caches and re-runs the colorizer pipeline against the new IsDarkTheme value.
+ TextArea?.TextView?.Redraw();
+ }
+
+ protected override void OnDetachedFromVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e)
+ {
+ ThemeManager.Current.ThemeChanged -= OnThemeChanged;
+ base.OnDetachedFromVisualTree(e);
+ }
+ }
+}
diff --git a/ILSpy/TextView/DecompilerTextView.axaml b/ILSpy/TextView/DecompilerTextView.axaml
index 18fd2310a..729560664 100644
--- a/ILSpy/TextView/DecompilerTextView.axaml
+++ b/ILSpy/TextView/DecompilerTextView.axaml
@@ -12,12 +12,14 @@
-
+
+
diff --git a/ILSpy/TextView/ThemeAwareHighlightingColorizer.cs b/ILSpy/TextView/ThemeAwareHighlightingColorizer.cs
new file mode 100644
index 000000000..b5570bf16
--- /dev/null
+++ b/ILSpy/TextView/ThemeAwareHighlightingColorizer.cs
@@ -0,0 +1,65 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Collections.Generic;
+
+using AvaloniaEdit.Highlighting;
+using AvaloniaEdit.Rendering;
+
+using ILSpy.Themes;
+
+namespace ILSpy.TextView
+{
+ ///
+ /// Wraps AvaloniaEdit's default colorizer so colours flip when the active theme is
+ /// Dark. The decision is per-paint and reads
+ /// directly, so a theme switch followed by an editor redraw renders in the new
+ /// palette without needing a second colorizer instance. The remapped colours are
+ /// cached per source instance.
+ ///
+ public sealed class ThemeAwareHighlightingColorizer : HighlightingColorizer
+ {
+ readonly Dictionary darkColors = new();
+ readonly bool definitionIsThemeAware;
+
+ public ThemeAwareHighlightingColorizer(IHighlightingDefinition highlightingDefinition)
+ : base(highlightingDefinition)
+ {
+ definitionIsThemeAware = ThemeManager.Current.IsThemeAware(highlightingDefinition);
+ }
+
+ protected override void ApplyColorToElement(VisualLineElement element, HighlightingColor color)
+ {
+ if (!definitionIsThemeAware && ThemeManager.Current.IsDarkTheme)
+ color = GetCachedDarkColor(color);
+ base.ApplyColorToElement(element, color);
+ }
+
+ HighlightingColor GetCachedDarkColor(HighlightingColor lightColor)
+ {
+ if (lightColor.Foreground is null && lightColor.Background is null)
+ return lightColor;
+ if (!darkColors.TryGetValue(lightColor, out var darkColor))
+ {
+ darkColor = ThemeManager.GetColorForDarkTheme(lightColor);
+ darkColors[lightColor] = darkColor;
+ }
+ return darkColor;
+ }
+ }
+}
diff --git a/ILSpy/Themes/ThemeManager.cs b/ILSpy/Themes/ThemeManager.cs
index d4384179c..96f4bd75e 100644
--- a/ILSpy/Themes/ThemeManager.cs
+++ b/ILSpy/Themes/ThemeManager.cs
@@ -16,12 +16,16 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
+using System;
using System.Collections.Generic;
using System.ComponentModel;
using Avalonia;
+using Avalonia.Media;
using Avalonia.Styling;
+using AvaloniaEdit.Highlighting;
+
namespace ILSpy.Themes
{
///
@@ -31,6 +35,11 @@ namespace ILSpy.Themes
///
public sealed class ThemeManager
{
+ // XSHD property marker that opts a highlighting definition out of the dark-theme
+ // remap — used by definitions that already ship theme-correct (e.g. an XSHD that
+ // declares its own dark palette in two variants).
+ const string IsThemeAwareKey = "ILSpy.IsThemeAware";
+
public static ThemeManager Current { get; } = new();
public string DefaultTheme => "Light";
@@ -44,6 +53,13 @@ namespace ILSpy.Themes
public bool IsDarkTheme => Theme == "Dark";
+ ///
+ /// Raised after changes. Consumers (chiefly the decompiler text
+ /// editor) re-render to pick up the new colour palette — Avalonia's
+ /// RequestedThemeVariant swap doesn't itself force AvaloniaEdit to redraw.
+ ///
+ public event EventHandler? ThemeChanged;
+
ThemeManager()
{
}
@@ -71,6 +87,122 @@ namespace ILSpy.Themes
{
app.RequestedThemeVariant = Theme == "Dark" ? ThemeVariant.Dark : ThemeVariant.Light;
}
+ ThemeChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Reads the XSHD's ILSpy.IsThemeAware property to decide whether the
+ /// definition opts out of the dark-theme colour remap. Case-sensitive: only the
+ /// literal string "True" opts in, mirroring WPF.
+ ///
+ public bool IsThemeAware(IHighlightingDefinition highlightingDefinition)
+ {
+ ArgumentNullException.ThrowIfNull(highlightingDefinition);
+ return highlightingDefinition.Properties.TryGetValue(IsThemeAwareKey, out var value)
+ && value == bool.TrueString;
+ }
+
+ ///
+ /// Clones with its foreground/background brushes flipped
+ /// for a dark-theme background. Lightness inverts with a small curve adjustment;
+ /// over-saturated colours are softened so they don't burn through the dark editor
+ /// background. Non-colour style attributes (bold/italic/underline) pass through
+ /// unchanged. When the input has no colour brushes at all, returns it as-is so the
+ /// caller's cache can short-circuit.
+ ///
+ public static HighlightingColor GetColorForDarkTheme(HighlightingColor lightColor)
+ {
+ ArgumentNullException.ThrowIfNull(lightColor);
+ if (lightColor.Foreground is null && lightColor.Background is null)
+ return lightColor;
+
+ var darkColor = (HighlightingColor)lightColor.Clone();
+ darkColor.Foreground = AdjustForDarkTheme(darkColor.Foreground);
+ darkColor.Background = AdjustForDarkTheme(darkColor.Background);
+ return darkColor;
+ }
+
+ static HighlightingBrush? AdjustForDarkTheme(HighlightingBrush? lightBrush)
+ {
+ if (lightBrush is null)
+ return null;
+ // AvaloniaEdit's SimpleHighlightingBrush is the only public concrete impl; for
+ // anything else (e.g. a gradient/themed brush a future XSHD might supply) we
+ // pass through unmodified — guessing colours would be worse than leaving them.
+ var color = lightBrush.GetColor(null!);
+ if (color is null)
+ return lightBrush;
+ return new SimpleHighlightingBrush(AdjustForDarkTheme(color.Value));
+ }
+
+ static Color AdjustForDarkTheme(Color color)
+ {
+ var (h, s, l) = RgbToHsl(color.R, color.G, color.B);
+
+ // Invert lightness, but lift the floor slightly so the darkest colours don't
+ // land right at white — keeps a sense of relative brightness in the output.
+ l = 1f - MathF.Pow(l, 1.2f);
+
+ // Desaturate intense colours — at full saturation they'd otherwise glow against
+ // a dark editor background.
+ if (s > 0.75f && l < 0.75f)
+ {
+ s *= 0.75f;
+ l *= 1.2f;
+ }
+
+ var (r, g, b) = HslToRgb(h, s, l);
+ return Color.FromArgb(color.A, r, g, b);
}
+
+ static (float h, float s, float l) RgbToHsl(byte rByte, byte gByte, byte bByte)
+ {
+ float r = rByte / 255f, g = gByte / 255f, b = bByte / 255f;
+ float max = MathF.Max(r, MathF.Max(g, b));
+ float min = MathF.Min(r, MathF.Min(g, b));
+ float l = (max + min) / 2f;
+ float h, s;
+ if (max == min)
+ {
+ h = 0f;
+ s = 0f;
+ }
+ else
+ {
+ float d = max - min;
+ s = l > 0.5f ? d / (2f - max - min) : d / (max + min);
+ if (max == r)
+ h = (g - b) / d + (g < b ? 6f : 0f);
+ else if (max == g)
+ h = (b - r) / d + 2f;
+ else
+ h = (r - g) / d + 4f;
+ h *= 60f;
+ }
+ return (h, s, l);
+ }
+
+ static (byte r, byte g, byte b) HslToRgb(float h, float s, float l)
+ {
+ // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
+ float c = (1f - MathF.Abs(2f * l - 1f)) * s;
+ h = h % 360f / 60f;
+ float x = c * (1f - MathF.Abs(h % 2f - 1f));
+ var (r1, g1, b1) = (int)MathF.Floor(h) switch {
+ 0 => (c, x, 0f),
+ 1 => (x, c, 0f),
+ 2 => (0f, c, x),
+ 3 => (0f, x, c),
+ 4 => (x, 0f, c),
+ _ => (c, 0f, x),
+ };
+ float m = l - c / 2f;
+ byte r = ClampToByte((r1 + m) * 255f);
+ byte g = ClampToByte((g1 + m) * 255f);
+ byte b = ClampToByte((b1 + m) * 255f);
+ return (r, g, b);
+ }
+
+ static byte ClampToByte(float v) => (byte)Math.Clamp(v, 0f, 255f);
}
}