Skip to content

Alt-N tab numbering skips an ordinal per split: ProportionalDock splitters are counted as tabs in DockTabOrder.Collect #1342

Description

@JoshuaRowePhantom

Summary

When a workspace pane's content is split into multiple regions, the Alt+digit ("Alt-N") tab number badges skip a number at each split boundary. In the reported layout the badges read 1, 2, 4, 5, 6, 7, 8, 93 is missing. Root cause: DockTabOrder.Collect counts any non-IDock IDockable as a numbered tab, including the IProportionalDockSplitter instances that ProportionalDock interleaves between its child regions. Each splitter consumes one ordinal but never renders a badge (splitters have no DocumentTabStrip container), so the ordinal silently vanishes and the next real tab jumps a number. Because per #1067 the label index and the activation index come from the same list, Alt+ activation is off-by-one-per-split in the same way — display and activation are wrong together.

The fix is to make the ordering a whitelist of the dockable type that actually renders a switchable, badged tab — IDocument — rather than "everything that isn't an IDock". This is robust not only against splitters but against any other non-document dockable (tools, future dockable kinds), all of which would otherwise consume an ordinal without a badge.

Root Cause

The single source of truth for the badge number is DockTabOrder.ComputeDockTabOrder.Collect. features\Phantom.Dock.Avalonia.TabSwitching\Phantom.Dock.Avalonia.TabSwitching\DockTabOrder.cs:53-73:

private static void Collect(IDock dock, List<DockTabEntry> acc, Func<IDock, bool>? isSwitchable)
{
    var visible = dock.VisibleDockables;
    if (visible is null) return;

    foreach (var dockable in visible)
    {
        switch (dockable)
        {
            case IDock childDock:
                Collect(childDock, acc, isSwitchable);
                break;
            case not null when isSwitchable is null || isSwitchable(dock):
                acc.Add(new DockTabEntry(dock, dockable));   // ← any non-IDock IDockable consumes an ordinal
                break;
        }
    }
}

IProportionalDockSplitter extends ISplitter : IDockable (avalonia\Dock\src\Dock.Model\Controls\ISplitter.cs) but is not an IDock. A ProportionalDock places splitters inline between its child docks in VisibleDockables (see the app's own template features\Phantom.Workspaces\Templates\DockDataTemplates.axaml:30-32, 174-178). So when Collect walks a two-region proportional layout [stripA, splitter, stripB]:

  • stripA (IDock) → recursed → contributes tabs at indices 0,1 → labels "1","2".
  • splitter → not an IDock, matches the catch-all second case, is appended → consumes index 2 → would be label "3".
  • stripB (IDock) → recursed → its first tab is index 3 → label "4".

Producing exactly the observed 1, 2, (gap), 4, 5, 6, 7, 8, 9.

The label is assigned by flat position in that list (DockTabSwitchController.RefreshLabels, DockTabSwitchController.cs:741-770: index = IndexOf(order, dockable); digit map DockTabSwitchGestures.cs:58-61 maps indices 0..9 → "1".."9","0").

Why a whitelist (and why the badge never appears for the consumed ordinal)

Badges are only ever rendered on document tabs. The controller hooks exclusively DocumentTabStrip containers (DockTabSwitchController.cs:510 _hookedStrips is HashSet<DocumentTabStrip>; discovery at :633 and :866 is GetVisualDescendants().OfType<DocumentTabStrip>()), and the badge is composed per-DocumentTabStripItem (DockIndexBadgeBehavior.cs:27). The dockable that backs a DocumentTabStripItem is an IDocument.

Therefore the only dockable that both (a) participates in a DocumentTabStrip and (b) receives a visible badge is an IDocument. Any other leaf dockable the ordering counts — a splitter today, or a tool (ITool, which renders in a tool strip, not a DocumentTabStrip) or any future dockable kind tomorrow — will consume an ordinal with no badge to show for it, reintroducing exactly this gap. Positively selecting IDocument (a whitelist) fixes the reported splitter gap and is inherently robust to every other non-document dockable, whereas special-casing each non-tab type (a blacklist) is open-ended and will drift.

The Alt-numbered content lives in the inner per-WorkspacePaneDocument DockControl (features\Phantom.Workspaces\Templates\DockDataTemplates.axaml:112-122, bound Modifiers="Alt" Keys="Digits" Scope="AllSwitchable"), whose WorkspacePane.ContentLayout is (per #1334) a multi-region ProportionalDock of WorkspaceContentDock leaves — exactly the two-region + splitter shape in the screenshot.

Not-a-bug observation: the un-numbered top region

In the screenshot the two top tabs ("Phantom.Workspaces", "Phantom.Workspaces Verification") show no badge under Alt. They are workspace-pane tabs on the outer TopLevelDockControl (features\Phantom.Workspaces\MainWindow.axaml:253-266), bound to a different chord Modifiers="Alt,Shift". Per-label visibility is exact-modifier match (DockTabSwitchController.cs:482-483, 599-616, IsLabelVisibleFor: labelModifiers != None && held == labelModifiers), so under an Alt-only chord the outer Alt+Shift labels correctly stay hidden (#1121). This is working as configured, not a numbering-computation bug. (If the intent is that Alt-N should also number the workspace-pane tabs, that is a separate design change — a second Alt-only binding on the outer DockControl — and should be tracked separately.)

Affected Files

File Contribution
features\Phantom.Dock.Avalonia.TabSwitching\Phantom.Dock.Avalonia.TabSwitching\DockTabOrder.cs Collect (:53-73) counts any non-IDock dockable — the bug; the whitelist goes here
features\Phantom.Dock.Avalonia.TabSwitching\Phantom.Dock.Avalonia.TabSwitching\DockTabSwitchController.cs Labels by flat index (:741-770); badges only on DocumentTabStrip (:510, 633, 866) — establishes IDocument as the badged type
features\Phantom.Dock.Avalonia.TabSwitching\Phantom.Dock.Avalonia.TabSwitching\DockIndexBadgeBehavior.cs Per-DocumentTabStripItem badge composition (:27)
features\Phantom.Dock.Avalonia.TabSwitching\Phantom.Dock.Avalonia.TabSwitching\DockTabSwitchGestures.cs Digit map (:58-61)
features\Phantom.Workspaces\Templates\DockDataTemplates.axaml Inner content DockControl Alt binding (:112-122); ProportionalDock splitter template (:30-32, 174-178)

Design / Fix

Whitelist the badged dockable type in DockTabOrder.Collect. Replace the catch-all second switch arm with a positive IDocument match so only document leaves — the only dockables that render a badge — are numbered:

switch (dockable)
{
    case IDock childDock:
        Collect(childDock, acc, isSwitchable);
        break;
    case IDocument when isSwitchable is null || isSwitchable(dock):   // only document tabs are numbered/badged
        acc.Add(new DockTabEntry(dock, dockable));
        break;
}

IDocument is Dock.Model.Controls.IDocument (already available via the existing using Dock.Model.Core; plus a using Dock.Model.Controls;). This fixes both the badge display and the Alt+ activation index simultaneously (they share this list), and — unlike a splitter-specific skip — cannot be defeated by any future non-document dockable that Dock may interleave into VisibleDockables. Confirm the ordering root/IsSwitchable behaviour is unchanged (the isSwitchable(dock) guard is preserved).

Update the XML-doc on Collect/Compute to state that numbering is restricted to IDocument leaves (the badged type), keeping the design §4.2/§4.5 "single source of truth" contract explicit.

Considered / Background — blacklist (splitter-skip) alternative (rejected)

An earlier proposal was to keep the catch-all arm and add an explicit skip for splitters:

case ISplitter:   // splitters are structural, not tabs → skip
    break;
case not null when isSwitchable is null || isSwitchable(dock):
    acc.Add(new DockTabEntry(dock, dockable));
    break;

This fixes the immediate 3-skip, but it is a denylist: it only excludes the one non-tab type we currently know about, and any other non-document dockable (e.g. ITool in a mixed layout, or a future dockable kind) would still consume an un-badged ordinal and reintroduce the gap. The whitelist above is preferred because it positively restricts numbering to the exact type that renders a badge. (A still-broader denylist reading of design §4.2/§4.5 was also considered and rejected for the same open-ended-ness.)

Expected Tests

Test Name Class What It Verifies
Compute_ProportionalDockWithSplitterBetweenStrips_DoesNotConsumeOrdinalForSplitter DockTabOrderTests A splitter sibling between two strips is not added to the order
Compute_ProportionalDockWithSplitter_YieldsContiguousIndicesAcrossRegions DockTabOrderTests Indices are contiguous 0..N across both regions (no gap)
Compute_MultipleSplittersBetweenStrips_AllSplittersSkipped DockTabOrderTests Every splitter in a multi-split layout is skipped
Compute_NonDocumentDockableInStrip_IsNotNumbered DockTabOrderTests A non-IDocument leaf dockable (e.g. a tool or a bare dockable) does not consume an ordinal — locks in the whitelist
Compute_OnlyDocumentLeaves_AreNumberedInVisualOrder DockTabOrderTests Only IDocument leaves are numbered, in visual order
RefreshLabels_TwoDocumentDocksSeparatedBySplitter_LabelsAre1Through9WithoutGap DockTabSwitchControllerTests Rendered labels across a split are 1..9 with no missing number
Activate_IndexFollowingSplitter_ActivatesFirstTabOfSecondRegion DockTabSwitchControllerTests Alt+ activation and displayed label agree across a split (regression guard for #1067)

Existing tests (DockTabOrderTests.cs:38-62, DockTabOrderScopeTests.cs) build ProportionalDock.VisibleDockables = [stripA, stripB] with no splitter between them, so the production scenario is untested — these new tests close that gap. All in features\Phantom.Dock.Avalonia.TabSwitching\Phantom.Dock.Avalonia.TabSwitching.Tests\.

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiednext-upverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions