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