Browse Source

Grayscale-aware image for disabled toolbar buttons

GrayscaleAwareImage : Image bakes a luma-transformed Bitmap on the disabled
edge (BT.601 weights) and swaps Source. Replaces the blanket Opacity=0.35
setter that was the previous cue. Applied to the static toolbar buttons in
MainToolBar.axaml plus the MEF-built buttons constructed in BuildButton.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
4b13b25770
  1. 18
      ILSpy.Tests/MainWindow/MainWindowTests.cs
  2. 139
      ILSpy/Controls/GrayscaleAwareImage.cs
  3. 24
      ILSpy/Views/MainToolBar.axaml
  4. 3
      ILSpy/Views/MainToolBar.axaml.cs

18
ILSpy.Tests/MainWindow/MainWindowTests.cs

@ -32,6 +32,7 @@ using ILSpy.AssemblyTree; @@ -32,6 +32,7 @@ using ILSpy.AssemblyTree;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using ILSpy.Views.Controls;
using NUnit.Framework;
@ -76,11 +77,12 @@ public class MainWindowTests @@ -76,11 +77,12 @@ public class MainWindowTests
}
[AvaloniaTest]
public async Task Toolbar_Disabled_Button_Renders_Dimmed_Icon()
public async Task Toolbar_Disabled_Button_Icon_Is_GrayscaleAware()
{
// At startup the Back button is disabled (no nav history yet — the bound Command's
// CanExecute returns false). Its icon should render visibly dimmer so the user can
// tell at a glance that it's not clickable.
// CanExecute returns false). Its icon must be a GrayscaleAwareImage so it desaturates
// instead of just dimming. We trust IsEffectivelyEnabled to drive the swap inside the
// control; the visual result is verified manually via CaptureAndShow when needed.
// Arrange + Act — show the window, wait until at least one disabled icon-bearing button
// has been laid out.
@ -92,15 +94,15 @@ public class MainWindowTests @@ -92,15 +94,15 @@ public class MainWindowTests
.Any(b => !b.IsEffectivelyEnabled
&& b.GetVisualDescendants().OfType<Image>().Any()));
// Assert — every disabled toolbar Image must render at <= 0.35 opacity.
var dimmedImages = window.GetVisualDescendants()
// Assert — every disabled toolbar icon is the grayscale-aware variant, not a plain Image.
var disabledIcons = window.GetVisualDescendants()
.OfType<Button>()
.Where(b => !b.IsEffectivelyEnabled)
.SelectMany(b => b.GetVisualDescendants().OfType<Image>())
.ToList();
dimmedImages.Should().NotBeEmpty();
dimmedImages.Should().OnlyContain(img => img.Opacity <= 0.35,
"disabled toolbar buttons must render their icon at < 0.4 opacity to be visually distinct");
disabledIcons.Should().NotBeEmpty();
disabledIcons.Should().OnlyContain(img => img is GrayscaleAwareImage,
"disabled toolbar buttons must use GrayscaleAwareImage so their icons desaturate");
}
[AvaloniaTest]

139
ILSpy/Controls/GrayscaleAwareImage.cs

@ -0,0 +1,139 @@ @@ -0,0 +1,139 @@
// 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 Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace ILSpy.Views.Controls
{
/// <summary>
/// An <see cref="Image"/> that swaps in a desaturated copy of its <see cref="Image.Source"/>
/// whenever the control is effectively disabled — i.e. the templated parent (a toolbar Button
/// / ToggleButton / SplitButton) is disabled, propagated through <c>IsEffectivelyEnabled</c>.
/// The grayscale copy is baked once per source by rendering the original <see cref="IImage"/>
/// into a <see cref="RenderTargetBitmap"/> and applying a BT.601 luma transform to the pixels.
/// Caching is per instance: the same <see cref="Image.Source"/> reference will be re-baked if
/// the user reuses the control with a different image.
/// </summary>
public sealed class GrayscaleAwareImage : Image
{
static GrayscaleAwareImage()
{
SourceProperty.Changed.AddClassHandler<GrayscaleAwareImage>(static (c, e) => c.OnSourceChanged(e));
IsEffectivelyEnabledProperty.Changed.AddClassHandler<GrayscaleAwareImage>(static (c, _) => c.UpdateEffectiveSource());
}
IImage? originalSource;
Bitmap? grayscaleCache;
bool suppressSourceCallback;
void OnSourceChanged(AvaloniaPropertyChangedEventArgs e)
{
// Re-entrancy guard: SetSourceInternal triggers the property-changed handler too.
if (suppressSourceCallback)
return;
originalSource = e.NewValue as IImage;
grayscaleCache = null;
UpdateEffectiveSource();
}
void UpdateEffectiveSource()
{
if (originalSource is null)
return;
if (IsEffectivelyEnabled)
{
SetSourceInternal(originalSource);
}
else
{
grayscaleCache ??= TryBakeGrayscale(originalSource);
// Fall back to the original on bake failure (e.g. zero-sized source) so the
// button still has a recognisable icon, just not desaturated.
SetSourceInternal((IImage?)grayscaleCache ?? originalSource);
}
}
void SetSourceInternal(IImage source)
{
suppressSourceCallback = true;
try
{ Source = source; }
finally { suppressSourceCallback = false; }
}
static Bitmap? TryBakeGrayscale(IImage source)
{
var size = source.Size;
if (size.Width <= 0 || size.Height <= 0)
return null;
var pixelSize = new PixelSize(
Math.Max(1, (int)Math.Ceiling(size.Width)),
Math.Max(1, (int)Math.Ceiling(size.Height)));
var dpi = new Vector(96, 96);
// Render into a RenderTargetBitmap at the source's intrinsic size so vector
// sources (SVG) rasterize at native resolution. The Image control then scales the
// result to its own bounds, same as the live IImage path.
using var rtb = new RenderTargetBitmap(pixelSize, dpi);
using (var ctx = rtb.CreateDrawingContext())
{
source.Draw(ctx, new Rect(size), new Rect(0, 0, pixelSize.Width, pixelSize.Height));
}
var target = new WriteableBitmap(pixelSize, dpi, PixelFormat.Bgra8888, AlphaFormat.Premul);
using (var fb = target.Lock())
{
int stride = fb.RowBytes;
int byteCount = stride * pixelSize.Height;
rtb.CopyPixels(new PixelRect(pixelSize), fb.Address, byteCount, stride);
ApplyLuma(fb.Address, pixelSize, stride);
}
return target;
}
static unsafe void ApplyLuma(IntPtr address, PixelSize pixelSize, int stride)
{
// BT.601 luma weights × 256 = (77, 150, 29) for R, G, B. In premultiplied BGRA the
// channels are (B, G, R, A) and luma is linear in α, so the same weighted sum works
// without an unpremul/repremul round-trip.
byte* basePtr = (byte*)address;
for (int y = 0; y < pixelSize.Height; y++)
{
byte* row = basePtr + y * stride;
for (int x = 0; x < pixelSize.Width; x++)
{
byte* px = row + x * 4;
int luma = (77 * px[2] + 150 * px[1] + 29 * px[0]) >> 8;
if (luma > 255)
luma = 255;
byte y8 = (byte)luma;
px[0] = y8;
px[1] = y8;
px[2] = y8;
}
}
}
}
}

24
ILSpy/Views/MainToolBar.axaml

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ILSpy.ViewModels"
xmlns:images="using:ILSpy.Images"
xmlns:controls="using:ILSpy.Views.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="26"
x:Class="ILSpy.MainToolBar"
x:DataType="vm:MainWindowViewModel">
@ -89,8 +90,9 @@ @@ -89,8 +90,9 @@
<!-- Disabled buttons: paint a grayed-out box behind the content (so the button reads
as a "filled-but-inactive" cell rather than just a faint icon on the toolbar
gradient), dim the icon, and force the arrow cursor so the user doesn't get
a click hint. -->
gradient) and force the arrow cursor so the user doesn't get a click hint. The
icon itself desaturates via GrayscaleAwareImage observing IsEffectivelyEnabled,
so no Opacity setter is needed here. -->
<Style Selector="StackPanel#ToolbarRoot > Button:disabled, StackPanel#ToolbarRoot > ToggleButton:disabled">
<Setter Property="Cursor" Value="Arrow" />
</Style>
@ -98,14 +100,6 @@ @@ -98,14 +100,6 @@
<Setter Property="Background" Value="#22000000" />
<Setter Property="BorderBrush" Value="#33000000" />
</Style>
<Style Selector="StackPanel#ToolbarRoot > Button:disabled Image, StackPanel#ToolbarRoot > ToggleButton:disabled Image">
<Setter Property="Opacity" Value="0.35" />
</Style>
<!-- SplitButton's primary half lives in a nested template-internal Button, so the
descendant selector above doesn't reach the Image. Match it explicitly. -->
<Style Selector="StackPanel#ToolbarRoot > SplitButton:disabled Image">
<Setter Property="Opacity" Value="0.35" />
</Style>
<!-- Default Fluent SplitButton paints both halves with an opaque dark fill on press
(looks black against our white toolbar). Replace with the toolbar accent palette
for both hover and pressed, on both primary and secondary halves. -->
@ -185,7 +179,7 @@ @@ -185,7 +179,7 @@
Theme="{StaticResource ToolbarSplitButton}"
ToolTip.Tip="Back (Alt+Left)"
Command="{Binding DockWorkspace.NavigateBackCommand}">
<Image Source="{x:Static images:Images.Back}" Width="16" Height="16" />
<controls:GrayscaleAwareImage Source="{x:Static images:Images.Back}" Width="16" Height="16" />
<SplitButton.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedLeft" />
</SplitButton.Flyout>
@ -194,7 +188,7 @@ @@ -194,7 +188,7 @@
Theme="{StaticResource ToolbarSplitButton}"
ToolTip.Tip="Forward (Alt+Right)"
Command="{Binding DockWorkspace.NavigateForwardCommand}">
<Image Source="{x:Static images:Images.Forward}" Width="16" Height="16" />
<controls:GrayscaleAwareImage Source="{x:Static images:Images.Forward}" Width="16" Height="16" />
<SplitButton.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedLeft" />
</SplitButton.Flyout>
@ -206,17 +200,17 @@ @@ -206,17 +200,17 @@
<ToggleButton Name="ShowPublicOnlyButton"
ToolTip.Tip="Show only public types and members"
IsChecked="{Binding LanguageSettings.ApiVisPublicOnly, Mode=TwoWay}">
<Image Source="{x:Static images:Images.ShowPublicOnly}" Width="16" Height="16" />
<controls:GrayscaleAwareImage Source="{x:Static images:Images.ShowPublicOnly}" Width="16" Height="16" />
</ToggleButton>
<ToggleButton Name="ShowPrivateInternalButton"
ToolTip.Tip="Show public, private and internal"
IsChecked="{Binding LanguageSettings.ApiVisPublicAndInternal, Mode=TwoWay}">
<Image Source="{x:Static images:Images.ShowPrivateInternal}" Width="16" Height="16" />
<controls:GrayscaleAwareImage Source="{x:Static images:Images.ShowPrivateInternal}" Width="16" Height="16" />
</ToggleButton>
<ToggleButton Name="ShowAllButton"
ToolTip.Tip="Show all types and members"
IsChecked="{Binding LanguageSettings.ApiVisAll, Mode=TwoWay}">
<Image Source="{x:Static images:Images.ShowAll}" Width="16" Height="16" />
<controls:GrayscaleAwareImage Source="{x:Static images:Images.ShowAll}" Width="16" Height="16" />
</ToggleButton>
<Separator />
<!-- MEF-driven toolbar buttons (Open etc.) get inserted by MainToolBar.axaml.cs.

3
ILSpy/Views/MainToolBar.axaml.cs

@ -26,6 +26,7 @@ using Avalonia.Media; @@ -26,6 +26,7 @@ using Avalonia.Media;
using ILSpy.AppEnv;
using ILSpy.Commands;
using ILSpy.ViewModels;
using ILSpy.Views.Controls;
namespace ILSpy;
@ -135,7 +136,7 @@ public partial class MainToolBar : UserControl @@ -135,7 +136,7 @@ public partial class MainToolBar : UserControl
var icon = ResolveIcon(entry.Metadata.ToolbarIcon);
if (icon != null)
{
button.Content = new Image {
button.Content = new GrayscaleAwareImage {
Width = 16,
Height = 16,
Source = icon,

Loading…
Cancel
Save