Browse Source

Use a thin auto-hiding overlay scrollbar for the omnibar breadcrumb

When a breadcrumb path was longer than the bar, the Simple theme's horizontal
scrollbar appeared in a reserved layout row inside the 28px bar: it shifted the
crumbs up when it showed and covered more than half the bar's height.

Three ways to handle the overflow were considered:

  1. Collapse the head into an "..." overflow dropdown (leading crumbs fold into
     a left button; the deepest crumbs stay visible) - the Windows Explorer / VS
     nav-bar / Files behaviour.
  2. Hide the scrollbar entirely and scroll the trail with the mouse wheel,
     keeping the current-node end visible.
  3. [CHOSEN] A thin, auto-hiding overlay scrollbar that draws over the content
     (reserves no height, so nothing shifts) and fades in only when the path
     overflows and the pointer is over the bar.

Avalonia's Simple ScrollViewer reserves an Auto grid row for the horizontal
scrollbar and its ScrollBar has no auto-hide, so the overlay is built here: the
breadcrumb's built-in bar is hidden (no reserved row, no shift) and a thin,
button-less ScrollBar is layered at the bottom, bound to the viewer's Offset,
ScrollBarMaximum, and Viewport via small Vector/Size converters. It fades in on
pointer-over and the mouse wheel scrolls the trail horizontally.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3794/head
Christoph Wille 3 weeks ago
parent
commit
f6d7638441
  1. 2
      ILSpy/App.axaml
  2. 73
      ILSpy/Controls/Omnibar/Omnibar.axaml
  3. 16
      ILSpy/Controls/Omnibar/Omnibar.axaml.cs
  4. 76
      ILSpy/Controls/Omnibar/OmnibarScrollConverters.cs

2
ILSpy/App.axaml

@ -95,6 +95,7 @@
<SolidColorBrush x:Key="ILSpy.OmnibarBackground" Color="#F3F3F3" /> <SolidColorBrush x:Key="ILSpy.OmnibarBackground" Color="#F3F3F3" />
<SolidColorBrush x:Key="ILSpy.OmnibarBorder" Color="#FFC8CDD3" /> <SolidColorBrush x:Key="ILSpy.OmnibarBorder" Color="#FFC8CDD3" />
<SolidColorBrush x:Key="ILSpy.OmnibarHover" Color="#330078D7" /> <SolidColorBrush x:Key="ILSpy.OmnibarHover" Color="#330078D7" />
<SolidColorBrush x:Key="ILSpy.OmnibarScrollThumb" Color="#A6555555" />
</ResourceDictionary> </ResourceDictionary>
<ResourceDictionary x:Key="Dark"> <ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="ILSpy.EditorBackground" Color="#1E1E1E" /> <SolidColorBrush x:Key="ILSpy.EditorBackground" Color="#1E1E1E" />
@ -152,6 +153,7 @@
<SolidColorBrush x:Key="ILSpy.OmnibarBackground" Color="#2D2D30" /> <SolidColorBrush x:Key="ILSpy.OmnibarBackground" Color="#2D2D30" />
<SolidColorBrush x:Key="ILSpy.OmnibarBorder" Color="#3F3F46" /> <SolidColorBrush x:Key="ILSpy.OmnibarBorder" Color="#3F3F46" />
<SolidColorBrush x:Key="ILSpy.OmnibarHover" Color="#330078D7" /> <SolidColorBrush x:Key="ILSpy.OmnibarHover" Color="#330078D7" />
<SolidColorBrush x:Key="ILSpy.OmnibarScrollThumb" Color="#A6C8C8C8" />
</ResourceDictionary> </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries> </ResourceDictionary.ThemeDictionaries>

73
ILSpy/Controls/Omnibar/Omnibar.axaml

@ -9,7 +9,57 @@
x:DataType="omnibar:OmnibarViewModel" x:DataType="omnibar:OmnibarViewModel"
Name="self"> Name="self">
<UserControl.Resources>
<!-- Thin, button-less overlay scrollbar for the breadcrumb. The Simple theme's scrollbar
reserves a layout row (shifting the crumbs) and never auto-hides, so the breadcrumb
hides its built-in bar and overlays this one instead: a bare track + rounded thumb that
fades in (see the fade style below) only while the path overflows and the pointer is
over the bar. -->
<ControlTheme x:Key="OmnibarOverflowScrollBar" TargetType="ScrollBar">
<Setter Property="Height" Value="6" />
<Setter Property="MinHeight" Value="6" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.15" />
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<Border Background="Transparent" Padding="2,0,2,1">
<Track Name="PART_Track"
Orientation="Horizontal"
Minimum="{TemplateBinding Minimum}"
Maximum="{TemplateBinding Maximum}"
Value="{TemplateBinding Value, Mode=TwoWay}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Height="4" MinWidth="24" VerticalAlignment="Center"
Background="{DynamicResource ILSpy.OmnibarScrollThumb}">
<Thumb.Template>
<ControlTemplate>
<Border Background="{TemplateBinding Background}" CornerRadius="2" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
</UserControl.Resources>
<UserControl.Styles> <UserControl.Styles>
<!-- Reveal the overflow scrollbar (and make it grabbable) only while the pointer is over the
breadcrumb; it fades back out on leave via the Opacity transition above. -->
<Style Selector="Grid#BreadcrumbFace:pointerover ScrollBar#BreadcrumbScrollbar">
<Setter Property="Opacity" Value="1" />
<Setter Property="IsHitTestVisible" Value="True" />
</Style>
<!-- Flat, transparent-until-hover treatment mirroring the toolbar buttons, so the bar <!-- Flat, transparent-until-hover treatment mirroring the toolbar buttons, so the bar
reads as chrome rather than a row of raised buttons. --> reads as chrome rather than a row of raised buttons. -->
<Style Selector="Button.crumb"> <Style Selector="Button.crumb">
@ -54,11 +104,16 @@
<Panel> <Panel>
<!-- Breadcrumb face: the trail plus a search affordance on the right. --> <!-- Breadcrumb face: the trail plus a search affordance on the right. -->
<Grid ColumnDefinitions="*,Auto" IsVisible="{Binding IsBreadcrumbMode}"> <Grid Name="BreadcrumbFace" ColumnDefinitions="*,Auto" IsVisible="{Binding IsBreadcrumbMode}">
<ScrollViewer Grid.Column="0" <!-- The trail scrolls horizontally with its built-in bar hidden (the Simple theme's
HorizontalScrollBarVisibility="Auto" bar would reserve a row and shift the crumbs); a thin overlay bar is layered on
top instead, and the mouse wheel scrolls the trail (see code-behind). -->
<Panel Grid.Column="0">
<ScrollViewer Name="BreadcrumbScroll"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Disabled"
VerticalAlignment="Center"> VerticalAlignment="Center"
PointerWheelChanged="OnBreadcrumbWheel">
<ItemsControl ItemsSource="{Binding Segments}"> <ItemsControl ItemsSource="{Binding Segments}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
@ -92,6 +147,16 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
<ScrollBar Name="BreadcrumbScrollbar"
Orientation="Horizontal"
VerticalAlignment="Bottom"
Theme="{StaticResource OmnibarOverflowScrollBar}"
Minimum="0"
Maximum="{Binding #BreadcrumbScroll.ScrollBarMaximum, Converter={x:Static omnibar:OmnibarScrollConverters.VectorX}}"
ViewportSize="{Binding #BreadcrumbScroll.Viewport, Converter={x:Static omnibar:OmnibarScrollConverters.SizeWidth}}"
Value="{Binding #BreadcrumbScroll.Offset, Mode=TwoWay, Converter={x:Static omnibar:OmnibarScrollConverters.VectorX}}"
IsVisible="{Binding #BreadcrumbScroll.ScrollBarMaximum, Converter={x:Static omnibar:OmnibarScrollConverters.VectorXPositive}}" />
</Panel>
<Button Grid.Column="1" Classes="searchToggle" Click="OnEnterSearchClick" <Button Grid.Column="1" Classes="searchToggle" Click="OnEnterSearchClick"
VerticalAlignment="Center" VerticalAlignment="Center"
ToolTip.Tip="Search (Ctrl+L)"> ToolTip.Tip="Search (Ctrl+L)">

16
ILSpy/Controls/Omnibar/Omnibar.axaml.cs

@ -98,6 +98,22 @@ namespace ICSharpCode.ILSpy.Controls.Omnibar
void OnEnterSearchClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) void OnEnterSearchClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
=> FocusSearch(); => FocusSearch();
// Vertical scrolling is disabled on the breadcrumb, so the wheel is otherwise idle here;
// map it to horizontal scroll so the trail can be panned without hunting for the thin
// overlay thumb.
void OnBreadcrumbWheel(object? sender, PointerWheelEventArgs e)
{
var max = BreadcrumbScroll.ScrollBarMaximum.X;
if (max <= 0)
return;
// A wheel notch is one unit; crumbs are far wider, so scale up for a usable step.
const double step = 48;
var delta = (e.Delta.Y != 0 ? e.Delta.Y : e.Delta.X) * step;
var x = Math.Clamp(BreadcrumbScroll.Offset.X - delta, 0, max);
BreadcrumbScroll.Offset = BreadcrumbScroll.Offset.WithX(x);
e.Handled = true;
}
void OnClearSearchClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) void OnClearSearchClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{ {
viewModel.SearchText = string.Empty; viewModel.SearchText = string.Empty;

76
ILSpy/Controls/Omnibar/OmnibarScrollConverters.cs

@ -0,0 +1,76 @@
// 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 System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
namespace ICSharpCode.ILSpy.Controls.Omnibar
{
/// <summary>
/// Adapters that let a plain horizontal <c>ScrollBar</c> drive and reflect a
/// <c>ScrollViewer</c>'s scroll state, which Avalonia exposes only as <c>Vector</c>/<c>Size</c>
/// (<c>Offset</c>, <c>ScrollBarMaximum</c>, <c>Viewport</c>) rather than the scalar
/// value/maximum/viewport a <c>ScrollBar</c> binds to. Used by the omnibar's overlay scrollbar
/// so the breadcrumb can scroll horizontally without the Simple theme's reserved scrollbar row.
/// </summary>
public static class OmnibarScrollConverters
{
/// <summary>
/// Two-way bridge between a <c>ScrollBar.Value</c> (double) and a <c>ScrollViewer.Offset</c>
/// (<see cref="Vector"/>): forward yields <c>Offset.X</c>; back rebuilds <c>(value, 0)</c>.
/// The omnibar disables vertical scrolling, so the Y component is always 0.
/// </summary>
public static readonly IValueConverter VectorX = new VectorXConverter();
/// <summary>Width of a <see cref="Size"/> (e.g. a <c>ScrollViewer.Viewport</c>).</summary>
public static readonly IValueConverter SizeWidth = new SizeWidthConverter();
/// <summary>True when a <see cref="Vector"/>'s X is greater than 0 (i.e. content overflows).</summary>
public static readonly IValueConverter VectorXPositive = new VectorXPositiveConverter();
sealed class VectorXConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is Vector v ? v.X : 0d;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> new Vector(value is double d ? d : 0d, 0d);
}
sealed class SizeWidthConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is Size s ? s.Width : 0d;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
sealed class VectorXPositiveConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is Vector v && v.X > 0;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}
}
Loading…
Cancel
Save