Skip to content
286 changes: 276 additions & 10 deletions windows/Ghostty/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -2312,6 +2397,157 @@ private void ApplyButtonColors(
if (prev.PressedFg != pressedFg) tb.ButtonPressedForegroundColor = pressedFg;
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;

/// <summary>
/// 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).
/// </summary>
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;

/// <summary>
/// How far back into the selected row the vertical seam cover starts.
/// </summary>
private const double VerticalSeamOverlap = 4.0;

/// <summary>
/// Place the vertical strip's seam cover over the pane's left border,
/// for the height of the selected row.
/// </summary>
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;
}

/// <summary>
/// Update config-driven chrome colors: pane border and the vertical tab
/// accent bar track cursor-color; the horizontal selected-tab background
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
}

/// <summary>
/// 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
/// <see cref="OnSelectedTabSeamChanged"/>.
/// </summary>
private bool _stripForciblyHidden;

private void OnQuakePinChanged(object sender, RoutedEventArgs e)
{
_quakePinned = QuakePinButton.IsChecked == true;
Expand Down
Loading