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); } }