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();