Browse Source

Give dark-theme syntax colors a perceptual contrast floor

The dark palette is hand-authored for C# only; every other highlighting
definition -- XML, IL, Asm, and all AvaloniaEdit built-ins -- is derived by
inverting HSL lightness. HSL lightness is not perceptual luminance, so the
result depended entirely on hue: blue carries a 0.0722 luminance weight, so
plain Blue landed at 4.08:1 against the editor canvas, and an already-light
source such as Asm's #8080FF inverted downwards to 1.29:1 -- invisible.
Reported against XML resources in #3986.

Enforcing a 5.5:1 WCAG floor on the converted foreground fixes every affected
definition in the one place they all route through, which a per-language
palette would not: the AvaloniaEdit built-ins (JSON, Markdown, JS, HTML, CSS,
Python) have no palette to author. 5.5 is where the existing CSharpDark values
already sit; the 4.5 AA threshold was measured and only moves the reported blue
to 4.51. The floor is deliberately foreground-only -- forcing a span background
to contrast with the canvas would repaint Asm's #EEEEEE Registers background as
a bright block and bury the text on top of it -- and it is measured against the
surface the foreground lands on, which is that span background when the colour
declares one, so a light-on-dark span cannot be pulled apart into two colours
that no longer contrast with each other.

The same function's desaturation guard only fired when the inverted lightness
stayed below 0.75, so a dark fully saturated source (DarkMagenta) came back
light and still fully saturated -- exactly the neon the softening exists to
prevent. Only the softening becomes unconditional; the lightness lift paired
with it stays scoped to over-saturated colours, because it is not monotone
across its own 0.75 boundary and would reorder neighbouring greys.

Hyperlinks were a second, unrelated path: nothing ever set
TextView.LinkTextForegroundBrush, so the About page and every decompiler-view
link used AvaloniaEdit's registered default of pure blue, 1.94:1 on dark. They
now share a themed ILSpy.LinkForeground with the metadata table's token cells,
which take it from a style rather than a local Foreground so the selected row's
white override still wins over the accent fill.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
pull/4013/head
Christoph Wille 1 month ago
parent
commit
002748d5d0
  1. 32
      ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs
  2. 93
      ILSpy.Tests/Themes/LinkColorTests.cs
  3. 132
      ILSpy.Tests/Themes/ThemeManagerTests.cs
  4. 26
      ILSpy/App.axaml
  5. 5
      ILSpy/Metadata/MetadataColumnBuilder.cs
  6. 130
      ILSpy/Themes/ThemeManager.cs

32
ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs

@ -20,6 +20,9 @@ using System.Linq; @@ -20,6 +20,9 @@ using System.Linq;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AwesomeAssertions;
@ -128,4 +131,33 @@ public class MetadataColumnBuilderTests @@ -128,4 +131,33 @@ public class MetadataColumnBuilderTests
columns[1].Should().BeOfType<DataGridTemplateColumn>("Method is Kind=Token");
columns[2].Should().BeOfType<DataGridTextColumn>("Name has no [ColumnInfo]");
}
[AvaloniaTest]
public void Token_Cell_Link_Color_Comes_From_The_Theme_And_Yields_To_The_Selected_Row()
{
// Foreground is inherited, so a local brush on the button would outrank the
// DataGridRow:selected white override and leave the link unreadable on the accent fill --
// which is exactly the row navigation lands on. The "link" class lets the row win while
// still carrying the themed colour everywhere else.
var grid = new DataGrid { AutoGenerateColumns = false };
foreach (var column in MetadataColumnBuilder.For<SampleEntryWithToken>())
grid.Columns.Add(column);
grid.ItemsSource = new[] { new SampleEntryWithToken { RID = 1, Method = 0x06000001 } };
var window = new Window { Content = grid, Width = 400, Height = 200 };
window.Show();
Dispatcher.UIThread.RunJobs();
var link = grid.GetVisualDescendants().OfType<Button>().Single(b => b.Classes.Contains("link"));
var themed = link.Foreground;
themed.Should().NotBeNull();
window.TryFindResource("ILSpy.LinkForeground", window.ActualThemeVariant, out var expected).Should().BeTrue();
(themed as ISolidColorBrush)!.Color.Should().Be((expected as ISolidColorBrush)!.Color);
grid.SelectedIndex = 0;
Dispatcher.UIThread.RunJobs();
(link.Foreground as ISolidColorBrush)!.Color.Should().Be(Colors.White,
"the selected row paints its text white over the accent fill");
}
}

93
ILSpy.Tests/Themes/LinkColorTests.cs

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
// Copyright (c) 2026 Christoph Wille
//
// 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 Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using AwesomeAssertions;
using ICSharpCode.ILSpy.TextView;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Themes;
/// <summary>
/// The About page and every other hyperlink in a decompiler view are AvaloniaEdit
/// <c>VisualLineLinkText</c> runs, which take their color from
/// <c>TextView.LinkTextForegroundBrush</c>. AvaloniaEdit's registered default is pure blue,
/// 1.9:1 against the dark editor canvas, so App.axaml restyles the property through
/// <c>ILSpy.LinkForeground</c>. Verified here because a typo in the selector or the xmlns
/// would silently fall back to that unreadable default.
/// </summary>
[TestFixture]
public class LinkColorTests
{
[AvaloniaTest]
public void Editor_TextView_Takes_Its_Link_Color_From_The_Theme()
{
var textView = HostedTextView();
BrushColor(textView.LinkTextForegroundBrush)
.Should().NotBe(Colors.Blue, "the App.axaml style must replace AvaloniaEdit's default link brush");
}
[AvaloniaTest]
public void Link_Color_Follows_The_Theme_Variant()
{
var app = Application.Current ?? throw new InvalidOperationException("no Application");
var previous = app.RequestedThemeVariant;
try
{
var textView = HostedTextView();
app.RequestedThemeVariant = ThemeVariant.Light;
Dispatcher.UIThread.RunJobs();
var light = BrushColor(textView.LinkTextForegroundBrush);
app.RequestedThemeVariant = ThemeVariant.Dark;
Dispatcher.UIThread.RunJobs();
var dark = BrushColor(textView.LinkTextForegroundBrush);
dark.Should().NotBe(light, "each theme dictionary defines its own ILSpy.LinkForeground");
}
finally
{
app.RequestedThemeVariant = previous;
}
}
// Application-level styles only apply once the control is attached to a TopLevel.
static AvaloniaEdit.Rendering.TextView HostedTextView()
{
var editor = new DecompilerTextEditor();
var window = new Window { Content = editor, Width = 400, Height = 300 };
window.Show();
Dispatcher.UIThread.RunJobs();
return editor.TextArea.TextView;
}
static Color BrushColor(IBrush? brush)
=> (brush as ISolidColorBrush)?.Color ?? throw new InvalidOperationException("not a solid color brush");
}

132
ILSpy.Tests/Themes/ThemeManagerTests.cs

@ -19,6 +19,10 @@ @@ -19,6 +19,10 @@
using System;
using System.Collections.Generic;
// ResourceNodeExtensions.TryFindResource lives in Avalonia.Controls.
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using AvaloniaEdit.Highlighting;
using AwesomeAssertions;
@ -137,6 +141,134 @@ public class ThemeManagerTests @@ -137,6 +141,134 @@ public class ThemeManagerTests
remapped.FontStyle.Should().Be((AvFontStyle?)AvFontStyle.Italic);
}
// Every distinct *named* foreground shipped by the repo's own XSHDs (ILSpy/TextView/*.xshd).
// Colours declared inline on a rule are anonymous, never land in NamedHighlightingColors and
// so are never converted at all. None of the definitions declares ILSpy.IsThemeAware and only
// "C#" has a hand-authored dark palette, so all of these reach the algorithmic conversion.
static readonly string[] ShippedXshdForegrounds = {
// XML-Mode.xshd
"Green", "Blue", "DarkMagenta", "Red", "Teal", "Olive",
// ILAsm-Mode.xshd
"Magenta",
// Asm-Mode.xshd
"Orange", "#0080C0", "Brown", "#8080FF", "DarkBlue",
};
[Test]
[TestCaseSource(nameof(ShippedXshdForegrounds))]
public void GetColorForDarkTheme_Lifts_Every_Shipped_Foreground_To_The_Contrast_Floor(string light)
{
// The reported symptom in #3986: XSHD colours surviving the HSL inversion with too
// little perceptual contrast against the dark editor canvas. HSL lightness is not
// luminance, so without a floor the hue decides the outcome: plain Blue lands at
// 4.08:1, and the already-light #8080FF inverts *downwards* to 1.29:1 (invisible).
var color = new HighlightingColor { Foreground = new SimpleHighlightingBrush(AvColor.Parse(light)) };
var remapped = ThemeManager.GetColorForDarkTheme(color);
ContrastAgainstDarkEditor(ColorOf(remapped.Foreground!))
.Should().BeGreaterThanOrEqualTo(ThemeManager.MinimumDarkContrastRatio,
$"'{light}' must stay readable on the dark editor background");
}
[Test]
public void GetColorForDarkTheme_Leaves_Backgrounds_Below_The_Contrast_Floor()
{
// The floor is a *foreground* rule. Asm-Mode.xshd gives the Registers token a light
// #EEEEEE background; forcing that to contrast with the canvas would repaint it as a
// bright block and bury the foreground drawn on top of it.
var color = new HighlightingColor { Background = new SimpleHighlightingBrush(AvColor.Parse("#EEEEEE")) };
var remapped = ThemeManager.GetColorForDarkTheme(color);
ContrastAgainstDarkEditor(ColorOf(remapped.Background!))
.Should().BeLessThan(ThemeManager.MinimumDarkContrastRatio);
}
[Test]
public void GetColorForDarkTheme_Softens_Saturation_Of_Colors_That_Invert_To_Light()
{
// DarkMagenta inverts to lightness ~0.79. Saturation softening has to apply wherever the
// inverted lightness lands, or a dark fully saturated source comes back as light and
// still fully saturated: neon #FF93FF, exactly what the softening exists to prevent.
var color = new HighlightingColor { Foreground = new SimpleHighlightingBrush(AvColors.DarkMagenta) };
var remapped = ThemeManager.GetColorForDarkTheme(color);
var fg = ColorOf(remapped.Foreground!);
Math.Max(fg.R, Math.Max(fg.G, fg.B)).Should().BeLessThan(255,
"a fully saturated channel means the softening was skipped");
}
[Test]
public void GetColorForDarkTheme_Measures_The_Foreground_Against_Its_Own_Span_Background()
{
// A colour that carries both is painted on its own background, not on the editor canvas.
// White-on-Navy is the pathological case: both invert across the canvas, so measuring the
// foreground against the canvas would push it to a mid grey sitting on a light blue block.
var color = new HighlightingColor {
Foreground = new SimpleHighlightingBrush(AvColors.White),
Background = new SimpleHighlightingBrush(AvColors.Navy),
};
var remapped = ThemeManager.GetColorForDarkTheme(color);
Contrast(ColorOf(remapped.Foreground!), ColorOf(remapped.Background!))
.Should().BeGreaterThanOrEqualTo(ThemeManager.MinimumDarkContrastRatio,
"the span background is the surface the foreground has to read against");
}
[Test]
public void GetColorForDarkTheme_Keeps_Neighboring_Greys_In_Order()
{
// The lightness lift that pairs with the saturation softening is not monotone across its
// 0.75 boundary, so it must stay scoped to over-saturated colours. Applied to greys it
// would map #505050 brighter than the lighter #525252.
var darker = ColorOf(ThemeManager.GetColorForDarkTheme(Foreground("#505050")).Foreground!);
var lighter = ColorOf(ThemeManager.GetColorForDarkTheme(Foreground("#525252")).Foreground!);
darker.R.Should().BeGreaterThanOrEqualTo(lighter.R,
"inversion has to preserve the ordering of the source colours");
}
[AvaloniaTest]
public void DarkEditorBackground_Constant_Tracks_The_Theme_Resource()
{
// The floor is measured against a constant copy of the dark canvas so the conversion
// stays pure static math. Retuning ILSpy.EditorBackground in App.axaml without updating
// the constant would leave every floor test green while the shipped colours drift below
// the real floor -- Blue clears it by 0.02.
var window = new global::Avalonia.Controls.Window();
window.TryFindResource("ILSpy.EditorBackground", global::Avalonia.Styling.ThemeVariant.Dark, out var resource)
.Should().BeTrue("the dark theme dictionary defines the editor canvas");
(resource as global::Avalonia.Media.ISolidColorBrush)?.Color
.Should().Be(ThemeManager.DarkEditorBackground);
}
static HighlightingColor Foreground(string color)
=> new() { Foreground = new SimpleHighlightingBrush(AvColor.Parse(color)) };
static double ContrastAgainstDarkEditor(AvColor color)
=> Contrast(color, ThemeManager.DarkEditorBackground);
// WCAG relative-luminance contrast, computed independently of the production code so the
// tests don't validate themselves.
static double Contrast(AvColor color, AvColor surface)
{
var (a, b) = (Luminance(color) + 0.05, Luminance(surface) + 0.05);
return a > b ? a / b : b / a;
static double Luminance(AvColor c)
=> 0.2126 * Channel(c.R) + 0.7152 * Channel(c.G) + 0.0722 * Channel(c.B);
static double Channel(byte value)
{
var v = value / 255.0;
return v <= 0.03928 ? v / 12.92 : Math.Pow((v + 0.055) / 1.055, 2.4);
}
}
static AvColor ColorOf(HighlightingBrush brush)
=> brush.GetColor(null!) ?? throw new InvalidOperationException("brush has no color");

26
ILSpy/App.axaml

@ -12,6 +12,7 @@ @@ -12,6 +12,7 @@
xmlns:dockTheme="using:Dock.Avalonia.Themes.Simple"
xmlns:dockControls="using:Dock.Avalonia.Controls"
xmlns:aeFolding="using:AvaloniaEdit.Folding"
xmlns:aeRendering="using:AvaloniaEdit.Rendering"
xmlns:recycling="using:Avalonia.Controls.Recycling"
xmlns:docking="using:ICSharpCode.ILSpy.Docking"
xmlns:controls="using:ICSharpCode.ILSpy.Controls"
@ -43,6 +44,11 @@ @@ -43,6 +44,11 @@
TextArea style leaves SelectionForeground unset). -->
<SolidColorBrush x:Key="ILSpy.EditorSelectionBrush" Color="#007ACC" Opacity="0.3" />
<SolidColorBrush x:Key="ILSpy.EditorWaitAdornerBackground" Color="#80FFFFFF" />
<!-- Clickable text: About-page and decompiler-view hyperlinks (AvaloniaEdit's
TextView.LinkTextForegroundBrush, see the style below) and the metadata
table's token cells. AvaloniaEdit's own default is pure Blue, which is
unreadable on the dark canvas. -->
<SolidColorBrush x:Key="ILSpy.LinkForeground" Color="#0066CC" />
<SolidColorBrush x:Key="ILSpy.ToolbarBackground" Color="White" />
<SolidColorBrush x:Key="ILSpy.ToolbarBorder" Color="#FFA9B0B7" />
<SolidColorBrush x:Key="ILSpy.ToolbarSeparator" Color="#FFC8CDD3" />
@ -105,6 +111,7 @@ @@ -105,6 +111,7 @@
dark editor background. -->
<SolidColorBrush x:Key="ILSpy.EditorSelectionBrush" Color="#3794FF" Opacity="0.35" />
<SolidColorBrush x:Key="ILSpy.EditorWaitAdornerBackground" Color="#A0000000" />
<SolidColorBrush x:Key="ILSpy.LinkForeground" Color="#4DA6FF" />
<SolidColorBrush x:Key="ILSpy.ToolbarBackground" Color="#2D2D30" />
<SolidColorBrush x:Key="ILSpy.ToolbarBorder" Color="#3F3F46" />
<SolidColorBrush x:Key="ILSpy.ToolbarSeparator" Color="#3F3F46" />
@ -410,6 +417,14 @@ @@ -410,6 +417,14 @@
<Setter Property="Foreground" Value="{DynamicResource ILSpy.ToolChromeActiveTitleForeground}" />
</Style>
<!-- AvaloniaEdit paints VisualLineLinkText with TextView.LinkTextForegroundBrush, whose
registered default is pure Blue, 1.9:1 against the dark editor canvas. Route it
through the theme so About-page links, resource links, and every other hyperlink in
a decompiler view follow the active theme. -->
<Style Selector="aeRendering|TextView">
<Setter Property="LinkTextForegroundBrush" Value="{DynamicResource ILSpy.LinkForeground}" />
</Style>
<!-- AvaloniaEdit folding margin: the +/- toggle squares are painted by FoldingMargin
with its own brush properties (Background, Marker, plus selected variants for
pointer-over). Default Light values are hardcoded white-on-gray; route them
@ -480,6 +495,17 @@ @@ -480,6 +495,17 @@
<Setter Property="Foreground" Value="White" />
</Style>
<!-- Metadata token cells (MetadataColumnBuilder token columns) are links. Styled here
rather than with a local Foreground on the button so the selected row's white can
override them: on the accent-color selection fill neither link color has usable
contrast, and navigating to a cell selects its row. -->
<Style Selector="DataGridRow Button.link">
<Setter Property="Foreground" Value="{DynamicResource ILSpy.LinkForeground}" />
</Style>
<Style Selector="DataGridRow:selected Button.link">
<Setter Property="Foreground" Value="White" />
</Style>
<!-- Menu separators between MenuCategory groups. Simple theme's default is a thin
line at very low contrast against the menu background — barely visible. Boost
height and use a more contrasting brush so the MEF-injected groups (Save vs

5
ILSpy/Metadata/MetadataColumnBuilder.cs

@ -434,9 +434,12 @@ namespace ICSharpCode.ILSpy.Metadata @@ -434,9 +434,12 @@ namespace ICSharpCode.ILSpy.Metadata
Padding = new Thickness(2, 0),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Foreground = Brushes.Blue,
Cursor = new global::Avalonia.Input.Cursor(global::Avalonia.Input.StandardCursorType.Hand),
Content = FormatTokenValue(row, prop, format),
// Token cells are links. The colour comes from the "Button.link" styles in
// App.axaml rather than a local Foreground, so the selected row's white
// override still wins over it -- neither link colour reads on the accent fill.
Classes = { "link" },
};
btn.Click += (_, _) => {
if (btn.FindAncestorOfType<Views.MetadataTablePage>()?.DataContext is MetadataTablePageModel page)

130
ILSpy/Themes/ThemeManager.cs

@ -41,6 +41,25 @@ namespace ICSharpCode.ILSpy.Themes @@ -41,6 +41,25 @@ namespace ICSharpCode.ILSpy.Themes
// declares its own dark palette in two variants).
const string IsThemeAwareKey = "ILSpy.IsThemeAware";
/// <summary>
/// Minimum WCAG contrast ratio a dark-converted FOREGROUND must reach against the surface
/// it is painted on. 5.5 sits where the hand-authored <see cref="SyntaxColorPalettes.CSharpDark"/>
/// values already live (5.0-8.8) and matches VS Code's own dark keyword blue (5.65).
/// The 4.5 WCAG AA threshold is not enough here: it leaves plain Blue at 4.51, which
/// still reads as too dark against the editor background.
/// </summary>
internal const double MinimumDarkContrastRatio = 5.5;
// The dark editor canvas a foreground is measured against when its colour declares no
// span background of its own. Mirrors ILSpy.EditorBackground in the Dark theme dictionary
// of App.axaml, which ThemeManagerTests.DarkEditorBackground_Constant_Tracks_The_Theme_Resource
// pins it to -- kept as a constant so the conversion stays pure static math, usable
// without a running Application.
internal static readonly Color DarkEditorBackground = Color.FromRgb(0x1E, 0x1E, 0x1E);
static readonly double DarkEditorBackgroundLuminance =
RelativeLuminance(DarkEditorBackground.R, DarkEditorBackground.G, DarkEditorBackground.B);
// Highlighting definitions whose named colours we re-theme on every theme switch.
readonly List<IHighlightingDefinition> themableDefinitions = new();
@ -193,10 +212,17 @@ namespace ICSharpCode.ILSpy.Themes @@ -193,10 +212,17 @@ namespace ICSharpCode.ILSpy.Themes
/// 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.
/// background, and the foreground is then moved to <see cref="MinimumDarkContrastRatio"/>
/// against the surface it lands on. 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>
/// <remarks>
/// The contrast guarantee assumes opaque colours: the ratio is computed on the RGB
/// channels and the source alpha is carried over untouched, so a translucent foreground
/// composites to less contrast than the floor promises. Every named XSHD colour that
/// reaches this path today is opaque.
/// </remarks>
public static HighlightingColor GetColorForDarkTheme(HighlightingColor lightColor)
{
ArgumentNullException.ThrowIfNull(lightColor);
@ -204,12 +230,26 @@ namespace ICSharpCode.ILSpy.Themes @@ -204,12 +230,26 @@ namespace ICSharpCode.ILSpy.Themes
return lightColor;
var darkColor = (HighlightingColor)lightColor.Clone();
darkColor.Foreground = AdjustForDarkTheme(darkColor.Foreground);
darkColor.Background = AdjustForDarkTheme(darkColor.Background);
// The background converts first: when a colour declares one, that -- not the editor
// canvas -- is the surface its own foreground is painted on, so it is what the
// foreground's contrast has to be measured against.
darkColor.Background = AdjustForDarkTheme(darkColor.Background, contrastReference: null);
darkColor.Foreground = AdjustForDarkTheme(darkColor.Foreground,
contrastReference: LuminanceOf(darkColor.Background) ?? DarkEditorBackgroundLuminance);
return darkColor;
}
static HighlightingBrush? AdjustForDarkTheme(HighlightingBrush? lightBrush)
static double? LuminanceOf(HighlightingBrush? brush)
{
var color = brush?.GetColor(null!);
return color is null ? null : RelativeLuminance(color.Value.R, color.Value.G, color.Value.B);
}
// contrastReference is the luminance of the surface the colour will be painted on, or
// null for a colour that IS a surface: backgrounds are left where the inversion put them,
// or a light XSHD span background would be repainted as a bright block that buries the
// text drawn on top of it.
static HighlightingBrush? AdjustForDarkTheme(HighlightingBrush? lightBrush, double? contrastReference)
{
if (lightBrush is null)
return null;
@ -219,29 +259,93 @@ namespace ICSharpCode.ILSpy.Themes @@ -219,29 +259,93 @@ namespace ICSharpCode.ILSpy.Themes
var color = lightBrush.GetColor(null!);
if (color is null)
return lightBrush;
return new SimpleHighlightingBrush(AdjustForDarkTheme(color.Value));
return new SimpleHighlightingBrush(AdjustForDarkTheme(color.Value, contrastReference));
}
static Color AdjustForDarkTheme(Color color)
static Color AdjustForDarkTheme(Color color, double? contrastReference)
{
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.
// 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)
// Soften intense colours: at full saturation they'd glow against a dark editor
// background. This has to apply wherever the inverted lightness lands, because a
// dark, fully saturated source (DarkMagenta) inverts to a *light*, still fully
// saturated colour -- exactly the neon the softening exists to prevent. The paired
// lightness lift only makes sense for a softened colour that landed dark, and is
// deliberately not extended to unsaturated ones: it is not monotone across the 0.75
// boundary, so neighbouring greys would swap order.
if (s > 0.75f)
{
s *= 0.75f;
l *= 1.2f;
if (l < 0.75f)
l *= 1.2f;
}
// HSL lightness is not perceptual luminance, so inversion alone leaves hues with a
// low luminance weight (blue above all) too dim to read, and drags an already-light
// source colour *downwards* into its surface. Hence the contrast floor.
if (contrastReference is { } reference)
l = MoveToContrastFloor(h, s, l, reference);
var (r, g, b) = HslToRgb(h, s, l);
return Color.FromArgb(color.A, r, g, b);
}
/// <summary>
/// Moves HSL lightness until the colour clears <see cref="MinimumDarkContrastRatio"/>
/// against a surface of luminance <paramref name="reference"/>. Hue and saturation are
/// preserved, so the token keeps its identity and only its brightness moves. Luminance
/// grows monotonically with lightness at a fixed hue/saturation, so the search heads for
/// whichever end of the lightness range offers more contrast -- white above the dark
/// canvas, black under a light span background -- and bisects for the smallest move that
/// clears the floor. If even that end misses the floor it is still the best available.
/// </summary>
static float MoveToContrastFloor(float h, float s, float l, double reference)
{
if (ContrastAtLightness(h, s, l, reference) >= MinimumDarkContrastRatio)
return l;
float target = ContrastAtLightness(h, s, 1f, reference) >= ContrastAtLightness(h, s, 0f, reference)
? 1f
: 0f;
if (ContrastAtLightness(h, s, target, reference) < MinimumDarkContrastRatio)
return target;
float near = l;
for (int i = 0; i < 20; i++)
{
float mid = (near + target) / 2f;
if (ContrastAtLightness(h, s, mid, reference) >= MinimumDarkContrastRatio)
target = mid;
else
near = mid;
}
return target;
}
static double ContrastAtLightness(float h, float s, float l, double reference)
{
var (r, g, b) = HslToRgb(h, s, l);
var luminance = RelativeLuminance(r, g, b);
var (brighter, darker) = luminance > reference
? (luminance, reference)
: (reference, luminance);
return (brighter + 0.05) / (darker + 0.05);
}
// WCAG 2.x relative luminance over sRGB-linearised channels.
static double RelativeLuminance(byte r, byte g, byte b)
=> 0.2126 * Linearize(r) + 0.7152 * Linearize(g) + 0.0722 * Linearize(b);
static double Linearize(byte channel)
{
double v = channel / 255.0;
return v <= 0.03928 ? v / 12.92 : Math.Pow((v + 0.055) / 1.055, 2.4);
}
static (float h, float s, float l) RgbToHsl(byte rByte, byte gByte, byte bByte)
{
float r = rByte / 255f, g = gByte / 255f, b = bByte / 255f;

Loading…
Cancel
Save