Browse Source

Theme-aware syntax highlighting

Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
9ff236579e
  1. 68
      ILSpy.Tests/Themes/ThemeAwareHighlightingColorizerTests.cs
  2. 152
      ILSpy.Tests/Themes/ThemeManagerTests.cs
  3. 70
      ILSpy/TextView/DecompilerTextEditor.cs
  4. 14
      ILSpy/TextView/DecompilerTextView.axaml
  5. 65
      ILSpy/TextView/ThemeAwareHighlightingColorizer.cs
  6. 132
      ILSpy/Themes/ThemeManager.cs

68
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;
/// <summary>
/// Verifies the editor → colorizer wiring: <see cref="DecompilerTextEditor.CreateColorizer"/>
/// returns a <see cref="ThemeAwareHighlightingColorizer"/>, and the AvaloniaEdit
/// pipeline installs it into the text view's <c>LineTransformers</c> on
/// <c>SyntaxHighlighting</c> assignment. Without this seam the theme-aware colour math
/// in <c>ThemeManager</c> never reaches the rendered output.
/// </summary>
[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");
}
}

152
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;
/// <summary>
/// Unit coverage for the theme-color machinery that backs
/// <c>ThemeAwareHighlightingColorizer</c>: the XSHD-marker check and the light→dark
/// brush remapper. Live colorizer behaviour against a real editor is covered separately
/// in the integration tests.
/// </summary>
[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
// <Property name="ILSpy.IsThemeAware" value="True" />
// 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<IHighlightingDefinition>();
var dict = new Dictionary<string, string>();
foreach (var (k, v) in properties)
dict[k] = v;
def.Properties.Returns(dict);
return def;
}
}

70
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
{
/// <summary>
/// <see cref="TextEditor"/> subclass that hooks two theme concerns:
/// (a) overrides <see cref="TextEditor.CreateColorizer"/> so syntax highlighting goes
/// through <see cref="ThemeAwareHighlightingColorizer"/> and adapts to the active theme;
/// (b) listens for <see cref="ThemeManager.ThemeChanged"/> and forces a TextView redraw
/// so an already-rendered editor picks up the new palette without needing the user
/// to scroll or reselect.
/// </summary>
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);
}
}
}

14
ILSpy/TextView/DecompilerTextView.axaml

@ -12,12 +12,14 @@
<!-- Wait-adorner: a translucent overlay above the editor with a centred title + <!-- Wait-adorner: a translucent overlay above the editor with a centred title +
progress bar + cancel button while a decompilation is in flight. --> progress bar + cancel button while a decompilation is in flight. -->
<Grid> <Grid>
<ae:TextEditor Name="Editor" <!-- DecompilerTextEditor subclasses ae:TextEditor to install ThemeAwareHighlightingColorizer
IsReadOnly="True" and to redraw the text view when the active theme variant changes. -->
ShowLineNumbers="True" <textView:DecompilerTextEditor Name="Editor"
Background="#FFFFE1" IsReadOnly="True"
FontFamily="Consolas, Menlo, Monospace" ShowLineNumbers="True"
FontSize="13" /> Background="#FFFFE1"
FontFamily="Consolas, Menlo, Monospace"
FontSize="13" />
<Border Name="WaitAdorner" <Border Name="WaitAdorner"
Background="#80FFFFFF" Background="#80FFFFFF"
IsVisible="{Binding IsDecompiling}"> IsVisible="{Binding IsDecompiling}">

65
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
{
/// <summary>
/// Wraps AvaloniaEdit's default colorizer so colours flip when the active theme is
/// Dark. The decision is per-paint and reads <see cref="ThemeManager.IsDarkTheme"/>
/// 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 <see cref="HighlightingColor"/> instance.
/// </summary>
public sealed class ThemeAwareHighlightingColorizer : HighlightingColorizer
{
readonly Dictionary<HighlightingColor, HighlightingColor> 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;
}
}
}

132
ILSpy/Themes/ThemeManager.cs

@ -16,12 +16,16 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using Avalonia; using Avalonia;
using Avalonia.Media;
using Avalonia.Styling; using Avalonia.Styling;
using AvaloniaEdit.Highlighting;
namespace ILSpy.Themes namespace ILSpy.Themes
{ {
/// <summary> /// <summary>
@ -31,6 +35,11 @@ namespace ILSpy.Themes
/// </summary> /// </summary>
public sealed class ThemeManager 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 static ThemeManager Current { get; } = new();
public string DefaultTheme => "Light"; public string DefaultTheme => "Light";
@ -44,6 +53,13 @@ namespace ILSpy.Themes
public bool IsDarkTheme => Theme == "Dark"; public bool IsDarkTheme => Theme == "Dark";
/// <summary>
/// Raised after <see cref="Theme"/> changes. Consumers (chiefly the decompiler text
/// editor) re-render to pick up the new colour palette — Avalonia's
/// <c>RequestedThemeVariant</c> swap doesn't itself force AvaloniaEdit to redraw.
/// </summary>
public event EventHandler? ThemeChanged;
ThemeManager() ThemeManager()
{ {
} }
@ -71,6 +87,122 @@ namespace ILSpy.Themes
{ {
app.RequestedThemeVariant = Theme == "Dark" ? ThemeVariant.Dark : ThemeVariant.Light; app.RequestedThemeVariant = Theme == "Dark" ? ThemeVariant.Dark : ThemeVariant.Light;
} }
ThemeChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Reads the XSHD's <c>ILSpy.IsThemeAware</c> property to decide whether the
/// definition opts out of the dark-theme colour remap. Case-sensitive: only the
/// literal string <c>"True"</c> opts in, mirroring WPF.
/// </summary>
public bool IsThemeAware(IHighlightingDefinition highlightingDefinition)
{
ArgumentNullException.ThrowIfNull(highlightingDefinition);
return highlightingDefinition.Properties.TryGetValue(IsThemeAwareKey, out var value)
&& value == bool.TrueString;
}
/// <summary>
/// Clones <paramref name="lightColor"/> 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.
/// </summary>
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);
} }
} }

Loading…
Cancel
Save