Split-pane support (#13)#56
Merged
Merged
Conversation
Tabs hold exactly one TerminalControl today, so there is no way to host more than one terminal surface inside a tab. The pane-tree data model and the custom Panel that lays it out are prerequisites for #13; both are introduced here as a standalone foundation, without yet touching Tab ownership or any ghostty action wiring. Without this, every later phase (NEW_SPLIT handling, drag-resize, direction-based navigation, close-pane semantics) has nothing to attach to. New files: * Pane.h — pure C++ binary-tree node. Either a leaf wrapping a UIElement, or an internal node with orientation + ratio + two children. Move-disabled so the parent back-pointers stay valid. * SplitPanel.idl/.h/.cpp — Panel-derived runtime class. Owns the Pane root, walks the tree in MeasureOverride / ArrangeOverride, and keeps the framework's Children() collection synced with the leaf set so hit-testing and focus work without nested Panels. No call sites yet — Phase 2 will swap Tab's single TerminalControl member for a Pane root and route NEW_SPLIT through it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab still held a single TerminalControl directly, so adding a second pane to a tab would require changing every host caller that goes through Tab — the API quietly assumed one terminal per tab. Leaving the assumption in place would make NEW_SPLIT a cross-cutting rewrite instead of a localised one. SplitPanel now owns the Pane tree (single unique_ptr owner per its docs); Tab borrows it via panel.Root() and tracks the active leaf as a non-owning pointer. TabFactory wraps the new TerminalControl in a single-leaf Pane tree, hosts it in a SplitPanel, and assigns the SplitPanel as the TabViewItem's content — MainWindow no longer sets Content directly. Tabs::FindBySurface walks the tree so it stays correct once a tab has more than one leaf. Behaviour is unchanged today: every tab still has exactly one leaf, so the SplitPanel collapses to "arrange the single child at the full rect", input/IME/focus paths still route to ActiveControl().
close_surface_cb's userdata was a TabId — one ID per tab. That's only correct while every tab has exactly one pane. Once a tab can host multiple panes, the same TabId would route every pane's exit-on-shell-close to the same handler, leaving the host unable to identify which leaf to tear down. Renames TabId → PaneId, allocates one per leaf at MakeLeaf time, and stores it on Pane itself. Tabs::FindByPaneId now walks each tab's tree and returns both the owning Tab and the matching leaf, so close_surface_cb can target the exact leaf. The leaf-level Detach call replaces the prior ActiveControl().Detach() so a stale callback against a non-active pane will still detach the right control. Behaviour is unchanged today (one leaf per tab → PaneId 1:1 to Tab); the multi-pane close handling that builds on this lookup lands in a later commit alongside NEW_SPLIT.
SplitPanel only supported full-tree replacement via SetRoot. NEW_SPLIT and CLOSE_PANE both need to edit one node of an existing tree — sending the whole tree through SetRoot would invalidate every Pane*/active-leaf pointer the host holds, even for nodes that aren't changing. Adds two primitives: Pane::ReplaceChild swaps a single child unique_ptr on an internal node (fixing up the parent back-pointer); SplitPanel::ReplaceLeaf wraps it with the root-vs-non-root branch and triggers the children-sync / layout invalidation. Both return false for invalid inputs (leaf, mismatched child, null subtree) so the caller can fail closed. No callers wired up yet; the NEW_SPLIT action handler lands next.
Without this, the default ctrl+shift+o / ctrl+shift+e keybinds for horizontal/vertical splits do nothing — ghostty fires the action, the host's action_cb falls through, the user sees an unresponsive keybind. TabFactory grows a public MakeLeaf that builds a new TerminalControl + DComp handle + ghostty surface (the work Make() already did inline, hoisted into a helper). Make() now uses it and only owns the SplitPanel/Tab wiring. The NEW_SPLIT handler on MainWindow dispatches to the UI thread, looks up the source pane, asks the factory for a sibling leaf, then builds a split subtree wrapping the source content + the new leaf and swaps it in via SplitPanel::ReplaceLeaf. Active leaf shifts to the new pane and focus is requested. Direction maps as in every other terminal: RIGHT/DOWN put the new pane after the source on the layout axis, LEFT/UP before. Initial swap chain size is the source pane's current size halved on the split axis — close enough that the first frame doesn't have to stretch; the SplitPanel's first arrange pass corrects both leaves to their final extent via SizeChanged → ghostty resize. Close semantics for non-active panes (shell exit in a split leaf) are still tab-wide — fixed in the next commit.
After NEW_SPLIT, a tab can hold more than one pane. Without this, shell-exit / Ctrl+D in a non-active pane would still take down the whole tab (because close_surface_cb's old path always called the TabView RemoveAt), and closing a tab via the X button only detached the active pane's swap chain — leaving the other panes' swap chains bound when their SwapChainPanel got unparented, which is exactly the AV at +0x1F8 documented in TabCloseRequested. SplitPanel learns RemoveLeaf: it pulls the surviving sibling out of the parent split, then replaces the parent in its slot with the sibling subtree. The doomed leaf and the now-childless parent are destroyed when their unique_ptrs are overwritten. Returns whether the tree is now empty (caller closes the tab) or just collapsed (tab keeps rendering). Tab exposes DetachAll() so every close path sweeps every leaf before the panel is unparented. close_surface_cb now routes through MainWindow::CloseSurfaceByPaneId: detach the leaf, attempt the in-tree collapse, retarget the active leaf into the surviving sibling if the closed pane had focus, and fall back to the tab-close path only when the tree is actually empty.
After NEW_SPLIT, the boundary between two panes was fixed at the 0.5 ratio Pane::MakeSplit started with. Without a visible / grabbable splitter, the user has no way to redistribute space between panes — every other terminal that supports splits exposes this. SplitPanel grows one Border per internal node, inserted into Children() alongside the leaf content and arranged at the boundary ArrangeNode is computing anyway (reserving 6 DIP of thickness on the split axis). The cursor flips to SizeWestEast / SizeNorthSouth over the strip and PointerPressed → CapturePointer → PointerMoved updates the parent's ratio; ratio clamping in Pane keeps both children visible. Pane caches its arranged rect so the move math can read it back without re-walking from the root. Keyboard-driven RESIZE_SPLIT is the next commit — same underlying ratio mutation, different input source.
The splitter-drag path covers mouse resize; ghostty also has keybind-driven RESIZE_SPLIT (ctrl+shift+arrows in defaults) which the host was ignoring, so users on keyboard-only workflows had no way to redistribute pane space. The handler walks up from the active leaf to the nearest ancestor split whose axis matches the direction, then nudges its ratio by `amount` DIPs along the split axis. The first/second-child side of the active leaf determines whether RIGHT/DOWN translates to a ratio increase or decrease — so the boundary always moves in the direction the user actually pressed regardless of which side of the split they're focused on.
Without this, ghostty's pane navigation keybinds (ctrl+alt+arrows in defaults, plus ctrl+shift+] / [ for next/prev) silently do nothing once a tab has more than one pane — the user can split but not move between panes by keyboard. PREVIOUS / NEXT cycle the tab's leaves in depth-first order (matching the natural left-to-right reading of the tree). The cardinal variants pick the leaf whose arranged rect is adjacent in that direction, scored as primary-axis distance plus a perpendicular penalty so an off-axis pane never beats an aligned one. On a focus move the active leaf retargets and the new pane's TerminalControl takes focus through the same path NEW_SPLIT and split collapse use.
In a multi-pane tab nothing told the user which pane would receive their next keystroke — both panes look identical until a key actually goes somewhere. With GOTO_SPLIT moving focus invisibly, the user ended up typing into the wrong pane. Wraps the inner SwapChainPanel in a Border with constant BorderThickness=1; GotFocus flips BorderBrush to a fixed accent colour and LostFocus flips it back to transparent. Keeping the thickness constant means focus toggles don't reflow the terminal grid (which would resize ghostty's surface on every focus change, visible as a brief content shift). A 1 DIP transparent ring is permanently allocated around each pane, visible only as whatever the SplitPanel paints behind the terminal in the non-focused state — a small cost for unambiguous focus indication.
After arbitrary resize-by-drag / RESIZE_SPLIT input, ratios drift unevenly and there was no way to snap back to "every pane the same size". Without this the user had to close panes and re-split to get predictable proportions. SplitPanel grows EqualizeAll(): walks the internal-node list it already maintains for splitters and resets every Ratio() to 0.5, then invalidates layout. MainWindow exposes the dispatched handler that the action_cb routes EQUALIZE_SPLITS through. No-op for a single-leaf tree.
In multi-pane tabs there's no way to temporarily focus on one pane at full size without closing siblings — useful when one pane is running a long compile / log tail and you want to read it without the visual clutter of the other splits. SplitPanel tracks an m_zoomedLeaf; SetZoomed flips Visibility on every child (zoomed leaf Visible, others / splitters Collapsed) and MeasureOverride / ArrangeOverride short-circuit to just that leaf at the panel's full extent. Hidden SwapChainPanels keep their bound swap chain handle, so unzooming restores the previous layout without re-creating surfaces. Any tree mutation (NEW_SPLIT, ReplaceLeaf, RemoveLeaf) clears the zoom because SyncChildrenFromTree resets the state pointer — the prior leaf pointer can no longer be trusted across a tree rebuild. A second TOGGLE press anywhere unzooms; targeting a single-leaf tab is a no-op.
ghostty_surface_new walks into the DirectX backend and has historically tripped NVIDIA driver hardware exceptions in dx_create_texture. CreateTab already wraps the call in RunSEHGuarded so a driver AV during initial tab creation doesn't take the process out. NEW_SPLIT goes through the same MakeLeaf path but had no such guard — a driver fault while splitting would kill every other pane in the window, even ones the user wasn't touching. Mirrors the CreateTab recovery: hide the window, show an explanatory dialog, post WM_CLOSE so the next launch can wait for the driver to settle (existing crash-flag recovery in main.cpp). The non-SEH "MakeLeaf returned nullptr" path is unchanged — that covers handle / surface_new soft failures where the heap is still fine.
ProtectedCursor is a protected method on UIElement; Border is sealed in WinUI 3 so we can't subclass it to expose a public setter. Trying to call border.ProtectedCursor(...) from outside fails to compile and the cascading failure (cppwinrt projection not regenerated) showed up as hundreds of bogus "can't open winrt/*.h" / "member not found" errors in the IDE. Dropping the cursor change for now — the splitter strip is still visibly distinct (6 DIP semi-transparent gray) so it can be grabbed without the cursor hint. A follow-up can swap the Border for a custom UserControl-based splitter whose own impl can call ProtectedCursor on itself.
Passing `*this` to GetCurrentPoint relies on a two-step implicit conversion (impl -> SplitPanel projection -> UIElement projection) that C++ won't follow without help; only one user-defined conversion is allowed in an implicit sequence. Going through get_strong().as<UIElement>() makes the QueryInterface step explicit so the call site compiles cleanly.
The split work introduced the first uses of std::max and std::numeric_limits<T>::max() in the project. Without NOMINMAX, windows.h's max/min function-like macros silently rewrite every std::max(a, b) into std::((a > b) ? a : b) — a syntax error — and std::numeric_limits<T>::max() into ::() with zero arguments, which trips the "max macro: too few arguments" warning that propagated into a full PCH compile failure and a flood of cascading "can't open winrt/*.h" / "member not found" errors in every TU. Defining NOMINMAX at the top of pch.h before windows.h suppresses the macros entirely; nothing in the codebase uses the un-prefixed max() so there's no caller to migrate. /utf-8 silences the C4819 warning for source files saved as UTF-8 without BOM (em-dash etc. in comments on Japanese codepage systems) by telling MSVC the input encoding explicitly.
Adding /utf-8 caused the PCH to fail to regenerate and IntelliSense to deadlock on the Generated Files directory (locks held by the language service prevent the next build from cleaning them, every subsequent rebuild fails to regenerate the cppwinrt projection, errors cascade through every TU). The C4819 warnings the flag was meant to silence are non-fatal — just MSVC noting that the source contains characters CP932 can't display. The build itself proceeds and the comments stay readable in any UTF-8 editor. Trading a warning we can ignore for a working PCH is the obvious move. NOMINMAX stays — that one is load-bearing for std::max / std::numeric_limits::max().
After zooming a pane (Visibility=Collapsed on every other leaf) and then closing the zoomed pane via shell exit, the surviving panes showed up blank: SyncChildrenFromTree cleared and re-appended the Children() collection, but Visibility is a per-element property that survives reparenting — so the leaves re-entered the tree still flagged Collapsed. Calls UpdateChildVisibility() at the end of SyncChildrenFromTree with m_zoomedLeaf already reset, which flips every child back to Visible. The path is also future-proof: any time the tree is rebuilt (NEW_SPLIT, ReplaceLeaf, RemoveLeaf), Visibility is guaranteed to match the post-mutation zoom state.
With one TerminalControl per tab the inline Focus call inside the Activated handler was the last writer wins and stuck. With multiple panes per tab there are multiple IsTabStop=true UserControls in the same window, and WinUI's default-focus pass on reactivation lands on a different one than our active leaf — sometimes leaving no visible focus border and dead keyboard input. Switching tabs recovered because SelectionChanged is itself dispatcher-scheduled so the Focus call there is naturally last. Mirrors that ordering: re-route the activation Focus call through DispatcherQueue.TryEnqueue at Low priority so it runs after every default-focus assignment XAML queued for this activation cycle. The Deactivated branch stays inline since NotifyImeFocusLeave doesn't compete with anything.
User-reported regressions (keyboard resize, prev/next cycle,
equalize, window-activation focus) can come from either bind-side
(ghostty not firing the action because the keybind isn't
configured) or host-side (action arrives but handler bails). The
two require different fixes and are indistinguishable without a
trace.
Adds OutputDebugStringA at:
* Activated handler entry — tags Deactivated / CodeActivated /
PointerActivated state.
* Deferred Focus call inside the dispatched lambda — logs the
Tab::Focus return value so a silent "Focus refused" becomes
visible.
* action_cb just inside the split-family fanout — confirms each
ghostty action actually arrives.
* Each handler (Equalize / Goto / Resize / Zoom) entry + early-
bail points — pins down which guard returned.
Output goes to DebugView / VS Output. Remove once the issues are
diagnosed.
Adds OutputDebugStringA traces at the four signals that pin down
the title-bar focus-loss bug:
* TerminalControl GotFocus / LostFocus, tagged with the surface
pointer so it's clear which pane lost or gained focus when
multiple are open.
* DragRegion PointerEntered / PointerPressed / PointerReleased,
so we can tell whether the OS routes the title-bar click through
XAML at all (HTCAPTION processing may swallow it before XAML
sees it).
With these the user can click the title bar empty area once, copy
the log slice spanning the click, and we can reconstruct the exact
event order — pointer events, focus transitions, and the existing
Activated state log — instead of guessing whether the root cause
is the Win32 title-bar tracking modal loop, a XAML focus shift to
some hidden default-focus element, or something else entirely.
Confirmed via TC GotFocus/LostFocus + Activated state diagnostic
logs that clicking the DragRegion (empty title-bar strip set via
SetTitleBar) reliably:
* Fires TerminalControl LostFocus.
* Transitions the window to WindowActivationState::Deactivated
with no follow-up CodeActivated / PointerActivated event.
So the deferred-Focus path in the Activated handler never re-runs
and the focus border / keyboard input stay dead until the user
clicks somewhere that re-activates the window (tab switch was the
incidental recovery they observed).
PointerReleased on DragRegion still fires through XAML, so we
hook it and call Window.Activate() on ourselves. The synthetic
activation re-enters the existing Activated handler with
CodeActivated, which queues the Focus restore. Net effect: the
title-bar click is now visually transparent — focus border stays
on the active leaf and keystrokes keep flowing.
The previous fix only handled clicks that fire DragRegion's XAML PointerReleased — but title-bar caption-area clicks outside the DragRegion strip (between tabs and the right edge, above tab headers, etc.) go through the OS HTCAPTION path without raising any XAML pointer event. The user-visible bug — focus border gone, keyboard input dead until the user switches tabs — still reproduced. Diagnostic logs confirmed: the broken click sequence reached [TC] LostFocus + [Activated] Deactivated with no DragRegion pointer events at all. Generalising the fix: in the Activated handler's Deactivated branch, check whether our HWND is still GetForegroundWindow(). If yes, the deactivation is spurious (Win32 title-bar tracking modal loop or similar) and never auto-recovers, so we schedule a self-Activate via the dispatcher queue. The resulting CodeActivated re-enters the activated path and the existing deferred-Focus restore puts focus back on the active leaf. Genuine deactivation (alt-tab, click another window) is unaffected because by the time we observe Deactivated, the foreground window has already moved to the other app and the equality check fails.
The synchronous GetForegroundWindow() check inside the Activated handler's Deactivated branch reads a transient value during Win32 title-bar tracking — DefWindowProc's HTCAPTION modal loop briefly hands foreground to internal tracking proxies, so an inline check sees our HWND is NOT the foreground and the spurious-deactivation recovery never fires (confirmed in user logs: PointerPressed / Released round-trip for several title-bar clicks, eventual Deactivated, no "spurious deactivation" log line, no recovery). Bouncing the check through the dispatcher queue moves it to after the modal loop returns and foreground state stabilises. By then, either we're still in front (the click never really backgrounded us, so we self-Activate to re-trigger the focus-restore path) or the foreground has settled on another app (genuine alt-tab away, leave the window alone).
The deferred GetForegroundWindow() check inside the Activated handler's Deactivated branch still returned false in the title-bar-click repro — the OS HTCAPTION tracking proxy briefly owns the foreground window slot, and even after the dispatcher tick the foreground hasn't returned to our HWND. Diagnostic logs confirmed: PointerReleased fired, TC LostFocus fired immediately, Deactivated fired only after several clicks, and the deferred check reported "genuine deactivation, leaving alone". Since the PointerReleased event reliably fires on every DragRegion click, recover focus directly from there instead of trying to disambiguate spurious vs genuine deactivation later. Defer through the dispatcher so the Focus call lands after the HTCAPTION tracking modal loop returns and XAML has finished moving focus into limbo. The Activated-handler deferred check still runs for clicks that miss DragRegion (caption-area outside the strip), giving us a second recovery path for cases where the foreground does eventually return to our window before the deferred tick.
The OutputDebugStringA traces in the Activated handler, TerminalControl GotFocus / LostFocus, DragRegion pointer events, the action_cb split-family fanout, and each split-action handler served their purpose pinning down the title-bar click focus loss (spurious deactivation classifier was wrong, PointerReleased was the right recovery hook). Keep the recovery logic, drop the noise. Also drops the now-unused <cstdio> include in both TUs.
User feedback: with the focus border drawn permanently around the active pane, the lines on the outer window edge (not adjacent to any sibling pane) bleed into the visible frame and clutter the chrome. They wanted lines between panes only. Instead of removing the border entirely or threading position awareness into TerminalControl to suppress the outer edges, make the indicator transient: GotFocus still flips BorderBrush to the accent, but a DispatcherTimer fires 1.5 s later and flips it back to transparent. Subsequent focus changes restart the timer so the border stays visible through rapid pane hopping but settles to hidden during normal typing. Net effect: a brief "you landed here" flash on every focus move, no persistent outer-edge line. LostFocus also stops the timer immediately so the previously- focused pane's border doesn't linger past the focus change.
Two follow-ups to the focus-border feedback:
* The 6 DIP splitter strip was wider than the user wanted given
that focus border is no longer there to balance it visually.
Drop to 2 DIPs — visible as a thin separator line, still
grabbable for drag-resize.
* The focus border around each TerminalControl reserved 1 DIP of
layout space whether visible or not, which always left a thin
transparent ring between the SwapChainPanel and the pane edge
(showing the SplitPanel / backdrop behind it as an outer
"frame"). Auto-hide didn't help because the layout slot is
permanent. Remove the Border element entirely so the
SwapChainPanel fills the pane directly; with no extra ring,
splits show as adjacent terminal grids separated only by the
splitter line.
Loses the brief focus-changed flash that the auto-hide border
gave. The user's existing signal — typing lands in the pane they
just clicked / navigated to — remains the focus tell, and the
splitter line itself is the only chrome between panes.
The earlier outer-frame complaint was about the 1 DIP layout slot the wrapping Border permanently reserved (visible as a gap around each pane even when the border was transparent). With the Border removed entirely the visual chrome looked clean, but there was no "which pane just took focus" indicator anymore. Put the Border back as a sibling overlay inside a Grid rather than a wrapper: same z-cell as the SwapChainPanel, IsHitTestVisible=False so input passes through. The Border still draws on its 1 DIP edge slot, but because it's overlapping the SwapChainPanel (which renders the terminal cells) instead of displacing it, there's no gap when BorderBrush is Transparent — the only visible difference is the accent line during the flash. ShowFocusBorder + DispatcherTimer come back from the auto-hide-then-remove iteration: GotFocus flips the brush to the accent and (re)starts a 1.5 s timer, Tick (or LostFocus) flips it back. Quick pane hops keep the border visible through the rapid focus changes; settle on one pane and the indicator fades out.
Inline ARGB literals + a std::chrono::milliseconds(1500) buried in ShowFocusBorder made tuning the visual feel a search-and-replace exercise across the function body. Hoist the values into kFocusBorderAccent, kFocusBorderHidden, and kFocusBorderHideDelay in an unnamed namespace at the top of the TU so the call sites read like prose and adjusting the look only touches the constant table.
User test report: pressing the resize-split arrow keys moved the boundary opposite the arrow whenever the active pane sat on the second-child side of the split. With multiple nested splits where focus can land on either side, every direction looked reversed. The handler was applying a tmux-style "grow the active pane in the arrow direction" rule that flipped the ratio sign when the active leaf was the second child. Dropping the flip leaves the simpler "arrow = direction the boundary moves" mapping: LEFT / UP shrink the ratio (boundary toward -axis), RIGHT / DOWN grow it (boundary toward +axis), no matter which side has focus.
34 tasks
Mirror upstream's getActiveSurface pattern (#62) so future APP-target actions, multi-window focus delivery, IPC bridges, and the upcoming dim overlay all have a single source of truth for "which surface is the user actually looking at." Wiring: TabFactory takes an optional onLeafFocused callback at construction and stamps it onto every TerminalControl it creates; the control fires the callback from its GotFocus handler with the underlying ghostty_surface_t. MainWindow registers the callback when it builds the factory, parks the surface in m_activeSurface, and exposes GetActiveSurface() for downstream readers. The TerminalControl never reaches into MainWindow globals directly, which keeps the layering host -> control rather than the other way round. Clear m_activeSurface in CloseSurfaceByPaneId when the surface being torn down is the cached one. The next focus delivery on the retargeted sibling refills it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch the active-split indicator from the 1.5s accent-border flash to the dim-overlay convention both upstream apprts use (macOS SurfaceView, GTK unfocused-split style). Dim is more discoverable — the bright pane reads as active without the user having to find an explicit border — and it picks up the cross-platform unfocused-split-opacity / unfocused-split-fill config keys users already expect from the docs. TerminalControl.xaml now hosts an UnfocusedDim Rectangle in place of FocusBorder, hit-test transparent so pointer routing stays unchanged across focus state. ApplyFocusVisual replaces the old ShowFocusBorder timer dance: GotFocus hides the overlay, LostFocus shows it with the cached fill / opacity. TabFactory resolves both values from ghostty config in MakeLeaf (`unfocused-split-opacity`, `unfocused-split-fill`, falling back to `background` when fill is left at default) and hands them to SetUnfocusedAppearance on the new control. GhosttyApp grows a ConfigHandle() getter so the factory can do ghostty_config_get lookups without round-tripping through MainWindow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 2 DIP line was wide enough to read as a "gutter" between panes, which fought the dim-overlay aesthetic the previous commit just introduced — the bright/dim contrast already conveys the boundary, so the chrome line should be hairline rather than load-bearing. 1 DIP also matches the upstream split-divider visual weight. The click hit-target shrinks correspondingly, but the existing single- Border implementation conflates visual and hit area; if drag-resize gets too finicky we can layer in a wider transparent hit area later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the hardcoded semi-transparent grey on every splitter Border with a brush resolved from ghostty config the same way upstream's macOS apprt does it (Ghostty.Config.swift `splitDividerColor`): prefer the user-set `split-divider-color`; otherwise derive from `background` by darkening — 8% for light backgrounds, 40% for dark ones — so the divider naturally matches the palette without anyone touching config. Plumbing: TabFactory resolves the colour once in its ctor (config is read-only here), caches it in m_dividerColor, and stamps it onto each SplitPanel via SetDividerColor before SetRoot — the first splitter Border SyncChildrenFromTree builds therefore already paints with the right brush. SetDividerColor also walks existing splitter Borders so a future reload path can swap colours without rebuilding the tree. The old fallback grey is kept as the brush MakeSplitter uses when m_dividerBrush is null, just to keep brand-new SplitPanels visible across the brief window between construction and SetDividerColor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 4th argument is the length of the key string, not the size of
the output buffer (see upstream Ghostty.Config.swift). We were
passing sizeof(out) which truncated every key to 2-3 characters
and made every lookup miss: "split-divider-color" matched on
"spl" → no hit → divider fell back to the background-darken
default. unfocused-split-opacity and unfocused-split-fill had the
same bug but read like they worked because the fallbacks (0.7
opacity, black fill on a dark background) happened to land close
to the upstream defaults.
Fix every callsite to pass `sizeof("key") - 1` so the constant key
length is computed by the compiler from the string literal. After
this, setting `split-divider-color = ff6b35` in user config
actually paints the splitter orange, and configured
`unfocused-split-fill` / `-opacity` values take effect instead of
silently using fallbacks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary / 概要
Adds split-pane support — every
ghostty_action_*split*action the host can dispatch is wired up, panes share a tab, and the visual chrome is kept minimal (a 2 DIP separator line + a 1.5 s focus flash on pane change). Closes #13.日本語
ペイン分割を追加。ホストがディスパッチできる
ghostty_action_*split*系のアクションを全部ハンドリングし、複数ペインが 1 タブ内で共存する。ペイン間の装飾は 2 DIP の境界線とフォーカス時 1.5 秒の枠だけに絞ってある。Closes #13。Features / 機能
The following ghostty actions are now handled:
NEW_SPLIT— split the current pane horizontally / vertically.GOTO_SPLIT— both direction-based (UP / DOWN / LEFT / RIGHT) and sequential (PREVIOUS / NEXT) cycling.RESIZE_SPLIT— tmux-style: the arrow direction is the direction the boundary moves.EQUALIZE_SPLITS— reset every split ratio in the active tab to 0.5.TOGGLE_SPLIT_ZOOM— expand one pane to fill the tab, second press restores the layout.Default ghostty keybinds cover
NEW_SPLITand the direction variant ofGOTO_SPLIT; the rest need user config (keybind = …:resize_split:left,10etc.). Implementation is here, the keybinds aren't.日本語
実装した ghostty action:
NEW_SPLIT— ペインの横/縦分割GOTO_SPLIT— 方向ベース (UP / DOWN / LEFT / RIGHT) と巡回 (PREVIOUS / NEXT) 両方RESIZE_SPLIT— tmux と同じ: 矢印 = 境界の動く方向EQUALIZE_SPLITS— アクティブタブの全 split ratio を 0.5 にリセットTOGGLE_SPLIT_ZOOM— 1 ペインを全面化、再度押すと元に戻すghostty のデフォルトキーバインドは
NEW_SPLITと方向版のGOTO_SPLITのみカバー。残りはユーザー側でkeybind = …:resize_split:left,10のように config 追加が必要。コード側の実装はここで完結。Internal design / 内部設計
Paneis a binary tree: leaf = aUIElement(in practice aTerminalControl), internal = orientation + ratio + two children.SplitPanel(customPanelruntime class) owns the tree and lays out leaves through itsMeasureOverride/ArrangeOverride.Tabkeeps theSplitPanelplus a non-owning pointer to the active leaf. The active TerminalControl is reached through the leaf, so input / IME / clipboard paths keep working unchanged when the tree gains more leaves.PaneId(the renamed-and-movedTabId) lives on each leaf and is what we pass throughcfg.userdata.close_surface_cblooks the leaf up by ID so per-pane shell exits collapse the right split and don't take the whole tab down.RunSEHGuardedthat protectsCreateTabfrom NVIDIAdx_create_texturehardware exceptions.日本語
Paneは二分木。leaf =UIElement(実態はTerminalControl)、内部ノード = orientation + ratio + 子 2 つ。SplitPanel(自作のPanelruntime class) がツリーを所有し、MeasureOverride/ArrangeOverrideで leaf を矩形に配置する。TabはSplitPanelと active leaf への非所有ポインタを持つ。アクティブな TerminalControl は active leaf 経由でたどる構造に統一したので、入力 / IME / clipboard のパスは leaf が複数になっても変更不要。PaneId(旧TabIdをリネームして leaf 側に移動) を leaf ごとに保持し、cfg.userdataに渡す。close_surface_cbは PaneId で leaf を引いてくるので、ペインごとの shell exit が正しい split を畳む (タブごと閉じない)。CreateTabを NVIDIAdx_create_textureのハードウェア例外から守っているのと同じRunSEHGuardedで囲ってある。Issues found during testing / 動作確認で見つかった問題
Surfaced and fixed in this same branch:
exitin it: surviving panes rendered blank.Visibility=Collapsedsurvived theSyncChildrenFromTreeclear / re-append because it's a per-element property. Reset visibility at the end of the rebuild.IsTabStop=truecontrols in the window, WinUI's default-focus pass on activation outran the inlinetab->Focus(). Deferred the call throughDispatcherQueueat Low priority.PointerReleasedrecovers the active leaf directly, and a deferredGetForegroundWindow()check inside theDeactivatedbranch catches click variants that miss the XAML routed event.日本語
このブランチ内で発見して修正したもの:
exit入力 → 残ったペインの描画が崩れる。Visibility=CollapsedがSyncChildrenFromTreeの clear / re-append を生き残ってしまっていた (Visibility は要素のプロパティで、親子関係を変えても保持される)。再構築の末尾で visibility をリセット。IsTabStop=trueな control が複数あると、activation 時の WinUI のデフォルトフォーカス処理がインラインのtab->Focus()を上書きしてしまう。DispatcherQueueの Low priority で defer して対応。PointerReleasedで active leaf に直接戻すパスと、Deactivated分岐内の遅延したGetForegroundWindow()チェック (XAML routed event を通らないクリックバリエーション向け) の両方を入れた。Test plan / 検証
Ctrl+Shift+O/Ctrl+Shift+E: pane splits, new pane takes focus, accent border flashes for 1.5 s.Ctrl+Shift+Alt+arrows(after adding the keybind in config): boundary moves via keyboard.Ctrl+Alt+arrows: focus moves to the adjacent pane in that direction.Ctrl+Shift+]/[(after adding the keybind): cycle focus through panes.Ctrl+Shift+=(after adding the keybind): every split in the tab snaps to 0.5.Ctrl+Shift+Enter(after adding the keybind): zoom / unzoom the active pane.exitin a non-only pane: that pane closes, sibling expands, focus retargets onto the survivor.exitin the last pane: tab closes.日本語
Ctrl+Shift+O/Ctrl+Shift+E: ペインが分割され、新ペインにフォーカスが移って 1.5 秒のアクセント枠が出る。Ctrl+Shift+Alt+矢印(config で bind 追加後): キーボードで境界が動く。Ctrl+Alt+矢印: 隣接ペインにフォーカスが移る。Ctrl+Shift+]/[(bind 追加後): ペイン間を巡回。Ctrl+Shift+=(bind 追加後): タブ内の全 split が 0.5 比率に揃う。Ctrl+Shift+Enter(bind 追加後): アクティブペインをズーム/解除。exit: そのペインが閉じ、隣が拡張、フォーカスが残ペインへ。exit: タブが閉じる。🤖 Generated with Claude Code