From 7c2ee29260ce53dbb829aa879484e0996c93bdcf Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 4 Jun 2026 20:58:41 +0200 Subject: [PATCH] Add a themed dark syntax-highlighting scheme for C# Dark mode rendered the decompiled C# dark-on-dark: the port only had the algorithmic colour inversion, and it was applied solely to the .xshd fallback colorizer -- never the semantic RichTextColorizer that actually paints the decompiler output. Port the WPF theming model instead: a SyntaxColor palette (SyntaxColorPalettes.CSharpDark, a hand-authored first cut) that ThemeManager.ApplyHighlightingColors writes onto the definition's named HighlightingColor instances in place, restoring the .xshd defaults for Light and falling back to the algorithmic conversion for any unlisted token. Definitions register with the theme manager on load and are re-themed on every switch. The semantic path needed one extra step: RichTextModel.SetHighlighting clones the colours, so the model freezes them at decompile time. AvaloniaEditTextOutput now also keeps the spans referencing the live named colours, and DecompilerTextView rebuilds the model from them on ThemeChanged so already-decompiled output repaints with the new palette. Colours are a first cut, to be tuned. --- ILSpy/Commands/AboutCommand.cs | 1 + ILSpy/TextView/AvaloniaEditTextOutput.cs | 12 +++ ILSpy/TextView/DecompilerTabPageModel.cs | 11 +++ ILSpy/TextView/DecompilerTextView.axaml.cs | 24 ++++++ ILSpy/TextView/HighlightingService.cs | 9 ++- ILSpy/Themes/SyntaxColor.cs | 46 +++++++++++ ILSpy/Themes/SyntaxColorPalettes.cs | 91 ++++++++++++++++++++++ ILSpy/Themes/ThemeManager.cs | 75 ++++++++++++++++++ 8 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 ILSpy/Themes/SyntaxColor.cs create mode 100644 ILSpy/Themes/SyntaxColorPalettes.cs diff --git a/ILSpy/Commands/AboutCommand.cs b/ILSpy/Commands/AboutCommand.cs index ca42bb896..9cbf303a1 100644 --- a/ILSpy/Commands/AboutCommand.cs +++ b/ILSpy/Commands/AboutCommand.cs @@ -116,6 +116,7 @@ namespace ILSpy.Commands SyntaxExtension = syntaxExtension, Text = output.GetText(), HighlightingModel = output.HighlightingModel, + HighlightingSpans = output.HighlightingSpans, References = output.References, DefinitionLookup = output.DefinitionLookup, UIElements = output.UIElements, diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs index daf0445b7..9287e2138 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -54,6 +54,15 @@ namespace ILSpy.TextView public RichTextModel HighlightingModel { get; } = new RichTextModel(); + // The same spans that build HighlightingModel, but holding the SHARED named + // HighlightingColor instances (RichTextModel.SetHighlighting clones them, so the model + // itself freezes the colours at decompile time). Kept so the view can rebuild the model + // on a theme switch -- the shared instances are re-coloured in place by ThemeManager. + readonly List<(int Start, int Length, HighlightingColor Color)> highlightingSpans = new(); + + /// The highlighting spans referencing the live named colours; see the field note. + public IReadOnlyList<(int Start, int Length, HighlightingColor Color)> HighlightingSpans => highlightingSpans; + /// Foldings collected during writing — only ones spanning more than one line. public IReadOnlyList Foldings => foldings; @@ -226,7 +235,10 @@ namespace ILSpy.TextView var (start, color) = openSpans.Pop(); var length = builder.Length - start; if (length > 0) + { HighlightingModel.SetHighlighting(start, length, color); + highlightingSpans.Add((start, length, color)); + } } } } diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index cf181d49c..2e52d1eec 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -80,6 +80,13 @@ namespace ILSpy.TextView [ObservableProperty] private RichTextModel? highlightingModel; + /// + /// The highlighting spans behind , referencing the live + /// named colours (the model itself clones them). The view rebuilds the model from these on + /// a theme switch so the re-coloured palette reaches the already-decompiled output. + /// + public System.Collections.Generic.IReadOnlyList<(int Start, int Length, AvaloniaEdit.Highlighting.HighlightingColor Color)>? HighlightingSpans { get; set; } + /// /// Multi-line fold ranges collected by the decompiler (member bodies, attribute blocks, /// hidden compiler-generated regions, …). Fed to a FoldingManager on the editor. @@ -339,6 +346,7 @@ namespace ILSpy.TextView // "Folding must be within document boundary". This path is hit by // DockWorkspace.OnLanguagePropertyChanged's CurrentNode toggle-through-null. HighlightingModel = null; + HighlightingSpans = null; Foldings = null; References = null; DefinitionLookup = null; @@ -420,6 +428,7 @@ namespace ILSpy.TextView using (ILSpy.AppEnv.AppLog.Phase($"DecompileAsync #{callNumber}: collect output (GetText + collateral)")) rendered = output.GetText(); var model = output.HighlightingModel; + var collectedSpans = output.HighlightingSpans; var collectedFoldings = output.Foldings; var collectedReferences = output.References; var collectedLookup = output.DefinitionLookup; @@ -433,6 +442,7 @@ namespace ILSpy.TextView Title = cachedBaseTitle; SyntaxExtension = effectiveSyntaxExtension; HighlightingModel = model; + HighlightingSpans = collectedSpans; Foldings = collectedFoldings; References = collectedReferences; DefinitionLookup = collectedLookup; @@ -550,6 +560,7 @@ namespace ILSpy.TextView activeCts = null; SyntaxExtension = output.SyntaxExtensionOverride ?? Language?.FileExtension ?? string.Empty; HighlightingModel = output.HighlightingModel; + HighlightingSpans = output.HighlightingSpans; Foldings = output.Foldings; References = output.References; DefinitionLookup = output.DefinitionLookup; diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index ff44fcdb7..8839e65cb 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -202,6 +202,30 @@ namespace ILSpy.TextView RoutingStrategies.Tunnel, handledEventsToo: false); Editor.KeyDown += OnEditorKeyDownForZoom; + + // A theme switch re-colours the shared named HighlightingColors in place, but the + // semantic RichTextModel cloned them at decompile time (RichTextModel.SetHighlighting + // clones). Rebuild the model from the captured spans -- which still reference the live, + // re-coloured instances -- so the already-decompiled output repaints with the new palette. + ILSpy.Themes.ThemeManager.Current.ThemeChanged += OnThemeChangedRebuildHighlighting; + } + + void OnThemeChangedRebuildHighlighting(object? sender, System.EventArgs e) + { + if (boundModel?.HighlightingSpans is not { Count: > 0 } spans) + return; + var transformers = Editor.TextArea.TextView.LineTransformers; + if (activeColorizer != null) + { + transformers.Remove(activeColorizer); + activeColorizer = null; + } + var model = new RichTextModel(); + foreach (var (start, length, color) in spans) + model.SetHighlighting(start, length, color); + activeColorizer = new RichTextColorizer(model); + transformers.Add(activeColorizer); + Editor.TextArea.TextView.Redraw(); } void OnEditorPointerWheelChanged(object? sender, PointerWheelEventArgs e) diff --git a/ILSpy/TextView/HighlightingService.cs b/ILSpy/TextView/HighlightingService.cs index cfcf4a3a4..d0a843ad0 100644 --- a/ILSpy/TextView/HighlightingService.cs +++ b/ILSpy/TextView/HighlightingService.cs @@ -23,6 +23,8 @@ using System.Xml; using AvaloniaEdit.Highlighting; using AvaloniaEdit.Highlighting.Xshd; +using ILSpy.Themes; + namespace ILSpy.TextView { /// @@ -73,7 +75,12 @@ namespace ILSpy.TextView .GetManifestResourceStream(ResourcePrefix + resourceName) ?? throw new InvalidOperationException($"Highlighting resource not found: {resourceName}"); using var reader = XmlReader.Create(stream); - return HighlightingLoader.Load(reader, HighlightingManager.Instance); + var definition = HighlightingLoader.Load(reader, HighlightingManager.Instance); + // Hand the freshly-loaded definition to the theme manager: it applies the active + // theme's colours now (so the first decompile is themed) and re-themes it on every + // later theme switch. + ThemeManager.Current.RegisterThemableDefinition(definition); + return definition; } } } diff --git a/ILSpy/Themes/SyntaxColor.cs b/ILSpy/Themes/SyntaxColor.cs new file mode 100644 index 000000000..618eb1ed6 --- /dev/null +++ b/ILSpy/Themes/SyntaxColor.cs @@ -0,0 +1,46 @@ +// 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 Avalonia.Media; + +using AvaloniaEdit.Highlighting; + +namespace ILSpy.Themes +{ + /// + /// A theme's colour override for a single named (ported from + /// the WPF ILSpy theming model). A theme supplies one per syntax token; + /// writes it onto the shared HighlightingColor instance, so both the .xshd colorizer and the + /// semantic RichTextModel -- which reference the same instance -- pick up the change at once. + /// + public sealed class SyntaxColor + { + public Color? Foreground { get; init; } + public Color? Background { get; init; } + public FontWeight? FontWeight { get; init; } + public FontStyle? FontStyle { get; init; } + + public void ApplyTo(HighlightingColor color) + { + color.Foreground = Foreground is { } foreground ? new SimpleHighlightingBrush(foreground) : null; + color.Background = Background is { } background ? new SimpleHighlightingBrush(background) : null; + color.FontWeight = FontWeight; + color.FontStyle = FontStyle; + } + } +} diff --git a/ILSpy/Themes/SyntaxColorPalettes.cs b/ILSpy/Themes/SyntaxColorPalettes.cs new file mode 100644 index 000000000..ed09a3c06 --- /dev/null +++ b/ILSpy/Themes/SyntaxColorPalettes.cs @@ -0,0 +1,91 @@ +// 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 Avalonia.Media; + +namespace ILSpy.Themes +{ + /// + /// Per-theme syntax-colour palettes, keyed by the named <Color> from the .xshd. + /// Only the dark C# palette is hand-authored today (the light scheme stays the .xshd default); + /// any token without an entry falls back to the algorithmic dark conversion in + /// . These values are a first cut and meant to + /// be tuned -- they live here so the palette is one easy-to-edit place. + /// + public static class SyntaxColorPalettes + { + static Color C(string hex) => Color.Parse(hex); + + static SyntaxColor Fg(string hex) => new() { Foreground = C(hex) }; + static SyntaxColor Bold(string hex) => new() { Foreground = C(hex), FontWeight = FontWeight.Bold }; + static SyntaxColor Italic(string hex) => new() { Foreground = C(hex), FontStyle = FontStyle.Italic }; + + // Token families on a #1E1E1E editor background: + // keyword blue #569CD6 control-flow #C586C0 type teal #4EC9B0 + // method yellow #DCDCAA local/param #9CDCFE string #CE9178 + // number green #B5CEA8 comment #6A9955 + public static IReadOnlyDictionary CSharpDark { get; } = new Dictionary { + ["Comment"] = Fg("#6A9955"), + ["String"] = Fg("#CE9178"), + ["StringInterpolation"] = Fg("#D7BA7D"), + ["Char"] = Fg("#CE9178"), + ["Preprocessor"] = Fg("#9B9B9B"), + ["Punctuation"] = Fg("#D4D4D4"), + ["NumberLiteral"] = Fg("#B5CEA8"), + + ["Keywords"] = Fg("#569CD6"), + ["ValueTypeKeywords"] = Fg("#569CD6"), + ["ReferenceTypeKeywords"] = Fg("#569CD6"), + ["GotoKeywords"] = Fg("#C586C0"), + ["QueryKeywords"] = Fg("#C586C0"), + ["ExceptionKeywords"] = Fg("#C586C0"), + ["CheckedKeyword"] = Fg("#569CD6"), + ["UnsafeKeywords"] = Fg("#569CD6"), + ["OperatorKeywords"] = Fg("#569CD6"), + ["ParameterModifiers"] = Fg("#569CD6"), + ["Modifiers"] = Fg("#569CD6"), + ["Visibility"] = Fg("#569CD6"), + ["NamespaceKeywords"] = Fg("#569CD6"), + ["GetSetAddRemove"] = Fg("#569CD6"), + ["TrueFalse"] = Fg("#569CD6"), + ["TypeKeywords"] = Fg("#569CD6"), + ["AttributeKeywords"] = Fg("#C586C0"), + ["ThisOrBaseReference"] = Fg("#569CD6"), + ["NullOrValueKeywords"] = Fg("#569CD6"), + + ["ReferenceTypes"] = Fg("#4EC9B0"), + ["InterfaceTypes"] = Fg("#4EC9B0"), + ["TypeParameters"] = Fg("#4EC9B0"), + ["DelegateTypes"] = Fg("#4EC9B0"), + ["ValueTypes"] = Fg("#4EC9B0"), + ["EnumTypes"] = Fg("#4EC9B0"), + + ["MethodDeclaration"] = Bold("#DCDCAA"), + ["MethodCall"] = Bold("#DCDCAA"), + ["FieldDeclaration"] = Italic("#9CDCFE"), + ["FieldAccess"] = Italic("#9CDCFE"), + ["Variable"] = Fg("#9CDCFE"), + ["Parameter"] = Fg("#9CDCFE"), + + ["InactiveCode"] = Fg("#808080"), + ["SemanticError"] = Fg("#F44747"), + }; + } +} diff --git a/ILSpy/Themes/ThemeManager.cs b/ILSpy/Themes/ThemeManager.cs index 96f4bd75e..49fab1b78 100644 --- a/ILSpy/Themes/ThemeManager.cs +++ b/ILSpy/Themes/ThemeManager.cs @@ -40,6 +40,12 @@ namespace ILSpy.Themes // declares its own dark palette in two variants). const string IsThemeAwareKey = "ILSpy.IsThemeAware"; + // Highlighting definitions whose named colours we re-theme on every theme switch, plus a + // snapshot of each definition's ORIGINAL (light, .xshd-default) colours so switching back + // to Light restores them exactly. Keyed by colour name within each definition. + readonly List themableDefinitions = new(); + readonly Dictionary> originalColors = new(); + public static ThemeManager Current { get; } = new(); public string DefaultTheme => "Light"; @@ -87,9 +93,78 @@ namespace ILSpy.Themes { app.RequestedThemeVariant = Theme == "Dark" ? ThemeVariant.Dark : ThemeVariant.Light; } + // Re-theme the syntax colours BEFORE notifying the editors, so their Redraw on + // ThemeChanged repaints against the new palette. + foreach (var definition in themableDefinitions) + ApplyHighlightingColors(definition); ThemeChanged?.Invoke(this, EventArgs.Empty); } + /// + /// Registers a highlighting definition for theme-aware colouring and applies the current + /// theme to it immediately. Called by HighlightingService when a definition is first + /// loaded; from then on the definition is re-themed on every theme switch. + /// + public void RegisterThemableDefinition(IHighlightingDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + if (!themableDefinitions.Contains(definition)) + themableDefinitions.Add(definition); + ApplyHighlightingColors(definition); + } + + /// + /// Writes the active theme's colours onto a definition's named + /// instances IN PLACE -- the same instances the semantic RichTextModel references, so the + /// decompiled output and the .xshd colorizer both pick up the change. Light restores the + /// original .xshd colours; Dark applies the hand-authored palette where one exists and the + /// algorithmic conversion elsewhere. Marks the definition theme-aware so the per-paint + /// colorizer doesn't additionally remap it. + /// + public void ApplyHighlightingColors(IHighlightingDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + if (!originalColors.TryGetValue(definition, out var snapshot)) + { + snapshot = new Dictionary(); + foreach (var color in definition.NamedHighlightingColors) + snapshot[color.Name] = color.Clone(); + originalColors[definition] = snapshot; + } + + var darkPalette = definition.Name == "C#" ? SyntaxColorPalettes.CSharpDark : null; + foreach (var color in definition.NamedHighlightingColors) + { + if (!snapshot.TryGetValue(color.Name, out var original)) + continue; + if (IsDarkTheme) + { + if (darkPalette is not null && darkPalette.TryGetValue(color.Name, out var syntaxColor)) + syntaxColor.ApplyTo(color); + else + CopyColor(GetColorForDarkTheme(original), color); + } + else + { + CopyColor(original, color); + } + } + + definition.Properties[IsThemeAwareKey] = bool.TrueString; + } + + // Copies colour/style fields from one HighlightingColor onto another. Used instead of + // swapping instances because the RichTextModel holds references to the targets. + static void CopyColor(HighlightingColor source, HighlightingColor target) + { + target.Foreground = source.Foreground; + target.Background = source.Background; + target.FontWeight = source.FontWeight; + target.FontStyle = source.FontStyle; + target.Underline = source.Underline; + target.Strikethrough = source.Strikethrough; + } + /// /// Reads the XSHD's ILSpy.IsThemeAware property to decide whether the /// definition opts out of the dark-theme colour remap. Case-sensitive: only the