Browse Source

Freeze the preview tab on drag and add Ctrl+W tab close

Pinning the One immovably at index 0 made a drag on it a dead no-op, which reads as a broken tab. Dragging now freezes it instead: pulling the tab out is itself the gesture to keep it, mirroring the snowflake and right-click Freeze, and the next tree selection forges a fresh preview at index 0. Index-0 protection and the self-healing re-assert now apply only while the One is still the preview.

Ctrl+W closes the active document; closing the One drops the cached decompiler viewmodel so the next selection rebuilds it. Long type signatures no longer stretch a tab without bound -- the on-tab title is capped and ellipsised with the full title in a tooltip, and the close button names its shortcut.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
05c7f02aca
  1. 90
      ILSpy.Tests/Docking/PreviewTabPromotionTests.cs
  2. 10
      ILSpy/App.axaml
  3. 19
      ILSpy/Docking/DockWorkspace.cs
  4. 41
      ILSpy/Docking/ILSpyDockFactory.cs
  5. 16
      ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs
  6. 2
      ILSpy/Views/MainWindow.axaml

90
ILSpy.Tests/Docking/PreviewTabPromotionTests.cs

@ -538,6 +538,82 @@ public class PreviewTabPromotionTests
"after the Theme-copy refactor the custom class-based styling is unused; the Theme drives all visuals"); "after the Theme-copy refactor the custom class-based styling is unused; the Theme drives all visuals");
} }
[AvaloniaTest]
public async Task Close_Button_Has_A_Ctrl_W_Tooltip()
{
// Dock's tab template gives the close button no tooltip. PreviewTabFreezeButtonBehavior
// names its Ctrl+W shortcut so the gesture is discoverable on hover.
var (window, vm) = await TestHarness.BootAsync(3);
// Open a carve-out so the close button is realised (single-tab scenario hides it).
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
var closeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.FirstOrDefault(b => (b.Tag as string) != "PreviewTabFreezeButton");
closeButton.Should().NotBeNull("baseline: close button exists as a sibling of the freeze");
global::Avalonia.Controls.ToolTip.GetTip(closeButton!).Should().Be("Close (Ctrl+W)",
"the close button must name its Ctrl+W shortcut on hover");
}
[AvaloniaTest]
public async Task Ctrl_W_Closes_The_Active_Document_And_Reforges_The_One()
{
// Ctrl+W (CloseActiveDocumentCommand) closes whatever document is active. Closing a frozen
// carve-out leaves the One untouched; closing the One drops the cached decompiler VM so the
// next tree selection forges a fresh preview at index 0.
var (_, vm) = await TestHarness.BootAsync(3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var docs = vm.DockWorkspace.Documents!;
var typeA = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeA);
vm.DockWorkspace.SettleSelection();
var theOne = factory.MainTab!;
theOne.IsPreview.Should().BeTrue("precondition: the One starts as the preview");
// A frozen carve-out keeps the dock non-empty so the One isn't the last tab when closed.
var typeC = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Private.Uri", "System", "System.Uri");
vm.DockWorkspace.OpenNodeInNewTab(typeC);
var frozen = docs.VisibleDockables!.OfType<ContentTabPage>().Last(t => t.SourceNode == typeC);
// Closing the One while it is active removes it from the dock; the frozen tab survives.
factory.SetActiveDockable(theOne);
vm.DockWorkspace.CloseActiveDocument();
docs.VisibleDockables!.Should().NotContain(theOne, "Ctrl+W closes the One when it is active");
docs.VisibleDockables!.Should().Contain(frozen, "the frozen carve-out must survive closing the One");
// The next selection forges a brand-new One (the closed one is not resurrected).
var typeD = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Private.Uri", "System", "System.UriBuilder");
vm.AssemblyTreeModel.SelectNode(typeD);
vm.DockWorkspace.SettleSelection();
var freshOne = factory.MainTab;
freshOne.Should().NotBeNull("a fresh One is forged on the next selection");
freshOne.Should().NotBeSameAs(theOne, "the forged One is a new instance, not the closed tab");
freshOne!.IsPreview.Should().BeTrue("the forged One is a preview tab");
// Closing a frozen carve-out leaves the (fresh) One untouched.
factory.SetActiveDockable(frozen);
vm.DockWorkspace.CloseActiveDocument();
docs.VisibleDockables!.Should().NotContain(frozen, "Ctrl+W closes the active frozen tab");
factory.MainTab.Should().BeSameAs(freshOne, "closing a frozen tab must not disturb the One");
}
[AvaloniaTest] [AvaloniaTest]
public async Task Tree_Selection_While_Frozen_Tab_Active_Reuses_The_One() public async Task Tree_Selection_While_Frozen_Tab_Active_Reuses_The_One()
{ {
@ -622,10 +698,11 @@ public class PreviewTabPromotionTests
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Preview_Cannot_Be_Reordered_Out_Of_Slot_0() public async Task Dragging_The_Preview_Freezes_It()
{ {
// The in-strip reorder drag commits through the same-dock MoveDockable; it's overridden to // The in-strip reorder drag commits through the same-dock MoveDockable; it's overridden so
// refuse moving the One out of slot 0. Drive that commit directly (what ItemDragHelper does). // that dragging the One freezes it (instead of refusing the move). Drive that commit
// directly (what ItemDragHelper does).
var (_, vm) = await TestHarness.BootAsync(3); var (_, vm) = await TestHarness.BootAsync(3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var docs = vm.DockWorkspace.Documents!; var docs = vm.DockWorkspace.Documents!;
@ -635,6 +712,7 @@ public class PreviewTabPromotionTests
vm.AssemblyTreeModel.SelectNode(typeA); vm.AssemblyTreeModel.SelectNode(typeA);
vm.DockWorkspace.SettleSelection(); vm.DockWorkspace.SettleSelection();
var theOne = factory.MainTab!; var theOne = factory.MainTab!;
theOne.IsPreview.Should().BeTrue("precondition: the One starts as the preview");
var typeC = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeC = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Private.Uri", "System", "System.Uri"); "System.Private.Uri", "System", "System.Uri");
vm.DockWorkspace.OpenNodeInNewTab(typeC); vm.DockWorkspace.OpenNodeInNewTab(typeC);
@ -643,8 +721,10 @@ public class PreviewTabPromotionTests
factory.MoveDockable(docs, theOne, frozen); factory.MoveDockable(docs, theOne, frozen);
docs.VisibleDockables!.IndexOf(theOne).Should().Be(0, theOne.IsPreview.Should().BeFalse(
"the One must stay at index 0 -- it is immovable"); "dragging the One must freeze it -- it becomes an ordinary, movable tab");
factory.MainTab.Should().BeSameAs(theOne,
"freeze-on-drag does not rotate MainTab; the next tree selection forges a fresh One");
} }
[AvaloniaTest] [AvaloniaTest]

10
ILSpy/App.axaml

@ -76,7 +76,7 @@
<SolidColorBrush x:Key="ILSpy.PreviewTabHoverBackground" Color="#BB8FCE" /> <SolidColorBrush x:Key="ILSpy.PreviewTabHoverBackground" Color="#BB8FCE" />
<!-- Focused tool pane: a subtle neutral header tint + readable title, instead of <!-- Focused tool pane: a subtle neutral header tint + readable title, instead of
the theme's saturated system accent. --> the theme's saturated system accent. -->
<SolidColorBrush x:Key="ILSpy.ToolChromeActiveHeaderBackground" Color="#DCDCDC" /> <SolidColorBrush x:Key="ILSpy.ToolChromeActiveHeaderBackground" Color="#C4C4C4" />
<SolidColorBrush x:Key="ILSpy.ToolChromeActiveTitleForeground" Color="#1E1E1E" /> <SolidColorBrush x:Key="ILSpy.ToolChromeActiveTitleForeground" Color="#1E1E1E" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerBackground" Color="#FFFFE1" /> <SolidColorBrush x:Key="ILSpy.FoldingMarkerBackground" Color="#FFFFE1" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerForeground" Color="#808080" /> <SolidColorBrush x:Key="ILSpy.FoldingMarkerForeground" Color="#808080" />
@ -279,6 +279,14 @@
<Setter Property="Background" Value="{DynamicResource ILSpy.DockTabItemBackground}" /> <Setter Property="Background" Value="{DynamicResource ILSpy.DockTabItemBackground}" />
<Setter Property="Foreground" Value="{DynamicResource ILSpy.DockTabItemForeground}" /> <Setter Property="Foreground" Value="{DynamicResource ILSpy.DockTabItemForeground}" />
</Style> </Style>
<!-- Full title in a tooltip on every document tab. The on-tab ellipsis is applied to the
realised title TextBlock by PreviewTabFreezeButtonBehavior, NOT with a descendant
selector here: a `DocumentTabStripItem TextBlock` selector also matched the tooltip's
own TextBlock and trimmed it to the same ellipsised text. Tool panes have short fixed
names, so this is document-only. -->
<Style Selector="dockControls|DocumentTabStripItem">
<Setter Property="ToolTip.Tip" Value="{Binding Title, Mode=OneWay, x:CompileBindings=False}" />
</Style>
<!-- A merely-selected (but not focused) tab gets the seam-matching fill. The active <!-- A merely-selected (but not focused) tab gets the seam-matching fill. The active
tab keeps Dock's accent highlight (see below). Splitting the two states is tab keeps Dock's accent highlight (see below). Splitting the two states is
required: an application-level Style outranks Dock's DocumentTabStripItem required: an application-level Style outranks Dock's DocumentTabStripItem

19
ILSpy/Docking/DockWorkspace.cs

@ -91,6 +91,11 @@ namespace ILSpy.Docking
/// </summary> /// </summary>
public IRelayCommand ShowSearchCommand { get; } public IRelayCommand ShowSearchCommand { get; }
/// <summary>Closes the active document tab. Wired to Ctrl+W on the main window. The
/// documents dock only ever holds <see cref="ContentTabPage"/>s, so this never touches a
/// tool pane.</summary>
public IRelayCommand CloseActiveDocumentCommand { get; }
// Read-only history snapshots for the Back/Forward split-button dropdowns; oldest-first. // Read-only history snapshots for the Back/Forward split-button dropdowns; oldest-first.
// The UI reverses these for newest-first display. // The UI reverses these for newest-first display.
public IReadOnlyList<NavigationEntry> BackHistory => history.BackEntries; public IReadOnlyList<NavigationEntry> BackHistory => history.BackEntries;
@ -165,6 +170,7 @@ namespace ILSpy.Docking
NavigateToHistoryCommand = new RelayCommand<NavigationEntry>(NavigateToHistory, NavigateToHistoryCommand = new RelayCommand<NavigationEntry>(NavigateToHistory,
entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry))); entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry)));
ShowSearchCommand = new RelayCommand(ExecuteShowSearch); ShowSearchCommand = new RelayCommand(ExecuteShowSearch);
CloseActiveDocumentCommand = new RelayCommand(CloseActiveDocument);
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor + CreateLayout + InitLayout")) using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor + CreateLayout + InitLayout"))
{ {
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor")) using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor"))
@ -1204,6 +1210,19 @@ namespace ILSpy.Docking
factory.CloseDockable(doc); factory.CloseDockable(doc);
} }
/// <summary>Closes the active document tab (Ctrl+W). The documents dock only holds
/// <see cref="ContentTabPage"/>s, so tool panes are never affected. Closing the One drops
/// the cached decompiler viewmodel so the next selection forges a fresh preview tab.</summary>
public void CloseActiveDocument()
{
if (factory.Documents?.ActiveDockable is not ContentTabPage active)
return;
bool wasTheOne = ReferenceEquals(active, factory.MainTab);
factory.CloseDockable(active);
if (wasTheOne)
decompilerContent = null;
}
public void ResetLayout() public void ResetLayout()
{ {
// Rebuild from the factory's default layout. The active tree node, if any, will be // Rebuild from the factory's default layout. The active tree node, if any, will be

41
ILSpy/Docking/ILSpyDockFactory.cs

@ -83,20 +83,25 @@ namespace ILSpy.Docking
return fresh; return fresh;
} }
// The One is immovable at documents-dock index 0. A within-strip reorder drag commits // The One sits at documents-dock index 0. A within-strip reorder drag commits through this
// through this same-dock MoveDockable (Dock's DocumentTabStripItem/ItemDragHelper does NOT // same-dock MoveDockable (Dock's DocumentTabStripItem/ItemDragHelper does NOT consult
// consult IDockable.CanDrag), so overriding it is the choke point that pins the One to // IDockable.CanDrag), so this override is the choke point: dragging the One FREEZES it (it
// slot 0 and keeps frozen tabs to its right. // then moves like any other tab), and while it is still the preview no frozen tab may slip
// in front of it.
public override void MoveDockable(IDock dock, IDockable sourceDockable, IDockable targetDockable) public override void MoveDockable(IDock dock, IDockable sourceDockable, IDockable targetDockable)
{ {
if (ReferenceEquals(dock, Documents)) if (ReferenceEquals(dock, Documents))
{ {
// Never drag the One out of slot 0. // Dragging the One freezes it (replacing the old "refuse the move" behaviour): it
if (ReferenceEquals(sourceDockable, MainTab)) // stops being the preview slot and lands wherever it's dropped; the next tree
return; // selection forges a fresh One at index 0.
// A frozen tab must not land at or before the One: retarget to the tab at index 1 if (ReferenceEquals(sourceDockable, MainTab) && MainTab is { IsPreview: true })
// so the source lands at position 1, not 0. {
if (ReferenceEquals(targetDockable, MainTab)) FreezeCurrentMainTab();
}
// While the One is still the preview, a frozen tab must not land at or before it:
// retarget to the tab at index 1 so the source lands at position 1, not 0.
else if (ReferenceEquals(targetDockable, MainTab) && MainTab is { IsPreview: true })
{ {
var kids = dock.VisibleDockables; var kids = dock.VisibleDockables;
if (kids is { Count: > 1 }) if (kids is { Count: > 1 })
@ -108,21 +113,23 @@ namespace ILSpy.Docking
base.MoveDockable(dock, sourceDockable, targetDockable); base.MoveDockable(dock, sourceDockable, targetDockable);
} }
// Cross-dock move: never pull the One into another document dock (only reachable if the // Cross-dock move (only reachable if the document area is ever split): dragging the One out
// document area is ever split). // freezes it too, consistent with the in-strip case.
public override void MoveDockable(IDock sourceDock, IDock targetDock, IDockable sourceDockable, IDockable? targetDockable) public override void MoveDockable(IDock sourceDock, IDock targetDock, IDockable sourceDockable, IDockable? targetDockable)
{ {
if (ReferenceEquals(sourceDockable, MainTab)) if (ReferenceEquals(sourceDockable, MainTab) && MainTab is { IsPreview: true })
return; FreezeCurrentMainTab();
base.MoveDockable(sourceDock, targetDock, sourceDockable, targetDockable); base.MoveDockable(sourceDock, targetDock, sourceDockable, targetDockable);
} }
// Self-healing insurance: whatever drag path ran, re-assert the One at index 0. Idempotent // Self-healing insurance: re-assert the One at index 0 after any drag -- but ONLY while it is
// and cheap -- guards against a future Dock version routing a reorder past MoveDockable. // still the preview. Once dragged (which freezes it) it's an ordinary tab and must stay where
// the user dropped it. Idempotent; guards against a future Dock version routing a reorder
// past MoveDockable.
public override void OnDockableMoved(IDockable? dockable) public override void OnDockableMoved(IDockable? dockable)
{ {
base.OnDockableMoved(dockable); base.OnDockableMoved(dockable);
if (MainTab is not { } one || Documents?.VisibleDockables is not { } kids) if (MainTab is not { IsPreview: true } one || Documents?.VisibleDockables is not { } kids)
return; return;
int idx = kids.IndexOf(one); int idx = kids.IndexOf(one);
if (idx > 0) if (idx > 0)

16
ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs

@ -102,6 +102,17 @@ namespace ILSpy.Themes
// paint on top of it. // paint on top of it.
titleStack.ClipToBounds = true; titleStack.ClipToBounds = true;
// Cap the title and ellipsise overruns so a long type signature can't make a giant tab;
// the full title is surfaced in the tab tooltip (App.axaml). Applied to the specific
// realised title TextBlock here rather than via a `DocumentTabStripItem TextBlock` style
// selector, which also matched -- and trimmed -- the tooltip's own TextBlock.
var titleText = titleStack.GetVisualDescendants().OfType<TextBlock>().FirstOrDefault();
if (titleText is not null)
{
titleText.MaxWidth = 240;
titleText.TextTrimming = TextTrimming.CharacterEllipsis;
}
// Snowflake glyph for "freeze" (Font Awesome Free v7 "snowflake", CC-BY-4.0). A filled // Snowflake glyph for "freeze" (Font Awesome Free v7 "snowflake", CC-BY-4.0). A filled
// vector Path (not a font glyph): a Segoe Fluent fallback was Windows-only and rendered // vector Path (not a font glyph): a Segoe Fluent fallback was Windows-only and rendered
// as tofu on Linux / macOS. Fill is bound to the tab Foreground so the glyph inherits // as tofu on Linux / macOS. Fill is bound to the tab Foreground so the glyph inherits
@ -147,6 +158,11 @@ namespace ILSpy.Themes
if (closeButton?.Theme is { } closeTheme) if (closeButton?.Theme is { } closeTheme)
freezeButton.Theme = closeTheme; freezeButton.Theme = closeTheme;
ToolTip.SetTip(freezeButton, "Freeze tab"); ToolTip.SetTip(freezeButton, "Freeze tab");
// The close button carries no tooltip from Dock's template; name its Ctrl+W shortcut.
// TryInject runs for every document tab (the freeze button is injected on all of them,
// just hidden when not the One), so this covers preview and frozen tabs alike.
if (closeButton is not null)
ToolTip.SetTip(closeButton, "Close (Ctrl+W)");
// IsVisible follows IsPreview — the button hides once the tab is frozen. // IsVisible follows IsPreview — the button hides once the tab is frozen.
freezeButton.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) { freezeButton.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) {

2
ILSpy/Views/MainWindow.axaml

@ -28,6 +28,8 @@
"search box" gesture VS uses. Both bring the bottom search pane to focus. --> "search box" gesture VS uses. Both bring the bottom search pane to focus. -->
<KeyBinding Gesture="Ctrl+Shift+F" Command="{Binding DockWorkspace.ShowSearchCommand}" /> <KeyBinding Gesture="Ctrl+Shift+F" Command="{Binding DockWorkspace.ShowSearchCommand}" />
<KeyBinding Gesture="Ctrl+E" Command="{Binding DockWorkspace.ShowSearchCommand}" /> <KeyBinding Gesture="Ctrl+E" Command="{Binding DockWorkspace.ShowSearchCommand}" />
<!-- Close the active document tab (documents only; tool panes are unaffected). -->
<KeyBinding Gesture="Ctrl+W" Command="{Binding DockWorkspace.CloseActiveDocumentCommand}" />
</Window.KeyBindings> </Window.KeyBindings>
<DockPanel> <DockPanel>

Loading…
Cancel
Save