Browse Source

Rename the preview-tab 'pin' concept to 'freeze'

Pure rename, no behavior change. 'Pin' conventionally means stuck-at-front-of-
strip + excluded-from-close-all, which is a different (future) feature; the
gesture that makes the preview tab stop following the tree selection is now
called Freeze throughout: FreezeCurrentTab / FreezeCurrentMainTab, the
PreviewTabFreezeButtonBehavior, the 'Freeze tab' menu entry/tooltip, and the
Window_FreezeCurrentTab command + resource. The pushpin glyph and accent colour
are unchanged here -- they move in the following commit.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
1659b8e956
  1. 228
      ILSpy.Tests/Docking/PreviewTabPromotionTests.cs
  2. 4
      ILSpy/App.axaml
  3. 12
      ILSpy/Commands/WindowCommands.cs
  4. 26
      ILSpy/Docking/DockWorkspace.cs
  5. 16
      ILSpy/Docking/ILSpyDockFactory.cs
  6. 4
      ILSpy/Properties/Resources.Designer.cs
  7. 4
      ILSpy/Properties/Resources.resx
  8. 16
      ILSpy/Themes/PreviewTabContextMenuBehavior.cs
  9. 74
      ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs
  10. 4
      ILSpy/ViewModels/ContentTabPage.cs

228
ILSpy.Tests/Docking/PreviewTabPromotionTests.cs

@ -44,21 +44,21 @@ public class PreviewTabPromotionTests
public void MainTab_Starts_In_Preview_State() public void MainTab_Starts_In_Preview_State()
{ {
// The persistent MainTab is the preview slot; tree-node clicks should replace its // The persistent MainTab is the preview slot; tree-node clicks should replace its
// Content in place until the user explicitly pins it. // Content in place until the user explicitly freezes it.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
TestCapture.Step("booted"); TestCapture.Step("booted");
var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
mainTab.IsPreview.Should().BeTrue( mainTab.IsPreview.Should().BeTrue(
"the freshly-created MainTab is the preview slot until the user pins it"); "the freshly-created MainTab is the preview slot until the user freezes it");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Carve_Out_Tabs_Are_Born_Pinned() public async Task Carve_Out_Tabs_Are_Born_Frozen()
{ {
// Open-in-new-tab tabs are explicit user intent — they should never be replaced // Open-in-new-tab tabs are explicit user intent — they should never be replaced
// by subsequent tree-node selections, so they're born pinned (IsPreview=false). // by subsequent tree-node selections, so they're born frozen (IsPreview=false).
var (_, vm) = await TestHarness.BootAsync(3); var (_, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
@ -74,19 +74,19 @@ public class PreviewTabPromotionTests
} }
[AvaloniaTest] [AvaloniaTest]
public async Task PinCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab() public async Task FreezeCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab()
{ {
// New semantics: Pin only flips IsPreview=false on the current MainTab. No new // New semantics: Freeze only flips IsPreview=false on the current MainTab. No new
// preview tab spawns at pin time. A fresh preview tab opens lazily later, when a // preview tab spawns at freeze time. A fresh preview tab opens lazily later, when a
// tree-selection change finds the active tab frozen — see // tree-selection change finds the active tab frozen — see
// Selecting_A_Different_Node_After_Pin_Opens_A_New_Preview_Tab below. // Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Tab below.
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 previousMainTab = factory.MainTab!; var previousMainTab = factory.MainTab!;
previousMainTab.IsPreview.Should().BeTrue("baseline"); previousMainTab.IsPreview.Should().BeTrue("baseline");
// Load content into MainTab so the pin has something meaningful to keep. // Load content into MainTab so the freeze has something meaningful to keep.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode); vm.AssemblyTreeModel.SelectNode(typeNode);
@ -97,31 +97,31 @@ public class PreviewTabPromotionTests
var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count; var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count;
// Pin. // Freeze.
vm.DockWorkspace.PinCurrentTab(); vm.DockWorkspace.FreezeCurrentTab();
TestCapture.Step("tab-pinned"); TestCapture.Step("tab-frozen");
// Tab is now pinned, content preserved. // Tab is now frozen, content preserved.
previousMainTab.IsPreview.Should().BeFalse( previousMainTab.IsPreview.Should().BeFalse(
"after pin, the previously-preview MainTab becomes a regular pinned tab"); "after freeze, the previously-preview MainTab becomes a regular frozen tab");
ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue( ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue(
"pin must not throw away the tab's content"); "freeze must not throw away the tab's content");
// factory.MainTab still points at the same (now-pinned) tab — no rotation. // factory.MainTab still points at the same (now-frozen) tab — no rotation.
factory.MainTab.Should().BeSameAs(previousMainTab, factory.MainTab.Should().BeSameAs(previousMainTab,
"pin alone must not rotate factory.MainTab — the pinned tab keeps the slot until a selection change spawns a new preview"); "freeze alone must not rotate factory.MainTab — the frozen tab keeps the slot until a selection change spawns a new preview");
// And critically: no new tab spawned. // And critically: no new tab spawned.
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore, vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore,
"pinning alone must not open a new tab; new tabs open lazily on the next selection change"); "freezing alone must not open a new tab; new tabs open lazily on the next selection change");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Selecting_A_Different_Node_After_Pin_Opens_A_New_Preview_Tab() public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Tab()
{ {
// Pin holds the current tab's content. Selecting a different tree node while // Freeze holds the current tab's content. Selecting a different tree node while
// the (now-pinned) tab is active must spawn a fresh preview tab beside it and // the (now-frozen) tab is active must spawn a fresh preview tab beside it and
// route the new content there — never overwrite the pinned tab. // route the new content there — never overwrite the frozen tab.
var (_, vm) = await TestHarness.BootAsync(3); var (_, vm) = await TestHarness.BootAsync(3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
@ -132,15 +132,15 @@ public class PreviewTabPromotionTests
vm.AssemblyTreeModel.SelectNode(typeA); vm.AssemblyTreeModel.SelectNode(typeA);
vm.DockWorkspace.SettleSelection(); vm.DockWorkspace.SettleSelection();
TestCapture.Step("type-a-selected"); TestCapture.Step("type-a-selected");
var pinnedTab = factory.MainTab!; var frozenTab = factory.MainTab!;
var pinnedContent = pinnedTab.Content; var frozenContent = frozenTab.Content;
// Phase 2: pin. factory.MainTab still points at the same (now-pinned) tab — // Phase 2: freeze. factory.MainTab still points at the same (now-frozen) tab —
// no spawn yet (covered by PinCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab). // no spawn yet (covered by FreezeCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab).
var tabCountBeforeSelection = vm.DockWorkspace.Documents!.VisibleDockables!.Count; var tabCountBeforeSelection = vm.DockWorkspace.Documents!.VisibleDockables!.Count;
vm.DockWorkspace.PinCurrentTab(); vm.DockWorkspace.FreezeCurrentTab();
TestCapture.Step("tab-pinned"); TestCapture.Step("tab-frozen");
factory.MainTab.Should().BeSameAs(pinnedTab, "pin alone keeps the slot"); factory.MainTab.Should().BeSameAs(frozenTab, "freeze alone keeps the slot");
// Phase 3: select a different type — NOW a new preview tab should spawn AND // Phase 3: select a different type — NOW a new preview tab should spawn AND
// receive the new content. // receive the new content.
@ -150,21 +150,21 @@ public class PreviewTabPromotionTests
vm.DockWorkspace.SettleSelection(); vm.DockWorkspace.SettleSelection();
TestCapture.Step("type-b-opens-new-preview"); TestCapture.Step("type-b-opens-new-preview");
// New tab spawned beside the pinned one. // New tab spawned beside the frozen one.
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBeforeSelection + 1, vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBeforeSelection + 1,
"selecting a different node while the active tab is pinned must spawn a new preview tab"); "selecting a different node while the active tab is frozen must spawn a new preview tab");
var newMainTab = factory.MainTab!; var newMainTab = factory.MainTab!;
newMainTab.Should().NotBeSameAs(pinnedTab, newMainTab.Should().NotBeSameAs(frozenTab,
"factory.MainTab must rotate to the freshly-spawned preview tab once a selection change forces it"); "factory.MainTab must rotate to the freshly-spawned preview tab once a selection change forces it");
newMainTab.IsPreview.Should().BeTrue( newMainTab.IsPreview.Should().BeTrue(
"the freshly-spawned tab is itself a preview tab (the user can pin it next)"); "the freshly-spawned tab is itself a preview tab (the user can freeze it next)");
ReferenceEquals(newMainTab.SourceNode, typeB).Should().BeTrue( ReferenceEquals(newMainTab.SourceNode, typeB).Should().BeTrue(
"new tree selection must land in the freshly-spawned preview tab"); "new tree selection must land in the freshly-spawned preview tab");
ReferenceEquals(pinnedTab.SourceNode, typeA).Should().BeTrue( ReferenceEquals(frozenTab.SourceNode, typeA).Should().BeTrue(
"the pinned tab must keep type A — not be overwritten by the new selection"); "the frozen tab must keep type A — not be overwritten by the new selection");
pinnedTab.Content.Should().BeSameAs(pinnedContent, frozenTab.Content.Should().BeSameAs(frozenContent,
"pinned tab's Content reference must survive subsequent tree selections"); "frozen tab's Content reference must survive subsequent tree selections");
} }
[AvaloniaTest] [AvaloniaTest]
@ -172,7 +172,7 @@ public class PreviewTabPromotionTests
{ {
// Visual contract: the DocumentTabStripItem for the preview MainTab binds its // Visual contract: the DocumentTabStripItem for the preview MainTab binds its
// FontStyle to ContentTabPage.IsPreview via BoolToFontStyleConverter.Italic. // FontStyle to ContentTabPage.IsPreview via BoolToFontStyleConverter.Italic.
// Pinned tabs and tool-pane tabs fall through to FontStyle.Normal. // Frozen tabs and tool-pane tabs fall through to FontStyle.Normal.
var (window, vm) = await TestHarness.BootAsync(); var (window, vm) = await TestHarness.BootAsync();
// Wait for the tab strip to realise its items. // Wait for the tab strip to realise its items.
@ -213,13 +213,13 @@ public class PreviewTabPromotionTests
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Pin_Button_Fits_Inside_The_Tab_When_Close_Button_Is_Visible() public async Task Freeze_Button_Fits_Inside_The_Tab_When_Close_Button_Is_Visible()
{ {
// The single-tab scenario hides the close button (UpdateLastDocumentCanClose sets // The single-tab scenario hides the close button (UpdateLastDocumentCanClose sets
// CanClose=false), so the pin has lots of room. The bug shows up only when the // CanClose=false), so the freeze has lots of room. The bug shows up only when the
// close button is visible and competes for horizontal space. Open a carve-out // close button is visible and competes for horizontal space. Open a carve-out
// tab so both MainTab and carve-out exist; close button becomes visible; verify // tab so both MainTab and carve-out exist; close button becomes visible; verify
// the pin's right edge stays inside the tab's right edge. // the freeze's right edge stays inside the tab's right edge.
var (window, vm) = await TestHarness.BootAsync(3); var (window, vm) = await TestHarness.BootAsync(3);
@ -242,12 +242,12 @@ public class PreviewTabPromotionTests
mainTabItem.Arrange(new global::Avalonia.Rect(mainTabItem.DesiredSize)); mainTabItem.Arrange(new global::Avalonia.Rect(mainTabItem.DesiredSize));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var pinButton = mainTabItem.GetVisualDescendants() var freezeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>() .OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton"); .Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
// Walk up the visual tree summing each ancestor's bounds origin until we reach // Walk up the visual tree summing each ancestor's bounds origin until we reach
// mainTabItem — gives the pin's top-left in tab coordinates without depending on // mainTabItem — gives the freeze's top-left in tab coordinates without depending on
// TranslatePoint (which lives on different namespaces across Avalonia versions). // TranslatePoint (which lives on different namespaces across Avalonia versions).
static global::Avalonia.Point OriginRelativeTo(global::Avalonia.Visual node, global::Avalonia.Visual ancestor) static global::Avalonia.Point OriginRelativeTo(global::Avalonia.Visual node, global::Avalonia.Visual ancestor)
{ {
@ -261,36 +261,36 @@ public class PreviewTabPromotionTests
} }
return new global::Avalonia.Point(x, y); return new global::Avalonia.Point(x, y);
} }
var pinTopLeft = OriginRelativeTo(pinButton, mainTabItem); var freezeTopLeft = OriginRelativeTo(freezeButton, mainTabItem);
var pinRight = pinTopLeft.X + pinButton.Bounds.Width; var freezeRight = freezeTopLeft.X + freezeButton.Bounds.Width;
var tabRight = mainTabItem.Bounds.Width; var tabRight = mainTabItem.Bounds.Width;
// Also find the close button so we can include it in the failure message for context. // Also find the close button so we can include it in the failure message for context.
var closeButton = mainTabItem.GetVisualDescendants() var closeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>() .OfType<global::Avalonia.Controls.Button>()
.Where(b => (b.Tag as string) != "PreviewTabPinButton") .Where(b => (b.Tag as string) != "PreviewTabFreezeButton")
.Select(b => new { b, p = OriginRelativeTo(b, mainTabItem) }) .Select(b => new { b, p = OriginRelativeTo(b, mainTabItem) })
.OrderByDescending(x => x.p.X) .OrderByDescending(x => x.p.X)
.FirstOrDefault(); .FirstOrDefault();
var closeInfo = closeButton == null ? "no close button found" var closeInfo = closeButton == null ? "no close button found"
: $"close at x={closeButton.p.X:0}-{closeButton.p.X + closeButton.b.Bounds.Width:0}"; : $"close at x={closeButton.p.X:0}-{closeButton.p.X + closeButton.b.Bounds.Width:0}";
TestContext.WriteLine($"tab width={tabRight:0}; pin at x={pinTopLeft.X:0}-{pinRight:0}; {closeInfo}"); TestContext.WriteLine($"tab width={tabRight:0}; freeze at x={freezeTopLeft.X:0}-{freezeRight:0}; {closeInfo}");
pinRight.Should().BeLessThanOrEqualTo(tabRight, freezeRight.Should().BeLessThanOrEqualTo(tabRight,
"pin button's right edge must fit inside the tab's right edge (cut-off bug)"); "freeze button's right edge must fit inside the tab's right edge (cut-off bug)");
if (closeButton != null && closeButton.b.Bounds.Width > 0) if (closeButton != null && closeButton.b.Bounds.Width > 0)
{ {
pinRight.Should().BeLessThanOrEqualTo(closeButton.p.X + 0.5, freezeRight.Should().BeLessThanOrEqualTo(closeButton.p.X + 0.5,
"pin button must sit to the LEFT of the close button, not overlap it"); "freeze button must sit to the LEFT of the close button, not overlap it");
} }
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Pin_Button_Fits_Inside_The_Tab_Even_With_A_Long_Title() public async Task Freeze_Button_Fits_Inside_The_Tab_Even_With_A_Long_Title()
{ {
// Production stress case: tabs with long titles can exceed the tab strip's available // Production stress case: tabs with long titles can exceed the tab strip's available
// width if the title isn't capped. The pin button must still fit inside the tab's // width if the title isn't capped. The freeze button must still fit inside the tab's
// right edge regardless of title length. // right edge regardless of title length.
var (window, vm) = await TestHarness.BootAsync(3); var (window, vm) = await TestHarness.BootAsync(3);
@ -320,9 +320,9 @@ public class PreviewTabPromotionTests
mainTabItem.Arrange(new global::Avalonia.Rect(0, 0, 200, mainTabItem.DesiredSize.Height)); mainTabItem.Arrange(new global::Avalonia.Rect(0, 0, 200, mainTabItem.DesiredSize.Height));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var pinButton = mainTabItem.GetVisualDescendants() var freezeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>() .OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton"); .Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
static global::Avalonia.Point OriginRelativeTo(global::Avalonia.Visual node, global::Avalonia.Visual ancestor) static global::Avalonia.Point OriginRelativeTo(global::Avalonia.Visual node, global::Avalonia.Visual ancestor)
{ {
@ -336,29 +336,29 @@ public class PreviewTabPromotionTests
} }
return new global::Avalonia.Point(x, y); return new global::Avalonia.Point(x, y);
} }
var pinTopLeft = OriginRelativeTo(pinButton, mainTabItem); var freezeTopLeft = OriginRelativeTo(freezeButton, mainTabItem);
var pinRight = pinTopLeft.X + pinButton.Bounds.Width; var freezeRight = freezeTopLeft.X + freezeButton.Bounds.Width;
var tabRight = mainTabItem.Bounds.Width; var tabRight = mainTabItem.Bounds.Width;
TestContext.WriteLine($"long-title scenario: tab={tabRight:0}, pin x={pinTopLeft.X:0}-{pinRight:0}"); TestContext.WriteLine($"long-title scenario: tab={tabRight:0}, freeze x={freezeTopLeft.X:0}-{freezeRight:0}");
pinRight.Should().BeLessThanOrEqualTo(tabRight, freezeRight.Should().BeLessThanOrEqualTo(tabRight,
$"pin must fit inside tab width even with long titles (tab={tabRight:0}, pinRight={pinRight:0})"); $"freeze must fit inside tab width even with long titles (tab={tabRight:0}, freezeRight={freezeRight:0})");
// Lock the rendering fixes in: // Lock the rendering fixes in:
// 1. The title StackPanel must clip — otherwise a long title TextBlock measures // 1. The title StackPanel must clip — otherwise a long title TextBlock measures
// at its full unconstrained width and paints over the pin and close buttons. // at its full unconstrained width and paints over the freeze and close buttons.
var titleStack = mainTabItem.GetVisualDescendants().OfType<global::Avalonia.Controls.StackPanel>().First(); var titleStack = mainTabItem.GetVisualDescendants().OfType<global::Avalonia.Controls.StackPanel>().First();
titleStack.ClipToBounds.Should().BeTrue( titleStack.ClipToBounds.Should().BeTrue(
"the title StackPanel must clip its content so a long title TextBlock doesn't render over the pin button"); "the title StackPanel must clip its content so a long title TextBlock doesn't render over the freeze button");
// 2. The pin Button itself must NOT clip — Avalonia Buttons default to ClipToBounds=true // 2. The freeze Button itself must NOT clip — Avalonia Buttons default to ClipToBounds=true
// which cuts the right edge of Segoe Fluent Icon glyphs (whose visual extent // which cuts the right edge of Segoe Fluent Icon glyphs (whose visual extent
// exceeds the reported advance width). // exceeds the reported advance width).
pinButton.ClipToBounds.Should().BeFalse( freezeButton.ClipToBounds.Should().BeFalse(
"pin button must not clip so the glyph's visual extent (often wider than the font advance width) survives"); "freeze button must not clip so the glyph's visual extent (often wider than the font advance width) survives");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Inline_Pin_Button_Appears_On_Preview_Tab_And_Pins_When_Clicked() public async Task Inline_Freeze_Button_Appears_On_Preview_Tab_And_Freezes_When_Clicked()
{ {
var (window, vm) = await TestHarness.BootAsync(); var (window, vm) = await TestHarness.BootAsync();
@ -369,39 +369,39 @@ public class PreviewTabPromotionTests
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>() var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); .Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
// Pin button is injected via PreviewTabPinButtonBehavior — find it by Tag. // Freeze button is injected via PreviewTabFreezeButtonBehavior — find it by Tag.
await Waiters.WaitForAsync(() => mainTabItem.GetVisualDescendants() await Waiters.WaitForAsync(() => mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>() .OfType<global::Avalonia.Controls.Button>()
.Any(b => (b.Tag as string) == "PreviewTabPinButton"), .Any(b => (b.Tag as string) == "PreviewTabFreezeButton"),
System.TimeSpan.FromSeconds(5)); System.TimeSpan.FromSeconds(5));
var pinButton = mainTabItem.GetVisualDescendants() var freezeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>() .OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton"); .Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
pinButton.IsVisible.Should().BeTrue("pin button must show while the tab is preview"); freezeButton.IsVisible.Should().BeTrue("freeze button must show while the tab is preview");
var previousMainTab = factory.MainTab!; var previousMainTab = factory.MainTab!;
var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count; var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count;
// Simulate a user click on the pin button. // Simulate a user click on the freeze button.
pinButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs( freezeButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(
global::Avalonia.Controls.Button.ClickEvent)); global::Avalonia.Controls.Button.ClickEvent));
TestCapture.Step("pin-button-clicked"); TestCapture.Step("freeze-button-clicked");
// New semantics: clicking pin flips IsPreview, doesn't spawn a new tab. // New semantics: clicking freeze flips IsPreview, doesn't spawn a new tab.
previousMainTab.IsPreview.Should().BeFalse( previousMainTab.IsPreview.Should().BeFalse(
"clicking the pin button must flip IsPreview=false on the current MainTab"); "clicking the freeze button must flip IsPreview=false on the current MainTab");
factory.MainTab.Should().BeSameAs(previousMainTab, factory.MainTab.Should().BeSameAs(previousMainTab,
"pin alone must not rotate factory.MainTab; the new preview tab opens lazily on the next tree selection"); "freeze alone must not rotate factory.MainTab; the new preview tab opens lazily on the next tree selection");
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore, vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore,
"pin alone must not change tab count"); "freeze alone must not change tab count");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Pin_Entry_Appears_In_Tab_Context_Menu_Only_When_Preview() public async Task Freeze_Entry_Appears_In_Tab_Context_Menu_Only_When_Preview()
{ {
// The right-click context-menu Pin entry must be visible on the preview MainTab // The right-click context-menu Freeze entry must be visible on the preview MainTab
// and hidden (its IsVisible flips) on pinned carve-out tabs. // and hidden (its IsVisible flips) on frozen carve-out tabs.
var (window, vm) = await TestHarness.BootAsync(3); var (window, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
@ -417,17 +417,17 @@ public class PreviewTabPromotionTests
var carveOutItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>() var carveOutItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => item.DataContext is ContentTabPage t && t.SourceNode == typeNode); .Single(item => item.DataContext is ContentTabPage t && t.SourceNode == typeNode);
var mainPinEntry = mainTabItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single(); var mainFreezeEntry = mainTabItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single();
mainPinEntry.Header.Should().Be("Pin tab"); mainFreezeEntry.Header.Should().Be("Freeze tab");
mainPinEntry.IsVisible.Should().BeTrue("MainTab is preview — Pin tab entry must be visible"); mainFreezeEntry.IsVisible.Should().BeTrue("MainTab is preview — Freeze tab entry must be visible");
var carvePinEntry = carveOutItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single(); var carveFreezeEntry = carveOutItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single();
carvePinEntry.IsVisible.Should().BeFalse( carveFreezeEntry.IsVisible.Should().BeFalse(
"carve-out tabs are already pinned — the Pin entry must hide"); "carve-out tabs are already frozen — the Freeze entry must hide");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Pinned_Carve_Out_Tab_Header_Renders_Upright() public async Task Frozen_Carve_Out_Tab_Header_Renders_Upright()
{ {
// Regression guard for the bug where every DocumentTabStripItem got italicised // Regression guard for the bug where every DocumentTabStripItem got italicised
// because the App.axaml setter used a static `FontStyle="Italic"` value instead // because the App.axaml setter used a static `FontStyle="Italic"` value instead
@ -447,38 +447,38 @@ public class PreviewTabPromotionTests
var carveOutModel = vm.DockWorkspace.Documents!.VisibleDockables! var carveOutModel = vm.DockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>() .OfType<ContentTabPage>()
.Last(t => t.SourceNode == typeNode); .Last(t => t.SourceNode == typeNode);
carveOutModel.IsPreview.Should().BeFalse("baseline: carve-out tab is pinned"); carveOutModel.IsPreview.Should().BeFalse("baseline: carve-out tab is frozen");
var carveOutItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>() var carveOutItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, carveOutModel)); .Single(item => ReferenceEquals(item.DataContext, carveOutModel));
carveOutItem.FontStyle.Should().Be(FontStyle.Normal, carveOutItem.FontStyle.Should().Be(FontStyle.Normal,
"a pinned (IsPreview=false) document tab must render with upright FontStyle"); "a frozen (IsPreview=false) document tab must render with upright FontStyle");
} }
[AvaloniaTest] [AvaloniaTest]
public void PinCurrentTab_Is_Noop_When_MainTab_Already_Pinned() public void FreezeCurrentTab_Is_Noop_When_MainTab_Already_Frozen()
{ {
// Idempotency: a second pin against an already-pinned MainTab is a no-op. The new // Idempotency: a second freeze against an already-frozen MainTab is a no-op. The new
// "pin = flip-only, spawn-lazy" semantics make this simpler than the old version: // "freeze = flip-only, spawn-lazy" semantics make this simpler than the old version:
// PinCurrentMainTab() returns null when MainTab.IsPreview is already false, and // FreezeCurrentMainTab() returns null when MainTab.IsPreview is already false, and
// PinCurrentTab early-returns. // FreezeCurrentTab early-returns.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
TestCapture.Step("booted"); TestCapture.Step("booted");
// Manually flip the current MainTab to pinned, simulating the post-pin state. // Manually flip the current MainTab to frozen, simulating the post-freeze state.
factory.MainTab!.IsPreview = false; factory.MainTab!.IsPreview = false;
var before = factory.MainTab; var before = factory.MainTab;
vm.DockWorkspace.PinCurrentTab(); vm.DockWorkspace.FreezeCurrentTab();
TestCapture.Step("pin-noop"); TestCapture.Step("freeze-noop");
factory.MainTab.Should().BeSameAs(before, factory.MainTab.Should().BeSameAs(before,
"with no preview MainTab to flip, PinCurrentTab must leave the factory state untouched"); "with no preview MainTab to flip, FreezeCurrentTab must leave the factory state untouched");
factory.MainTab.IsPreview.Should().BeFalse( factory.MainTab.IsPreview.Should().BeFalse(
"the already-pinned tab stays pinned"); "the already-frozen tab stays frozen");
} }
[AvaloniaTest] [AvaloniaTest]
@ -501,15 +501,15 @@ public class PreviewTabPromotionTests
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Pin_Button_Inherits_The_Close_Button_Theme() public async Task Freeze_Button_Inherits_The_Close_Button_Theme()
{ {
// Visual parity: the pin button should look like the close button — same size, same // Visual parity: the freeze button should look like the close button — same size, same
// hover background. Both are plain Avalonia.Controls.Button instances; the close // hover background. Both are plain Avalonia.Controls.Button instances; the close
// button gets its visual identity from a ControlTheme applied by Dock's tab // button gets its visual identity from a ControlTheme applied by Dock's tab
// template. Pin should copy that Theme rather than carry its own custom style. // template. Freeze should copy that Theme rather than carry its own custom style.
var (window, vm) = await TestHarness.BootAsync(3); var (window, vm) = await TestHarness.BootAsync(3);
// Open a carve-out so the close button is visible alongside the pin (single-tab // Open a carve-out so the close button is visible alongside the freeze (single-tab
// scenario hides the close button). // scenario hides the close button).
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
@ -524,24 +524,24 @@ public class PreviewTabPromotionTests
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>() var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); .Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
var pinButton = mainTabItem.GetVisualDescendants() var freezeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>() .OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton"); .Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
var closeButton = mainTabItem.GetVisualDescendants() var closeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>() .OfType<global::Avalonia.Controls.Button>()
.FirstOrDefault(b => (b.Tag as string) != "PreviewTabPinButton"); .FirstOrDefault(b => (b.Tag as string) != "PreviewTabFreezeButton");
closeButton.Should().NotBeNull("baseline: close button exists as a sibling of the pin"); closeButton.Should().NotBeNull("baseline: close button exists as a sibling of the freeze");
pinButton.Theme.Should().BeSameAs(closeButton!.Theme, freezeButton.Theme.Should().BeSameAs(closeButton!.Theme,
"pin button must use the same ControlTheme as the close button so size + hover bg match"); "freeze button must use the same ControlTheme as the close button so size + hover bg match");
pinButton.Classes.Should().NotContain("preview-pin", freezeButton.Classes.Should().NotContain("preview-freeze",
"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] [AvaloniaTest]
public async Task Tree_Selection_While_Frozen_Tab_Active_Opens_A_New_Preview_Tab() public async Task Tree_Selection_While_Frozen_Tab_Active_Opens_A_New_Preview_Tab()
{ {
// "Frozen" covers: (a) user-pinned tabs (IsPreview=false via PinCurrentTab), // "Frozen" covers: (a) user-frozen tabs (IsPreview=false via FreezeCurrentTab),
// (b) carve-out tabs (born IsPreview=false via OpenNodeInNewTab), and (c) static // (b) carve-out tabs (born IsPreview=false via OpenNodeInNewTab), and (c) static
// content tabs (Options, About) whose Content is a static viewmodel. Tree-node // content tabs (Options, About) whose Content is a static viewmodel. Tree-node
// selections while any of those are active must spawn a fresh preview tab and // selections while any of those are active must spawn a fresh preview tab and
@ -551,7 +551,7 @@ public class PreviewTabPromotionTests
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
// Open a carve-out (born pinned) and let it become the active dockable. // Open a carve-out (born frozen) and let it become the active dockable.
var typeA = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( var typeA = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable"); "System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeA); vm.DockWorkspace.OpenNodeInNewTab(typeA);

4
ILSpy/App.axaml

@ -387,7 +387,7 @@
Value="{Binding IsPreview, Converter={x:Static themes:BoolToBrushConverter.PreviewAccent}, FallbackValue={x:Null}, Mode=OneWay, x:CompileBindings=False}" /> Value="{Binding IsPreview, Converter={x:Static themes:BoolToBrushConverter.PreviewAccent}, FallbackValue={x:Null}, Mode=OneWay, x:CompileBindings=False}" />
<Setter Property="BorderThickness" <Setter Property="BorderThickness"
Value="{Binding IsPreview, Converter={x:Static themes:BoolToThicknessConverter.LeftStripe}, FallbackValue=0, Mode=OneWay, x:CompileBindings=False}" /> Value="{Binding IsPreview, Converter={x:Static themes:BoolToThicknessConverter.LeftStripe}, FallbackValue=0, Mode=OneWay, x:CompileBindings=False}" />
<!-- Per-tab right-click "Pin tab" entry. The Behavior creates a fresh <!-- Per-tab right-click "Freeze tab" entry. The Behavior creates a fresh
ContextMenu per attachment (visuals can have only one parent) and toggles ContextMenu per attachment (visuals can have only one parent) and toggles
the entry's IsVisible on the tab's IsPreview. --> the entry's IsVisible on the tab's IsPreview. -->
<Setter Property="themes:PreviewTabContextMenuBehavior.Enable" Value="True" /> <Setter Property="themes:PreviewTabContextMenuBehavior.Enable" Value="True" />
@ -395,7 +395,7 @@
theme DLL; the Behavior walks the visual tree and injects a Button between theme DLL; the Behavior walks the visual tree and injects a Button between
the title and the close button. Bound to IsPreview so the icon hides once the title and the close button. Bound to IsPreview so the icon hides once
the tab is pinned. --> the tab is pinned. -->
<Setter Property="themes:PreviewTabPinButtonBehavior.Enable" Value="True" /> <Setter Property="themes:PreviewTabFreezeButtonBehavior.Enable" Value="True" />
</Style> </Style>

12
ILSpy/Commands/WindowCommands.cs

@ -42,16 +42,16 @@ namespace ILSpy.Commands
/// <summary> /// <summary>
/// VS-style "promote preview tab" command. The persistent MainTab starts as preview /// VS-style "promote preview tab" command. The persistent MainTab starts as preview
/// (its content is replaced by tree-node selections); pinning makes it a regular tab /// (its content is replaced by tree-node selections); freezing makes it a regular tab
/// that keeps its content, and a fresh preview MainTab takes over the /// that keeps its content, and a fresh preview MainTab takes over the
/// tree-selection slot. <see cref="DockWorkspace.PinCurrentTab"/> is a no-op when /// tree-selection slot. <see cref="DockWorkspace.FreezeCurrentTab"/> is a no-op when
/// there's nothing pinnable (no MainTab, or MainTab already pinned). /// there's nothing freezable (no MainTab, or MainTab already frozen).
/// </summary> /// </summary>
[ExportMainMenuCommand(Header = nameof(Resources.Window_PinCurrentTab), ParentMenuID = nameof(Resources._Window), MenuCategory = "Window", MenuOrder = 0)] [ExportMainMenuCommand(Header = nameof(Resources.Window_FreezeCurrentTab), ParentMenuID = nameof(Resources._Window), MenuCategory = "Window", MenuOrder = 0)]
[Shared] [Shared]
[method: ImportingConstructor] [method: ImportingConstructor]
sealed class PinCurrentTabCommand(DockWorkspace dockWorkspace) : SimpleCommand sealed class FreezeCurrentTabCommand(DockWorkspace dockWorkspace) : SimpleCommand
{ {
public override void Execute(object? parameter) => dockWorkspace.PinCurrentTab(); public override void Execute(object? parameter) => dockWorkspace.FreezeCurrentTab();
} }
} }

26
ILSpy/Docking/DockWorkspace.cs

@ -696,8 +696,8 @@ namespace ILSpy.Docking
// here on a single click, so dedupe to avoid creating two TabPageModels for the // here on a single click, so dedupe to avoid creating two TabPageModels for the
// same selection — the second one's columns would replace the first's, but the // same selection — the second one's columns would replace the first's, but the
// first's filter wiring would be left dangling on stale ColumnFilter instances. // first's filter wiring would be left dangling on stale ColumnFilter instances.
// Dedupe is also what makes "re-clicking the same node after pin" a no-op: the // Dedupe is also what makes "re-clicking the same node after freeze" a no-op: the
// pinned tab stays active, no new preview tab spawns. // frozen tab stays active, no new preview tab spawns.
if (lastShownNodes is { } prev && prev.SequenceEqual(nodes)) if (lastShownNodes is { } prev && prev.SequenceEqual(nodes))
{ {
if (factory.MainTab is { } existingMain) if (factory.MainTab is { } existingMain)
@ -708,7 +708,7 @@ namespace ILSpy.Docking
// Pick or spawn the target tab. If the currently-active document tab is a // Pick or spawn the target tab. If the currently-active document tab is a
// writable preview ContentTabPage we replace its content in place. Anything // writable preview ContentTabPage we replace its content in place. Anything
// else — pinned tab, Options page, About page, no Documents dock — counts as // else — frozen tab, Options page, About page, no Documents dock — counts as
// frozen and gets a brand-new preview tab spawned beside it. // frozen and gets a brand-new preview tab spawned beside it.
ContentTabPage? main; ContentTabPage? main;
if (IsWritablePreview(factory.Documents?.ActiveDockable) is ContentTabPage activePreview) if (IsWritablePreview(factory.Documents?.ActiveDockable) is ContentTabPage activePreview)
@ -854,7 +854,7 @@ namespace ILSpy.Docking
/// <see cref="DecompilerTabPageModel"/> is spawned and asked to decompile the /// <see cref="DecompilerTabPageModel"/> is spawned and asked to decompile the
/// node. Shared between the assembly-tree MMB handler, the metadata-grid MMB /// node. Shared between the assembly-tree MMB handler, the metadata-grid MMB
/// handler, and the "Decompile to new tab" context-menu entry. The created tab /// handler, and the "Decompile to new tab" context-menu entry. The created tab
/// is pinned (born with <see cref="ContentTabPage.IsPreview"/> false) — see /// is frozen (born with <see cref="ContentTabPage.IsPreview"/> false) — see
/// <see cref="OpenNewTab"/>. /// <see cref="OpenNewTab"/>.
/// </summary> /// </summary>
public void OpenNodeInNewTab(ILSpyTreeNode node) public void OpenNodeInNewTab(ILSpyTreeNode node)
@ -1093,7 +1093,7 @@ namespace ILSpy.Docking
public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null) public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null)
{ {
// Carve-outs are born pinned — they survive tree-node selections instead of // Carve-outs are born frozen — they survive tree-node selections instead of
// being replaced like the preview MainTab. // being replaced like the preview MainTab.
var tab = new ContentTabPage { Content = content, SourceNode = sourceNode, IsPreview = false }; var tab = new ContentTabPage { Content = content, SourceNode = sourceNode, IsPreview = false };
if (factory.Documents != null) if (factory.Documents != null)
@ -1138,7 +1138,7 @@ namespace ILSpy.Docking
/// <summary> /// <summary>
/// Renders <paramref name="content"/> in the reusable MainTab as a transient welcome /// Renders <paramref name="content"/> in the reusable MainTab as a transient welcome
/// page (the About page shown at startup when nothing is selected). Unlike Help &gt; /// page (the About page shown at startup when nothing is selected). Unlike Help &gt;
/// About this does not pin a static page: the content is non-static, so the first /// About this does not freeze a static page: the content is non-static, so the first
/// tree-node selection reuses the MainTab and replaces it, leaving the user with a /// tree-node selection reuses the MainTab and replaces it, leaving the user with a
/// single tab rather than a stray empty one. /// single tab rather than a stray empty one.
/// </summary> /// </summary>
@ -1162,12 +1162,12 @@ namespace ILSpy.Docking
} }
/// <summary> /// <summary>
/// VS-style "pin tab" gesture. The current <c>factory.MainTab</c> keeps its position, /// VS-style "freeze tab" gesture. The current <c>factory.MainTab</c> keeps its position,
/// content, and active state but flips to pinned /// content, and active state but flips to frozen
/// (<see cref="ContentTabPage.IsPreview"/> = false). No new tab spawns at pin time: /// (<see cref="ContentTabPage.IsPreview"/> = false). No new tab spawns at freeze time:
/// the next tree-node selection that finds the active tab frozen will lazily spawn /// the next tree-node selection that finds the active tab frozen will lazily spawn
/// a fresh preview tab inside <see cref="ShowSelectedNode"/>. Re-clicking the same /// a fresh preview tab inside <see cref="ShowSelectedNode"/>. Re-clicking the same
/// node after pin is a no-op (the dedupe activates the pinned tab). /// node after freeze is a no-op (the dedupe activates the frozen tab).
/// </summary> /// </summary>
/// <summary> /// <summary>
/// Pump dispatcher jobs so synchronously-set selection state (SourceNode, IsPreview, /// Pump dispatcher jobs so synchronously-set selection state (SourceNode, IsPreview,
@ -1180,11 +1180,11 @@ namespace ILSpy.Docking
Avalonia.Threading.Dispatcher.UIThread.RunJobs(); Avalonia.Threading.Dispatcher.UIThread.RunJobs();
} }
public void PinCurrentTab() public void FreezeCurrentTab()
{ {
if (factory.PinCurrentMainTab() is null) if (factory.FreezeCurrentMainTab() is null)
return; return;
// Cached decompiler viewmodel was bound to the just-pinned tab — drop the cache // Cached decompiler viewmodel was bound to the just-frozen tab — drop the cache
// so the next selection-change-into-frozen path materialises a fresh // so the next selection-change-into-frozen path materialises a fresh
// DecompilerTabPageModel for the newly-spawned preview tab. // DecompilerTabPageModel for the newly-spawned preview tab.
decompilerContent = null; decompilerContent = null;

16
ILSpy/Docking/ILSpyDockFactory.cs

@ -42,21 +42,21 @@ namespace ILSpy.Docking
/// The single persistent Document the host puts in <see cref="Documents"/>. Its /// The single persistent Document the host puts in <see cref="Documents"/>. Its
/// <see cref="ContentTabPage.Content"/> swaps between viewmodels (decompiler text / /// <see cref="ContentTabPage.Content"/> swaps between viewmodels (decompiler text /
/// metadata grid) on tree-node selection — DockWorkspace owns the population. /// metadata grid) on tree-node selection — DockWorkspace owns the population.
/// Identity rotates through <see cref="PromotePreviewMainTab"/> — after a pin, the /// Identity rotates through <see cref="FreezeCurrentMainTab"/> — after a freeze, the
/// just-pinned tab keeps its position and content while MainTab points at a fresh /// just-frozen tab keeps its position and content while MainTab points at a fresh
/// preview tab beside it. /// preview tab beside it.
/// </summary> /// </summary>
public ContentTabPage? MainTab { get; internal set; } public ContentTabPage? MainTab { get; internal set; }
/// <summary> /// <summary>
/// Flips the current <see cref="MainTab"/> to pinned /// Flips the current <see cref="MainTab"/> to frozen
/// (<see cref="ContentTabPage.IsPreview"/> = false). Returns the pinned tab, or /// (<see cref="ContentTabPage.IsPreview"/> = false). Returns the frozen tab, or
/// <see langword="null"/> when there's nothing to pin (no current MainTab, MainTab /// <see langword="null"/> when there's nothing to freeze (no current MainTab, MainTab
/// already pinned). Does NOT spawn a new preview tab — the next selection change /// already frozen). Does NOT spawn a new preview tab — the next selection change
/// that finds the active tab frozen will spawn one lazily via /// that finds the active tab frozen will spawn one lazily via
/// <see cref="CreateAndAttachPreviewTab"/>. /// <see cref="CreateAndAttachPreviewTab"/>.
/// </summary> /// </summary>
public ContentTabPage? PinCurrentMainTab() public ContentTabPage? FreezeCurrentMainTab()
{ {
if (MainTab is not { IsPreview: true } current) if (MainTab is not { IsPreview: true } current)
return null; return null;
@ -67,7 +67,7 @@ namespace ILSpy.Docking
/// <summary> /// <summary>
/// Creates a fresh preview <see cref="ContentTabPage"/>, attaches it to /// Creates a fresh preview <see cref="ContentTabPage"/>, attaches it to
/// <see cref="Documents"/>, and sets <see cref="MainTab"/> to it. Used when a /// <see cref="Documents"/>, and sets <see cref="MainTab"/> to it. Used when a
/// tree-node selection changes while the active tab is frozen (pinned, Options, /// tree-node selection changes while the active tab is frozen (frozen, Options,
/// About, …) — a brand-new preview slot is needed to host the new content. /// About, …) — a brand-new preview slot is needed to host the new content.
/// Returns the new tab, or <see langword="null"/> if <see cref="Documents"/> is /// Returns the new tab, or <see langword="null"/> if <see cref="Documents"/> is
/// missing. /// missing.

4
ILSpy/Properties/Resources.Designer.cs generated

@ -3163,9 +3163,9 @@ namespace ICSharpCode.ILSpy.Properties {
/// <summary> /// <summary>
/// Looks up a localized string similar to _Pin current tab. /// Looks up a localized string similar to _Pin current tab.
/// </summary> /// </summary>
public static string Window_PinCurrentTab { public static string Window_FreezeCurrentTab {
get { get {
return ResourceManager.GetString("Window_PinCurrentTab", resourceCulture); return ResourceManager.GetString("Window_FreezeCurrentTab", resourceCulture);
} }
} }

4
ILSpy/Properties/Resources.resx

@ -1084,8 +1084,8 @@ Do you want to continue?</value>
<data name="Window_CloseAllDocuments" xml:space="preserve"> <data name="Window_CloseAllDocuments" xml:space="preserve">
<value>_Close all documents</value> <value>_Close all documents</value>
</data> </data>
<data name="Window_PinCurrentTab" xml:space="preserve"> <data name="Window_FreezeCurrentTab" xml:space="preserve">
<value>_Pin current tab</value> <value>_Freeze current tab</value>
</data> </data>
<data name="Window_ResetLayout" xml:space="preserve"> <data name="Window_ResetLayout" xml:space="preserve">
<value>_Reset layout</value> <value>_Reset layout</value>

16
ILSpy/Themes/PreviewTabContextMenuBehavior.cs

@ -31,10 +31,10 @@ namespace ILSpy.Themes
{ {
/// <summary> /// <summary>
/// Attached property that wires a per-instance <see cref="ContextMenu"/> onto every /// Attached property that wires a per-instance <see cref="ContextMenu"/> onto every
/// <see cref="DocumentTabStripItem"/>. The menu hosts a single "Pin tab" entry whose /// <see cref="DocumentTabStripItem"/>. The menu hosts a single "Freeze tab" entry whose
/// <see cref="MenuItem.IsVisible"/> tracks <see cref="ContentTabPage.IsPreview"/> on /// <see cref="MenuItem.IsVisible"/> tracks <see cref="ContentTabPage.IsPreview"/> on
/// the tab's underlying viewmodel — invoking it routes to /// the tab's underlying viewmodel — invoking it routes to
/// <see cref="DockWorkspace.PinCurrentTab"/>. Each tab gets a fresh ContextMenu so /// <see cref="DockWorkspace.FreezeCurrentTab"/>. Each tab gets a fresh ContextMenu so
/// the popup has a unique parent (Avalonia visuals must have at most one parent). /// the popup has a unique parent (Avalonia visuals must have at most one parent).
/// </summary> /// </summary>
public static class PreviewTabContextMenuBehavior public static class PreviewTabContextMenuBehavior
@ -75,23 +75,23 @@ namespace ILSpy.Themes
return; return;
} }
var pinItem = new MenuItem { var freezeItem = new MenuItem {
Header = "Pin tab", Header = "Freeze tab",
IsVisible = tab.IsPreview, IsVisible = tab.IsPreview,
}; };
pinItem.Click += (_, _) => TryGetDockWorkspace()?.PinCurrentTab(); freezeItem.Click += (_, _) => TryGetDockWorkspace()?.FreezeCurrentTab();
// Keep IsVisible in sync with IsPreview so the entry hides once the tab is // Keep IsVisible in sync with IsPreview so the entry hides once the tab is
// pinned (or after promotion happens through some other code path). // frozen (freeze is one-way — there's no matching unfreeze entry).
tab.PropertyChanged += OnTabPropertyChanged; tab.PropertyChanged += OnTabPropertyChanged;
void OnTabPropertyChanged(object? _, PropertyChangedEventArgs args) void OnTabPropertyChanged(object? _, PropertyChangedEventArgs args)
{ {
if (args.PropertyName == nameof(ContentTabPage.IsPreview)) if (args.PropertyName == nameof(ContentTabPage.IsPreview))
pinItem.IsVisible = tab.IsPreview; freezeItem.IsVisible = tab.IsPreview;
} }
item.DocumentContextMenu = new ContextMenu { item.DocumentContextMenu = new ContextMenu {
ItemsSource = new[] { pinItem }, ItemsSource = new[] { freezeItem },
}; };
} }

74
ILSpy/Themes/PreviewTabPinButtonBehavior.cs → ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs

@ -36,24 +36,24 @@ using ILSpy.ViewModels;
namespace ILSpy.Themes namespace ILSpy.Themes
{ {
/// <summary> /// <summary>
/// Injects an inline "pin" Button into the tab header of every <see cref="DocumentTabStripItem"/>. /// Injects an inline "freeze" Button into the tab header of every <see cref="DocumentTabStripItem"/>.
/// Dock's tab template is compiled into the theme DLL and not directly overridable without /// Dock's tab template is compiled into the theme DLL and not directly overridable without
/// reproducing the whole template, so the button is added at runtime by walking the visual /// reproducing the whole template, so the button is added at runtime by walking the visual
/// tree to the inner title StackPanel and appending a child there. The button's /// tree to the inner title StackPanel and appending a child there. The button's
/// <see cref="Visual.IsVisible"/> is bound to <see cref="ContentTabPage.IsPreview"/> on the /// <see cref="Visual.IsVisible"/> is bound to <see cref="ContentTabPage.IsPreview"/> on the
/// tab's underlying viewmodel and the click handler routes to /// tab's underlying viewmodel — so it shows only on the One preview tab — and the click
/// <see cref="DockWorkspace.PinCurrentTab"/>. Styling lives in App.axaml under the /// handler routes to <see cref="DockWorkspace.FreezeCurrentTab"/>. It reuses the sibling
/// <c>Button.preview-pin</c> class selector so the theme's <c>:pointerover</c> states still /// close button's ControlTheme so it renders at the same size and inherits the same
/// apply (local Background/BorderBrush values here would block them). /// <c>:pointerover</c> states.
/// </summary> /// </summary>
public static class PreviewTabPinButtonBehavior public static class PreviewTabFreezeButtonBehavior
{ {
const string PinButtonTag = "PreviewTabPinButton"; const string FreezeButtonTag = "PreviewTabFreezeButton";
public static readonly AttachedProperty<bool> EnableProperty = public static readonly AttachedProperty<bool> EnableProperty =
AvaloniaProperty.RegisterAttached<DocumentTabStripItem, bool>( AvaloniaProperty.RegisterAttached<DocumentTabStripItem, bool>(
"Enable", "Enable",
typeof(PreviewTabPinButtonBehavior)); typeof(PreviewTabFreezeButtonBehavior));
public static void SetEnable(DocumentTabStripItem element, bool value) public static void SetEnable(DocumentTabStripItem element, bool value)
=> element.SetValue(EnableProperty, value); => element.SetValue(EnableProperty, value);
@ -61,7 +61,7 @@ namespace ILSpy.Themes
public static bool GetEnable(DocumentTabStripItem element) public static bool GetEnable(DocumentTabStripItem element)
=> element.GetValue(EnableProperty); => element.GetValue(EnableProperty);
static PreviewTabPinButtonBehavior() static PreviewTabFreezeButtonBehavior()
{ {
EnableProperty.Changed.AddClassHandler<DocumentTabStripItem>(OnEnableChanged); EnableProperty.Changed.AddClassHandler<DocumentTabStripItem>(OnEnableChanged);
} }
@ -78,16 +78,16 @@ namespace ILSpy.Themes
static void TryInject(DocumentTabStripItem item) static void TryInject(DocumentTabStripItem item)
{ {
// Idempotent: bail if our button is already in the tree. // Idempotent: bail if our button is already in the tree.
if (item.GetVisualDescendants().OfType<Button>().Any(b => (b.Tag as string) == PinButtonTag)) if (item.GetVisualDescendants().OfType<Button>().Any(b => (b.Tag as string) == FreezeButtonTag))
return; return;
if (item.DataContext is not ContentTabPage) if (item.DataContext is not ContentTabPage)
return; return;
// Anchor: the Grid that's the parent of the title StackPanel. The Grid also hosts // Anchor: the Grid that's the parent of the title StackPanel. The Grid also hosts
// the close-button ContentPresenter as a sibling column. We inject pin as a new // the close-button ContentPresenter as a sibling column. We inject the freeze button
// Auto-sized column between the two so the title can't push pin past the tab's // as a new Auto-sized column between the two so the title can't push it past the tab's
// right edge — appending to the StackPanel puts pin inside the title column, // right edge — appending to the StackPanel puts it inside the title column, where the
// where the unconstrained title TextBlock claims all the space and the pin // unconstrained title TextBlock claims all the space and the button overflows behind
// overflows behind the close button. // the close button.
var titleStack = item.GetVisualDescendants().OfType<StackPanel>().FirstOrDefault(); var titleStack = item.GetVisualDescendants().OfType<StackPanel>().FirstOrDefault();
if (titleStack is null) if (titleStack is null)
return; return;
@ -97,9 +97,9 @@ namespace ILSpy.Themes
// Clip the StackPanel's content so the title TextBlock (which measures at its // Clip the StackPanel's content so the title TextBlock (which measures at its
// full unconstrained text width — 900px+ for long type signatures) doesn't // full unconstrained text width — 900px+ for long type signatures) doesn't
// render OVER the pin and close buttons that sit in adjacent Grid columns. // render OVER the freeze and close buttons that sit in adjacent Grid columns.
// Without this, the pin appears "cut off" because the title's pixels paint // Without this, the freeze button appears "cut off" because the title's pixels
// on top of it. // paint on top of it.
titleStack.ClipToBounds = true; titleStack.ClipToBounds = true;
// Tilted-pushpin silhouette matching the Visual Studio preview-tab affordance. // Tilted-pushpin silhouette matching the Visual Studio preview-tab affordance.
@ -123,11 +123,11 @@ namespace ILSpy.Themes
Source = item, Source = item,
Mode = BindingMode.OneWay, Mode = BindingMode.OneWay,
}); });
var pin = new Button { var freezeButton = new Button {
Tag = PinButtonTag, Tag = FreezeButtonTag,
Content = glyph, Content = glyph,
// Left margin preserves the gap between pin and close (~4px) regardless of // Left margin preserves the gap between the freeze and close buttons (~4px)
// what the inherited ControlTheme decides for its own Margin. // regardless of what the inherited ControlTheme decides for its own Margin.
Margin = new Thickness(4, 0, 0, 0), Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center,
@ -138,28 +138,28 @@ namespace ILSpy.Themes
// right edge at the content-area boundary. // right edge at the content-area boundary.
ClipToBounds = false, ClipToBounds = false,
}; };
// Copy the close button's ControlTheme so pin renders at the same size and uses // Copy the close button's ControlTheme so the freeze button renders at the same size
// the same :pointerover background. The close button (a plain Avalonia.Controls.Button // and uses the same :pointerover background. The close button (a plain
// with no classes, theme applied by Dock's tab template) sits as a sibling under // Avalonia.Controls.Button with no classes, theme applied by Dock's tab template)
// the same Grid. Without this, pin defaulted to a class-styled custom look that // sits as a sibling under the same Grid. Without this, it defaulted to a class-styled
// was bigger and used a different hover tint than its neighbour. // custom look that was bigger and used a different hover tint than its neighbour.
var closeButton = item.GetVisualDescendants().OfType<Button>() var closeButton = item.GetVisualDescendants().OfType<Button>()
.FirstOrDefault(b => (b.Tag as string) != PinButtonTag); .FirstOrDefault(b => (b.Tag as string) != FreezeButtonTag);
if (closeButton?.Theme is { } closeTheme) if (closeButton?.Theme is { } closeTheme)
pin.Theme = closeTheme; freezeButton.Theme = closeTheme;
ToolTip.SetTip(pin, "Pin tab"); ToolTip.SetTip(freezeButton, "Freeze tab");
// IsVisible follows IsPreview — the button hides once the tab is pinned. // IsVisible follows IsPreview — the button hides once the tab is frozen.
pin.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) { freezeButton.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) {
Source = item.DataContext, Source = item.DataContext,
Mode = BindingMode.OneWay, Mode = BindingMode.OneWay,
}); });
pin.Click += (_, _) => TryGetDockWorkspace()?.PinCurrentTab(); freezeButton.Click += (_, _) => TryGetDockWorkspace()?.FreezeCurrentTab();
// Insert a new Auto-sized column right before the last (close) column, bump any // Insert a new Auto-sized column right before the last (close) column, bump any
// existing children at or past that index by +1, and dock pin there. This gives // existing children at or past that index by +1, and dock the freeze button there.
// pin a dedicated cell the title can't encroach on. // This gives it a dedicated cell the title can't encroach on.
int newColIndex = grid.ColumnDefinitions.Count - 1; int newColIndex = grid.ColumnDefinitions.Count - 1;
grid.ColumnDefinitions.Insert(newColIndex, new ColumnDefinition(GridLength.Auto)); grid.ColumnDefinitions.Insert(newColIndex, new ColumnDefinition(GridLength.Auto));
foreach (var child in grid.Children) foreach (var child in grid.Children)
@ -168,8 +168,8 @@ namespace ILSpy.Themes
if (col >= newColIndex) if (col >= newColIndex)
Grid.SetColumn(child, col + 1); Grid.SetColumn(child, col + 1);
} }
Grid.SetColumn(pin, newColIndex); Grid.SetColumn(freezeButton, newColIndex);
grid.Children.Add(pin); grid.Children.Add(freezeButton);
} }
static DockWorkspace? TryGetDockWorkspace() static DockWorkspace? TryGetDockWorkspace()

4
ILSpy/ViewModels/ContentTabPage.cs

@ -71,10 +71,10 @@ namespace ILSpy.ViewModels
/// <summary> /// <summary>
/// VS-style "preview" / transient tab flag. The persistent <c>MainTab</c> starts as /// VS-style "preview" / transient tab flag. The persistent <c>MainTab</c> starts as
/// preview (true): every tree-node click replaces its <see cref="Content"/> in /// preview (true): every tree-node click replaces its <see cref="Content"/> in
/// place. The user can pin it via <c>DockWorkspace.PinCurrentTab</c>, which flips /// place. The user can freeze it via <c>DockWorkspace.FreezeCurrentTab</c>, which flips
/// this to false and creates a new preview-state MainTab beside it. Carved-out /// this to false and creates a new preview-state MainTab beside it. Carved-out
/// tabs (created via <c>OpenNewTab</c> / "Open in new tab" / "Freeze tab") are /// tabs (created via <c>OpenNewTab</c> / "Open in new tab" / "Freeze tab") are
/// born pinned — they're never replaced by tree-selection content. /// born frozen — they're never replaced by tree-selection content.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
private bool isPreview = true; private bool isPreview = true;

Loading…
Cancel
Save