diff --git a/windows/Ghostty/MainWindow.xaml.cs b/windows/Ghostty/MainWindow.xaml.cs
index e4fbf4dd30..4849426fc8 100644
--- a/windows/Ghostty/MainWindow.xaml.cs
+++ b/windows/Ghostty/MainWindow.xaml.cs
@@ -614,6 +614,57 @@ void OnContentLoadedOnce(object s, RoutedEventArgs e)
Canvas.SetZIndex(_verticalTabHost, -1);
RootGrid.Children.Add(_verticalTabHost);
+ // Covers the active pane's top border across the selected tab, so
+ // the tab's fill runs into the terminal with no line between them.
+ // Lives in the pane's row rather than the strip's: drawn from the
+ // strip it would have to overhang its own parent to reach the
+ // border, and that overhang is clipped.
+ _tabSeamCover = new Microsoft.UI.Xaml.Shapes.Rectangle
+ {
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top,
+ IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
+ // Exactly the gutter every leaf keeps clear, which is what the
+ // stroke is drawn in. Deeper would reach past it and paint the
+ // tab's fill over the first row of cells: harmless while that
+ // fill matches the terminal background, visible the moment a tab
+ // carries a preset colour.
+ Height = Core.Panes.PaneChrome.SurfaceInset,
+ };
+ Grid.SetRow(_tabSeamCover, 1);
+ Grid.SetColumn(_tabSeamCover, 1);
+ RootGrid.Children.Add(_tabSeamCover);
+ _horizontalTabHost.SelectedTabSeamChanged += OnSelectedTabSeamChanged;
+
+ // The same seam on the vertical strip, rotated: there the selected
+ // row meets the pane along its right edge, so the cover is a
+ // vertical bar over the pane's left border. Placed across the whole
+ // RootGrid rather than in one cell, because the vertical strip spans
+ // both rows and a per-cell margin would need the row heights to
+ // convert; a margin in the grid's own space needs nothing.
+ _verticalSeamCover = new Microsoft.UI.Xaml.Shapes.Rectangle
+ {
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top,
+ IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
+ // The overlap back into the row, plus exactly the gutter the
+ // stroke is drawn in. Overshooting to the left is free, since
+ // that lands on the row's own fill. Overshooting to the right is
+ // not: past the gutter are live cells, and this fill is the
+ // row's, which for a tab carrying a preset colour is that
+ // colour rather than the terminal's. Erring narrow leaves a
+ // line, so the gutter is the number to match, not to pad.
+ Width = VerticalSeamOverlap + Core.Panes.PaneChrome.SurfaceInset,
+ };
+ Grid.SetRow(_verticalSeamCover, 0);
+ Grid.SetRowSpan(_verticalSeamCover, 2);
+ Grid.SetColumn(_verticalSeamCover, 0);
+ Grid.SetColumnSpan(_verticalSeamCover, 2);
+ RootGrid.Children.Add(_verticalSeamCover);
+ _verticalTabHost.SelectionRowChanged += OnVerticalSeamChanged;
+
// Apply initial shell theme now that tab hosts exist, then
// paint RootGrid.Background from the resolved state.
ApplyShellTheme();
@@ -665,12 +716,7 @@ void OnContentLoadedOnce(object s, RoutedEventArgs e)
// or crashes never runs the close path, and the splash would
// then keep falling back to the built-in default and flash a
// mismatched colour on every subsequent start.
- var splashBackground = _configService.BackgroundColor & 0x00FFFFFFu;
- if (_windowState.BackgroundRgb != splashBackground)
- {
- _windowState.BackgroundRgb = splashBackground;
- _windowState.Save();
- }
+ if (RecordSplashBackground()) _windowState.Save();
}
_tabManager.TabAdded += (_, t) =>
@@ -730,6 +776,22 @@ void OnContentLoadedOnce(object s, RoutedEventArgs e)
if (_verticalTabsVisible)
_verticalTabHost.SyncSelectionFromManager();
+ // Tell both strips the terminal's colours. This only ever ran from
+ // OnConfigReloadedChrome, so a session whose config was never
+ // reloaded left both hosts on their own fallbacks -- survivable in
+ // the horizontal strip, but the vertical strip's fallback calibrates
+ // the selected row's title against the system accent rather than the
+ // row it is drawn on, which put a white title on the light half of
+ // the theme at 1.11:1.
+ //
+ // Deliberately here and not earlier beside ApplyShellTheme. It drives
+ // the vertical strip's NavigationView (theme refresh, per-item
+ // brushes, selection chrome), and doing that before Snap has decided
+ // which strip is live -- and before the control is loaded -- left
+ // MUXC in a state where a later SelectedItem assignment took an
+ // access violation inside NavigationView.
+ UpdateCursorAccentColors();
+
_titleBar = new TitleBarCoordinator(
this,
_tabManager,
@@ -965,6 +1027,12 @@ private void AnimateTabLayoutTo(bool vertical)
_pendingLayoutTarget = null;
_verticalTabsVisible = vertical;
_tabHost = vertical ? _verticalTabHost : _horizontalTabHost;
+ // The seam covers are gated on the flag just set, and the strip that
+ // is coming back may not raise anything on its own (a switch does not
+ // resize it or move its selection). Ask both for a fresh placement so
+ // whichever one now owns the seam draws it, and the other hides.
+ _tabSeamCover.Visibility = Visibility.Collapsed;
+ _verticalSeamCover.Visibility = Visibility.Collapsed;
// Paint caption/title chrome before the cross-fade so the OS
// buttons and drag row do not flash stale horizontal colors.
ApplyVerticalTitleBarChrome();
@@ -994,8 +1062,19 @@ private void AnimateTabLayoutTo(bool vertical)
if (_isClosed) return;
RefreshTabHostChrome();
- if (vertical)
- _verticalTabHost.SyncSelectionFromManager();
+
+ // Place the seam only now. The switch is animated, so the strip
+ // that is arriving has no final geometry until it lands -- a
+ // placement made when the switch was requested reads the offsets
+ // the strip had before it, which are non-zero and therefore look
+ // valid, and the cover ends up rubbing out a stretch of border
+ // nowhere near the tab.
+ //
+ // Only the strip that arrived. Asking the one that just left
+ // would arm its layout retry against a collapsed control that
+ // reports zero bounds and never stops.
+ if (vertical) _verticalTabHost.SyncSelectionFromManager();
+ else _horizontalTabHost.RefreshSeam();
_titleBar.ApplyForCurrentMode();
var leaf = _tabManager.ActiveTab?.PaneHost?.ActiveLeaf;
if (leaf is not null)
@@ -1437,6 +1516,12 @@ private async void OnClosedAsync(object sender, WindowEventArgs args)
_configService.ConfigChanged -= OnConfigReloaded;
_configService.ConfigChanged -= OnConfigReloadedChrome;
_shellTheme.ThemeChanged -= OnShellThemeChanged;
+ // Both hosts are owned by this window, so leaving these attached
+ // leaks nothing. Detached anyway: they are the only two raised from
+ // dispatcher-queued and layout callbacks, which are exactly the ones
+ // that can still land after the tree starts coming down.
+ _horizontalTabHost.SelectedTabSeamChanged -= OnSelectedTabSeamChanged;
+ _verticalTabHost.SelectionRowChanged -= OnVerticalSeamChanged;
// UISettings is an OS object and calls back on a thread-pool thread.
// Left attached, an OS light/dark flip, accent change or high-contrast
// toggle during teardown puts AppSetColorScheme through the app
@@ -1482,7 +1567,7 @@ private async void OnClosedAsync(object sender, WindowEventArgs args)
// Carried purely for the next cold start's splash, which runs
// before any theme has been resolved and would otherwise have
// to guess this colour.
- _windowState.BackgroundRgb = _configService.BackgroundColor & 0x00FFFFFFu;
+ RecordSplashBackground();
_windowState.Save();
}
@@ -2312,6 +2397,157 @@ private void ApplyButtonColors(
if (prev.PressedFg != pressedFg) tb.ButtonPressedForegroundColor = pressedFg;
}
+ ///
+ /// Copy the resolved terminal background into the window state for the
+ /// next cold start's splash, which runs before any theme is resolved and
+ /// would otherwise have to guess. Returns true when anything moved, so a
+ /// caller can skip a write.
+ ///
+ ///
+ /// One place for both callers because they used to be two, and the one
+ /// that ran at startup wrote the colour without the flag beside it. That
+ /// left every session claiming a background the desktop could not flip
+ /// out from under, and the splash went on trusting a stale colour.
+ ///
+ private bool RecordSplashBackground()
+ {
+ var background = _configService.BackgroundColor & 0x00FFFFFFu;
+
+ // Neither a configured background nor a configured theme means the
+ // colour is the built-in theme's, which tracks the desktop and so is
+ // only good for as long as that does not move.
+ var followsOs = !_configService.IsConfiguredInFile("background")
+ && string.IsNullOrEmpty(_configService.CurrentTheme);
+
+ if (_windowState.BackgroundRgb == background
+ && _windowState.BackgroundFollowsOsTheme == followsOs)
+ {
+ return false;
+ }
+
+ _windowState.BackgroundRgb = background;
+ _windowState.BackgroundFollowsOsTheme = followsOs;
+ return true;
+ }
+
+ private readonly Microsoft.UI.Xaml.Shapes.Rectangle _tabSeamCover;
+
+ ///
+ /// Place the seam cover under the selected tab, or hide it when the
+ /// strip has nothing to join to (vertical layout, or before the strip
+ /// has arranged).
+ ///
+ private void OnSelectedTabSeamChanged(double left, double width, Brush? fill)
+ {
+ if (_isClosed) return;
+
+ // Only meaningful in horizontal layout: in vertical the strip is
+ // beside the pane, not above it, and the seam is a different edge.
+ //
+ // Gated on the layout MainWindow last applied, NOT on the hosts'
+ // Visibility. Visibility is not a layout signal: both hosts are
+ // Visible by default until the first Snap, and PrimeHiddenStrip
+ // deliberately makes the collapsed one Visible at zero opacity for a
+ // few frames. Reading it here meant the first placement of every
+ // session decided it was in vertical layout and hid the cover, and
+ // nothing re-fired until the window happened to be resized.
+ if (width <= 0 || fill is null || _verticalTabsVisible || _stripForciblyHidden)
+ {
+ _tabSeamCover.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ _tabSeamCover.Margin = new Thickness(left, 0, 0, 0);
+ _tabSeamCover.Width = width;
+ _tabSeamCover.Fill = fill;
+ _tabSeamCover.Visibility = Visibility.Visible;
+ }
+
+ private readonly Microsoft.UI.Xaml.Shapes.Rectangle _verticalSeamCover;
+
+ ///
+ /// How far back into the selected row the vertical seam cover starts.
+ ///
+ private const double VerticalSeamOverlap = 4.0;
+
+ ///
+ /// Place the vertical strip's seam cover over the pane's left border,
+ /// for the height of the selected row.
+ ///
+ private void OnVerticalSeamChanged()
+ {
+ if (_isClosed) return;
+
+ var row = _verticalTabHost.SelectionRowElement;
+ // Same reasoning as the horizontal gate: the layout MainWindow last
+ // applied, not the host's Visibility.
+ if (!_verticalTabsVisible
+ || _stripForciblyHidden
+ || row.Visibility != Visibility.Visible
+ || row.ActualWidth <= 0
+ || row.ActualHeight <= 2
+ || row is not Border { Background: { } fill })
+ {
+ _verticalSeamCover.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ // Start at the row's own right edge, which is already the terminal
+ // colour, so the cover cannot bleed back over the strip.
+ Windows.Foundation.Point start;
+ try
+ {
+ start = row.TransformToVisual(RootGrid)
+ .TransformPoint(new Windows.Foundation.Point(row.ActualWidth, 0));
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
+ or System.Runtime.InteropServices.COMException or NullReferenceException)
+ {
+ // The row is not in the tree yet, or is being torn out of it.
+ // The next SelectionRowChanged places it.
+ _verticalSeamCover.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ // Started a few pixels back inside the row rather than exactly at its
+ // edge: the row's right edge and the pane border are not flush, and
+ // the strip's own surface shows through whatever is left between
+ // them. Backing into the row costs nothing since both are filled
+ // with the same colour.
+ //
+ // Inside the row's top and bottom strokes, so those still close onto
+ // the pane border the way the horizontal tab's corners do.
+ const double edgeStroke = 1.0;
+ var top = start.Y + edgeStroke;
+ var bottom = start.Y + row.ActualHeight - edgeStroke;
+
+ // Clip to the scrolling row list. With more tabs than fit, the
+ // selected row can be scrolled out of it while its layout offset
+ // still reports where it would have been, and a cover placed there
+ // is a bar of terminal colour drawn across the pane at a height with
+ // no tab beside it.
+ //
+ // The list, not the host: the host is Row 0 with RowSpan 2, so it
+ // covers the whole window and clamping to it does nothing at all.
+ if (_verticalTabHost.SelectionViewport(RootGrid) is { } viewport)
+ {
+ top = Math.Max(top, viewport.Top);
+ bottom = Math.Min(bottom, viewport.Bottom);
+ }
+
+ if (bottom - top <= 0)
+ {
+ _verticalSeamCover.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ _verticalSeamCover.Margin = new Thickness(
+ start.X - VerticalSeamOverlap, top, 0, 0);
+ _verticalSeamCover.Height = bottom - top;
+ _verticalSeamCover.Fill = fill;
+ _verticalSeamCover.Visibility = Visibility.Visible;
+ }
+
///
/// Update config-driven chrome colors: pane border and the vertical tab
/// accent bar track cursor-color; the horizontal selected-tab background
@@ -2333,6 +2569,10 @@ private void UpdateCursorAccentColors()
var cc = _configService.CursorColor ?? _configService.ForegroundColor;
var wuiColor = Windows.UI.Color.FromArgb(0xFF,
(byte)(cc >> 16), (byte)(cc >> 8), (byte)cc);
+ // Both hosts, from the one value that also draws the pane border
+ // below: the selected tab is stroked in it on the three sides that
+ // do not meet the pane, so tab and pane read as a single shape.
+ _horizontalTabHost.SetAccentColor(wuiColor);
_verticalTabHost.SetAccentColor(wuiColor);
ApplyPerTabChrome();
@@ -2918,9 +3158,35 @@ private void UpdateQuakeStripVisibility()
// ordering accident in another method, not a guard here.
if (_isClosed) return;
if (!IsQuickTerminal) return;
- _layout.SetStripHidden(_tabManager.Tabs.Count <= 1, _verticalTabsVisible);
+
+ var hidden = _tabManager.Tabs.Count <= 1;
+ _layout.SetStripHidden(hidden, _verticalTabsVisible);
+ _stripForciblyHidden = hidden;
+
+ // The seam covers join the selected tab to the pane, so with no
+ // strip there is nothing to join and the cover is a bar of tab
+ // colour lying across the terminal. Neither seam event re-fires on
+ // its own here: the strip is collapsed rather than relaid out.
+ if (hidden)
+ {
+ _tabSeamCover.Visibility = Visibility.Collapsed;
+ _verticalSeamCover.Visibility = Visibility.Collapsed;
+ }
+ else
+ {
+ if (_verticalTabsVisible) _verticalTabHost.RefreshSelectionChrome();
+ else _horizontalTabHost.RefreshSeam();
+ }
}
+ ///
+ /// Quake-only: the strip is forced hidden regardless of layout mode, so
+ /// the seam covers have nothing to join to. Distinct from the hosts'
+ /// Visibility, which is not a layout signal -- see
+ /// .
+ ///
+ private bool _stripForciblyHidden;
+
private void OnQuakePinChanged(object sender, RoutedEventArgs e)
{
_quakePinned = QuakePinButton.IsChecked == true;
diff --git a/windows/Ghostty/Tabs/TabHost.xaml.cs b/windows/Ghostty/Tabs/TabHost.xaml.cs
index 80ce7d4a48..cab969d384 100644
--- a/windows/Ghostty/Tabs/TabHost.xaml.cs
+++ b/windows/Ghostty/Tabs/TabHost.xaml.cs
@@ -68,10 +68,17 @@ public TabHost(TabManager manager, PaneActionRouter router, DialogTracker dialog
foreach (var t in _manager.Tabs) AddItem(t);
SelectActive();
- _manager.TabAdded += (_, t) => { AddItem(t); SelectActive(); };
- _manager.TabRemoved += (_, t) => RemoveItem(t);
- _manager.TabMoved += (_, e) => MoveItem(e.tab, e.to);
- _manager.ActiveTabChanged += (_, _) => SelectActive();
+ _manager.TabAdded += (_, t) => { AddItem(t); SelectActive(); QueueBridgeUpdate(); };
+ _manager.TabRemoved += (_, t) => { RemoveItem(t); QueueBridgeUpdate(); };
+ _manager.TabMoved += (_, e) => { MoveItem(e.tab, e.to); QueueBridgeUpdate(); };
+ _manager.ActiveTabChanged += (_, _) => { SelectActive(); QueueBridgeUpdate(); };
+
+ // Every one of the calls above can run before the strip has arranged,
+ // and the bridge is placed from the selected item's layout slot, so
+ // it needs a pass once bounds exist. Width changes move every tab
+ // under Equal sizing, so the strip resizing moves it too.
+ Loaded += (_, _) => QueueBridgeUpdate();
+ TabViewControl.SizeChanged += (_, _) => UpdateSelectedTabBridge();
}
private void AddItem(TabModel tab)
@@ -286,6 +293,227 @@ internal void RefreshTabColors()
RefreshTabViewTheme();
if (selectedItem is not null)
NudgeTabViewItemVisual(selectedItem);
+ UpdateSelectedTabBridge();
+ }
+
+ ///
+ /// Where the selected tab sits horizontally, and what colour it is
+ /// filled with. Raised whenever either could have moved.
+ ///
+ ///
+ /// MainWindow uses this to cover the active pane's top border for
+ /// exactly that span, so the selected tab reads as continuous with the
+ /// terminal rather than having a line ruled between them. It is
+ /// MainWindow's to draw and not this control's: the border belongs to
+ /// the pane, which lives in the row below, and a cover drawn from up
+ /// here has to overhang its own parent to reach it -- where it is
+ /// clipped and never appears. Width is zero when there is nothing to
+ /// cover.
+ ///
+ internal event Action? SelectedTabSeamChanged;
+
+ ///
+ /// Re-raise the seam position. For callers that change something the
+ /// strip cannot observe -- a layout switch does not resize it or move
+ /// its selection, so nothing here would fire on its own.
+ ///
+ ///
+ /// Also arms the layout retry. A strip arriving from a cross-fade can
+ /// still be settling, and unlike the not-yet-arranged case its offsets
+ /// are non-zero, so the placement looks valid and would never be
+ /// revisited.
+ ///
+ internal void RefreshSeam()
+ {
+ QueueBridgeUpdate();
+ ArmBridgeRetry();
+ }
+
+ private void UpdateSelectedTabBridge()
+ {
+ var active = _manager.ActiveTab;
+ if (active is null
+ || !_itemByModel.TryGetValue(active, out var item)
+ || _selectedTabFillBrush is null)
+ {
+ SelectedTabSeamChanged?.Invoke(0, 0, null);
+ return;
+ }
+
+ if (item.ActualWidth <= 0)
+ {
+ // Not arranged yet. Hide rather than place from a stale offset,
+ // and come back on the pass that gives it bounds.
+ SelectedTabSeamChanged?.Invoke(0, 0, null);
+ ArmBridgeRetry();
+ return;
+ }
+
+ // The tab has bounds, so the retry budget did its job and resets for
+ // the next tab that arrives without any.
+ _bridgeRetries = 0;
+
+ Windows.Foundation.Point origin;
+ try
+ {
+ origin = item.TransformToVisual(this)
+ .TransformPoint(new Windows.Foundation.Point(0, 0));
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
+ or System.Runtime.InteropServices.COMException or NullReferenceException)
+ {
+ // The item is not in the tree yet, or is being pulled out of it
+ // (a close, or a drag to another window). COM and null are the
+ // shapes XAML interop throws once the tree is already going
+ // down, and this runs from a dispatcher callback, where an
+ // escaping exception is an unhandled one on the UI thread.
+ // The next refresh places it.
+ SelectedTabSeamChanged?.Invoke(0, 0, null);
+ return;
+ }
+
+ // Span the tab's full footprint. The side strokes sit at its outer
+ // edges and stop at the strip's bottom, so the border pixels that
+ // close the folder's two bottom corners are the ones just outside
+ // this span -- not inside it. Insetting by the stroke width instead
+ // leaves a pixel of border showing within the tab at each end, and
+ // that reads as a notch rather than a corner.
+ var left = origin.X;
+ var right = origin.X + item.ActualWidth;
+
+ // Clip to the list the tabs scroll inside. Once there are more tabs
+ // than fit, the selected one can be scrolled half out of view or
+ // right out of it, and its layout offset keeps reporting where the
+ // tab would be rather than where it is drawn. Uncovered, that walks
+ // the cover along the pane border and rubs out a stretch of it
+ // nowhere near the tab.
+ if (TabStripViewport() is { } viewport)
+ {
+ left = Math.Max(left, viewport.Left);
+ right = Math.Min(right, viewport.Right);
+ }
+
+ var width = right - left;
+ if (width <= 0)
+ {
+ SelectedTabSeamChanged?.Invoke(0, 0, null);
+ return;
+ }
+
+ var fill = active.Color != TabColor.None
+ ? TabColorBrush.From(TabColorPalette.Background(active.Color, selected: true))
+ : _selectedTabFillBrush;
+
+ SelectedTabSeamChanged?.Invoke(left, width, fill);
+ }
+
+ // The list the tab items scroll inside, looked up once out of the
+ // TabView's template. Null until the template has been applied.
+ private FrameworkElement? _tabListView;
+
+ ///
+ /// Bounds of the scrolling tab list, in this control's coordinates, or
+ /// null while the template has not been applied yet.
+ ///
+ private Windows.Foundation.Rect? TabStripViewport()
+ {
+ _tabListView ??= FindDescendantByName(TabViewControl, "TabListView");
+ if (_tabListView is not { ActualWidth: > 0 } list) return null;
+
+ try
+ {
+ var tl = list.TransformToVisual(this)
+ .TransformPoint(new Windows.Foundation.Point(0, 0));
+ return new Windows.Foundation.Rect(
+ tl.X, tl.Y, list.ActualWidth, list.ActualHeight);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
+ or System.Runtime.InteropServices.COMException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+
+ private static FrameworkElement? FindDescendantByName(DependencyObject root, string name)
+ {
+ var count = VisualTreeHelper.GetChildrenCount(root);
+ for (var i = 0; i < count; i++)
+ {
+ var child = VisualTreeHelper.GetChild(root, i);
+ if (child is FrameworkElement { } fe && fe.Name == name) return fe;
+ if (FindDescendantByName(child, name) is { } found) return found;
+ }
+ return null;
+ }
+
+ ///
+ /// Re-place the seam after the next layout pass, for the calls that land
+ /// before the strip has arranged (construction, a tab added or removed, a
+ /// drag reorder).
+ ///
+ ///
+ /// A dispatcher hop alone is not enough and the gap it leaves is not
+ /// cosmetic. A tab added at run time has no bounds by the time even a Low
+ /// priority callback runs, so the placement bails -- and nothing else
+ /// fires afterwards, because the strip's own size did not change. The
+ /// seam then stays uncovered until something unrelated moves. So the
+ /// bail arms a one-shot LayoutUpdated and the placement happens on the
+ /// pass that gives the tab its bounds. One-shot rather than a standing
+ /// subscription, which fires for every layout pass anywhere in the
+ /// window.
+ ///
+ private bool _bridgeUpdateQueued;
+
+ private void QueueBridgeUpdate()
+ {
+ // Coalesced. A single new tab reaches here from TabAdded and again
+ // from ActiveTabChanged, and each pass walks the visual tree twice
+ // and re-places the cover, all to the same answer.
+ if (_bridgeUpdateQueued) return;
+ _bridgeUpdateQueued = true;
+
+ DispatcherQueue.TryEnqueue(
+ Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
+ () =>
+ {
+ _bridgeUpdateQueued = false;
+ UpdateSelectedTabBridge();
+ });
+ }
+
+ private bool _bridgeRetryArmed;
+
+ ///
+ /// How many times a single placement may re-arm the layout retry before
+ /// giving up.
+ ///
+ ///
+ /// The retry exists for a tab that has no bounds yet and gets them on a
+ /// later pass. A strip that is collapsed and was never primed reports
+ /// zero forever, so without a cap the bail re-arms on every layout pass
+ /// anywhere in the window, permanently, and each one re-runs the
+ /// placement. Small because the legitimate case settles in one or two
+ /// passes; anything past that is the strip having no layout to wait for.
+ ///
+ private const int MaxBridgeRetries = 4;
+ private int _bridgeRetries;
+
+ private void ArmBridgeRetry()
+ {
+ if (_bridgeRetryArmed) return;
+ if (Visibility != Visibility.Visible) return;
+ if (_bridgeRetries >= MaxBridgeRetries) return;
+
+ _bridgeRetryArmed = true;
+ LayoutUpdated += OnBridgeRetryLayout;
+ }
+
+ private void OnBridgeRetryLayout(object? sender, object e)
+ {
+ LayoutUpdated -= OnBridgeRetryLayout;
+ _bridgeRetryArmed = false;
+ _bridgeRetries++;
+ UpdateSelectedTabBridge();
}
/// Force MUXC to re-read TabView/item header resources.
@@ -312,6 +540,33 @@ private void RefreshTabViewTheme()
"TabViewItemHeaderBackgroundSelectedPressed",
];
+ ///
+ /// Stroke around the selected tab: the same colour that frames the
+ /// active pane, so the tab and the pane it belongs to read as one shape
+ /// rather than as two pieces of chrome that happen to touch.
+ ///
+ ///
+ /// The folder shape itself is already in the WinUI template, which sets
+ /// the selected item's border thickness to 1,1,1,0 -- three sides
+ /// and nothing along the edge that meets the pane. It is invisible only
+ /// because the default brush is transparent, so painting that one brush
+ /// is the whole of the effect. Nothing here needs the strip to have a
+ /// surface of its own, which is why this works where recolouring the
+ /// strip did not: the strip is the window's Mica backdrop.
+ ///
+ private SolidColorBrush? _selectedBorderBrush;
+
+ ///
+ /// Set the stroke colour for the selected tab. Same value MainWindow
+ /// gives the active pane border, so the two cannot disagree.
+ ///
+ internal void SetAccentColor(Windows.UI.Color color)
+ {
+ if (_selectedBorderBrush?.Color == color) return;
+ _selectedBorderBrush = new SolidColorBrush(color);
+ RefreshTabColors();
+ }
+
///
/// Paint the full TabViewItem handle via per-item header resources.
/// The inner header panel stays transparent so the pill, close
@@ -334,6 +589,19 @@ private void ApplyTabChrome(
}
ApplyTabViewItemHeaderBrushes(viewItem, normalHandle, selectedHandle);
+
+ // A tab carrying a preset colour takes that colour's border, the same
+ // way its pane does, so the stroke keeps identifying which pane the
+ // tab belongs to instead of flattening every tab to the accent.
+ SolidColorBrush? selectedBorder = null;
+ if (selected)
+ {
+ selectedBorder = tab.Color != TabColor.None
+ ? TabColorBrush.From(TabColorPalette.Border(tab.Color))
+ : _selectedBorderBrush;
+ }
+ SetItemHeaderBrush(viewItem, "TabViewSelectedItemBorderBrush", selectedBorder);
+
headerPanel.Background = TransparentHeaderSelected;
}
diff --git a/windows/Ghostty/Tabs/VerticalTabHost.xaml.cs b/windows/Ghostty/Tabs/VerticalTabHost.xaml.cs
index 093391990a..ec2d79f650 100644
--- a/windows/Ghostty/Tabs/VerticalTabHost.xaml.cs
+++ b/windows/Ghostty/Tabs/VerticalTabHost.xaml.cs
@@ -294,8 +294,28 @@ internal void SetAccentColor(Windows.UI.Color color)
internal void SetSelectedTabColors(Windows.UI.Color background, Windows.UI.Color foreground)
=> _strip.SetSelectedTabColors(background, foreground);
+ ///
+ /// The filled row behind the selected tab, for MainWindow's seam cover.
+ ///
+ internal FrameworkElement SelectionRowElement => _strip.SelectionRowElement;
+
+ /// Raised whenever the selection row moves, resizes, or hides.
+ internal event Action? SelectionRowChanged
+ {
+ add => _strip.SelectionRowChanged += value;
+ remove => _strip.SelectionRowChanged -= value;
+ }
+
internal void RefreshSelectionChrome() => _strip.RefreshSelectionChrome();
+ ///
+ /// Vertical bounds of the scrolling row list, relative to
+ /// , or null before the strip's template
+ /// has been applied.
+ ///
+ internal (double Top, double Bottom)? SelectionViewport(UIElement reference)
+ => _strip.SelectionViewport(reference);
+
internal void SyncSelectionFromManager() => _strip.SyncSelectionFromManager();
internal void RefreshTabColors() => _strip.RefreshTabColors();
diff --git a/windows/Ghostty/Tabs/VerticalTabStrip.xaml.cs b/windows/Ghostty/Tabs/VerticalTabStrip.xaml.cs
index 716784715a..18103d58bd 100644
--- a/windows/Ghostty/Tabs/VerticalTabStrip.xaml.cs
+++ b/windows/Ghostty/Tabs/VerticalTabStrip.xaml.cs
@@ -40,6 +40,7 @@ internal sealed partial class VerticalTabStrip : UserControl
private SolidColorBrush? _defaultActiveTextBrush;
private bool _selectionRefreshScheduled;
private bool _placementSettleHooked;
+ private bool _selectionSyncDeferred;
private uint _stripBackdropPacked = 0x0C0C0C;
private static readonly SolidColorBrush TransparentBrush =
@@ -117,7 +118,18 @@ public VerticalTabStrip(TabManager manager)
SizeChanged += (_, _) => UpdateSelectionRow();
NavView.SizeChanged += (_, _) => UpdateSelectionRow();
NavView.Loaded += (_, _) => RefreshSelectionChrome();
- Loaded += (_, _) => RefreshSelectionChrome();
+ Loaded += (_, _) =>
+ {
+ // Everything SyncSelectionFromManager declined to do while this
+ // strip had no template, now that it has one.
+ if (_selectionSyncDeferred)
+ {
+ _selectionSyncDeferred = false;
+ SyncSelectionFromManager();
+ }
+
+ RefreshSelectionChrome();
+ };
_manager.Tabs.CollectionChanged += OnTabsCollectionChanged;
_manager.ActiveTabChanged += (_, _) => SyncSelectionFromManager();
@@ -190,10 +202,24 @@ internal void ApplyDefaultPaneChrome(ElementTheme theme)
ApplyDefaultSelectedTabResources();
+ // Unselected rows sit on the strip, which is a theme surface, so
+ // they go back to following the element theme.
ClearNavResource("NavigationViewItemForeground");
ClearNavResource("NavigationViewItemForegroundPointerOver");
- ClearNavResource("NavigationViewItemForegroundSelected");
- ClearNavResource("NavigationViewItemForegroundSelectedPointerOver");
+
+ // The selected row does not: it is painted with the terminal
+ // background, so its title has to keep the brush
+ // ApplyDefaultSelectedTabResources just calibrated against that
+ // background. Clearing it unconditionally, two lines after applying
+ // it, put the title back on the element theme's foreground -- which
+ // was survivable only while the terminal was always dark and that
+ // foreground was always white. Against the light half of the theme
+ // it came out white on #F4F6FB, at 1.11:1.
+ if (_defaultActiveTextBrush is null)
+ {
+ ClearNavResource("NavigationViewItemForegroundSelected");
+ ClearNavResource("NavigationViewItemForegroundSelectedPointerOver");
+ }
RefreshNavViewTheme();
RecolorNavItems();
@@ -313,11 +339,22 @@ internal void SetSelectionRowSuppressed(bool suppressed)
private bool _selectionRowSuppressed;
+ ///
+ /// The filled row behind the selected tab. Exposed so MainWindow can
+ /// measure where it ends and cover the pane border for exactly that
+ /// span, the way the horizontal strip's seam is covered.
+ ///
+ internal FrameworkElement SelectionRowElement => SelectionRow;
+
+ /// Raised whenever the selection row moves, resizes, or hides.
+ internal event Action? SelectionRowChanged;
+
private void UpdateSelectionRow()
{
if (_selectionRowSuppressed)
{
SelectionRow.Visibility = Visibility.Collapsed;
+ SelectionRowChanged?.Invoke();
return;
}
@@ -328,6 +365,7 @@ private void UpdateSelectionRow()
|| ActualWidth <= 0)
{
SelectionRow.Visibility = Visibility.Collapsed;
+ SelectionRowChanged?.Invoke();
return;
}
@@ -342,7 +380,18 @@ private void UpdateSelectionRow()
Canvas.SetTop(SelectionRow, topLeft.Y + RowInsetVertical);
SelectionRow.CornerRadius = new CornerRadius(0);
SelectionRow.Background = ResolveSelectionRowFill(_manager.ActiveTab);
+
+ // The same folder stroke the horizontal strip gets, rotated: the row
+ // meets the pane along its right edge, so that is the side left open
+ // and the other three carry the pane's own border colour. A tab with
+ // a preset colour is stroked in that colour, matching its pane.
+ SelectionRow.BorderBrush = _manager.ActiveTab.Color != TabColor.None
+ ? TabColorBrush.From(TabColorPalette.Border(_manager.ActiveTab.Color))
+ : AccentBrush;
+ SelectionRow.BorderThickness = new Thickness(1, 1, 0, 1);
+
SelectionRow.Visibility = Visibility.Visible;
+ SelectionRowChanged?.Invoke();
}
///
@@ -588,9 +637,29 @@ private void ScheduleSelectionLayoutPass(bool retryIfZeroBounds)
/// Collapsed -- MUXC can drop
/// and leave the active row off-screen in the pane scroller.
///
+ ///
+ /// Does nothing while this strip has never been loaded, which in
+ /// horizontal-tab mode is the whole session: the coordinator collapses
+ /// this host and deliberately does not prime it, because showing a
+ /// never-laid-out NavigationView from the constructor crashed XAML's
+ /// measure walk. Assigning SelectedItem is where MUXC resolves the
+ /// selected item's container and selection indicator, and on a control
+ /// with no template there is nothing to resolve -- which is where an
+ /// access violation inside set_SelectedItem has been reported from.
+ /// The work is latched and replayed on Loaded instead; every path that
+ /// makes this strip visible already calls back in here afterwards, so
+ /// the latch only has to cover the case where nothing else does.
+ ///
internal void SyncSelectionFromManager()
{
if (_syncing) return;
+
+ if (!IsLoaded)
+ {
+ _selectionSyncDeferred = true;
+ return;
+ }
+
if (_manager.ActiveTab is null) return;
if (!_items.TryGetValue(_manager.ActiveTab, out var item)) return;
@@ -604,6 +673,53 @@ internal void SyncSelectionFromManager()
ScheduleSelectionLayoutPass(retryIfZeroBounds: true);
}
+ // The scroller the rows live inside, out of the NavigationView's
+ // template. Null until that template has been applied.
+ private FrameworkElement? _menuItemsScroller;
+
+ ///
+ /// Vertical bounds of the scrolling row list, in RootGrid-relative
+ /// coordinates via , or null while the
+ /// template has not been applied.
+ ///
+ ///
+ /// Deliberately the scroller and not this control: with more tabs than
+ /// fit, the selected row scrolls out of the list while its layout offset
+ /// still reports where it would have been. A caller clipping to the
+ /// control instead clips to something that always contains the row, so
+ /// the clamp does nothing and a cover gets drawn across the pane at a
+ /// height with no tab beside it.
+ ///
+ internal (double Top, double Bottom)? SelectionViewport(UIElement reference)
+ {
+ _menuItemsScroller ??= FindDescendantByName(NavView, "MenuItemsScrollViewer");
+ if (_menuItemsScroller is not { ActualHeight: > 0 } scroller) return null;
+
+ try
+ {
+ var top = scroller.TransformToVisual(reference)
+ .TransformPoint(new Windows.Foundation.Point(0, 0)).Y;
+ return (top, top + scroller.ActualHeight);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
+ or System.Runtime.InteropServices.COMException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+
+ private static FrameworkElement? FindDescendantByName(DependencyObject root, string name)
+ {
+ var count = VisualTreeHelper.GetChildrenCount(root);
+ for (var i = 0; i < count; i++)
+ {
+ var child = VisualTreeHelper.GetChild(root, i);
+ if (child is FrameworkElement fe && fe.Name == name) return fe;
+ if (FindDescendantByName(child, name) is { } found) return found;
+ }
+ return null;
+ }
+
private void EnsureActiveItemVisible()
{
if (_manager.ActiveTab is null) return;
@@ -809,10 +925,22 @@ private void AddItem(TabModel tab, int index = -1)
_items[tab] = item;
_hooks[tab] = new TabHooks(textBinding, colorBinding, vm, iconHandler);
- if (index >= 0 && index <= NavView.MenuItems.Count)
- NavView.MenuItems.Insert(index, item);
- else
- NavView.MenuItems.Add(item);
+
+ // Fenced because an Insert before the current selection shifts what
+ // MUXC considers selected and raises SelectionChanged for a tab the
+ // user did not pick. Unfenced, that reaches OnNavSelectionChanged,
+ // activates the wrong tab, and comes back around to assign
+ // SelectedItem while MUXC is still inside its own notification.
+ _syncing = true;
+ try
+ {
+ if (index >= 0 && index <= NavView.MenuItems.Count)
+ NavView.MenuItems.Insert(index, item);
+ else
+ NavView.MenuItems.Add(item);
+ }
+ finally { _syncing = false; }
+
ApplyItemTabColor(item, tab);
}
@@ -839,7 +967,14 @@ private void OnRowCloseClick(object sender, RoutedEventArgs e)
private void RemoveItem(TabModel tab)
{
if (!_items.TryGetValue(tab, out var item)) return;
- NavView.MenuItems.Remove(item);
+
+ // Fenced for the same reason as the insert in AddItem: removing the
+ // selected row moves MUXC's selection to a neighbour and reports it
+ // as the user's choice.
+ _syncing = true;
+ try { NavView.MenuItems.Remove(item); }
+ finally { _syncing = false; }
+
_items.Remove(tab);
if (_hooks.Remove(tab, out var hooks))
hooks.Dispose();