Skip to content

Split-pane support (#13)#56

Merged
i999rri merged 36 commits into
devfrom
feat/split-phase1-pane-tree
May 23, 2026
Merged

Split-pane support (#13)#56
i999rri merged 36 commits into
devfrom
feat/split-phase1-pane-tree

Conversation

@i999rri
Copy link
Copy Markdown
Owner

@i999rri i999rri commented May 20, 2026

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_SPLIT and the direction variant of GOTO_SPLIT; the rest need user config (keybind = …:resize_split:left,10 etc.). 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 / 内部設計

  • Pane is a binary tree: leaf = a UIElement (in practice a TerminalControl), internal = orientation + ratio + two children. SplitPanel (custom Panel runtime class) owns the tree and lays out leaves through its MeasureOverride / ArrangeOverride.
  • Tab keeps the SplitPanel plus 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-moved TabId) lives on each leaf and is what we pass through cfg.userdata. close_surface_cb looks the leaf up by ID so per-pane shell exits collapse the right split and don't take the whole tab down.
  • Surface creation for a new split is wrapped in the same RunSEHGuarded that protects CreateTab from NVIDIA dx_create_texture hardware exceptions.
日本語
  • Pane は二分木。leaf = UIElement (実態は TerminalControl)、内部ノード = orientation + ratio + 子 2 つ。SplitPanel (自作の Panel runtime class) がツリーを所有し、MeasureOverride / ArrangeOverride で leaf を矩形に配置する。
  • TabSplitPanel と active leaf への非所有ポインタを持つ。アクティブな TerminalControl は active leaf 経由でたどる構造に統一したので、入力 / IME / clipboard のパスは leaf が複数になっても変更不要。
  • PaneId (旧 TabId をリネームして leaf 側に移動) を leaf ごとに保持し、cfg.userdata に渡す。close_surface_cb は PaneId で leaf を引いてくるので、ペインごとの shell exit が正しい split を畳む (タブごと閉じない)。
  • 分割で生成するサーフェスは CreateTab を NVIDIA dx_create_texture のハードウェア例外から守っているのと同じ RunSEHGuarded で囲ってある。

Issues found during testing / 動作確認で見つかった問題

Surfaced and fixed in this same branch:

  • Zoom one pane, type exit in it: surviving panes rendered blank. Visibility=Collapsed survived the SyncChildrenFromTree clear / re-append because it's a per-element property. Reset visibility at the end of the rebuild.
  • Alt-tab away and back: focus didn't return to the active leaf. With multiple IsTabStop=true controls in the window, WinUI's default-focus pass on activation outran the inline tab->Focus(). Deferred the call through DispatcherQueue at Low priority.
  • Click the title-bar drag region: focus died, keyboard input stopped working. PointerReleased recovers the active leaf directly, and a deferred GetForegroundWindow() check inside the Deactivated branch catches click variants that miss the XAML routed event.
日本語

このブランチ内で発見して修正したもの:

  • ズーム中に内部で exit 入力 → 残ったペインの描画が崩れる。Visibility=CollapsedSyncChildrenFromTree の clear / re-append を生き残ってしまっていた (Visibility は要素のプロパティで、親子関係を変えても保持される)。再構築の末尾で visibility をリセット。
  • Alt-tab で離れて戻る → active leaf にフォーカスが戻らない。ウインドウ内に IsTabStop=true な control が複数あると、activation 時の WinUI のデフォルトフォーカス処理がインラインの tab->Focus() を上書きしてしまう。DispatcherQueue の Low priority で defer して対応。
  • タイトルバーの drag region をクリック → focus が死亡、キーボード入力も停止。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.
  • Drag a splitter strip with the mouse: boundary moves.
  • 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.
  • exit in a non-only pane: that pane closes, sibling expands, focus retargets onto the survivor.
  • exit in the last pane: tab closes.
  • Close a multi-pane tab via the X button: no crash; every leaf's swap chain is detached before the panel unparents.
  • Nested split (horizontal split → split one side vertically): tree renders, every splitter is independently draggable.
  • Focus border auto-hides after 1.5 s of no focus change.
  • Click the title-bar empty area: focus stays on the active pane.
  • Alt-tab away and back: focus returns to the previously active pane.
日本語
  • 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: そのペインが閉じ、隣が拡張、フォーカスが残ペインへ。
  • 最後の 1 ペインで exit: タブが閉じる。
  • 複数ペインタブの X を押す: クラッシュなし。panel が unparent される前に全 leaf の swap chain を detach。
  • ネスト分割 (横分割 → 片方を縦分割): ツリーが描画され、各境界が独立にドラッグ可能。
  • フォーカスが 1.5 秒変化しなければ枠が自動非表示。
  • タイトルバーの空き領域クリック: アクティブペインのフォーカスが維持される。
  • Alt-tab で離れて戻る: 直前にアクティブだったペインにフォーカスが戻る。

🤖 Generated with Claude Code

i999rri and others added 30 commits May 19, 2026 23:48
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.
@i999rri i999rri self-assigned this May 20, 2026
@i999rri i999rri added this to the 0.4.0 milestone May 20, 2026
i999rri and others added 5 commits May 23, 2026 14:48
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>
@i999rri i999rri merged commit 8c80bd2 into dev May 23, 2026
4 checks passed
@i999rri i999rri mentioned this pull request May 23, 2026
10 tasks
@i999rri i999rri deleted the feat/split-phase1-pane-tree branch May 23, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split-pane support

1 participant