Browse Source

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.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
7c2ee29260
  1. 1
      ILSpy/Commands/AboutCommand.cs
  2. 12
      ILSpy/TextView/AvaloniaEditTextOutput.cs
  3. 11
      ILSpy/TextView/DecompilerTabPageModel.cs
  4. 24
      ILSpy/TextView/DecompilerTextView.axaml.cs
  5. 9
      ILSpy/TextView/HighlightingService.cs
  6. 46
      ILSpy/Themes/SyntaxColor.cs
  7. 91
      ILSpy/Themes/SyntaxColorPalettes.cs
  8. 75
      ILSpy/Themes/ThemeManager.cs

1
ILSpy/Commands/AboutCommand.cs

@ -116,6 +116,7 @@ namespace ILSpy.Commands @@ -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,

12
ILSpy/TextView/AvaloniaEditTextOutput.cs

@ -54,6 +54,15 @@ namespace ILSpy.TextView @@ -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();
/// <summary>The highlighting spans referencing the live named colours; see the field note.</summary>
public IReadOnlyList<(int Start, int Length, HighlightingColor Color)> HighlightingSpans => highlightingSpans;
/// <summary>Foldings collected during writing — only ones spanning more than one line.</summary>
public IReadOnlyList<NewFolding> Foldings => foldings;
@ -226,7 +235,10 @@ namespace ILSpy.TextView @@ -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));
}
}
}
}

11
ILSpy/TextView/DecompilerTabPageModel.cs

@ -80,6 +80,13 @@ namespace ILSpy.TextView @@ -80,6 +80,13 @@ namespace ILSpy.TextView
[ObservableProperty]
private RichTextModel? highlightingModel;
/// <summary>
/// The highlighting spans behind <see cref="HighlightingModel"/>, 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.
/// </summary>
public System.Collections.Generic.IReadOnlyList<(int Start, int Length, AvaloniaEdit.Highlighting.HighlightingColor Color)>? HighlightingSpans { get; set; }
/// <summary>
/// Multi-line fold ranges collected by the decompiler (member bodies, attribute blocks,
/// hidden compiler-generated regions, …). Fed to a <c>FoldingManager</c> on the editor.
@ -339,6 +346,7 @@ namespace ILSpy.TextView @@ -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 @@ -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 @@ -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 @@ -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;

24
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -202,6 +202,30 @@ namespace ILSpy.TextView @@ -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)

9
ILSpy/TextView/HighlightingService.cs

@ -23,6 +23,8 @@ using System.Xml; @@ -23,6 +23,8 @@ using System.Xml;
using AvaloniaEdit.Highlighting;
using AvaloniaEdit.Highlighting.Xshd;
using ILSpy.Themes;
namespace ILSpy.TextView
{
/// <summary>
@ -73,7 +75,12 @@ 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;
}
}
}

46
ILSpy/Themes/SyntaxColor.cs

@ -0,0 +1,46 @@ @@ -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
{
/// <summary>
/// A theme's colour override for a single named <see cref="HighlightingColor"/> (ported from
/// the WPF ILSpy theming model). A theme supplies one per syntax token; <see cref="ApplyTo"/>
/// 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.
/// </summary>
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;
}
}
}

91
ILSpy/Themes/SyntaxColorPalettes.cs

@ -0,0 +1,91 @@ @@ -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
{
/// <summary>
/// Per-theme syntax-colour palettes, keyed by the named <c>&lt;Color&gt;</c> 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
/// <see cref="ThemeManager.GetColorForDarkTheme"/>. These values are a first cut and meant to
/// be tuned -- they live here so the palette is one easy-to-edit place.
/// </summary>
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<string, SyntaxColor> CSharpDark { get; } = new Dictionary<string, SyntaxColor> {
["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"),
};
}
}

75
ILSpy/Themes/ThemeManager.cs

@ -40,6 +40,12 @@ namespace ILSpy.Themes @@ -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<IHighlightingDefinition> themableDefinitions = new();
readonly Dictionary<IHighlightingDefinition, Dictionary<string, HighlightingColor>> originalColors = new();
public static ThemeManager Current { get; } = new();
public string DefaultTheme => "Light";
@ -87,9 +93,78 @@ namespace ILSpy.Themes @@ -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);
}
/// <summary>
/// Registers a highlighting definition for theme-aware colouring and applies the current
/// theme to it immediately. Called by <c>HighlightingService</c> when a definition is first
/// loaded; from then on the definition is re-themed on every theme switch.
/// </summary>
public void RegisterThemableDefinition(IHighlightingDefinition definition)
{
ArgumentNullException.ThrowIfNull(definition);
if (!themableDefinitions.Contains(definition))
themableDefinitions.Add(definition);
ApplyHighlightingColors(definition);
}
/// <summary>
/// Writes the active theme's colours onto a definition's named <see cref="HighlightingColor"/>
/// 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.
/// </summary>
public void ApplyHighlightingColors(IHighlightingDefinition definition)
{
ArgumentNullException.ThrowIfNull(definition);
if (!originalColors.TryGetValue(definition, out var snapshot))
{
snapshot = new Dictionary<string, HighlightingColor>();
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;
}
/// <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

Loading…
Cancel
Save