From a74bc83e6f0bfb9b806662252f2f43be0fb21cbc Mon Sep 17 00:00:00 2001 From: i999rri Date: Tue, 19 May 2026 23:40:15 +0900 Subject: [PATCH 01/36] Add Pane tree + SplitPanel scaffolding for split-pane support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GhosttyWin32App/GhosttyWin32App.vcxproj | 6 ++ GhosttyWin32App/Pane.h | 115 ++++++++++++++++++++++ GhosttyWin32App/SplitPanel.cpp | 121 ++++++++++++++++++++++++ GhosttyWin32App/SplitPanel.h | 66 +++++++++++++ GhosttyWin32App/SplitPanel.idl | 15 +++ 5 files changed, 323 insertions(+) create mode 100644 GhosttyWin32App/Pane.h create mode 100644 GhosttyWin32App/SplitPanel.cpp create mode 100644 GhosttyWin32App/SplitPanel.h create mode 100644 GhosttyWin32App/SplitPanel.idl diff --git a/GhosttyWin32App/GhosttyWin32App.vcxproj b/GhosttyWin32App/GhosttyWin32App.vcxproj index 452b269..da842cf 100644 --- a/GhosttyWin32App/GhosttyWin32App.vcxproj +++ b/GhosttyWin32App/GhosttyWin32App.vcxproj @@ -130,6 +130,8 @@ MainWindow.xaml + + @@ -154,6 +156,7 @@ MainWindow.xaml + TerminalControl.xaml @@ -164,6 +167,9 @@ Code MainWindow.xaml + + Code + Code TerminalControl.xaml diff --git a/GhosttyWin32App/Pane.h b/GhosttyWin32App/Pane.h new file mode 100644 index 0000000..8a69e5d --- /dev/null +++ b/GhosttyWin32App/Pane.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include + +namespace winrt::GhosttyWin32::implementation { + +// Direction of a split node. +// +// Horizontal : children laid out side by side, the split bar is vertical. +// Vertical : children stacked, the split bar is horizontal. +enum class SplitOrientation { + Horizontal, + Vertical, +}; + +// Binary-tree node describing how a Tab's content is partitioned into +// terminal panes. A node is either a leaf (one UI element fills the +// node's rectangle) or an internal node (two children, split by +// `orientation` at `ratio`). +// +// Leaves hold a plain `UIElement` rather than a `TerminalControl` so +// the same tree can host placeholder Borders during layout-only tests +// and real TerminalControls in production. SplitPanel only cares about +// "something to arrange", so erasing to the base class also keeps that +// renderer agnostic. +// +// Ownership is unique_ptr-based: a parent owns its two children, the +// SplitPanel owns the root. Parent back-pointers (`m_parent`) are kept +// so direction-based navigation can walk up and across without a +// separate index; they're set when a node is attached as a child and +// cleared on detach. +// +// The class is move-disabled because raw `Pane*` back-pointers must +// stay valid for the node's lifetime — moving would invalidate every +// child's `m_parent`. +class Pane { +public: + // Construct a leaf wrapping `content`. `content` is the actual + // UIElement that will be added to the SplitPanel's Children + // collection when the leaf is arranged. + static std::unique_ptr MakeLeaf(Microsoft::UI::Xaml::UIElement content) { + auto p = std::unique_ptr(new Pane{}); + p->m_content = std::move(content); + return p; + } + + // Construct an internal node by combining two existing subtrees. + // `ratio` is the fraction of the available extent the first child + // gets along the split axis (clamped to [0.05, 0.95] to keep both + // children meaningfully visible). + static std::unique_ptr MakeSplit( + SplitOrientation orientation, + double ratio, + std::unique_ptr first, + std::unique_ptr second + ) { + auto p = std::unique_ptr(new Pane{}); + p->m_orientation = orientation; + p->m_ratio = ClampRatio(ratio); + p->m_first = std::move(first); + p->m_second = std::move(second); + if (p->m_first) p->m_first->m_parent = p.get(); + if (p->m_second) p->m_second->m_parent = p.get(); + return p; + } + + ~Pane() = default; + Pane(const Pane&) = delete; + Pane& operator=(const Pane&) = delete; + Pane(Pane&&) = delete; + Pane& operator=(Pane&&) = delete; + + bool IsLeaf() const noexcept { return static_cast(m_content); } + + // Leaf accessor. Returns null for internal nodes. + Microsoft::UI::Xaml::UIElement Content() const noexcept { return m_content; } + + // Internal-node accessors. Calling these on a leaf returns + // default-initialized values / nullptrs — callers should gate on + // IsLeaf() first. + SplitOrientation Orientation() const noexcept { return m_orientation; } + double Ratio() const noexcept { return m_ratio; } + void SetRatio(double ratio) noexcept { m_ratio = ClampRatio(ratio); } + Pane* First() const noexcept { return m_first.get(); } + Pane* Second() const noexcept { return m_second.get(); } + + // Back-pointer to the enclosing internal node, or null at the root. + Pane* Parent() const noexcept { return m_parent; } + +private: + Pane() = default; + + static double ClampRatio(double r) noexcept { + if (r < 0.05) return 0.05; + if (r > 0.95) return 0.95; + return r; + } + + // Leaf payload — non-null iff IsLeaf(). + Microsoft::UI::Xaml::UIElement m_content{ nullptr }; + + // Internal-node fields — unused when IsLeaf(). + SplitOrientation m_orientation{ SplitOrientation::Horizontal }; + double m_ratio{ 0.5 }; + std::unique_ptr m_first; + std::unique_ptr m_second; + + // Set by MakeSplit when this node is attached as a child; null at + // the root. Used by future phases for direction-based navigation + // and for collapsing the tree on pane close. + Pane* m_parent{ nullptr }; +}; + +} // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp new file mode 100644 index 0000000..59d1420 --- /dev/null +++ b/GhosttyWin32App/SplitPanel.cpp @@ -0,0 +1,121 @@ +#include "pch.h" +#include "SplitPanel.h" +#if __has_include("SplitPanel.g.cpp") +#include "SplitPanel.g.cpp" +#endif + +namespace winrt::GhosttyWin32::implementation { + +namespace { + +// Walks `node` and accumulates the union of every leaf's desired size. +// Stacked dimensions add, perpendicular dimensions take the max — so a +// horizontal split's width is `first + second` and its height is +// `max(first, second)`. Mirrored for vertical splits. +// +// Called from MeasureOverride so the framework knows how much room +// SplitPanel wants. Available size constrains each leaf so the +// terminal surface receives a Measure with the right cap (avoids the +// leaf reporting a content size that ignores the host's available +// area). +Windows::Foundation::Size MeasureNode(Pane& node, Windows::Foundation::Size available) { + if (node.IsLeaf()) { + if (auto element = node.Content()) { + element.Measure(available); + return element.DesiredSize(); + } + return {0, 0}; + } + + auto* first = node.First(); + auto* second = node.Second(); + if (!first && !second) return {0, 0}; + if (!first) return MeasureNode(*second, available); + if (!second) return MeasureNode(*first, available); + + Windows::Foundation::Size firstAvail = available; + Windows::Foundation::Size secondAvail = available; + if (node.Orientation() == SplitOrientation::Horizontal) { + firstAvail.Width = static_cast(available.Width * node.Ratio()); + secondAvail.Width = static_cast(available.Width * (1.0 - node.Ratio())); + } else { + firstAvail.Height = static_cast(available.Height * node.Ratio()); + secondAvail.Height = static_cast(available.Height * (1.0 - node.Ratio())); + } + + auto a = MeasureNode(*first, firstAvail); + auto b = MeasureNode(*second, secondAvail); + + if (node.Orientation() == SplitOrientation::Horizontal) { + return { a.Width + b.Width, std::max(a.Height, b.Height) }; + } + return { std::max(a.Width, b.Width), a.Height + b.Height }; +} + +} // namespace + +void SplitPanel::SetRoot(std::unique_ptr root) { + m_root = std::move(root); + SyncChildrenFromTree(); + InvalidateMeasure(); + InvalidateArrange(); +} + +void SplitPanel::SyncChildrenFromTree() { + Children().Clear(); + if (m_root) AppendLeavesToChildren(*m_root); +} + +void SplitPanel::AppendLeavesToChildren(Pane& node) { + if (node.IsLeaf()) { + if (auto element = node.Content()) { + Children().Append(element); + } + return; + } + if (auto* f = node.First()) AppendLeavesToChildren(*f); + if (auto* s = node.Second()) AppendLeavesToChildren(*s); +} + +Windows::Foundation::Size SplitPanel::MeasureOverride(Windows::Foundation::Size availableSize) { + if (!m_root) return {0, 0}; + return MeasureNode(*m_root, availableSize); +} + +Windows::Foundation::Size SplitPanel::ArrangeOverride(Windows::Foundation::Size finalSize) { + if (m_root) { + ArrangeNode(*m_root, Windows::Foundation::Rect{0, 0, finalSize.Width, finalSize.Height}); + } + return finalSize; +} + +void SplitPanel::ArrangeNode(Pane& node, Windows::Foundation::Rect rect) { + if (node.IsLeaf()) { + if (auto element = node.Content()) { + element.Arrange(rect); + } + return; + } + + auto* first = node.First(); + auto* second = node.Second(); + if (!first && !second) return; + if (!first) { ArrangeNode(*second, rect); return; } + if (!second) { ArrangeNode(*first, rect); return; } + + if (node.Orientation() == SplitOrientation::Horizontal) { + float w = rect.Width * static_cast(node.Ratio()); + Windows::Foundation::Rect firstRect{ rect.X, rect.Y, w, rect.Height }; + Windows::Foundation::Rect secondRect{ rect.X + w, rect.Y, rect.Width - w, rect.Height }; + ArrangeNode(*first, firstRect); + ArrangeNode(*second, secondRect); + } else { + float h = rect.Height * static_cast(node.Ratio()); + Windows::Foundation::Rect firstRect{ rect.X, rect.Y, rect.Width, h }; + Windows::Foundation::Rect secondRect{ rect.X, rect.Y + h, rect.Width, rect.Height - h }; + ArrangeNode(*first, firstRect); + ArrangeNode(*second, secondRect); + } +} + +} // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h new file mode 100644 index 0000000..3b18d9a --- /dev/null +++ b/GhosttyWin32App/SplitPanel.h @@ -0,0 +1,66 @@ +#pragma once + +#include "SplitPanel.g.h" +#include "Pane.h" +#include + +namespace winrt::GhosttyWin32::implementation { + +// Custom Panel that lays out a Pane tree. +// +// The Pane tree is the source of truth for geometry: each internal +// node holds an orientation + ratio, each leaf wraps a UIElement. +// SplitPanel owns the root (`m_root`); MeasureOverride / ArrangeOverride +// recurse the tree and split the available rectangle accordingly. +// +// Children registration with the underlying Panel is handled by +// SetRoot(): every leaf's `Content()` UIElement is appended to +// `Children()` so the framework's measure / arrange machinery and +// hit-testing see them. The XAML tree shape is intentionally flat — +// no nested Panels, no GridSplitter — so leaves don't pay for +// transform stacks they don't need, and a future "swap two leaves" +// operation reduces to swapping unique_ptrs in the tree without +// touching XAML beyond a Measure invalidation. +struct SplitPanel : SplitPanelT { + SplitPanel() = default; + + // Replaces the current Pane tree with `root` and refreshes the + // Panel's children to contain exactly the leaf UIElements (in + // depth-first traversal order). Safe to call repeatedly; the old + // tree is destroyed, the new one takes effect on the next layout + // pass. + // + // Passing `nullptr` clears the panel: tree gone, no children. + void SetRoot(std::unique_ptr root); + + // Read-only access for the host (Tab will need this to walk for + // active-leaf focusing, but Phase 1 only uses it for diagnostics). + Pane* Root() const noexcept { return m_root.get(); } + + // Panel overrides. + Windows::Foundation::Size MeasureOverride(Windows::Foundation::Size availableSize); + Windows::Foundation::Size ArrangeOverride(Windows::Foundation::Size finalSize); + +private: + // Recursive arrange — `rect` is the area assigned to `node` in + // SplitPanel coordinates, before any nested splits apply. + void ArrangeNode(Pane& node, Windows::Foundation::Rect rect); + + // Repopulates `Children()` to match the current tree (depth-first + // leaf order). Called by SetRoot after the tree pointer swap. + void SyncChildrenFromTree(); + + // Append every leaf under `node` to `Children()`. Recursive helper + // for SyncChildrenFromTree. + void AppendLeavesToChildren(Pane& node); + + std::unique_ptr m_root; +}; + +} // namespace winrt::GhosttyWin32::implementation + +namespace winrt::GhosttyWin32::factory_implementation { + +struct SplitPanel : SplitPanelT {}; + +} // namespace winrt::GhosttyWin32::factory_implementation diff --git a/GhosttyWin32App/SplitPanel.idl b/GhosttyWin32App/SplitPanel.idl new file mode 100644 index 0000000..55c2297 --- /dev/null +++ b/GhosttyWin32App/SplitPanel.idl @@ -0,0 +1,15 @@ +namespace GhosttyWin32 +{ + // Layout host for a Pane tree. + // + // Declared in IDL so the C++/WinRT projection can derive from + // Microsoft.UI.Xaml.Controls.Panel — Panel itself is unsealed but + // can only be subclassed through a runtimeclass with metadata. + // The MeasureOverride / ArrangeOverride that walk the Pane tree + // live in the impl class; no XAML-side properties are needed yet. + [default_interface] + runtimeclass SplitPanel : Microsoft.UI.Xaml.Controls.Panel + { + SplitPanel(); + } +} From b8bcca3e62c68c117343d111d43f8e96898fd908 Mon Sep 17 00:00:00 2001 From: i999rri Date: Tue, 19 May 2026 23:58:22 +0900 Subject: [PATCH 02/36] Route Tab content through the Pane tree / SplitPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). --- GhosttyWin32App/MainWindow.xaml.cpp | 5 +- GhosttyWin32App/Tab.h | 160 +++++++++++++++++++--------- GhosttyWin32App/TabFactory.h | 45 ++++++-- GhosttyWin32App/Tabs.h | 31 +++++- 4 files changed, 175 insertions(+), 66 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index bc147d1..cf2bcab 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -613,7 +613,10 @@ namespace winrt::GhosttyWin32::implementation static constexpr wchar_t kDefaultTabTitle[] = L" "; item.Header(box_value(kDefaultTabTitle)); item.IsClosable(true); - item.Content(control); + // item.Content is set by TabFactory::Make (which wraps the + // control in a SplitPanel-backed Pane tree). Leaving it unset + // here keeps "the SplitPanel owns the pane tree" in a single + // place. // Same focus-retention story as the AddTabButton: TabViewItem is // a Control with IsTabStop=true by default, so clicking a tab // header lands focus on the header itself rather than the inner diff --git a/GhosttyWin32App/Tab.h b/GhosttyWin32App/Tab.h index 1df85a3..8e56bb9 100644 --- a/GhosttyWin32App/Tab.h +++ b/GhosttyWin32App/Tab.h @@ -1,62 +1,76 @@ #pragma once +#include "Pane.h" +#include "SplitPanel.h" #include "TabId.h" #include "TerminalControl.xaml.h" #include #include +#include namespace winrt::GhosttyWin32::implementation { // One tab in the window's TabView. // -// Conceptually a tab is the *root of a pane tree*: a container holding -// one or more TerminalControls (one per pane) plus the splitter -// metadata that describes how they're laid out. Every other terminal -// emulator that supports split panes (Windows Terminal, Alacritty, -// macOS Terminal, GTK Ghostty) follows this shape: +// Each Tab references: +// * A SplitPanel (`m_panel`) that owns the Pane tree describing how +// this tab's content is partitioned across one or more terminal +// panes, and is the TabViewItem's Content. The Panel keeps its +// Children() collection in sync with the tree's leaf set so +// framework input routing, hit-testing, and measure / arrange work +// end-to-end. Today the tree is always a single leaf — NEW_SPLIT +// plumbing comes in the next phase — but the structure is in place +// so callers (Tabs::FindBySurface, action callbacks) walk the tree +// instead of assuming a single TerminalControl per tab. +// * A pointer to the currently active leaf (`m_activeLeaf`). All +// "focused terminal" operations (key events, IME, clipboard, +// action targets) flow through this. Today there is exactly one +// leaf so it never moves; phase 4 (#13) will retarget it on +// GOTO_SPLIT / pointer focus. // -// Tab -// └── PaneTree -// ├── Leaf: TerminalControl -// └── Branch: split{H/V, child1, child2} -// ├── Leaf: TerminalControl -// └── Leaf: TerminalControl +// The Pane tree itself lives inside the SplitPanel (one unique_ptr +// owner) — Tab borrows it via panel.Root() and stores `m_activeLeaf` +// as a non-owning pointer into that tree. On tree mutations (future +// NEW_SPLIT / CLOSE_PANE) `m_activeLeaf` must be reset before any leaf +// is destroyed. // -// Today we don't yet implement splits, so each Tab holds exactly one -// TerminalControl directly. The class deliberately exposes only the -// "tab-level" API (active control, focus, id, item) and avoids -// delegating per-control accessors like Surface() — once panes land, -// those would silently change meaning ("the surface" becomes "which -// surface?") and every caller would need re-auditing. By forcing -// callers to go through ActiveControl() now, the eventual switch to -// "the focused leaf in the pane tree" is mechanical: only ActiveControl -// changes implementation, no caller-side audit needed. -// -// Construction is just validation + member init — all failable work +// Construction is just validation + member init — failable setup // (creating the surface handle, attaching it, calling // ghostty_surface_new) lives in TabFactory::Make. If you have a Tab*, -// you can operate on it freely without worrying about half-built state. +// you can operate on it freely without worrying about half-built +// state. class Tab { public: - Tab(winrt::GhosttyWin32::TerminalControl control, + Tab(winrt::GhosttyWin32::SplitPanel panel, Microsoft::UI::Xaml::Controls::TabViewItem item, TabId id) - : m_control(std::move(control)) + : m_panel(std::move(panel)) , m_item(std::move(item)) , m_id(id) { - if (!m_control || !m_item) { + if (!m_panel || !m_item) { throw winrt::hresult_error(E_INVALIDARG, L"Tab: missing resource"); } + auto* panelImpl = winrt::get_self(m_panel); + if (!panelImpl || !panelImpl->Root()) { + throw winrt::hresult_error(E_INVALIDARG, L"Tab: SplitPanel has no root"); + } + // Initial active leaf is the first (and currently only) leaf + // found via depth-first descent. + m_activeLeaf = FindFirstLeaf(panelImpl->Root()); + if (!m_activeLeaf) { + throw winrt::hresult_error(E_INVALIDARG, L"Tab: pane tree has no leaf"); + } } ~Tab() { - // Detach every TerminalControl in the tab — surface free, swap - // chain release, composition handle close, SizeChanged unhook - // all live on the control. Today there's a single control; - // when pane support lands this becomes a tree walk. - if (auto* c = ActiveControl()) { - c->Detach(); + // Detach every TerminalControl in the tree — surface free, + // swap chain release, composition handle close, SizeChanged + // unhook all live on the control. Walking the tree handles + // the post-split case naturally; with a single leaf it + // collapses to one Detach call. + if (auto* panelImpl = winrt::get_self(m_panel)) { + DetachAllLeaves(panelImpl->Root()); } } @@ -65,36 +79,82 @@ class Tab { Tab(Tab&&) = delete; Tab& operator=(Tab&&) = delete; - // Returns the currently-focused TerminalControl in this tab. Right - // now that's the single control; once panes land it'll be the - // focused leaf in the pane tree. Callers that need the surface, - // composition handle, or inner SwapChainPanel should go through - // here so they keep working when the tree gains additional leaves. + // Returns the currently-focused TerminalControl in this tab — + // i.e. the impl of the active leaf in the pane tree. Callers that + // need the surface, composition handle, or inner SwapChainPanel + // should go through here so they keep working when the tree gains + // additional leaves and the active leaf shifts on GOTO_SPLIT. implementation::TerminalControl* ActiveControl() const noexcept { - if (!m_control) return nullptr; - return winrt::get_self(m_control); + if (!m_activeLeaf) return nullptr; + return LeafToTerminalControl(*m_activeLeaf); } Microsoft::UI::Xaml::Controls::TabViewItem const& Item() const noexcept { return m_item; } TabId Id() const noexcept { return m_id; } - // Returns whether XAML accepted the focus request (used in - // diagnostics for the tab-switch focus path). TerminalControl is a - // UserControl with IsTabStop=true, so unlike a bare SwapChainPanel - // this Focus call actually moves focus reliably. Future: focus the - // active leaf of the pane tree, not just the single control. + // Read-only access to the SplitPanel hosting this tab's tree, used + // by Tabs::FindBySurface to walk every leaf and locate the one + // whose TerminalControl owns a given ghostty_surface_t. + winrt::GhosttyWin32::SplitPanel const& Panel() const noexcept { return m_panel; } + Pane* ActiveLeaf() const noexcept { return m_activeLeaf; } + + // Whether XAML accepted the focus request. The active leaf's + // TerminalControl is a UserControl with IsTabStop=true, so unlike + // a bare SwapChainPanel this Focus call actually moves focus + // reliably. bool Focus() { - if (!m_control) return false; - return m_control.Focus(Microsoft::UI::Xaml::FocusState::Programmatic); + if (!m_activeLeaf) return false; + auto element = m_activeLeaf->Content(); + if (!element) return false; + if (auto tc = element.try_as()) { + return tc.Focus(Microsoft::UI::Xaml::FocusState::Programmatic); + } + return false; + } + + // Extracts the impl pointer for a leaf's TerminalControl, or + // returns nullptr if the leaf hosts something else (Phase 1 + // scaffolding accepts any UIElement, but in practice every leaf + // in a real Tab is a TerminalControl). Kept public-static so + // helpers outside Tab can walk the tree consistently. + static implementation::TerminalControl* LeafToTerminalControl(Pane const& leaf) noexcept { + auto element = leaf.Content(); + if (!element) return nullptr; + if (auto tc = element.try_as()) { + return winrt::get_self(tc); + } + return nullptr; } private: - // Today: the single TerminalControl that fills the tab content. - // Future: the root of a pane tree (variant), - // where Pane itself holds two children + split orientation. - winrt::GhosttyWin32::TerminalControl m_control{ nullptr }; + static Pane* FindFirstLeaf(Pane* node) noexcept { + if (!node) return nullptr; + if (node->IsLeaf()) return node; + if (auto* p = FindFirstLeaf(node->First())) return p; + return FindFirstLeaf(node->Second()); + } + + static void DetachAllLeaves(Pane* node) { + if (!node) return; + if (node->IsLeaf()) { + if (auto* tc = LeafToTerminalControl(*node)) { + tc->Detach(); + } + return; + } + DetachAllLeaves(node->First()); + DetachAllLeaves(node->Second()); + } + + // SplitPanel owns the Pane tree (via its own m_root) and is set as + // the TabViewItem's Content by TabFactory. + winrt::GhosttyWin32::SplitPanel m_panel{ nullptr }; Microsoft::UI::Xaml::Controls::TabViewItem m_item{ nullptr }; TabId m_id{}; + // Borrowed pointer into the SplitPanel's tree — never owning. + // Reset to nullptr or another leaf on tree mutations before any + // leaf is destroyed. + Pane* m_activeLeaf{ nullptr }; }; } // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/TabFactory.h b/GhosttyWin32App/TabFactory.h index 1e7bc59..aed3c2e 100644 --- a/GhosttyWin32App/TabFactory.h +++ b/GhosttyWin32App/TabFactory.h @@ -1,5 +1,7 @@ #pragma once +#include "Pane.h" +#include "SplitPanel.h" #include "Tab.h" #include "TabId.h" #include "TabIdAllocator.h" @@ -34,18 +36,22 @@ class TabFactory { TabFactory& operator=(TabFactory&&) = delete; // Build a fully-formed Tab from a pre-created TerminalControl + item. - // The caller is expected to have already wired the control into the - // visual tree (item.Content(control) + tv.TabItems().Append(item)) - // so the inner panel's DispatcherQueue is reachable. + // The caller created `control` and `item` and appended `item` to + // the TabView, but did NOT set `item.Content` — this factory wraps + // `control` in a single-leaf Pane tree, hosts it in a SplitPanel, + // and assigns the SplitPanel as the item's content. That keeps the + // pane-tree ownership invariant ("SplitPanel owns the tree, Tab + // borrows it") in one place. // // Returns nullptr on failure (after cleaning up any partially- - // acquired resources). Call on the UI thread; the panel does NOT - // need to be in the visual tree yet — see issue #22, where making - // the panel visible before it had displayable content produced a - // flicker. The optional onActivated callback runs on the UI thread - // once ghostty has presented its first frame and we've bound the - // swap chain to the panel; the host uses it to switch the TabView - // so the panel becomes visible only with real content. + // acquired resources). Call on the UI thread; neither the inner + // SwapChainPanel nor the SplitPanel need to be in the visual tree + // yet — see issue #22, where making the panel visible before it + // had displayable content produced a flicker. The optional + // onActivated callback runs on the UI thread once ghostty has + // presented its first frame and we've bound the swap chain to the + // panel; the host uses it to switch the TabView so the panel + // becomes visible only with real content. // // Ordering: the DComp surface handle is bound to the panel only // AFTER ghostty's renderer thread has presented at least one real @@ -73,6 +79,23 @@ class TabFactory { return nullptr; } + // Wrap the control in a single-leaf Pane tree and host it in a + // SplitPanel. Setting the SplitPanel as item.Content here (not + // in MainWindow.CreateTab) keeps the "panel owns the tree, Tab + // borrows it" invariant centralised. With one leaf SplitPanel + // collapses to "arrange the single child at the full rect", + // matching the previous behaviour of placing the control + // directly under TabViewItem. + auto leaf = Pane::MakeLeaf(control); + winrt::GhosttyWin32::SplitPanel splitPanel{}; + auto* splitPanelImpl = winrt::get_self(splitPanel); + if (!splitPanelImpl) { + OutputDebugStringA("TabFactory::Make: get_self FAILED\n"); + return nullptr; + } + splitPanelImpl->SetRoot(std::move(leaf)); + item.Content(splitPanel); + HANDLE handle = nullptr; if (FAILED(DCompositionCreateSurfaceHandle(COMPOSITIONSURFACE_ALL_ACCESS, nullptr, &handle))) { OutputDebugStringA("TabFactory::Make: DCompositionCreateSurfaceHandle FAILED\n"); @@ -139,7 +162,7 @@ class TabFactory { controlImpl->Attach(m_app, surface, handle, m_hwnd, attach); try { - return std::make_unique(std::move(control), std::move(item), id); + return std::make_unique(std::move(splitPanel), std::move(item), id); } catch (winrt::hresult_error const&) { // Tab construction validation failed. Detach synchronously // so the surface/handle don't leak. diff --git a/GhosttyWin32App/Tabs.h b/GhosttyWin32App/Tabs.h index 651cdc9..168dc0d 100644 --- a/GhosttyWin32App/Tabs.h +++ b/GhosttyWin32App/Tabs.h @@ -1,5 +1,7 @@ #pragma once +#include "Pane.h" +#include "SplitPanel.h" #include "Tab.h" #include "ghostty.h" #include @@ -36,14 +38,19 @@ class Tabs { return nullptr; } - // O(N) over tabs; with future pane support this would walk the - // pane tree of each tab. Today every tab has at most one - // TerminalControl, so it's a flat scan. + // Resolve a ghostty_surface_t back to the owning Tab by walking + // each tab's Pane tree. ActiveControl alone would miss surfaces in + // non-active leaves once splits exist; the tree walk is correct + // for both the single-leaf case (today) and any future split + // configuration. O(N * leaves) — both factors are small enough + // that a flat scan is strictly cheaper than maintaining an index. Tab* FindBySurface(ghostty_surface_t surface) const { if (!surface) return nullptr; for (auto& t : m_tabs) { if (!t) continue; - if (auto* c = t->ActiveControl(); c && c->Surface() == surface) { + auto* panelImpl = winrt::get_self(t->Panel()); + if (!panelImpl) continue; + if (LeafMatchesSurface(panelImpl->Root(), surface)) { return t.get(); } } @@ -101,6 +108,22 @@ class Tabs { auto end() const noexcept { return m_tabs.end(); } private: + // Depth-first search for a leaf whose TerminalControl owns + // `surface`. Returns true on first match. Used by FindBySurface + // so each tab's tree is searched independently and the caller can + // map back to the owning Tab. + static bool LeafMatchesSurface(Pane const* node, ghostty_surface_t surface) { + if (!node) return false; + if (node->IsLeaf()) { + if (auto* c = Tab::LeafToTerminalControl(*node); c && c->Surface() == surface) { + return true; + } + return false; + } + return LeafMatchesSurface(node->First(), surface) + || LeafMatchesSurface(node->Second(), surface); + } + std::vector> m_tabs; }; From 05fd349abe8222bdfc3920620fdfd5b66b6105af Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 00:08:39 +0900 Subject: [PATCH 03/36] Move surface identification from Tab to leaf-level PaneId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/GhosttyWin32App.vcxproj | 4 +-- GhosttyWin32App/MainWindow.xaml.cpp | 18 +++++++---- GhosttyWin32App/MainWindow.xaml.h | 4 +-- GhosttyWin32App/Pane.h | 21 +++++++++++-- GhosttyWin32App/{TabId.h => PaneId.h} | 20 +++++++----- GhosttyWin32App/PaneIdAllocator.h | 30 ++++++++++++++++++ GhosttyWin32App/Tab.h | 7 +---- GhosttyWin32App/TabFactory.h | 35 +++++++++++---------- GhosttyWin32App/TabIdAllocator.h | 30 ------------------ GhosttyWin32App/Tabs.h | 42 +++++++++++++++++++------ 10 files changed, 130 insertions(+), 81 deletions(-) rename GhosttyWin32App/{TabId.h => PaneId.h} (57%) create mode 100644 GhosttyWin32App/PaneIdAllocator.h delete mode 100644 GhosttyWin32App/TabIdAllocator.h diff --git a/GhosttyWin32App/GhosttyWin32App.vcxproj b/GhosttyWin32App/GhosttyWin32App.vcxproj index da842cf..def85a4 100644 --- a/GhosttyWin32App/GhosttyWin32App.vcxproj +++ b/GhosttyWin32App/GhosttyWin32App.vcxproj @@ -131,11 +131,11 @@ MainWindow.xaml + + - - TerminalControl.xaml diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index cf2bcab..dda4cf2 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -563,20 +563,26 @@ namespace winrt::GhosttyWin32::implementation Clipboard::write(hwnd, Encoding::toUtf16(content[0].data)); }; // Shell exited (e.g. user typed `exit`), or ghostty asked to close - // the surface for any other reason. The userdata is the Tab ID + // the surface for any other reason. The userdata is the PaneId // we set in TabFactory::Make. Dispatch the TabView mutation to // the next UI tick to mirror the GHOSTTY_ACTION_CLOSE_TAB handler. + // + // Today every tab has exactly one pane, so closing the pane + // means closing the tab. Once NEW_SPLIT lands, this handler + // needs to collapse the split when the closed pane is one of + // several in a tab — phase 5 / Issue #13. rtConfig.close_surface_cb = [](void* userdata, bool /*process_alive*/) { if (!g_mainWindow || !userdata) return; - TabId id = TabId::FromUserdata(userdata); + PaneId id = PaneId::FromUserdata(userdata); auto mw = g_mainWindow; mw->DispatcherQueue().TryEnqueue([mw, id]() { - auto* t = mw->m_tabs.FindById(id); - if (!t) return; // Tab already closed via the UI + auto lookup = mw->m_tabs.FindByPaneId(id); + if (!lookup.tab || !lookup.leaf) return; // pane already closed via the UI + auto* t = lookup.tab; auto item = t->Item(); // Same Detach-before-RemoveAt pattern as the other // close paths — see TabCloseRequested. - if (auto* tc = t->ActiveControl()) { + if (auto* tc = Tab::LeafToTerminalControl(*lookup.leaf)) { tc->Detach(); } auto tv = mw->TabView(); @@ -595,7 +601,7 @@ namespace winrt::GhosttyWin32::implementation m_ghostty = GhosttyApp::Create(rtConfig); if (m_ghostty && m_hwnd) { - m_tabFactory = std::make_unique(m_ghostty->Handle(), m_hwnd, m_tabIds); + m_tabFactory = std::make_unique(m_ghostty->Handle(), m_hwnd, m_paneIds); } } diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index 7416252..5671c69 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -3,9 +3,9 @@ #include "MainWindow.g.h" #include "ghostty.h" #include "GhosttyApp.h" +#include "PaneIdAllocator.h" #include "Tab.h" #include "TabFactory.h" -#include "TabIdAllocator.h" #include "Tabs.h" namespace winrt::GhosttyWin32::implementation @@ -47,7 +47,7 @@ namespace winrt::GhosttyWin32::implementation std::unique_ptr m_ghostty; HWND m_hwnd = nullptr; - TabIdAllocator m_tabIds; + PaneIdAllocator m_paneIds; Tabs m_tabs; // Constructed once ghostty is initialized — needs the app handle // and HWND, neither available until InitGhostty has run. diff --git a/GhosttyWin32App/Pane.h b/GhosttyWin32App/Pane.h index 8a69e5d..3351ed0 100644 --- a/GhosttyWin32App/Pane.h +++ b/GhosttyWin32App/Pane.h @@ -1,5 +1,6 @@ #pragma once +#include "PaneId.h" #include #include @@ -38,10 +39,16 @@ class Pane { public: // Construct a leaf wrapping `content`. `content` is the actual // UIElement that will be added to the SplitPanel's Children - // collection when the leaf is arranged. - static std::unique_ptr MakeLeaf(Microsoft::UI::Xaml::UIElement content) { + // collection when the leaf is arranged. `id` identifies the leaf + // for ghostty's close_surface_cb routing — required to be non-zero + // for production callers; a default-constructed PaneId is accepted + // (and exposed as Id()) so layout-only tests can build trees + // without an allocator. + static std::unique_ptr MakeLeaf(Microsoft::UI::Xaml::UIElement content, + PaneId id = {}) { auto p = std::unique_ptr(new Pane{}); p->m_content = std::move(content); + p->m_id = id; return p; } @@ -76,6 +83,12 @@ class Pane { // Leaf accessor. Returns null for internal nodes. Microsoft::UI::Xaml::UIElement Content() const noexcept { return m_content; } + // Leaf-side identifier set at MakeLeaf time. Used as cfg.userdata + // for ghostty's close_surface_cb so the host can route the + // callback back to a specific leaf. Internal nodes have no ID + // (returns the zero sentinel). + PaneId Id() const noexcept { return m_id; } + // Internal-node accessors. Calling these on a leaf returns // default-initialized values / nullptrs — callers should gate on // IsLeaf() first. @@ -100,6 +113,10 @@ class Pane { // Leaf payload — non-null iff IsLeaf(). Microsoft::UI::Xaml::UIElement m_content{ nullptr }; + // Leaf identifier — zero sentinel for internal nodes and for + // layout-only test leaves built without an allocator. + PaneId m_id{}; + // Internal-node fields — unused when IsLeaf(). SplitOrientation m_orientation{ SplitOrientation::Horizontal }; double m_ratio{ 0.5 }; diff --git a/GhosttyWin32App/TabId.h b/GhosttyWin32App/PaneId.h similarity index 57% rename from GhosttyWin32App/TabId.h rename to GhosttyWin32App/PaneId.h index 08e2053..4f330fb 100644 --- a/GhosttyWin32App/TabId.h +++ b/GhosttyWin32App/PaneId.h @@ -4,19 +4,25 @@ namespace winrt::GhosttyWin32::implementation { -// Strongly-typed tab identifier. Wraps a uint64_t so that +// Strongly-typed pane identifier. Wraps a uint64_t so that // - random uint64_t values (DPI, sizes, counts) can't be mistakenly -// passed as a TabId +// passed as a PaneId // - the void* boundary with ghostty's cfg.userdata is centralized in // ToUserdata / FromUserdata, instead of bare reinterpret_casts // scattered through the host // +// Allocated once per leaf in TabFactory and threaded through +// cfg.userdata so close_surface_cb can route back to the right pane. +// Today every tab has exactly one pane so a PaneId 1:1 maps to a Tab, +// but once splits land each leaf gets its own ID and close_surface_cb +// can identify the specific pane without ambiguity. +// // Value 0 is reserved as a sentinel meaning "no ID" (default-constructed -// TabId converts to false). -struct TabId { +// PaneId converts to false). +struct PaneId { uint64_t value{ 0 }; - constexpr bool operator==(TabId const&) const noexcept = default; + constexpr bool operator==(PaneId const&) const noexcept = default; explicit constexpr operator bool() const noexcept { return value != 0; } // void* boundary helpers. cfg.userdata is opaque to ghostty — it @@ -25,8 +31,8 @@ struct TabId { void* ToUserdata() const noexcept { return reinterpret_cast(static_cast(value)); } - static TabId FromUserdata(void* p) noexcept { - return TabId{ static_cast(reinterpret_cast(p)) }; + static PaneId FromUserdata(void* p) noexcept { + return PaneId{ static_cast(reinterpret_cast(p)) }; } }; diff --git a/GhosttyWin32App/PaneIdAllocator.h b/GhosttyWin32App/PaneIdAllocator.h new file mode 100644 index 0000000..6c0c19d --- /dev/null +++ b/GhosttyWin32App/PaneIdAllocator.h @@ -0,0 +1,30 @@ +#pragma once + +#include "PaneId.h" +#include + +namespace winrt::GhosttyWin32::implementation { + +// Issues monotonically increasing PaneIds. One instance per MainWindow — +// the counter is per-allocator (not process-global) so that test setups +// or future multi-window scenarios get an isolated ID space without +// having to clear hidden static state. +// +// Move-disabled: fixed location for the lifetime of MainWindow. +class PaneIdAllocator { +public: + PaneIdAllocator() = default; + PaneIdAllocator(const PaneIdAllocator&) = delete; + PaneIdAllocator& operator=(const PaneIdAllocator&) = delete; + PaneIdAllocator(PaneIdAllocator&&) = delete; + PaneIdAllocator& operator=(PaneIdAllocator&&) = delete; + + PaneId Allocate() noexcept { + return PaneId{ m_next.fetch_add(1, std::memory_order_relaxed) }; + } + +private: + std::atomic m_next{ 1 }; // 0 reserved as sentinel by PaneId +}; + +} // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/Tab.h b/GhosttyWin32App/Tab.h index 8e56bb9..d579010 100644 --- a/GhosttyWin32App/Tab.h +++ b/GhosttyWin32App/Tab.h @@ -2,7 +2,6 @@ #include "Pane.h" #include "SplitPanel.h" -#include "TabId.h" #include "TerminalControl.xaml.h" #include #include @@ -42,11 +41,9 @@ namespace winrt::GhosttyWin32::implementation { class Tab { public: Tab(winrt::GhosttyWin32::SplitPanel panel, - Microsoft::UI::Xaml::Controls::TabViewItem item, - TabId id) + Microsoft::UI::Xaml::Controls::TabViewItem item) : m_panel(std::move(panel)) , m_item(std::move(item)) - , m_id(id) { if (!m_panel || !m_item) { throw winrt::hresult_error(E_INVALIDARG, L"Tab: missing resource"); @@ -90,7 +87,6 @@ class Tab { } Microsoft::UI::Xaml::Controls::TabViewItem const& Item() const noexcept { return m_item; } - TabId Id() const noexcept { return m_id; } // Read-only access to the SplitPanel hosting this tab's tree, used // by Tabs::FindBySurface to walk every leaf and locate the one @@ -150,7 +146,6 @@ class Tab { // the TabViewItem's Content by TabFactory. winrt::GhosttyWin32::SplitPanel m_panel{ nullptr }; Microsoft::UI::Xaml::Controls::TabViewItem m_item{ nullptr }; - TabId m_id{}; // Borrowed pointer into the SplitPanel's tree — never owning. // Reset to nullptr or another leaf on tree mutations before any // leaf is destroyed. diff --git a/GhosttyWin32App/TabFactory.h b/GhosttyWin32App/TabFactory.h index aed3c2e..b73e078 100644 --- a/GhosttyWin32App/TabFactory.h +++ b/GhosttyWin32App/TabFactory.h @@ -1,10 +1,10 @@ #pragma once #include "Pane.h" +#include "PaneId.h" +#include "PaneIdAllocator.h" #include "SplitPanel.h" #include "Tab.h" -#include "TabId.h" -#include "TabIdAllocator.h" #include "TerminalControl.xaml.h" #include "ghostty.h" #include @@ -19,15 +19,16 @@ namespace winrt::GhosttyWin32::implementation { // Builds Tabs. Holds the cross-cutting context (ghostty app handle, the -// HWND for DPI/initial-size, and the TabIdAllocator that produces fresh -// IDs) so callers don't have to thread those through every Make() call. +// HWND for DPI/initial-size, and the PaneIdAllocator that produces fresh +// per-leaf IDs) so callers don't have to thread those through every +// Make() call. // // Stateless beyond the injected references — no mutable state of its -// own. ID counter mutation lives in TabIdAllocator; the factory only +// own. ID counter mutation lives in PaneIdAllocator; the factory only // borrows it. class TabFactory { public: - TabFactory(ghostty_app_t app, HWND hwnd, TabIdAllocator& idAllocator) noexcept + TabFactory(ghostty_app_t app, HWND hwnd, PaneIdAllocator& idAllocator) noexcept : m_app(app), m_hwnd(hwnd), m_idAllocator(idAllocator) {} TabFactory(const TabFactory&) = delete; @@ -79,6 +80,14 @@ class TabFactory { return nullptr; } + // Allocate the PaneId for this leaf up-front — it's used both + // as the close_surface_cb routing key (cfg.userdata) and as + // the leaf's stable identifier inside the tree. Allocated + // before surface_new so cfg.userdata is set; the value is + // opaque to ghostty and travels back to us through + // close_surface_cb. + PaneId paneId = m_idAllocator.Allocate(); + // Wrap the control in a single-leaf Pane tree and host it in a // SplitPanel. Setting the SplitPanel as item.Content here (not // in MainWindow.CreateTab) keeps the "panel owns the tree, Tab @@ -86,7 +95,7 @@ class TabFactory { // collapses to "arrange the single child at the full rect", // matching the previous behaviour of placing the control // directly under TabViewItem. - auto leaf = Pane::MakeLeaf(control); + auto leaf = Pane::MakeLeaf(control, paneId); winrt::GhosttyWin32::SplitPanel splitPanel{}; auto* splitPanelImpl = winrt::get_self(splitPanel); if (!splitPanelImpl) { @@ -112,19 +121,13 @@ class TabFactory { // surface_new fails). auto* attachOwned = new std::shared_ptr(attach); - // ID for the close-surface callback path. Allocated before - // surface_new because cfg.userdata must be set up-front; the - // value is opaque to ghostty and travels back to us through - // close_surface_cb. - TabId id = m_idAllocator.Allocate(); - ghostty_surface_config_s cfg = ghostty_surface_config_new(); cfg.platform_tag = GHOSTTY_PLATFORM_WINDOWS; cfg.platform.windows.hwnd = m_hwnd; cfg.platform.windows.composition_surface_handle = handle; cfg.platform.windows.swap_chain_ready_cb = &TerminalControl::OnSwapChainReady; cfg.platform.windows.swap_chain_ready_userdata = attachOwned; - cfg.userdata = id.ToUserdata(); + cfg.userdata = paneId.ToUserdata(); // Initial swap chain size: prefer the host's caller-supplied // estimate (typically the active tab's panel size, since the // new panel will land in the same TabView content area), then @@ -162,7 +165,7 @@ class TabFactory { controlImpl->Attach(m_app, surface, handle, m_hwnd, attach); try { - return std::make_unique(std::move(splitPanel), std::move(item), id); + return std::make_unique(std::move(splitPanel), std::move(item)); } catch (winrt::hresult_error const&) { // Tab construction validation failed. Detach synchronously // so the surface/handle don't leak. @@ -174,7 +177,7 @@ class TabFactory { private: ghostty_app_t m_app; HWND m_hwnd; - TabIdAllocator& m_idAllocator; + PaneIdAllocator& m_idAllocator; }; } // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/TabIdAllocator.h b/GhosttyWin32App/TabIdAllocator.h deleted file mode 100644 index 4e839e8..0000000 --- a/GhosttyWin32App/TabIdAllocator.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "TabId.h" -#include - -namespace winrt::GhosttyWin32::implementation { - -// Issues monotonically increasing TabIds. One instance per MainWindow — -// the counter is per-allocator (not process-global) so that test setups -// or future multi-window scenarios get an isolated ID space without -// having to clear hidden static state. -// -// Move-disabled: fixed location for the lifetime of MainWindow. -class TabIdAllocator { -public: - TabIdAllocator() = default; - TabIdAllocator(const TabIdAllocator&) = delete; - TabIdAllocator& operator=(const TabIdAllocator&) = delete; - TabIdAllocator(TabIdAllocator&&) = delete; - TabIdAllocator& operator=(TabIdAllocator&&) = delete; - - TabId Allocate() noexcept { - return TabId{ m_next.fetch_add(1, std::memory_order_relaxed) }; - } - -private: - std::atomic m_next{ 1 }; // 0 reserved as sentinel by TabId -}; - -} // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/Tabs.h b/GhosttyWin32App/Tabs.h index 168dc0d..9d51d85 100644 --- a/GhosttyWin32App/Tabs.h +++ b/GhosttyWin32App/Tabs.h @@ -57,18 +57,28 @@ class Tabs { return nullptr; } - // Look up a Tab by the monotonic ID it was assigned at creation. Used - // by the close_surface_cb callback path: ghostty hands us back the ID - // we placed in cfg.userdata, and the dispatched lambda calls this on - // the UI thread. Returns nullptr if the user already closed the tab - // via the UI before the dispatched close arrived (or if the ID is - // otherwise unknown), making stale callbacks a safe no-op. - Tab* FindById(TabId id) const { - if (!id) return nullptr; + // Look up the Tab + leaf for the pane carrying `id`. Used by the + // close_surface_cb callback path: ghostty hands us back the ID we + // placed in cfg.userdata, and the dispatched lambda calls this on + // the UI thread. Returns { nullptr, nullptr } if the user already + // closed the surface via the UI before the dispatched close + // arrived (or if the ID is otherwise unknown), making stale + // callbacks a safe no-op. + struct PaneLookup { + Tab* tab; + Pane* leaf; + }; + PaneLookup FindByPaneId(PaneId id) const { + if (!id) return { nullptr, nullptr }; for (auto& t : m_tabs) { - if (t && t->Id() == id) return t.get(); + if (!t) continue; + auto* panelImpl = winrt::get_self(t->Panel()); + if (!panelImpl) continue; + if (auto* leaf = FindLeafByPaneId(panelImpl->Root(), id)) { + return { t.get(), leaf }; + } } - return nullptr; + return { nullptr, nullptr }; } // The Tab whose TabViewItem is currently selected in the given TabView, @@ -124,6 +134,18 @@ class Tabs { || LeafMatchesSurface(node->Second(), surface); } + // Depth-first search for a leaf carrying `id`. Returns the leaf + // node (mutable so callers can update active-leaf pointers when + // collapsing the tree on close) or nullptr. + static Pane* FindLeafByPaneId(Pane* node, PaneId id) { + if (!node) return nullptr; + if (node->IsLeaf()) { + return node->Id() == id ? node : nullptr; + } + if (auto* p = FindLeafByPaneId(node->First(), id)) return p; + return FindLeafByPaneId(node->Second(), id); + } + std::vector> m_tabs; }; From 0844c49184c63e238f59d098d6d8b012f5d55900 Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 00:09:43 +0900 Subject: [PATCH 04/36] Add tree-mutation primitives for in-place pane edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/Pane.h | 26 ++++++++++++++++++++++++++ GhosttyWin32App/SplitPanel.cpp | 21 +++++++++++++++++++++ GhosttyWin32App/SplitPanel.h | 12 ++++++++++++ 3 files changed, 59 insertions(+) diff --git a/GhosttyWin32App/Pane.h b/GhosttyWin32App/Pane.h index 3351ed0..24fdf89 100644 --- a/GhosttyWin32App/Pane.h +++ b/GhosttyWin32App/Pane.h @@ -101,6 +101,32 @@ class Pane { // Back-pointer to the enclosing internal node, or null at the root. Pane* Parent() const noexcept { return m_parent; } + // Swap the child unique_ptr matching `oldChild` for `newChild`. + // Returns true when `oldChild` was one of this node's children + // (the old subtree is destroyed when its unique_ptr is overwritten; + // the new child's parent back-pointer is rewritten to point here). + // No-op + false if called on a leaf or if `oldChild` isn't a + // child — the caller is expected to verify membership when the + // distinction matters. + // + // Used by SplitPanel::ReplaceLeaf for in-place tree edits like + // NEW_SPLIT (replace a leaf with a split subtree wrapping it) and + // CLOSE_PANE (replace a split with its surviving child). + bool ReplaceChild(Pane* oldChild, std::unique_ptr newChild) noexcept { + if (IsLeaf() || !oldChild || !newChild) return false; + if (m_first.get() == oldChild) { + newChild->m_parent = this; + m_first = std::move(newChild); + return true; + } + if (m_second.get() == oldChild) { + newChild->m_parent = this; + m_second = std::move(newChild); + return true; + } + return false; + } + private: Pane() = default; diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 59d1420..2550d10 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -61,6 +61,27 @@ void SplitPanel::SetRoot(std::unique_ptr root) { InvalidateArrange(); } +bool SplitPanel::ReplaceLeaf(Pane* leaf, std::unique_ptr newSubtree) { + if (!leaf || !newSubtree || !m_root) return false; + + // Root replacement: defer to SetRoot so the same children-sync / + // invalidate path runs. + if (m_root.get() == leaf) { + SetRoot(std::move(newSubtree)); + return true; + } + + // Non-root: parent owns leaf via unique_ptr; rewrite that pointer. + auto* parent = leaf->Parent(); + if (!parent) return false; + if (!parent->ReplaceChild(leaf, std::move(newSubtree))) return false; + + SyncChildrenFromTree(); + InvalidateMeasure(); + InvalidateArrange(); + return true; +} + void SplitPanel::SyncChildrenFromTree() { Children().Clear(); if (m_root) AppendLeavesToChildren(*m_root); diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index 3b18d9a..42ae9c1 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -33,6 +33,18 @@ struct SplitPanel : SplitPanelT { // Passing `nullptr` clears the panel: tree gone, no children. void SetRoot(std::unique_ptr root); + // Replace `leaf` (which must currently be reachable from m_root) + // with the subtree `newSubtree`. Returns true on success — false + // if `leaf` isn't in this tree, or if either pointer is null. + // + // Used by NEW_SPLIT: the caller builds a split subtree whose + // first/second is a new leaf wrapping the existing leaf's content + // (preserving its PaneId so close_surface_cb still routes + // correctly) plus a fresh leaf for the new pane, then calls this + // to swap it in. The framework's Children() collection is + // refreshed and a layout pass is requested. + bool ReplaceLeaf(Pane* leaf, std::unique_ptr newSubtree); + // Read-only access for the host (Tab will need this to walk for // active-leaf focusing, but Phase 1 only uses it for diagnostics). Pane* Root() const noexcept { return m_root.get(); } From 3ac19de461c700b796d960ecda2b6c510ff32e72 Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 00:15:49 +0900 Subject: [PATCH 05/36] Handle GHOSTTY_ACTION_NEW_SPLIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 125 ++++++++++++++++++++++++++-- GhosttyWin32App/MainWindow.xaml.h | 8 ++ GhosttyWin32App/Tab.h | 8 ++ GhosttyWin32App/TabFactory.h | 117 ++++++++++++++++---------- 4 files changed, 207 insertions(+), 51 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index dda4cf2..34b87c1 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -518,6 +518,24 @@ namespace winrt::GhosttyWin32::implementation return true; } + // Split the source pane along the requested direction. The + // existing pane stays put and a new TerminalControl / + // ghostty surface is inserted alongside it; the active + // leaf shifts to the new pane so the user's next keystroke + // lands in the split they just created. + if (action.tag == GHOSTTY_ACTION_NEW_SPLIT + && target.tag == GHOSTTY_TARGET_SURFACE) { + auto surface = target.target.surface; + auto direction = action.action.new_split; + if (g_mainWindow && surface) { + auto mw = g_mainWindow; + mw->DispatcherQueue().TryEnqueue([mw, surface, direction]() { + mw->SplitActivePane(surface, direction); + }); + } + return true; + } + // Ctrl+click on a URL in the terminal. Hand off to the shell // verb opener so the user's default browser / mail client / // etc. handles it. Without this, libghostty falls back to @@ -610,11 +628,6 @@ namespace winrt::GhosttyWin32::implementation if (!m_ghostty || !m_hwnd) return; auto tv = TabView(); - // Each tab is a TerminalControl (UserControl wrapping a - // SwapChainPanel). Focus/IsTabStop/etc. are set in the XAML - // template, so no per-instance setup is needed here. - auto control = winrt::GhosttyWin32::TerminalControl(); - auto item = muxc::TabViewItem(); static constexpr wchar_t kDefaultTabTitle[] = L" "; item.Header(box_value(kDefaultTabTitle)); @@ -680,7 +693,6 @@ namespace winrt::GhosttyWin32::implementation // callback below. if (!m_tabFactory) return; struct CreateCtx { - winrt::GhosttyWin32::TerminalControl const* control; muxc::TabViewItem const* item; TabFactory* factory; std::function onActivated; @@ -688,10 +700,10 @@ namespace winrt::GhosttyWin32::implementation uint32_t initialHeight; std::unique_ptr result; }; - CreateCtx ctx{ &control, &item, m_tabFactory.get(), std::move(onActivated), initialW, initialH, nullptr }; + CreateCtx ctx{ &item, m_tabFactory.get(), std::move(onActivated), initialW, initialH, nullptr }; int ok = RunSEHGuarded([](void* arg) noexcept { auto* c = static_cast(arg); - c->result = c->factory->Make(*c->control, *c->item, std::move(c->onActivated), c->initialWidth, c->initialHeight); + c->result = c->factory->Make(*c->item, std::move(c->onActivated), c->initialWidth, c->initialHeight); }, &ctx); std::unique_ptr tab = std::move(ctx.result); @@ -735,6 +747,103 @@ namespace winrt::GhosttyWin32::implementation m_tabs.Add(std::move(tab)); } + namespace { + // Depth-first search for the leaf hosting `surface`. The pane + // tree is small (a handful of leaves at most) so a flat walk + // is strictly cheaper than maintaining a side index. + Pane* FindLeafForSurface(Pane* node, ghostty_surface_t surface) { + if (!node) return nullptr; + if (node->IsLeaf()) { + auto* tc = Tab::LeafToTerminalControl(*node); + return (tc && tc->Surface() == surface) ? node : nullptr; + } + if (auto* p = FindLeafForSurface(node->First(), surface)) return p; + return FindLeafForSurface(node->Second(), surface); + } + } + + void MainWindow::SplitActivePane(ghostty_surface_t surface, + ghostty_action_split_direction_e direction) + { + if (!m_tabFactory || !surface) return; + auto* sourceTab = m_tabs.FindBySurface(surface); + if (!sourceTab) return; + auto* panelImpl = winrt::get_self(sourceTab->Panel()); + if (!panelImpl) return; + + Pane* sourceLeaf = FindLeafForSurface(panelImpl->Root(), surface); + if (!sourceLeaf || !sourceLeaf->IsLeaf()) return; + + // The source leaf's UIElement + PaneId are about to be moved + // into a new wrapper leaf inside the split subtree we build + // below. Capturing them here means the wrapper has its own + // reference to the underlying TerminalControl before the + // ReplaceLeaf call destroys the original Pane node. + auto sourceContent = sourceLeaf->Content(); + PaneId sourcePaneId = sourceLeaf->Id(); + + // ghostty's split-direction maps to (orientation, which-side- + // does-the-new-pane-take). RIGHT/DOWN put the new pane after + // the source on the layout axis; LEFT/UP put it before. + SplitOrientation orient; + bool newFirst; + switch (direction) { + case GHOSTTY_SPLIT_DIRECTION_RIGHT: orient = SplitOrientation::Horizontal; newFirst = false; break; + case GHOSTTY_SPLIT_DIRECTION_LEFT: orient = SplitOrientation::Horizontal; newFirst = true; break; + case GHOSTTY_SPLIT_DIRECTION_DOWN: orient = SplitOrientation::Vertical; newFirst = false; break; + case GHOSTTY_SPLIT_DIRECTION_UP: orient = SplitOrientation::Vertical; newFirst = true; break; + default: return; + } + + // Size hint for the new ghostty surface: the source pane's + // current SwapChainPanel size halved on the split axis. The + // SplitPanel's first arrange pass after ReplaceLeaf will + // re-size both leaves to their actual half-extent and trigger + // SizeChanged → ghostty resize anyway; this just keeps the + // initial swap chain close to the eventual size so the first + // frame doesn't have to stretch. + uint32_t srcW = 0, srcH = 0; + if (auto* srcTc = Tab::LeafToTerminalControl(*sourceLeaf)) { + auto p = srcTc->InnerPanel(); + srcW = static_cast(p.ActualWidth()); + srcH = static_cast(p.ActualHeight()); + } + uint32_t newW = (orient == SplitOrientation::Horizontal) ? srcW / 2 : srcW; + uint32_t newH = (orient == SplitOrientation::Vertical) ? srcH / 2 : srcH; + + auto newLeaf = m_tabFactory->MakeLeaf(newW, newH); + if (!newLeaf) return; + Pane* newLeafPtr = newLeaf.get(); + auto newControl = newLeaf->Content().try_as(); + + // Build the replacement subtree: a split node whose children + // are (a) a wrapper around the original source content and + // (b) the new leaf, ordered per `newFirst`. + auto sourceWrapper = Pane::MakeLeaf(sourceContent, sourcePaneId); + auto subtree = newFirst + ? Pane::MakeSplit(orient, 0.5, std::move(newLeaf), std::move(sourceWrapper)) + : Pane::MakeSplit(orient, 0.5, std::move(sourceWrapper), std::move(newLeaf)); + + if (!panelImpl->ReplaceLeaf(sourceLeaf, std::move(subtree))) { + // Tree mutation failed after the new surface was already + // attached — detach so it doesn't leak. + if (newControl) { + if (auto* tc = winrt::get_self(newControl)) { + tc->Detach(); + } + } + return; + } + + // Focus shifts to the freshly-created pane: matches the + // expectation set by every other terminal (a `:vsplit` lands + // the cursor in the new pane). + sourceTab->SetActiveLeaf(newLeafPtr); + if (newControl) { + newControl.Focus(Microsoft::UI::Xaml::FocusState::Programmatic); + } + } + // Caption button click handlers. We route through Win32 messages // rather than OverlappedPresenter state changes (which tripped the // NVIDIA driver crash in issue #26). The OS handles min/max/restore diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index 5671c69..ea4a01b 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -45,6 +45,14 @@ namespace winrt::GhosttyWin32::implementation // depending on the current OverlappedPresenter state. void UpdateMaximizeGlyph(); + // Handle GHOSTTY_ACTION_NEW_SPLIT: locate the source pane for + // `surface`, create a new TerminalControl + ghostty surface, + // and insert it next to the source according to `direction`. + // The new pane becomes the active leaf and takes focus. UI + // thread only. + void SplitActivePane(ghostty_surface_t surface, + ghostty_action_split_direction_e direction); + std::unique_ptr m_ghostty; HWND m_hwnd = nullptr; PaneIdAllocator m_paneIds; diff --git a/GhosttyWin32App/Tab.h b/GhosttyWin32App/Tab.h index d579010..7ebb324 100644 --- a/GhosttyWin32App/Tab.h +++ b/GhosttyWin32App/Tab.h @@ -94,6 +94,14 @@ class Tab { winrt::GhosttyWin32::SplitPanel const& Panel() const noexcept { return m_panel; } Pane* ActiveLeaf() const noexcept { return m_activeLeaf; } + // Retarget the active leaf — used by NEW_SPLIT (focus shifts to + // the freshly-created pane), GOTO_SPLIT (direction-based pane + // nav), and any future pointer-focus path. `leaf` must reach back + // to a leaf currently inside this tab's SplitPanel tree; + // callers are expected to verify with FindLeafByPaneId or by + // building the leaf themselves before the tree mutation. + void SetActiveLeaf(Pane* leaf) noexcept { m_activeLeaf = leaf; } + // Whether XAML accepted the focus request. The active leaf's // TerminalControl is a UserControl with IsTabStop=true, so unlike // a bare SwapChainPanel this Focus call actually moves focus diff --git a/GhosttyWin32App/TabFactory.h b/GhosttyWin32App/TabFactory.h index b73e078..5137dcc 100644 --- a/GhosttyWin32App/TabFactory.h +++ b/GhosttyWin32App/TabFactory.h @@ -36,13 +36,12 @@ class TabFactory { TabFactory(TabFactory&&) = delete; TabFactory& operator=(TabFactory&&) = delete; - // Build a fully-formed Tab from a pre-created TerminalControl + item. - // The caller created `control` and `item` and appended `item` to - // the TabView, but did NOT set `item.Content` — this factory wraps - // `control` in a single-leaf Pane tree, hosts it in a SplitPanel, - // and assigns the SplitPanel as the item's content. That keeps the - // pane-tree ownership invariant ("SplitPanel owns the tree, Tab - // borrows it") in one place. + // Build a fully-formed Tab. The caller created `item` and appended + // it to the TabView, but did NOT set `item.Content` — this factory + // creates the TerminalControl, wraps it in a single-leaf Pane + // tree, hosts it in a SplitPanel, and assigns the SplitPanel as + // the item's content. That keeps the pane-tree ownership invariant + // ("SplitPanel owns the tree, Tab borrows it") in one place. // // Returns nullptr on failure (after cleaning up any partially- // acquired resources). Call on the UI thread; neither the inner @@ -61,22 +60,64 @@ class TabFactory { // not from swap-chain creation, so the back buffer is guaranteed to // have displayable content by the time we attach. std::unique_ptr Make( - winrt::GhosttyWin32::TerminalControl control, Microsoft::UI::Xaml::Controls::TabViewItem item, std::function onActivated = {}, uint32_t initialWidth = 0, uint32_t initialHeight = 0) + { + auto leaf = MakeLeaf(initialWidth, initialHeight, std::move(onActivated)); + if (!leaf) return nullptr; + + // Wrap the leaf in a SplitPanel and assign as item.Content. + // With one leaf SplitPanel collapses to "arrange the single + // child at the full rect", matching the previous behaviour of + // placing the control directly under TabViewItem. + winrt::GhosttyWin32::SplitPanel splitPanel{}; + auto* splitPanelImpl = winrt::get_self(splitPanel); + if (!splitPanelImpl) { + OutputDebugStringA("TabFactory::Make: get_self FAILED\n"); + DetachLeaf(*leaf); + return nullptr; + } + splitPanelImpl->SetRoot(std::move(leaf)); + item.Content(splitPanel); + + try { + return std::make_unique(std::move(splitPanel), std::move(item)); + } catch (winrt::hresult_error const&) { + // Tab construction validation failed. Detach synchronously + // so the surface/handle don't leak. The splitPanel / + // tree own the leaf at this point. + if (auto* root = splitPanelImpl->Root()) DetachLeaf(*root); + return nullptr; + } + } + + // Build a new pane: TerminalControl + DComp surface handle + + // ghostty surface + freshly-allocated PaneId, returned as a Pane + // leaf. Shared between Make() (the leaf becomes the only pane in + // a brand-new tab) and the NEW_SPLIT action handler (the leaf is + // inserted into an existing tab's tree alongside its source pane). + // + // Returns nullptr on any failure. Resources acquired before the + // failure point are released before the return — caller doesn't + // need to clean up after a null result. + std::unique_ptr MakeLeaf( + uint32_t initialWidth, + uint32_t initialHeight, + std::function onActivated = {}) { constexpr DWORD COMPOSITIONSURFACE_ALL_ACCESS = 0x0003L; + auto control = winrt::GhosttyWin32::TerminalControl(); auto* controlImpl = winrt::get_self(control); if (!controlImpl) { - OutputDebugStringA("TabFactory::Make: get_self FAILED\n"); + OutputDebugStringA("TabFactory::MakeLeaf: get_self FAILED\n"); return nullptr; } auto panel = controlImpl->InnerPanel(); if (!panel) { - OutputDebugStringA("TabFactory::Make: TerminalControl has no inner panel\n"); + OutputDebugStringA("TabFactory::MakeLeaf: TerminalControl has no inner panel\n"); return nullptr; } @@ -88,26 +129,9 @@ class TabFactory { // close_surface_cb. PaneId paneId = m_idAllocator.Allocate(); - // Wrap the control in a single-leaf Pane tree and host it in a - // SplitPanel. Setting the SplitPanel as item.Content here (not - // in MainWindow.CreateTab) keeps the "panel owns the tree, Tab - // borrows it" invariant centralised. With one leaf SplitPanel - // collapses to "arrange the single child at the full rect", - // matching the previous behaviour of placing the control - // directly under TabViewItem. - auto leaf = Pane::MakeLeaf(control, paneId); - winrt::GhosttyWin32::SplitPanel splitPanel{}; - auto* splitPanelImpl = winrt::get_self(splitPanel); - if (!splitPanelImpl) { - OutputDebugStringA("TabFactory::Make: get_self FAILED\n"); - return nullptr; - } - splitPanelImpl->SetRoot(std::move(leaf)); - item.Content(splitPanel); - HANDLE handle = nullptr; if (FAILED(DCompositionCreateSurfaceHandle(COMPOSITIONSURFACE_ALL_ACCESS, nullptr, &handle))) { - OutputDebugStringA("TabFactory::Make: DCompositionCreateSurfaceHandle FAILED\n"); + OutputDebugStringA("TabFactory::MakeLeaf: DCompositionCreateSurfaceHandle FAILED\n"); return nullptr; } @@ -129,8 +153,8 @@ class TabFactory { cfg.platform.windows.swap_chain_ready_userdata = attachOwned; cfg.userdata = paneId.ToUserdata(); // Initial swap chain size: prefer the host's caller-supplied - // estimate (typically the active tab's panel size, since the - // new panel will land in the same TabView content area), then + // estimate (typically the active tab/pane's panel size, since + // the new panel will land in the same content area), then // fall back to the panel's own ActualWidth/Height. With // deferred SelectedItem (issue #22) the panel isn't in the // visual tree yet so its ActualWidth is 0 — without the host @@ -150,7 +174,7 @@ class TabFactory { ghostty_surface_t surface = ghostty_surface_new(m_app, &cfg); if (!surface) { - OutputDebugStringA("TabFactory::Make: ghostty_surface_new FAILED\n"); + OutputDebugStringA("TabFactory::MakeLeaf: ghostty_surface_new FAILED\n"); // Callback won't fire — release the renderer's owning handle. delete attachOwned; CloseHandle(handle); @@ -158,23 +182,30 @@ class TabFactory { } // Hand surface ownership to the control. From here on the - // control's Detach() (called from Tab::~Tab) is responsible for - // freeing the surface and closing the handle. The app handle - // is needed inside the control to drive ghostty_app_tick after - // IME / keyboard input commits. + // control's Detach() is responsible for freeing the surface + // and closing the handle. controlImpl->Attach(m_app, surface, handle, m_hwnd, attach); - try { - return std::make_unique(std::move(splitPanel), std::move(item)); - } catch (winrt::hresult_error const&) { - // Tab construction validation failed. Detach synchronously - // so the surface/handle don't leak. - controlImpl->Detach(); - return nullptr; - } + return Pane::MakeLeaf(control, paneId); } private: + // Synchronously detach every TerminalControl under `node` so the + // surface / DComp handle don't leak when an error path discards a + // partially-constructed tree. Mirrors Tab::DetachAllLeaves but is + // factored locally so the error paths above can call it without + // depending on Tab. + static void DetachLeaf(Pane const& node) { + if (node.IsLeaf()) { + if (auto* tc = Tab::LeafToTerminalControl(node)) { + tc->Detach(); + } + return; + } + if (auto* f = node.First()) DetachLeaf(*f); + if (auto* s = node.Second()) DetachLeaf(*s); + } + ghostty_app_t m_app; HWND m_hwnd; PaneIdAllocator& m_idAllocator; From 2941377ffbbe4e7446b9fda732dd8b8cfbd9d5d1 Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 00:20:20 +0900 Subject: [PATCH 06/36] Collapse split when a pane in a multi-pane tab closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 128 +++++++++++++++++++++------- GhosttyWin32App/MainWindow.xaml.h | 4 + GhosttyWin32App/Pane.h | 18 ++++ GhosttyWin32App/SplitPanel.cpp | 41 +++++++++ GhosttyWin32App/SplitPanel.h | 22 +++++ GhosttyWin32App/Tab.h | 29 +++++-- 6 files changed, 202 insertions(+), 40 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 34b87c1..96d32c6 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -247,9 +247,11 @@ namespace winrt::GhosttyWin32::implementation // SetSwapChainHandle. Detach is idempotent, so the // ~Tab → ~TerminalControl path runs it again as a no-op. if (auto* t = m_tabs.FindByItem(item)) { - if (auto* tc = t->ActiveControl()) { - tc->Detach(); - } + // Detach every pane in the tab, not just the active + // one — multi-pane tabs have multiple swap chains + // and each needs SetSwapChainHandle(nullptr) before + // the panel is unparented. + t->DetachAll(); } uint32_t idx = 0; if (sender.TabItems().IndexOf(item, idx)) { @@ -403,9 +405,10 @@ namespace winrt::GhosttyWin32::implementation auto* t = mw->m_tabs.FindBySurface(surface); if (!t) return; auto item = t->Item(); - if (auto* tc = t->ActiveControl()) { - tc->Detach(); - } + // CLOSE_TAB closes the whole tab regardless of + // pane count — detach every leaf so each swap + // chain handle is cleared before unparent. + t->DetachAll(); auto tv = mw->TabView(); uint32_t idx = 0; if (tv.TabItems().IndexOf(item, idx)) { @@ -582,38 +585,22 @@ namespace winrt::GhosttyWin32::implementation }; // Shell exited (e.g. user typed `exit`), or ghostty asked to close // the surface for any other reason. The userdata is the PaneId - // we set in TabFactory::Make. Dispatch the TabView mutation to - // the next UI tick to mirror the GHOSTTY_ACTION_CLOSE_TAB handler. + // we set in TabFactory::MakeLeaf. Dispatch the UI mutation to + // the next UI tick so it happens off the renderer thread. // - // Today every tab has exactly one pane, so closing the pane - // means closing the tab. Once NEW_SPLIT lands, this handler - // needs to collapse the split when the closed pane is one of - // several in a tab — phase 5 / Issue #13. + // Two cases: + // * Leaf is the only pane in its tab → close the tab (same + // path as GHOSTTY_ACTION_CLOSE_TAB). + // * Leaf has a sibling → collapse the split. The surviving + // sibling takes the parent split's slot; if the closed + // pane was the active leaf, focus moves to the first leaf + // under the surviving subtree. rtConfig.close_surface_cb = [](void* userdata, bool /*process_alive*/) { if (!g_mainWindow || !userdata) return; PaneId id = PaneId::FromUserdata(userdata); auto mw = g_mainWindow; mw->DispatcherQueue().TryEnqueue([mw, id]() { - auto lookup = mw->m_tabs.FindByPaneId(id); - if (!lookup.tab || !lookup.leaf) return; // pane already closed via the UI - auto* t = lookup.tab; - auto item = t->Item(); - // Same Detach-before-RemoveAt pattern as the other - // close paths — see TabCloseRequested. - if (auto* tc = Tab::LeafToTerminalControl(*lookup.leaf)) { - tc->Detach(); - } - auto tv = mw->TabView(); - uint32_t idx = 0; - if (tv.TabItems().IndexOf(item, idx)) { - tv.TabItems().RemoveAt(idx); - } - DwmFlush(); - if (tv.TabItems().Size() == 0) { - mw->Close(); - } else { - mw->m_tabs.Remove(item); - } + mw->CloseSurfaceByPaneId(id); }); }; @@ -760,6 +747,16 @@ namespace winrt::GhosttyWin32::implementation if (auto* p = FindLeafForSurface(node->First(), surface)) return p; return FindLeafForSurface(node->Second(), surface); } + + // Returns the first leaf reached by depth-first descent — used + // to pick a focus target after a split collapses and the + // previously-active leaf is gone. + Pane* FirstLeafIn(Pane* node) { + if (!node) return nullptr; + if (node->IsLeaf()) return node; + if (auto* p = FirstLeafIn(node->First())) return p; + return FirstLeafIn(node->Second()); + } } void MainWindow::SplitActivePane(ghostty_surface_t surface, @@ -844,6 +841,73 @@ namespace winrt::GhosttyWin32::implementation } } + void MainWindow::CloseSurfaceByPaneId(PaneId id) + { + auto lookup = m_tabs.FindByPaneId(id); + if (!lookup.tab || !lookup.leaf) return; + auto* tab = lookup.tab; + auto* leaf = lookup.leaf; + + // Detach first so the surface / DComp handle are released + // synchronously, before the Pane node holding the + // TerminalControl is destroyed. + if (auto* tc = Tab::LeafToTerminalControl(*leaf)) { + tc->Detach(); + } + + auto* panelImpl = winrt::get_self(tab->Panel()); + if (!panelImpl) return; + + // Identify the sibling subtree BEFORE the removal so we can + // retarget the active leaf into it (the leaf pointer is about + // to be invalidated). + Pane* sibling = nullptr; + bool closingActive = (tab->ActiveLeaf() == leaf); + if (auto* parent = leaf->Parent()) { + sibling = (parent->First() == leaf) ? parent->Second() : parent->First(); + } + // Clear the active-leaf pointer up front: regardless of which + // branch runs below, leaving it pointing at the doomed leaf + // would dangle until the SetActiveLeaf calls overwrite it. + if (closingActive) tab->SetActiveLeaf(nullptr); + + auto result = panelImpl->RemoveLeaf(leaf); + if (result == implementation::SplitPanel::RemovalResult::Collapsed) { + // Tab survives; retarget focus to the surviving subtree. + if (closingActive && sibling) { + if (auto* newActive = FirstLeafIn(sibling)) { + tab->SetActiveLeaf(newActive); + if (auto* tc = Tab::LeafToTerminalControl(*newActive)) { + auto element = newActive->Content(); + if (auto control = element.try_as()) { + control.Focus(Microsoft::UI::Xaml::FocusState::Programmatic); + } + } + } + } + return; + } + + // RemovedRoot or NotFound — treat as full-tab close. + // (NotFound shouldn't happen, but failing closed by closing + // the tab is the safer recovery than leaving a half-detached + // pane around.) DetachAll is idempotent against the leaf we + // already detached above and sweeps any remaining ones. + tab->DetachAll(); + auto item = tab->Item(); + auto tv = TabView(); + uint32_t idx = 0; + if (tv.TabItems().IndexOf(item, idx)) { + tv.TabItems().RemoveAt(idx); + } + DwmFlush(); + if (tv.TabItems().Size() == 0) { + Close(); + } else { + m_tabs.Remove(item); + } + } + // Caption button click handlers. We route through Win32 messages // rather than OverlappedPresenter state changes (which tripped the // NVIDIA driver crash in issue #26). The OS handles min/max/restore diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index ea4a01b..51ec239 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -53,6 +53,10 @@ namespace winrt::GhosttyWin32::implementation void SplitActivePane(ghostty_surface_t surface, ghostty_action_split_direction_e direction); + // Tear down the pane carrying `id` and update the tree / tab + // list. Dispatched from close_surface_cb. UI thread only. + void CloseSurfaceByPaneId(PaneId id); + std::unique_ptr m_ghostty; HWND m_hwnd = nullptr; PaneIdAllocator m_paneIds; diff --git a/GhosttyWin32App/Pane.h b/GhosttyWin32App/Pane.h index 24fdf89..2a0b78e 100644 --- a/GhosttyWin32App/Pane.h +++ b/GhosttyWin32App/Pane.h @@ -127,6 +127,24 @@ class Pane { return false; } + // Pull `child` out of this internal node and return ownership. + // The child becomes an orphan (parent back-pointer cleared) so + // the caller can re-attach it elsewhere — typically as a + // replacement for this node itself when collapsing a split. + // Returns nullptr if called on a leaf or if `child` isn't a child. + std::unique_ptr DetachChild(Pane* child) noexcept { + if (IsLeaf() || !child) return nullptr; + if (m_first.get() == child) { + m_first->m_parent = nullptr; + return std::move(m_first); + } + if (m_second.get() == child) { + m_second->m_parent = nullptr; + return std::move(m_second); + } + return nullptr; + } + private: Pane() = default; diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 2550d10..ef4262e 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -82,6 +82,47 @@ bool SplitPanel::ReplaceLeaf(Pane* leaf, std::unique_ptr newSubtree) { return true; } +SplitPanel::RemovalResult SplitPanel::RemoveLeaf(Pane* leaf) { + if (!leaf || !m_root) return RemovalResult::NotFound; + + if (m_root.get() == leaf) { + // Root removal — tree becomes empty. Caller decides the + // surrounding-tab action. + SetRoot(nullptr); + return RemovalResult::RemovedRoot; + } + + auto* parent = leaf->Parent(); + if (!parent) return RemovalResult::NotFound; + + // Identify the surviving sibling (the parent's other child) and + // detach it from the parent so its unique_ptr survives the parent + // destruction triggered below. + Pane* siblingRaw = (parent->First() == leaf) ? parent->Second() + : parent->First(); + if (!siblingRaw) return RemovalResult::NotFound; + auto sibling = parent->DetachChild(siblingRaw); + if (!sibling) return RemovalResult::NotFound; + + // Replace `parent` in its slot with the sibling subtree. The + // parent's unique_ptr is overwritten, which destroys the parent + // node and (transitively) the doomed leaf. The sibling subtree's + // contents are unaffected because we already detached it. + auto* grandparent = parent->Parent(); + if (!grandparent) { + // parent was root. + SetRoot(std::move(sibling)); + } else { + if (!grandparent->ReplaceChild(parent, std::move(sibling))) { + return RemovalResult::NotFound; // shouldn't happen, but fail closed + } + SyncChildrenFromTree(); + InvalidateMeasure(); + InvalidateArrange(); + } + return RemovalResult::Collapsed; +} + void SplitPanel::SyncChildrenFromTree() { Children().Clear(); if (m_root) AppendLeavesToChildren(*m_root); diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index 42ae9c1..a73bce5 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -45,6 +45,28 @@ struct SplitPanel : SplitPanelT { // refreshed and a layout pass is requested. bool ReplaceLeaf(Pane* leaf, std::unique_ptr newSubtree); + // Remove `leaf` from the tree and collapse its enclosing split + // node, promoting the surviving sibling into the slot the parent + // occupied. Returns the kind of removal that happened so the + // caller can take additional UI action: + // * RemovedRoot — leaf was the root; tree is now empty (the + // caller is expected to close the surrounding tab). + // * Collapsed — leaf had a sibling; parent split node was + // replaced with that sibling, tab continues to render. + // * NotFound — leaf wasn't reachable from m_root, or one of + // the back-pointer invariants was broken; no mutation + // happened. + // + // The leaf's TerminalControl is NOT detached here; the caller is + // expected to do that before invoking RemoveLeaf so the surface + // and DComp handle are released synchronously. + enum class RemovalResult { + NotFound, + RemovedRoot, + Collapsed, + }; + RemovalResult RemoveLeaf(Pane* leaf); + // Read-only access for the host (Tab will need this to walk for // active-leaf focusing, but Phase 1 only uses it for diagnostics). Pane* Root() const noexcept { return m_root.get(); } diff --git a/GhosttyWin32App/Tab.h b/GhosttyWin32App/Tab.h index 7ebb324..eb260af 100644 --- a/GhosttyWin32App/Tab.h +++ b/GhosttyWin32App/Tab.h @@ -61,14 +61,15 @@ class Tab { } ~Tab() { - // Detach every TerminalControl in the tree — surface free, - // swap chain release, composition handle close, SizeChanged - // unhook all live on the control. Walking the tree handles - // the post-split case naturally; with a single leaf it - // collapses to one Detach call. - if (auto* panelImpl = winrt::get_self(m_panel)) { - DetachAllLeaves(panelImpl->Root()); - } + // Catch-all teardown: any leaves still attached at destruction + // get released here. The host's close paths (TabCloseRequested, + // CLOSE_TAB action, close_surface_cb) all call DetachAll() + // explicitly first so the framework's panel unparenting doesn't + // run against a still-bound swap chain handle (the AV at +0x1F8 + // documented in MainWindow's close handlers). This destructor + // is idempotent against those calls — Detach itself is a no-op + // on an already-detached control. + DetachAll(); } Tab(const Tab&) = delete; @@ -102,6 +103,18 @@ class Tab { // building the leaf themselves before the tree mutation. void SetActiveLeaf(Pane* leaf) noexcept { m_activeLeaf = leaf; } + // Detach every TerminalControl in the tree (surface free, swap + // chain release, composition handle close, SizeChanged unhook). + // Must run while the SplitPanel is still in the live visual tree + // — see MainWindow close handlers for the AV that happens if a + // SwapChainPanel is unparented before its swap chain handle is + // cleared. + void DetachAll() { + if (auto* panelImpl = winrt::get_self(m_panel)) { + DetachAllLeaves(panelImpl->Root()); + } + } + // Whether XAML accepted the focus request. The active leaf's // TerminalControl is a UserControl with IsTabStop=true, so unlike // a bare SwapChainPanel this Focus call actually moves focus From 731fbdc444bbb27790e31d989bcc142e952e75a9 Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 22:44:02 +0900 Subject: [PATCH 07/36] Draggable splitter strip between sibling panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/Pane.h | 14 ++ GhosttyWin32App/SplitPanel.cpp | 264 +++++++++++++++++++++++++-------- GhosttyWin32App/SplitPanel.h | 54 ++++++- 3 files changed, 265 insertions(+), 67 deletions(-) diff --git a/GhosttyWin32App/Pane.h b/GhosttyWin32App/Pane.h index 2a0b78e..71503db 100644 --- a/GhosttyWin32App/Pane.h +++ b/GhosttyWin32App/Pane.h @@ -2,6 +2,7 @@ #include "PaneId.h" #include +#include #include namespace winrt::GhosttyWin32::implementation { @@ -101,6 +102,16 @@ class Pane { // Back-pointer to the enclosing internal node, or null at the root. Pane* Parent() const noexcept { return m_parent; } + // Most-recent rectangle this node was arranged into, in + // SplitPanel-local coordinates. Set by SplitPanel::ArrangeNode on + // every layout pass; consumed by splitter-drag resize math and + // direction-based GOTO_SPLIT (finding the leaf adjacent to the + // active one). Defaults to zero before the first arrange, which + // callers treat as "no info" — the host should defer any rect- + // dependent action until the user can see the panel anyway. + Windows::Foundation::Rect ArrangedRect() const noexcept { return m_arrangedRect; } + void SetArrangedRect(Windows::Foundation::Rect r) noexcept { m_arrangedRect = r; } + // Swap the child unique_ptr matching `oldChild` for `newChild`. // Returns true when `oldChild` was one of this node's children // (the old subtree is destroyed when its unique_ptr is overwritten; @@ -171,6 +182,9 @@ class Pane { // the root. Used by future phases for direction-based navigation // and for collapsing the tree on pane close. Pane* m_parent{ nullptr }; + + // Refreshed on every SplitPanel arrange pass — see ArrangedRect. + Windows::Foundation::Rect m_arrangedRect{}; }; } // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index ef4262e..57d7d46 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -6,54 +6,6 @@ namespace winrt::GhosttyWin32::implementation { -namespace { - -// Walks `node` and accumulates the union of every leaf's desired size. -// Stacked dimensions add, perpendicular dimensions take the max — so a -// horizontal split's width is `first + second` and its height is -// `max(first, second)`. Mirrored for vertical splits. -// -// Called from MeasureOverride so the framework knows how much room -// SplitPanel wants. Available size constrains each leaf so the -// terminal surface receives a Measure with the right cap (avoids the -// leaf reporting a content size that ignores the host's available -// area). -Windows::Foundation::Size MeasureNode(Pane& node, Windows::Foundation::Size available) { - if (node.IsLeaf()) { - if (auto element = node.Content()) { - element.Measure(available); - return element.DesiredSize(); - } - return {0, 0}; - } - - auto* first = node.First(); - auto* second = node.Second(); - if (!first && !second) return {0, 0}; - if (!first) return MeasureNode(*second, available); - if (!second) return MeasureNode(*first, available); - - Windows::Foundation::Size firstAvail = available; - Windows::Foundation::Size secondAvail = available; - if (node.Orientation() == SplitOrientation::Horizontal) { - firstAvail.Width = static_cast(available.Width * node.Ratio()); - secondAvail.Width = static_cast(available.Width * (1.0 - node.Ratio())); - } else { - firstAvail.Height = static_cast(available.Height * node.Ratio()); - secondAvail.Height = static_cast(available.Height * (1.0 - node.Ratio())); - } - - auto a = MeasureNode(*first, firstAvail); - auto b = MeasureNode(*second, secondAvail); - - if (node.Orientation() == SplitOrientation::Horizontal) { - return { a.Width + b.Width, std::max(a.Height, b.Height) }; - } - return { std::max(a.Width, b.Width), a.Height + b.Height }; -} - -} // namespace - void SplitPanel::SetRoot(std::unique_ptr root) { m_root = std::move(root); SyncChildrenFromTree(); @@ -125,33 +77,161 @@ SplitPanel::RemovalResult SplitPanel::RemoveLeaf(Pane* leaf) { void SplitPanel::SyncChildrenFromTree() { Children().Clear(); - if (m_root) AppendLeavesToChildren(*m_root); + m_splitters.clear(); + m_draggingNode = nullptr; + if (m_root) AppendNodeToChildren(*m_root); } -void SplitPanel::AppendLeavesToChildren(Pane& node) { +void SplitPanel::AppendNodeToChildren(Pane& node) { if (node.IsLeaf()) { if (auto element = node.Content()) { Children().Append(element); } return; } - if (auto* f = node.First()) AppendLeavesToChildren(*f); - if (auto* s = node.Second()) AppendLeavesToChildren(*s); + // Walk first → splitter → second. The visual order matches the + // layout order, and putting the splitter between the children in + // the Children() collection means it gets painted on top of the + // junction so the dragable strip is always reachable for input. + if (auto* f = node.First()) AppendNodeToChildren(*f); + auto splitter = MakeSplitter(&node); + Children().Append(splitter); + m_splitters.push_back({ splitter, &node }); + if (auto* s = node.Second()) AppendNodeToChildren(*s); +} + +Microsoft::UI::Xaml::Controls::Border SplitPanel::MakeSplitter(Pane* node) { + using namespace winrt::Microsoft::UI::Xaml; + using namespace winrt::Microsoft::UI::Xaml::Controls; + using namespace winrt::Microsoft::UI::Xaml::Input; + using namespace winrt::Microsoft::UI::Xaml::Media; + using namespace winrt::Microsoft::UI::Input; + + Border border{}; + // Semi-transparent gray — visible on both light- and dark-themed + // terminal backgrounds without dominating. Refined theming can + // come later; the priority here is "the user can see it and grab + // it" rather than "it matches the palette". + border.Background(SolidColorBrush(winrt::Windows::UI::Color{ 96, 128, 128, 128 })); + + // Resize cursor — perpendicular to the split axis. Set via + // ProtectedCursor on the UIElement (same path the MOUSE_SHAPE + // handler uses on TerminalControl). + auto cursorShape = (node->Orientation() == SplitOrientation::Horizontal) + ? InputSystemCursorShape::SizeWestEast + : InputSystemCursorShape::SizeNorthSouth; + border.ProtectedCursor(InputSystemCursor::Create(cursorShape)); + + // Wire pointer events. `node` is captured by raw pointer; this is + // safe because Borders are recreated on every SyncChildrenFromTree, + // so a stale `node` would only exist on a stale Border that's + // already been removed from Children() (and whose events therefore + // can't fire). + // + // `this` is also captured raw — the SplitPanel owns the Border via + // Children(), so the impl lives at least as long as any event the + // Border can fire. + border.PointerPressed([this, node](winrt::Windows::Foundation::IInspectable const& sender, + PointerRoutedEventArgs const& args) { + if (auto el = sender.try_as()) { + OnSplitterPointerPressed(el, node, args); + } + }); + border.PointerMoved([this, node](winrt::Windows::Foundation::IInspectable const&, + PointerRoutedEventArgs const& args) { + OnSplitterPointerMoved(node, args); + }); + border.PointerReleased([this](winrt::Windows::Foundation::IInspectable const& sender, + PointerRoutedEventArgs const& args) { + if (auto el = sender.try_as()) { + OnSplitterPointerReleased(el, args); + } + }); + border.PointerCaptureLost([this](winrt::Windows::Foundation::IInspectable const& sender, + PointerRoutedEventArgs const& args) { + if (auto el = sender.try_as()) { + OnSplitterPointerReleased(el, args); + } + }); + + return border; +} + +Microsoft::UI::Xaml::Controls::Border SplitPanel::SplitterForNode(Pane const* node) const { + for (auto const& entry : m_splitters) { + if (entry.node == node) return entry.element; + } + return nullptr; } Windows::Foundation::Size SplitPanel::MeasureOverride(Windows::Foundation::Size availableSize) { - if (!m_root) return {0, 0}; - return MeasureNode(*m_root, availableSize); + if (!m_root) return { 0, 0 }; + auto result = MeasureNode(*m_root, availableSize); + // Every Splitter must also be measured before Arrange — XAML's + // contract is "every child gets Measure before Arrange or the + // framework panics". They don't influence the panel's desired + // size, just have to be visited. + for (auto const& entry : m_splitters) { + if (entry.element) { + entry.element.Measure({ static_cast(kSplitterThickness), + static_cast(kSplitterThickness) }); + } + } + return result; +} + +Windows::Foundation::Size SplitPanel::MeasureNode(Pane& node, Windows::Foundation::Size available) { + if (node.IsLeaf()) { + if (auto element = node.Content()) { + element.Measure(available); + return element.DesiredSize(); + } + return { 0, 0 }; + } + + auto* first = node.First(); + auto* second = node.Second(); + if (!first && !second) return { 0, 0 }; + if (!first) return MeasureNode(*second, available); + if (!second) return MeasureNode(*first, available); + + // Reserve room for the splitter strip on the split axis so the + // children don't ask for space the splitter will end up taking. + auto firstAvail = available; + auto secondAvail = available; + float thickness = static_cast(kSplitterThickness); + if (node.Orientation() == SplitOrientation::Horizontal) { + float useable = std::max(0.0f, available.Width - thickness); + firstAvail.Width = static_cast(useable * node.Ratio()); + secondAvail.Width = static_cast(useable * (1.0 - node.Ratio())); + } else { + float useable = std::max(0.0f, available.Height - thickness); + firstAvail.Height = static_cast(useable * node.Ratio()); + secondAvail.Height = static_cast(useable * (1.0 - node.Ratio())); + } + + auto a = MeasureNode(*first, firstAvail); + auto b = MeasureNode(*second, secondAvail); + + if (node.Orientation() == SplitOrientation::Horizontal) { + return { a.Width + b.Width + thickness, std::max(a.Height, b.Height) }; + } + return { std::max(a.Width, b.Width), a.Height + b.Height + thickness }; } Windows::Foundation::Size SplitPanel::ArrangeOverride(Windows::Foundation::Size finalSize) { if (m_root) { - ArrangeNode(*m_root, Windows::Foundation::Rect{0, 0, finalSize.Width, finalSize.Height}); + ArrangeNode(*m_root, Windows::Foundation::Rect{ 0, 0, finalSize.Width, finalSize.Height }); } return finalSize; } void SplitPanel::ArrangeNode(Pane& node, Windows::Foundation::Rect rect) { + // Cache the arranged rect on the node so drag-resize and + // direction-based pane navigation can recover it without walking + // the tree from the root each time. + node.SetArrangedRect(rect); + if (node.IsLeaf()) { if (auto element = node.Content()) { element.Arrange(rect); @@ -162,22 +242,82 @@ void SplitPanel::ArrangeNode(Pane& node, Windows::Foundation::Rect rect) { auto* first = node.First(); auto* second = node.Second(); if (!first && !second) return; - if (!first) { ArrangeNode(*second, rect); return; } + if (!first) { ArrangeNode(*second, rect); return; } if (!second) { ArrangeNode(*first, rect); return; } + float thickness = static_cast(kSplitterThickness); + if (node.Orientation() == SplitOrientation::Horizontal) { - float w = rect.Width * static_cast(node.Ratio()); - Windows::Foundation::Rect firstRect{ rect.X, rect.Y, w, rect.Height }; - Windows::Foundation::Rect secondRect{ rect.X + w, rect.Y, rect.Width - w, rect.Height }; + float useable = std::max(0.0f, rect.Width - thickness); + float firstW = useable * static_cast(node.Ratio()); + float secondW = useable - firstW; + Windows::Foundation::Rect firstRect{ rect.X, rect.Y, firstW, rect.Height }; + Windows::Foundation::Rect splitRect{ rect.X + firstW, rect.Y, thickness, rect.Height }; + Windows::Foundation::Rect secondRect{ rect.X + firstW + thickness, rect.Y, secondW, rect.Height }; ArrangeNode(*first, firstRect); + if (auto sp = SplitterForNode(&node)) sp.Arrange(splitRect); ArrangeNode(*second, secondRect); } else { - float h = rect.Height * static_cast(node.Ratio()); - Windows::Foundation::Rect firstRect{ rect.X, rect.Y, rect.Width, h }; - Windows::Foundation::Rect secondRect{ rect.X, rect.Y + h, rect.Width, rect.Height - h }; + float useable = std::max(0.0f, rect.Height - thickness); + float firstH = useable * static_cast(node.Ratio()); + float secondH = useable - firstH; + Windows::Foundation::Rect firstRect{ rect.X, rect.Y, rect.Width, firstH }; + Windows::Foundation::Rect splitRect{ rect.X, rect.Y + firstH, rect.Width, thickness }; + Windows::Foundation::Rect secondRect{ rect.X, rect.Y + firstH + thickness, rect.Width, secondH }; ArrangeNode(*first, firstRect); + if (auto sp = SplitterForNode(&node)) sp.Arrange(splitRect); ArrangeNode(*second, secondRect); } } +void SplitPanel::OnSplitterPointerPressed(Microsoft::UI::Xaml::UIElement const& splitter, + Pane* node, + Microsoft::UI::Xaml::Input::PointerRoutedEventArgs const& args) +{ + if (!node) return; + if (!splitter.CapturePointer(args.Pointer())) return; + m_draggingNode = node; + args.Handled(true); +} + +void SplitPanel::OnSplitterPointerMoved(Pane* node, + Microsoft::UI::Xaml::Input::PointerRoutedEventArgs const& args) +{ + // Only consume moves that belong to the active drag — without + // this check, a stray PointerMoved on a non-pressed splitter + // (e.g. hover) would mutate the ratio. + if (!m_draggingNode || m_draggingNode != node) return; + + // Position in SplitPanel local coordinates so it can be compared + // against the parent split's arranged rect (also expressed in + // SplitPanel coordinates). + auto point = args.GetCurrentPoint(*this).Position(); + + // The parent split occupies the same rect ArrangeNode last + // assigned to it, cached on the Pane itself. + auto rect = node->ArrangedRect(); + double newRatio = node->Ratio(); + float thickness = static_cast(kSplitterThickness); + + if (node->Orientation() == SplitOrientation::Horizontal) { + float useable = std::max(1.0f, rect.Width - thickness); + newRatio = (point.X - rect.X) / useable; + } else { + float useable = std::max(1.0f, rect.Height - thickness); + newRatio = (point.Y - rect.Y) / useable; + } + node->SetRatio(newRatio); // clamped to [0.05, 0.95] inside Pane + InvalidateMeasure(); + InvalidateArrange(); + args.Handled(true); +} + +void SplitPanel::OnSplitterPointerReleased(Microsoft::UI::Xaml::UIElement const& splitter, + Microsoft::UI::Xaml::Input::PointerRoutedEventArgs const& args) +{ + splitter.ReleasePointerCapture(args.Pointer()); + m_draggingNode = nullptr; + args.Handled(true); +} + } // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index a73bce5..ece5566 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -3,6 +3,7 @@ #include "SplitPanel.g.h" #include "Pane.h" #include +#include namespace winrt::GhosttyWin32::implementation { @@ -75,20 +76,63 @@ struct SplitPanel : SplitPanelT { Windows::Foundation::Size MeasureOverride(Windows::Foundation::Size availableSize); Windows::Foundation::Size ArrangeOverride(Windows::Foundation::Size finalSize); + // Width of the draggable splitter strip in DIPs. Wide enough to + // hit reliably with mouse, narrow enough that it doesn't visually + // dominate the split — matches Windows Terminal's GridSplitter. + static constexpr double kSplitterThickness = 6.0; + private: + // Recursive measure — caps each subtree at its share of `available` + // along the split axis. Called by MeasureOverride. + Windows::Foundation::Size MeasureNode(Pane& node, Windows::Foundation::Size available); + // Recursive arrange — `rect` is the area assigned to `node` in // SplitPanel coordinates, before any nested splits apply. void ArrangeNode(Pane& node, Windows::Foundation::Rect rect); - // Repopulates `Children()` to match the current tree (depth-first - // leaf order). Called by SetRoot after the tree pointer swap. + // Repopulates `Children()` (and the parallel `m_splitters` list) + // to match the current tree. Called by SetRoot / ReplaceLeaf / + // RemoveLeaf after a tree mutation. void SyncChildrenFromTree(); - // Append every leaf under `node` to `Children()`. Recursive helper - // for SyncChildrenFromTree. - void AppendLeavesToChildren(Pane& node); + // Append a depth-first traversal of `node` to `Children()`: every + // leaf's content, and one fresh Splitter Border per internal node + // (recorded in m_splitters so MeasureNode / ArrangeNode can reach + // it from the corresponding Pane*). + void AppendNodeToChildren(Pane& node); + + // Build a Border for the drag-handle of an internal node and wire + // its pointer events. Borders are recreated on every + // SyncChildrenFromTree, so the captured node pointer is always + // current at the time the lambda runs. + Microsoft::UI::Xaml::Controls::Border MakeSplitter(Pane* node); + + // Find the Border previously created for `node`, or nullptr if + // none. Used during measure/arrange to position the strip. + Microsoft::UI::Xaml::Controls::Border SplitterForNode(Pane const* node) const; + + // Pointer event handlers. Called from the lambdas wired up in + // MakeSplitter. `splitter` is the Border that owns the event; + // `node` is the internal Pane the splitter is for. + void OnSplitterPointerPressed(Microsoft::UI::Xaml::UIElement const& splitter, + Pane* node, + Microsoft::UI::Xaml::Input::PointerRoutedEventArgs const& args); + void OnSplitterPointerMoved(Pane* node, + Microsoft::UI::Xaml::Input::PointerRoutedEventArgs const& args); + void OnSplitterPointerReleased(Microsoft::UI::Xaml::UIElement const& splitter, + Microsoft::UI::Xaml::Input::PointerRoutedEventArgs const& args); + + struct SplitterEntry { + Microsoft::UI::Xaml::Controls::Border element{ nullptr }; + Pane* node{ nullptr }; + }; std::unique_ptr m_root; + std::vector m_splitters; + // Set while a splitter drag is in progress (PointerPressed → + // PointerReleased / CaptureLost). Identifies which internal node's + // ratio is being updated by PointerMoved. + Pane* m_draggingNode{ nullptr }; }; } // namespace winrt::GhosttyWin32::implementation From 726b66718969121f9dd016d57eba1e85e8654fbd Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 22:45:29 +0900 Subject: [PATCH 08/36] Handle GHOSTTY_ACTION_RESIZE_SPLIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 75 +++++++++++++++++++++++++++++ GhosttyWin32App/MainWindow.xaml.h | 7 +++ 2 files changed, 82 insertions(+) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 96d32c6..c138967 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -521,6 +521,23 @@ namespace winrt::GhosttyWin32::implementation return true; } + // Keyboard-driven split resize. Same underlying ratio + // mutation as the splitter-drag path, just initiated from + // a ghostty keybind instead of pointer drag. `amount` is + // treated as DIPs along the split axis. + if (action.tag == GHOSTTY_ACTION_RESIZE_SPLIT + && target.tag == GHOSTTY_TARGET_SURFACE) { + auto surface = target.target.surface; + auto resize = action.action.resize_split; + if (g_mainWindow && surface) { + auto mw = g_mainWindow; + mw->DispatcherQueue().TryEnqueue([mw, surface, resize]() { + mw->ResizeSplitFromAction(surface, resize); + }); + } + return true; + } + // Split the source pane along the requested direction. The // existing pane stays put and a new TerminalControl / // ghostty surface is inserted alongside it; the active @@ -841,6 +858,64 @@ namespace winrt::GhosttyWin32::implementation } } + void MainWindow::ResizeSplitFromAction(ghostty_surface_t surface, + ghostty_action_resize_split_s resize) + { + if (!surface) return; + auto* tab = m_tabs.FindBySurface(surface); + if (!tab) return; + auto* panelImpl = winrt::get_self(tab->Panel()); + if (!panelImpl) return; + + Pane* leaf = FindLeafForSurface(panelImpl->Root(), surface); + if (!leaf) return; + + // The split axis we're resizing matches the direction axis: + // LEFT/RIGHT → Horizontal split, UP/DOWN → Vertical split. + SplitOrientation needOrient = + (resize.direction == GHOSTTY_RESIZE_SPLIT_LEFT + || resize.direction == GHOSTTY_RESIZE_SPLIT_RIGHT) + ? SplitOrientation::Horizontal + : SplitOrientation::Vertical; + + // Walk up to the nearest ancestor split with the right axis. + // `child` is the descendant of that ancestor that contains the + // active leaf — used to figure out whether the leaf is on the + // first/second side of the split so the direction sign is + // applied correctly. + Pane* node = leaf; + Pane* child = nullptr; + while (node && node->Parent()) { + auto* parent = node->Parent(); + if (parent->Orientation() == needOrient) { + child = node; + node = parent; + break; + } + node = parent; + } + if (!child || !node || node->IsLeaf()) return; + + bool activeIsFirst = (node->First() == child); + + auto rect = node->ArrangedRect(); + float extent = (needOrient == SplitOrientation::Horizontal) ? rect.Width : rect.Height; + float useable = std::max(1.0f, + extent - static_cast(implementation::SplitPanel::kSplitterThickness)); + double deltaRatio = static_cast(resize.amount) / useable; + + // RIGHT / DOWN push the boundary in the +axis direction. + // For the first-child side that's an increase in ratio; for + // the second-child side it's a decrease. + bool increase = (resize.direction == GHOSTTY_RESIZE_SPLIT_RIGHT + || resize.direction == GHOSTTY_RESIZE_SPLIT_DOWN); + if (!activeIsFirst) increase = !increase; + + node->SetRatio(node->Ratio() + (increase ? deltaRatio : -deltaRatio)); + panelImpl->InvalidateMeasure(); + panelImpl->InvalidateArrange(); + } + void MainWindow::CloseSurfaceByPaneId(PaneId id) { auto lookup = m_tabs.FindByPaneId(id); diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index 51ec239..864e6ba 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -57,6 +57,13 @@ namespace winrt::GhosttyWin32::implementation // list. Dispatched from close_surface_cb. UI thread only. void CloseSurfaceByPaneId(PaneId id); + // Handle GHOSTTY_ACTION_RESIZE_SPLIT: walk up from the active + // pane to the nearest ancestor split whose axis matches the + // direction, then nudge that split's ratio by `amount` DIPs + // in the requested direction. UI thread only. + void ResizeSplitFromAction(ghostty_surface_t surface, + ghostty_action_resize_split_s resize); + std::unique_ptr m_ghostty; HWND m_hwnd = nullptr; PaneIdAllocator m_paneIds; From 2266cd6a95ddaf62068672cf3893fb2c7b921630 Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 22:47:24 +0900 Subject: [PATCH 09/36] Handle GHOSTTY_ACTION_GOTO_SPLIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 152 ++++++++++++++++++++++++++++ GhosttyWin32App/MainWindow.xaml.h | 7 ++ 2 files changed, 159 insertions(+) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index c138967..14f4013 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -10,8 +10,12 @@ #include #include #include +#include +#include #include #include +#include +#include #pragma comment(lib, "dwmapi.lib") #pragma comment(lib, "shell32.lib") @@ -521,6 +525,22 @@ namespace winrt::GhosttyWin32::implementation return true; } + // Move focus to another pane within the same tab — the + // direction variants walk the tree by arranged-rect + // adjacency, the sequential variants cycle DFS order. + if (action.tag == GHOSTTY_ACTION_GOTO_SPLIT + && target.tag == GHOSTTY_TARGET_SURFACE) { + auto surface = target.target.surface; + auto direction = action.action.goto_split; + if (g_mainWindow && surface) { + auto mw = g_mainWindow; + mw->DispatcherQueue().TryEnqueue([mw, surface, direction]() { + mw->GotoSplitFromAction(surface, direction); + }); + } + return true; + } + // Keyboard-driven split resize. Same underlying ratio // mutation as the splitter-drag path, just initiated from // a ghostty keybind instead of pointer drag. `amount` is @@ -774,6 +794,94 @@ namespace winrt::GhosttyWin32::implementation if (auto* p = FirstLeafIn(node->First())) return p; return FirstLeafIn(node->Second()); } + + // Push every leaf under `node` into `out` in depth-first + // order — left subtree before right. PREVIOUS / NEXT pane + // navigation iterates this list to find neighbours of the + // currently active leaf. + void CollectLeaves(Pane* node, std::vector& out) { + if (!node) return; + if (node->IsLeaf()) { out.push_back(node); return; } + CollectLeaves(node->First(), out); + CollectLeaves(node->Second(), out); + } + + // Pick the leaf whose arranged rect is adjacent to `active` + // in the requested cardinal direction. Filters to leaves + // strictly on the requested side, then scores them by primary + // distance (along the axis) plus a perpendicular penalty so + // an aligned neighbour beats a far-off-axis one. + // + // Returns nullptr if no candidate qualifies — caller's job + // to decide whether to fall back (today: just ignore the + // input, matching how Windows Terminal handles "no neighbour + // in this direction"). + Pane* FindAdjacentLeaf(Pane* active, + std::vector const& leaves, + ghostty_action_goto_split_e dir) + { + if (!active) return nullptr; + auto a = active->ArrangedRect(); + float ax2 = a.X + a.Width; + float ay2 = a.Y + a.Height; + float aCenterX = a.X + a.Width * 0.5f; + float aCenterY = a.Y + a.Height * 0.5f; + + Pane* best = nullptr; + double bestScore = std::numeric_limits::max(); + for (auto* leaf : leaves) { + if (leaf == active) continue; + auto c = leaf->ArrangedRect(); + float cx2 = c.X + c.Width; + float cy2 = c.Y + c.Height; + float cCenterX = c.X + c.Width * 0.5f; + float cCenterY = c.Y + c.Height * 0.5f; + + double primary, perpendicular; + bool valid = false; + switch (dir) { + case GHOSTTY_GOTO_SPLIT_LEFT: + // Candidate must end at or before active starts — + // allow a tiny overlap to absorb float rounding. + if (cx2 > a.X + 1.0f) break; + primary = a.X - cx2; + perpendicular = std::abs(cCenterY - aCenterY); + valid = true; + break; + case GHOSTTY_GOTO_SPLIT_RIGHT: + if (c.X < ax2 - 1.0f) break; + primary = c.X - ax2; + perpendicular = std::abs(cCenterY - aCenterY); + valid = true; + break; + case GHOSTTY_GOTO_SPLIT_UP: + if (cy2 > a.Y + 1.0f) break; + primary = a.Y - cy2; + perpendicular = std::abs(cCenterX - aCenterX); + valid = true; + break; + case GHOSTTY_GOTO_SPLIT_DOWN: + if (c.Y < ay2 - 1.0f) break; + primary = c.Y - ay2; + perpendicular = std::abs(cCenterX - aCenterX); + valid = true; + break; + default: + return nullptr; // PREVIOUS / NEXT handled elsewhere + } + if (!valid) continue; + // Weight perpendicular gap twice as heavily as primary + // distance — keeps focus moves predictable when there + // are off-axis panes that are technically closer in + // straight-line distance. + double score = primary + 2.0 * perpendicular; + if (score < bestScore) { + bestScore = score; + best = leaf; + } + } + return best; + } } void MainWindow::SplitActivePane(ghostty_surface_t surface, @@ -858,6 +966,50 @@ namespace winrt::GhosttyWin32::implementation } } + void MainWindow::GotoSplitFromAction(ghostty_surface_t surface, + ghostty_action_goto_split_e direction) + { + if (!surface) return; + auto* tab = m_tabs.FindBySurface(surface); + if (!tab) return; + auto* panelImpl = winrt::get_self(tab->Panel()); + if (!panelImpl) return; + + Pane* active = FindLeafForSurface(panelImpl->Root(), surface); + if (!active) return; + + std::vector leaves; + CollectLeaves(panelImpl->Root(), leaves); + if (leaves.size() <= 1) return; // nothing to navigate to + + Pane* target = nullptr; + if (direction == GHOSTTY_GOTO_SPLIT_PREVIOUS + || direction == GHOSTTY_GOTO_SPLIT_NEXT) { + // Cycle through DFS order. wrap-around so the last pane's + // NEXT lands on the first and vice versa. + auto it = std::find(leaves.begin(), leaves.end(), active); + if (it == leaves.end()) return; + size_t idx = static_cast(std::distance(leaves.begin(), it)); + size_t newIdx; + if (direction == GHOSTTY_GOTO_SPLIT_NEXT) { + newIdx = (idx + 1) % leaves.size(); + } else { + newIdx = (idx == 0) ? leaves.size() - 1 : idx - 1; + } + target = leaves[newIdx]; + } else { + target = FindAdjacentLeaf(active, leaves, direction); + } + if (!target || target == active) return; + + tab->SetActiveLeaf(target); + if (auto element = target->Content()) { + if (auto control = element.try_as()) { + control.Focus(Microsoft::UI::Xaml::FocusState::Programmatic); + } + } + } + void MainWindow::ResizeSplitFromAction(ghostty_surface_t surface, ghostty_action_resize_split_s resize) { diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index 864e6ba..1fd8282 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -64,6 +64,13 @@ namespace winrt::GhosttyWin32::implementation void ResizeSplitFromAction(ghostty_surface_t surface, ghostty_action_resize_split_s resize); + // Handle GHOSTTY_ACTION_GOTO_SPLIT: move focus to another + // pane in the same tab. PREVIOUS/NEXT cycle the tree in + // depth-first order; UP/DOWN/LEFT/RIGHT pick the leaf whose + // arranged rect is adjacent in that direction. UI thread only. + void GotoSplitFromAction(ghostty_surface_t surface, + ghostty_action_goto_split_e direction); + std::unique_ptr m_ghostty; HWND m_hwnd = nullptr; PaneIdAllocator m_paneIds; From b9e3df18708a4d293a36c842b4c956828fe571fe Mon Sep 17 00:00:00 2001 From: i999rri Date: Wed, 20 May 2026 22:50:11 +0900 Subject: [PATCH 10/36] Show focus border around the active pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/TerminalControl.xaml | 18 ++++++++++++--- GhosttyWin32App/TerminalControl.xaml.cpp | 29 ++++++++++++++++++++---- GhosttyWin32App/TerminalControl.xaml.h | 7 ++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/GhosttyWin32App/TerminalControl.xaml b/GhosttyWin32App/TerminalControl.xaml index 15987ee..6c6efa5 100644 --- a/GhosttyWin32App/TerminalControl.xaml +++ b/GhosttyWin32App/TerminalControl.xaml @@ -9,7 +9,19 @@ IsTabStop="True" AllowFocusOnInteraction="True" IsHitTestVisible="True"> - + + + + diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index f0b979e..7eee5e8 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -76,14 +76,16 @@ namespace winrt::GhosttyWin32::implementation // for that case. GotFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); - if (!self || !self->m_editContext) return; - self->m_editContext.NotifyFocusEnter(); + if (!self) return; + if (self->m_editContext) self->m_editContext.NotifyFocusEnter(); + self->ShowFocusBorder(true); }); LostFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); - if (!self || !self->m_editContext) return; - self->m_editContext.NotifyFocusLeave(); + if (!self) return; + if (self->m_editContext) self->m_editContext.NotifyFocusLeave(); + self->ShowFocusBorder(false); }); PointerMoved([weakSelf](auto&&, muxi::PointerRoutedEventArgs const& args) { @@ -309,6 +311,25 @@ namespace winrt::GhosttyWin32::implementation ProtectedCursor(winrt::Microsoft::UI::Input::InputSystemCursor::Create(mapped)); } + void TerminalControl::ShowFocusBorder(bool visible) + { + auto border = FocusBorder(); + if (!border) return; + if (visible) { + // Hard-coded accent — bright enough to be obvious against + // typical dark / light terminal backgrounds without bleeding + // visual noise into the cell area. Using the system accent + // brush would require a ThemeResource lookup that varies + // with the user's Windows accent colour and could clash + // with the terminal palette. + border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( + winrt::Windows::UI::Color{ 255, 0, 120, 215 })); + } else { + border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( + winrt::Windows::UI::Color{ 0, 0, 0, 0 })); + } + } + TerminalControl::~TerminalControl() { // Belt-and-suspenders: Tab's destructor normally calls Detach diff --git a/GhosttyWin32App/TerminalControl.xaml.h b/GhosttyWin32App/TerminalControl.xaml.h index 0c78957..7bec395 100644 --- a/GhosttyWin32App/TerminalControl.xaml.h +++ b/GhosttyWin32App/TerminalControl.xaml.h @@ -108,6 +108,13 @@ namespace winrt::GhosttyWin32::implementation // borders, etc. void SetCursorShape(ghostty_action_mouse_shape_e shape); + // Flip the focus border between visible (accent colour) and + // hidden (transparent). The Border element keeps a constant + // BorderThickness of 1 so toggling the brush doesn't force a + // relayout / terminal grid reflow. Called from the GotFocus / + // LostFocus handlers wired up in the constructor. + void ShowFocusBorder(bool visible); + private: // Builds the per-control CoreTextEditContext and wires its // seven event handlers (TextRequested / SelectionRequested / From 8d831030e360c2b5d6c1e573f7ed0892c97c644f Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 00:12:44 +0900 Subject: [PATCH 11/36] Handle GHOSTTY_ACTION_EQUALIZE_SPLITS 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 24 ++++++++++++++++++++++++ GhosttyWin32App/MainWindow.xaml.h | 5 +++++ GhosttyWin32App/SplitPanel.cpp | 13 +++++++++++++ GhosttyWin32App/SplitPanel.h | 6 ++++++ 4 files changed, 48 insertions(+) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 14f4013..7eeb717 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -525,6 +525,20 @@ namespace winrt::GhosttyWin32::implementation return true; } + // Reset all split ratios in the source tab to 0.5 so each + // pane gets an even share of its parent split. + if (action.tag == GHOSTTY_ACTION_EQUALIZE_SPLITS + && target.tag == GHOSTTY_TARGET_SURFACE) { + auto surface = target.target.surface; + if (g_mainWindow && surface) { + auto mw = g_mainWindow; + mw->DispatcherQueue().TryEnqueue([mw, surface]() { + mw->EqualizeSplitsForSurface(surface); + }); + } + return true; + } + // Move focus to another pane within the same tab — the // direction variants walk the tree by arranged-rect // adjacency, the sequential variants cycle DFS order. @@ -966,6 +980,16 @@ namespace winrt::GhosttyWin32::implementation } } + void MainWindow::EqualizeSplitsForSurface(ghostty_surface_t surface) + { + if (!surface) return; + auto* tab = m_tabs.FindBySurface(surface); + if (!tab) return; + auto* panelImpl = winrt::get_self(tab->Panel()); + if (!panelImpl) return; + panelImpl->EqualizeAll(); + } + void MainWindow::GotoSplitFromAction(ghostty_surface_t surface, ghostty_action_goto_split_e direction) { diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index 1fd8282..8d348ca 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -71,6 +71,11 @@ namespace winrt::GhosttyWin32::implementation void GotoSplitFromAction(ghostty_surface_t surface, ghostty_action_goto_split_e direction); + // Handle GHOSTTY_ACTION_EQUALIZE_SPLITS: reset every split + // ratio in the active tab to 0.5 so each pane occupies an + // even share of its parent split. UI thread only. + void EqualizeSplitsForSurface(ghostty_surface_t surface); + std::unique_ptr m_ghostty; HWND m_hwnd = nullptr; PaneIdAllocator m_paneIds; diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 57d7d46..747d302 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -75,6 +75,19 @@ SplitPanel::RemovalResult SplitPanel::RemoveLeaf(Pane* leaf) { return RemovalResult::Collapsed; } +void SplitPanel::EqualizeAll() { + // m_splitters already holds one entry per internal node, so reuse + // it instead of re-walking the tree. The vector is rebuilt on + // every SyncChildrenFromTree, so it's always in sync with the + // current tree shape. + if (m_splitters.empty()) return; + for (auto const& entry : m_splitters) { + if (entry.node) entry.node->SetRatio(0.5); + } + InvalidateMeasure(); + InvalidateArrange(); +} + void SplitPanel::SyncChildrenFromTree() { Children().Clear(); m_splitters.clear(); diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index ece5566..8a6afb2 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -68,6 +68,12 @@ struct SplitPanel : SplitPanelT { }; RemovalResult RemoveLeaf(Pane* leaf); + // Reset every internal node's ratio to 0.5 (so each split divides + // its area evenly) and trigger a layout pass. Matches what the + // ghostty EQUALIZE_SPLITS action expects — no-op on a single-leaf + // tree. + void EqualizeAll(); + // Read-only access for the host (Tab will need this to walk for // active-leaf focusing, but Phase 1 only uses it for diagnostics). Pane* Root() const noexcept { return m_root.get(); } From a95d2bd1d0c695faac1d232fe6e5a05f4ac74139 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 00:14:25 +0900 Subject: [PATCH 12/36] Handle GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 46 ++++++++++++++++++++++ GhosttyWin32App/MainWindow.xaml.h | 6 +++ GhosttyWin32App/SplitPanel.cpp | 61 ++++++++++++++++++++++++++++- GhosttyWin32App/SplitPanel.h | 22 +++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 7eeb717..a5f9d28 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -525,6 +525,20 @@ namespace winrt::GhosttyWin32::implementation return true; } + // Zoom the source pane to fill the entire tab. A second + // press unzooms back to the regular split layout. + if (action.tag == GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM + && target.tag == GHOSTTY_TARGET_SURFACE) { + auto surface = target.target.surface; + if (g_mainWindow && surface) { + auto mw = g_mainWindow; + mw->DispatcherQueue().TryEnqueue([mw, surface]() { + mw->ToggleSplitZoomForSurface(surface); + }); + } + return true; + } + // Reset all split ratios in the source tab to 0.5 so each // pane gets an even share of its parent split. if (action.tag == GHOSTTY_ACTION_EQUALIZE_SPLITS @@ -990,6 +1004,38 @@ namespace winrt::GhosttyWin32::implementation panelImpl->EqualizeAll(); } + void MainWindow::ToggleSplitZoomForSurface(ghostty_surface_t surface) + { + if (!surface) return; + auto* tab = m_tabs.FindBySurface(surface); + if (!tab) return; + auto* panelImpl = winrt::get_self(tab->Panel()); + if (!panelImpl) return; + + // Already zoomed → unzoom regardless of which pane fired the + // action. Matches how Windows Terminal / iTerm exit zoom mode: + // a second press anywhere collapses it back. + if (panelImpl->ZoomedLeaf()) { + panelImpl->SetZoomed(nullptr); + return; + } + + Pane* leaf = FindLeafForSurface(panelImpl->Root(), surface); + if (!leaf) return; + // Single-leaf tabs skip the zoom — there's nothing to expand + // against, and the visual state would be identical to the + // normal layout. + if (leaf == panelImpl->Root()) return; + + panelImpl->SetZoomed(leaf); + tab->SetActiveLeaf(leaf); + // Re-focus so the zoomed pane keeps input even when zoom was + // toggled from a non-active pane via a remapped binding. + if (auto control = leaf->Content().try_as()) { + control.Focus(Microsoft::UI::Xaml::FocusState::Programmatic); + } + } + void MainWindow::GotoSplitFromAction(ghostty_surface_t surface, ghostty_action_goto_split_e direction) { diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index 8d348ca..30a93df 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -76,6 +76,12 @@ namespace winrt::GhosttyWin32::implementation // even share of its parent split. UI thread only. void EqualizeSplitsForSurface(ghostty_surface_t surface); + // Handle GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: if no leaf is + // currently zoomed in the source surface's tab, expand the + // source leaf to fill the panel; if a leaf is already zoomed + // there, restore the regular split layout. UI thread only. + void ToggleSplitZoomForSurface(ghostty_surface_t surface); + std::unique_ptr m_ghostty; HWND m_hwnd = nullptr; PaneIdAllocator m_paneIds; diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 747d302..7041abb 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -75,6 +75,37 @@ SplitPanel::RemovalResult SplitPanel::RemoveLeaf(Pane* leaf) { return RemovalResult::Collapsed; } +void SplitPanel::SetZoomed(Pane* leaf) { + m_zoomedLeaf = leaf; + UpdateChildVisibility(); + InvalidateMeasure(); + InvalidateArrange(); +} + +void SplitPanel::UpdateChildVisibility() { + using namespace winrt::Microsoft::UI::Xaml; + // No zoom in effect — every child stays visible. Walk Children() + // directly so this also recovers visibility for elements that + // were previously hidden by an earlier zoom. + if (!m_zoomedLeaf) { + for (auto&& child : Children()) { + if (auto el = child.try_as()) { + el.Visibility(Visibility::Visible); + } + } + return; + } + // Zoom active — only the zoomed leaf's content stays visible. + // Comparing UIElement projections by identity works because each + // element appears at most once in Children(). + auto zoomElement = m_zoomedLeaf->Content(); + for (auto&& child : Children()) { + if (auto el = child.try_as()) { + el.Visibility(el == zoomElement ? Visibility::Visible : Visibility::Collapsed); + } + } +} + void SplitPanel::EqualizeAll() { // m_splitters already holds one entry per internal node, so reuse // it instead of re-walking the tree. The vector is rebuilt on @@ -92,6 +123,11 @@ void SplitPanel::SyncChildrenFromTree() { Children().Clear(); m_splitters.clear(); m_draggingNode = nullptr; + // Any tree shape change invalidates a previously-stored zoom + // pointer (the leaf may have moved, been wrapped in a split, or + // gone away entirely). Clearing here is safer than auditing every + // call site for whether the zoomed leaf survived. + m_zoomedLeaf = nullptr; if (m_root) AppendNodeToChildren(*m_root); } @@ -179,6 +215,16 @@ Microsoft::UI::Xaml::Controls::Border SplitPanel::SplitterForNode(Pane const* no Windows::Foundation::Size SplitPanel::MeasureOverride(Windows::Foundation::Size availableSize) { if (!m_root) return { 0, 0 }; + // Zoom path: only the zoomed leaf participates in layout. The + // others are Visibility=Collapsed so Panel's base class skips + // them entirely — we don't need to Measure them. + if (m_zoomedLeaf && m_zoomedLeaf->IsLeaf()) { + if (auto element = m_zoomedLeaf->Content()) { + element.Measure(availableSize); + return element.DesiredSize(); + } + return { 0, 0 }; + } auto result = MeasureNode(*m_root, availableSize); // Every Splitter must also be measured before Arrange — XAML's // contract is "every child gets Measure before Arrange or the @@ -233,9 +279,20 @@ Windows::Foundation::Size SplitPanel::MeasureNode(Pane& node, Windows::Foundatio } Windows::Foundation::Size SplitPanel::ArrangeOverride(Windows::Foundation::Size finalSize) { - if (m_root) { - ArrangeNode(*m_root, Windows::Foundation::Rect{ 0, 0, finalSize.Width, finalSize.Height }); + if (!m_root) return finalSize; + Windows::Foundation::Rect fullRect{ 0, 0, finalSize.Width, finalSize.Height }; + // Zoom: only the zoomed leaf is arranged. Others are Collapsed so + // their ActualSize / SizeChanged don't fire; the SwapChainPanel + // they own keeps whatever swap chain it had bound and resumes + // when unzoomed. + if (m_zoomedLeaf && m_zoomedLeaf->IsLeaf()) { + m_zoomedLeaf->SetArrangedRect(fullRect); + if (auto element = m_zoomedLeaf->Content()) { + element.Arrange(fullRect); + } + return finalSize; } + ArrangeNode(*m_root, fullRect); return finalSize; } diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index 8a6afb2..52b0238 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -74,6 +74,19 @@ struct SplitPanel : SplitPanelT { // tree. void EqualizeAll(); + // Zoom one leaf to fill the entire SplitPanel — every other leaf + // and every splitter is hidden via Visibility=Collapsed, and + // MeasureOverride / ArrangeOverride lay out only the zoomed leaf + // at the full size. Pass nullptr to unzoom. + // + // `leaf` must be currently reachable from m_root; on any tree + // mutation (SetRoot / ReplaceLeaf / RemoveLeaf) the zoom is + // automatically cleared because SyncChildrenFromTree resets the + // state — the previously-stored pointer can no longer be trusted + // after the tree shape changes. + void SetZoomed(Pane* leaf); + Pane* ZoomedLeaf() const noexcept { return m_zoomedLeaf; } + // Read-only access for the host (Tab will need this to walk for // active-leaf focusing, but Phase 1 only uses it for diagnostics). Pane* Root() const noexcept { return m_root.get(); } @@ -133,12 +146,21 @@ struct SplitPanel : SplitPanelT { Pane* node{ nullptr }; }; + // Refresh Visibility on every child so the zoom state matches + // m_zoomedLeaf. Called from SetZoomed and from SyncChildrenFromTree + // (after rebuilding Children() but before the next layout pass). + void UpdateChildVisibility(); + std::unique_ptr m_root; std::vector m_splitters; // Set while a splitter drag is in progress (PointerPressed → // PointerReleased / CaptureLost). Identifies which internal node's // ratio is being updated by PointerMoved. Pane* m_draggingNode{ nullptr }; + // Set by SetZoomed — the leaf that should fill the entire panel + // while other leaves / splitters are hidden. Cleared by tree + // mutations because the stored pointer can no longer be trusted. + Pane* m_zoomedLeaf{ nullptr }; }; } // namespace winrt::GhosttyWin32::implementation From 578e5a38385c254fcddc1885714fb301b6a92bc7 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 00:14:57 +0900 Subject: [PATCH 13/36] SEH-guard the new-split surface creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 37 ++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index a5f9d28..f6c59b0 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -961,7 +961,42 @@ namespace winrt::GhosttyWin32::implementation uint32_t newW = (orient == SplitOrientation::Horizontal) ? srcW / 2 : srcW; uint32_t newH = (orient == SplitOrientation::Vertical) ? srcH / 2 : srcH; - auto newLeaf = m_tabFactory->MakeLeaf(newW, newH); + // Wrap MakeLeaf in an SEH guard for the same reason CreateTab + // does — ghostty_surface_new calls into dx_create_texture + // where NVIDIA drivers have historically thrown hardware + // exceptions. Without the guard, a driver AV here takes down + // every other pane / tab in the window. With it, we can fail + // closed for the new pane while leaving the rest of the + // window intact (or, if the heap is clearly corrupt, exit + // cleanly via the same cleanup path). + struct SplitCtx { + TabFactory* factory; + uint32_t initialWidth; + uint32_t initialHeight; + std::unique_ptr result; + }; + SplitCtx ctx{ m_tabFactory.get(), newW, newH, nullptr }; + int ok = RunSEHGuarded([](void* arg) noexcept { + auto* c = static_cast(arg); + c->result = c->factory->MakeLeaf(c->initialWidth, c->initialHeight); + }, &ctx); + if (!ok) { + // Driver-side hardware exception. Process state is + // unreliable from here — same recovery path as the tab- + // creation crash: hide window, show explanatory dialog, + // post WM_CLOSE. + if (m_hwnd) ShowWindow(m_hwnd, SW_HIDE); + MessageBoxW(nullptr, + L"A graphics driver error occurred while creating the new split.\n" + L"GhosttyWin32 will exit safely.\n\n" + L"Restarting the app usually recovers — the next launch\n" + L"will automatically wait 2 seconds for the driver.", + L"GhosttyWin32", + MB_OK | MB_ICONERROR | MB_TASKMODAL); + if (m_hwnd) PostMessageW(m_hwnd, WM_CLOSE, 0, 0); + return; + } + auto newLeaf = std::move(ctx.result); if (!newLeaf) return; Pane* newLeafPtr = newLeaf.get(); auto newControl = newLeaf->Content().try_as(); From 0957c1e59d0bfc6cf39be5c4ae57c23145309cce Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 00:23:25 +0900 Subject: [PATCH 14/36] =?UTF-8?q?Drop=20splitter=20cursor=20=E2=80=94=20Bo?= =?UTF-8?q?rder.ProtectedCursor=20isn't=20accessible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/SplitPanel.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 7041abb..78b90cf 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -154,22 +154,20 @@ Microsoft::UI::Xaml::Controls::Border SplitPanel::MakeSplitter(Pane* node) { using namespace winrt::Microsoft::UI::Xaml::Controls; using namespace winrt::Microsoft::UI::Xaml::Input; using namespace winrt::Microsoft::UI::Xaml::Media; - using namespace winrt::Microsoft::UI::Input; Border border{}; - // Semi-transparent gray — visible on both light- and dark-themed + // Semi-transparent gray, visible on both light- and dark-themed // terminal backgrounds without dominating. Refined theming can // come later; the priority here is "the user can see it and grab // it" rather than "it matches the palette". border.Background(SolidColorBrush(winrt::Windows::UI::Color{ 96, 128, 128, 128 })); - // Resize cursor — perpendicular to the split axis. Set via - // ProtectedCursor on the UIElement (same path the MOUSE_SHAPE - // handler uses on TerminalControl). - auto cursorShape = (node->Orientation() == SplitOrientation::Horizontal) - ? InputSystemCursorShape::SizeWestEast - : InputSystemCursorShape::SizeNorthSouth; - border.ProtectedCursor(InputSystemCursor::Create(cursorShape)); + // No resize cursor for now — ProtectedCursor is protected on + // UIElement and Border is sealed, so we can't set the per-element + // cursor from outside without subclassing. A follow-up can swap + // this Border for a custom UserControl-based splitter that + // exposes the cursor setup; until then the strip is visible + // enough to grab without the cursor hint. // Wire pointer events. `node` is captured by raw pointer; this is // safe because Borders are recreated on every SyncChildrenFromTree, From 27daf4aa7486cee1ff6eb28a6fca4da9dc87fa1f Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 00:24:52 +0900 Subject: [PATCH 15/36] Use get_strong().as() for GetCurrentPoint relativeTo 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() makes the QueryInterface step explicit so the call site compiles cleanly. --- GhosttyWin32App/SplitPanel.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 78b90cf..24b26d7 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -358,8 +358,12 @@ void SplitPanel::OnSplitterPointerMoved(Pane* node, // Position in SplitPanel local coordinates so it can be compared // against the parent split's arranged rect (also expressed in - // SplitPanel coordinates). - auto point = args.GetCurrentPoint(*this).Position(); + // SplitPanel coordinates). get_strong() returns the projected + // SplitPanel; .as() narrows it to the UIElement + // projection GetCurrentPoint expects without the multi-step + // implicit conversion `*this` would need. + auto self = get_strong().as(); + auto point = args.GetCurrentPoint(self).Position(); // The parent split occupies the same rect ArrangeNode last // assigned to it, cached on the Pane itself. From d4190ada8ce0ca36953be98d43c863048cb91d91 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 00:40:11 +0900 Subject: [PATCH 16/36] Define NOMINMAX + compile with /utf-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split work introduced the first uses of std::max and std::numeric_limits::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::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. --- GhosttyWin32App/GhosttyWin32App.vcxproj | 2 +- GhosttyWin32App/pch.h | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/GhosttyWin32App/GhosttyWin32App.vcxproj b/GhosttyWin32App/GhosttyWin32App.vcxproj index def85a4..95d6c48 100644 --- a/GhosttyWin32App/GhosttyWin32App.vcxproj +++ b/GhosttyWin32App/GhosttyWin32App.vcxproj @@ -93,7 +93,7 @@ $(IntDir)pch.pch Level4 stdcpp20 - %(AdditionalOptions) /bigobj + %(AdditionalOptions) /bigobj /utf-8 $(ProjectDir)..\ghostty;%(AdditionalIncludeDirectories) diff --git a/GhosttyWin32App/pch.h b/GhosttyWin32App/pch.h index ce405a3..7fc1cb4 100644 --- a/GhosttyWin32App/pch.h +++ b/GhosttyWin32App/pch.h @@ -1,4 +1,9 @@ #pragma once +// Suppress the windows.h `max` / `min` function-like macros so +// `std::max` and `std::numeric_limits::max()` don't get rewritten +// into ternary garbage at preprocessor time. Needs to be defined +// before windows.h is included. +#define NOMINMAX #include #include #include From 28cc9b8e4c9c29aecf7a26aa5c0f956c1c89da72 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 00:45:56 +0900 Subject: [PATCH 17/36] =?UTF-8?q?Revert=20/utf-8=20flag=20=E2=80=94=20brok?= =?UTF-8?q?e=20PCH=20compilation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). --- GhosttyWin32App/GhosttyWin32App.vcxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GhosttyWin32App/GhosttyWin32App.vcxproj b/GhosttyWin32App/GhosttyWin32App.vcxproj index 95d6c48..def85a4 100644 --- a/GhosttyWin32App/GhosttyWin32App.vcxproj +++ b/GhosttyWin32App/GhosttyWin32App.vcxproj @@ -93,7 +93,7 @@ $(IntDir)pch.pch Level4 stdcpp20 - %(AdditionalOptions) /bigobj /utf-8 + %(AdditionalOptions) /bigobj $(ProjectDir)..\ghostty;%(AdditionalIncludeDirectories) From e4c51406760f2698ad5cae4b76091a5c75f27d43 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 01:06:12 +0900 Subject: [PATCH 18/36] Reset pane Visibility on every tree rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/SplitPanel.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 24b26d7..0dd241e 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -129,6 +129,13 @@ void SplitPanel::SyncChildrenFromTree() { // call site for whether the zoomed leaf survived. m_zoomedLeaf = nullptr; if (m_root) AppendNodeToChildren(*m_root); + // Re-evaluate Visibility across the rebuilt Children collection: + // a previous zoom would have left some leaves with + // Visibility=Collapsed, and that state survives a Clear() + + // re-Append because Visibility is a property of the UIElement + // itself, not its parent-child relationship. With m_zoomedLeaf + // reset above, UpdateChildVisibility sets every child to Visible. + UpdateChildVisibility(); } void SplitPanel::AppendNodeToChildren(Pane& node) { From 776344249623029437d9015cc2e63f30354fd52c Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 01:09:00 +0900 Subject: [PATCH 19/36] Defer focus restore on window activation past XAML default-focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 38 +++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index f6c59b0..881294d 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -148,14 +148,38 @@ namespace winrt::GhosttyWin32::implementation if (auto* tc = self->ActiveControl()) { tc->NotifyImeFocusLeave(); } - } else { - if (auto* tab = self->ActiveTab()) { - tab->Focus(); - } - if (auto* tc = self->ActiveControl()) { - tc->NotifyImeFocusEnter(); - } + return; } + // Window came back into focus. Restoring focus + // inline used to be reliable when each tab had + // exactly one focusable TerminalControl, but with + // multiple panes WinUI's default-focus pass races + // with us and sometimes lands focus on a different + // TabStop (a sibling pane, or the TabView header). + // Deferring through the DispatcherQueue at Low + // priority puts our Focus call after every default- + // focus assignment XAML schedules for this + // activation, so the last write wins. Same trick + // as the SelectionChanged path which has always + // worked because the SelectedItem assignment is + // already on the dispatcher queue. + auto dq = self->DispatcherQueue(); + if (!dq) return; + dq.TryEnqueue( + winrt::Microsoft::UI::Dispatching::DispatcherQueuePriority::Low, + [weakActivated]() { + auto self = weakActivated.get(); + if (!self) return; + try { + if (auto* tab = self->ActiveTab()) { + tab->Focus(); + } + if (auto* tc = self->ActiveControl()) { + tc->NotifyImeFocusEnter(); + } + } catch (winrt::hresult_error const&) { + } + }); } catch (winrt::hresult_error const&) { } }); From 3e25ae37e23c877a5140074b5c2b7b6d4f871357 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 01:11:23 +0900 Subject: [PATCH 20/36] Add diagnostic logging for activation focus + split actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 55 ++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 881294d..b02e3d3 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -144,7 +145,13 @@ namespace winrt::GhosttyWin32::implementation if (!self) return; try { using State = winrt::Microsoft::UI::Xaml::WindowActivationState; - if (args.WindowActivationState() == State::Deactivated) { + auto state = args.WindowActivationState(); + OutputDebugStringA(state == State::Deactivated + ? "[Activated] Deactivated\n" + : (state == State::CodeActivated + ? "[Activated] CodeActivated\n" + : "[Activated] PointerActivated\n")); + if (state == State::Deactivated) { if (auto* tc = self->ActiveControl()) { tc->NotifyImeFocusLeave(); } @@ -171,13 +178,18 @@ namespace winrt::GhosttyWin32::implementation auto self = weakActivated.get(); if (!self) return; try { + bool focused = false; if (auto* tab = self->ActiveTab()) { - tab->Focus(); + focused = tab->Focus(); } + OutputDebugStringA(focused + ? "[Activated] deferred Focus -> true\n" + : "[Activated] deferred Focus -> false\n"); if (auto* tc = self->ActiveControl()) { tc->NotifyImeFocusEnter(); } } catch (winrt::hresult_error const&) { + OutputDebugStringA("[Activated] deferred Focus threw\n"); } }); } catch (winrt::hresult_error const&) { @@ -549,6 +561,17 @@ namespace winrt::GhosttyWin32::implementation return true; } + if (action.tag == GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM + || action.tag == GHOSTTY_ACTION_EQUALIZE_SPLITS + || action.tag == GHOSTTY_ACTION_RESIZE_SPLIT + || action.tag == GHOSTTY_ACTION_GOTO_SPLIT + || action.tag == GHOSTTY_ACTION_NEW_SPLIT) { + char buf[96]; + std::snprintf(buf, sizeof(buf), + "[action_cb] tag=%d (split family)\n", static_cast(action.tag)); + OutputDebugStringA(buf); + } + // Zoom the source pane to fill the entire tab. A second // press unzooms back to the regular split layout. if (action.tag == GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM @@ -1055,17 +1078,20 @@ namespace winrt::GhosttyWin32::implementation void MainWindow::EqualizeSplitsForSurface(ghostty_surface_t surface) { - if (!surface) return; + OutputDebugStringA("[Equalize] enter\n"); + if (!surface) { OutputDebugStringA("[Equalize] no surface\n"); return; } auto* tab = m_tabs.FindBySurface(surface); - if (!tab) return; + if (!tab) { OutputDebugStringA("[Equalize] no tab\n"); return; } auto* panelImpl = winrt::get_self(tab->Panel()); - if (!panelImpl) return; + if (!panelImpl) { OutputDebugStringA("[Equalize] no panelImpl\n"); return; } panelImpl->EqualizeAll(); + OutputDebugStringA("[Equalize] EqualizeAll done\n"); } void MainWindow::ToggleSplitZoomForSurface(ghostty_surface_t surface) { - if (!surface) return; + OutputDebugStringA("[Zoom] enter\n"); + if (!surface) { OutputDebugStringA("[Zoom] no surface\n"); return; } auto* tab = m_tabs.FindBySurface(surface); if (!tab) return; auto* panelImpl = winrt::get_self(tab->Panel()); @@ -1098,7 +1124,13 @@ namespace winrt::GhosttyWin32::implementation void MainWindow::GotoSplitFromAction(ghostty_surface_t surface, ghostty_action_goto_split_e direction) { - if (!surface) return; + { + char buf[64]; + std::snprintf(buf, sizeof(buf), + "[Goto] enter dir=%d\n", static_cast(direction)); + OutputDebugStringA(buf); + } + if (!surface) { OutputDebugStringA("[Goto] no surface\n"); return; } auto* tab = m_tabs.FindBySurface(surface); if (!tab) return; auto* panelImpl = winrt::get_self(tab->Panel()); @@ -1142,7 +1174,14 @@ namespace winrt::GhosttyWin32::implementation void MainWindow::ResizeSplitFromAction(ghostty_surface_t surface, ghostty_action_resize_split_s resize) { - if (!surface) return; + { + char buf[64]; + std::snprintf(buf, sizeof(buf), + "[Resize] enter dir=%d amount=%u\n", + static_cast(resize.direction), static_cast(resize.amount)); + OutputDebugStringA(buf); + } + if (!surface) { OutputDebugStringA("[Resize] no surface\n"); return; } auto* tab = m_tabs.FindBySurface(surface); if (!tab) return; auto* panelImpl = winrt::get_self(tab->Panel()); From aac0ea31f5a739e01ab954cca49e8637ea11e792 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 01:41:52 +0900 Subject: [PATCH 21/36] Log focus + drag-region pointer events for title-bar repro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 14 ++++++++++++++ GhosttyWin32App/TerminalControl.xaml.cpp | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index b02e3d3..ab6f4d7 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -199,6 +199,20 @@ namespace winrt::GhosttyWin32::implementation auto tv = TabView(); SetTitleBar(DragRegion()); + // Diagnostic: see whether title-bar drag-region clicks + // actually raise XAML pointer events (Win32 may eat them + // for HTCAPTION processing instead) and how they interleave + // with TC GotFocus/LostFocus + Activated state changes. + DragRegion().PointerPressed([](auto&&, auto&&) { + OutputDebugStringA("[DragRegion] PointerPressed\n"); + }); + DragRegion().PointerReleased([](auto&&, auto&&) { + OutputDebugStringA("[DragRegion] PointerReleased\n"); + }); + DragRegion().PointerEntered([](auto&&, auto&&) { + OutputDebugStringA("[DragRegion] PointerEntered\n"); + }); + // Pointer / keyboard / IME routing all live on // TerminalControl — each instance hooks the events on // itself and forwards directly to its own ghostty surface. diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index 7eee5e8..d7c9010 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -3,6 +3,7 @@ #include "Clipboard.h" #include "Encoding.h" #include "KeyModifiers.h" +#include #if __has_include("TerminalControl.g.cpp") #include "TerminalControl.g.cpp" #endif @@ -77,6 +78,12 @@ namespace winrt::GhosttyWin32::implementation GotFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); if (!self) return; + { + char buf[64]; + std::snprintf(buf, sizeof(buf), "[TC] GotFocus surface=%p\n", + static_cast(self->m_surface)); + OutputDebugStringA(buf); + } if (self->m_editContext) self->m_editContext.NotifyFocusEnter(); self->ShowFocusBorder(true); }); @@ -84,6 +91,12 @@ namespace winrt::GhosttyWin32::implementation LostFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); if (!self) return; + { + char buf[64]; + std::snprintf(buf, sizeof(buf), "[TC] LostFocus surface=%p\n", + static_cast(self->m_surface)); + OutputDebugStringA(buf); + } if (self->m_editContext) self->m_editContext.NotifyFocusLeave(); self->ShowFocusBorder(false); }); From cb50b309de2a17a7a3997dc4171cf24efd048e10 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 01:52:09 +0900 Subject: [PATCH 22/36] Re-activate window after title-bar drag-region click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 44 +++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index ab6f4d7..0869234 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -199,18 +199,38 @@ namespace winrt::GhosttyWin32::implementation auto tv = TabView(); SetTitleBar(DragRegion()); - // Diagnostic: see whether title-bar drag-region clicks - // actually raise XAML pointer events (Win32 may eat them - // for HTCAPTION processing instead) and how they interleave - // with TC GotFocus/LostFocus + Activated state changes. - DragRegion().PointerPressed([](auto&&, auto&&) { - OutputDebugStringA("[DragRegion] PointerPressed\n"); - }); - DragRegion().PointerReleased([](auto&&, auto&&) { - OutputDebugStringA("[DragRegion] PointerReleased\n"); - }); - DragRegion().PointerEntered([](auto&&, auto&&) { - OutputDebugStringA("[DragRegion] PointerEntered\n"); + // Title-bar drag-region click bug, root cause (confirmed + // via TC GotFocus/LostFocus + Activated state traces): + // + // 1. User clicks DragRegion (the empty title-bar strip + // we passed to SetTitleBar). + // 2. XAML routes PointerPressed/Released normally. + // 3. Either as part of the click handling, or via the + // Win32 title-bar tracking modal loop DefWindowProc + // runs for HTCAPTION clicks, the focused + // TerminalControl receives LostFocus and the window + // transitions to WindowActivationState::Deactivated. + // 4. No matching CodeActivated / PointerActivated event + // follows, so the deferred-Focus restore in the + // Activated handler never runs. + // 5. Visible effect: focus border disappears, keyboard + // input goes nowhere. Switching tabs recovers because + // the tab click programmatically reactivates the + // window. + // + // Hooking PointerReleased and calling Activate() on + // ourselves re-triggers the activation chain on the + // dispatcher queue: WM_ACTIVATE fires, our existing + // Activated handler runs, its deferred Focus call lands + // the keyboard back on the active leaf. + auto weakSelfDrag = get_weak(); + DragRegion().PointerReleased([weakSelfDrag](auto&&, auto&&) { + auto self = weakSelfDrag.get(); + if (!self) return; + try { + self->Activate(); + } catch (winrt::hresult_error const&) { + } }); // Pointer / keyboard / IME routing all live on From f5756b5afbbc4f9b81ae5855c75c36eb4c43f4e7 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 01:57:35 +0900 Subject: [PATCH 23/36] Recover spurious deactivation regardless of click path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 72 ++++++++++++++++------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 0869234..b8b099e 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -155,6 +155,35 @@ namespace winrt::GhosttyWin32::implementation if (auto* tc = self->ActiveControl()) { tc->NotifyImeFocusLeave(); } + // Spurious-deactivation recovery. If the OS + // says we're deactivated but our HWND is still + // the foreground window, the Deactivated event + // came from a Win32 title-bar tracking modal + // loop (clicking the title bar caption area + // outside our DragRegion, system menu probe, + // etc.) — a state that never auto-recovers and + // leaves focus dead. Schedule a re-activation; + // the resulting CodeActivated event re-enters + // this same handler on the activated branch + // and queues the focus restore. + // + // Genuine deactivation (alt-tab, click another + // window) changes the foreground window before + // we observe Deactivated, so the equality + // check skips re-activation and lets the + // window properly background. + if (self->m_hwnd && GetForegroundWindow() == self->m_hwnd) { + OutputDebugStringA("[Activated] spurious deactivation, scheduling re-Activate\n"); + auto dq = self->DispatcherQueue(); + if (dq) { + dq.TryEnqueue([weakActivated]() { + auto self = weakActivated.get(); + if (!self) return; + try { self->Activate(); } + catch (winrt::hresult_error const&) {} + }); + } + } return; } // Window came back into focus. Restoring focus @@ -199,38 +228,17 @@ namespace winrt::GhosttyWin32::implementation auto tv = TabView(); SetTitleBar(DragRegion()); - // Title-bar drag-region click bug, root cause (confirmed - // via TC GotFocus/LostFocus + Activated state traces): - // - // 1. User clicks DragRegion (the empty title-bar strip - // we passed to SetTitleBar). - // 2. XAML routes PointerPressed/Released normally. - // 3. Either as part of the click handling, or via the - // Win32 title-bar tracking modal loop DefWindowProc - // runs for HTCAPTION clicks, the focused - // TerminalControl receives LostFocus and the window - // transitions to WindowActivationState::Deactivated. - // 4. No matching CodeActivated / PointerActivated event - // follows, so the deferred-Focus restore in the - // Activated handler never runs. - // 5. Visible effect: focus border disappears, keyboard - // input goes nowhere. Switching tabs recovers because - // the tab click programmatically reactivates the - // window. - // - // Hooking PointerReleased and calling Activate() on - // ourselves re-triggers the activation chain on the - // dispatcher queue: WM_ACTIVATE fires, our existing - // Activated handler runs, its deferred Focus call lands - // the keyboard back on the active leaf. - auto weakSelfDrag = get_weak(); - DragRegion().PointerReleased([weakSelfDrag](auto&&, auto&&) { - auto self = weakSelfDrag.get(); - if (!self) return; - try { - self->Activate(); - } catch (winrt::hresult_error const&) { - } + // Diagnostic: see whether title-bar clicks raise XAML + // pointer events on the DragRegion specifically. The + // spurious-deactivation recovery in the Activated handler + // catches the recovery regardless of which path (XAML + // routed event, or OS-only HTCAPTION) the click took, so + // these logs are now just for confirming which case fires. + DragRegion().PointerPressed([](auto&&, auto&&) { + OutputDebugStringA("[DragRegion] PointerPressed\n"); + }); + DragRegion().PointerReleased([](auto&&, auto&&) { + OutputDebugStringA("[DragRegion] PointerReleased\n"); }); // Pointer / keyboard / IME routing all live on From a0bea82283d9cb81cbf83bfcda3823847be044b3 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:03:26 +0900 Subject: [PATCH 24/36] Defer the spurious-deactivation foreground check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- GhosttyWin32App/MainWindow.xaml.cpp | 57 ++++++++++++++++------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index b8b099e..9f85ec5 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -155,34 +155,41 @@ namespace winrt::GhosttyWin32::implementation if (auto* tc = self->ActiveControl()) { tc->NotifyImeFocusLeave(); } - // Spurious-deactivation recovery. If the OS - // says we're deactivated but our HWND is still - // the foreground window, the Deactivated event - // came from a Win32 title-bar tracking modal - // loop (clicking the title bar caption area - // outside our DragRegion, system menu probe, - // etc.) — a state that never auto-recovers and - // leaves focus dead. Schedule a re-activation; - // the resulting CodeActivated event re-enters - // this same handler on the activated branch - // and queues the focus restore. + // Spurious-deactivation recovery, deferred. + // The Win32 title-bar tracking modal loop + // DefWindowProc runs for HTCAPTION clicks + // briefly steals the foreground window + // (for tracking proxies / system menu probes), + // so a synchronous GetForegroundWindow() check + // here can read a transient non-our-HWND + // value and misclassify a spurious deactivation + // as a genuine one. Bounce the check through + // the dispatcher so it runs after the modal + // loop returns and foreground state has + // settled. // - // Genuine deactivation (alt-tab, click another - // window) changes the foreground window before - // we observe Deactivated, so the equality - // check skips re-activation and lets the - // window properly background. - if (self->m_hwnd && GetForegroundWindow() == self->m_hwnd) { - OutputDebugStringA("[Activated] spurious deactivation, scheduling re-Activate\n"); - auto dq = self->DispatcherQueue(); - if (dq) { - dq.TryEnqueue([weakActivated]() { - auto self = weakActivated.get(); - if (!self) return; + // If by then our HWND is still the foreground + // window, the Deactivated event was a Win32 + // tracking-loop artifact and we self-Activate + // to re-enter the activation path and trigger + // the deferred-Focus restore. Genuine + // deactivation leaves the foreground on the + // other app, so the deferred check skips and + // the window stays properly backgrounded. + auto dq = self->DispatcherQueue(); + if (dq) { + dq.TryEnqueue([weakActivated]() { + auto self = weakActivated.get(); + if (!self || !self->m_hwnd) return; + bool stillForeground = (GetForegroundWindow() == self->m_hwnd); + OutputDebugStringA(stillForeground + ? "[Activated] deferred check: spurious deactivation, re-Activating\n" + : "[Activated] deferred check: genuine deactivation, leaving alone\n"); + if (stillForeground) { try { self->Activate(); } catch (winrt::hresult_error const&) {} - }); - } + } + }); } return; } From 79adefd94434df29956690ed9469364ad8e5ad41 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:12:48 +0900 Subject: [PATCH 25/36] Restore focus directly from DragRegion PointerReleased MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 45 ++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 9f85ec5..3be6f67 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -235,17 +235,48 @@ namespace winrt::GhosttyWin32::implementation auto tv = TabView(); SetTitleBar(DragRegion()); - // Diagnostic: see whether title-bar clicks raise XAML - // pointer events on the DragRegion specifically. The - // spurious-deactivation recovery in the Activated handler - // catches the recovery regardless of which path (XAML - // routed event, or OS-only HTCAPTION) the click took, so - // these logs are now just for confirming which case fires. + // Title-bar click focus loss, observed behaviour: + // + // * Click DragRegion → PointerPressed / PointerReleased + // fire on XAML, then TerminalControl LostFocus fires + // immediately. Focus moves into limbo (not onto any + // TerminalControl), keyboard input dies. + // * Activated state transitions to Deactivated some time + // later (often after several clicks), but by then + // GetForegroundWindow() reports something other than + // our HWND (the OS HTCAPTION tracking proxy briefly + // owns foreground), so the spurious-deactivation + // check in the Activated handler treats it as a real + // deactivation and skips recovery. + // + // Direct recovery from PointerReleased is more reliable: + // we know the click went through XAML, we know the user + // wants the window to keep its focus, and we don't need + // to disambiguate spurious vs genuine deactivation. Defer + // the Focus call through the dispatcher so it runs after + // the DefWindowProc HTCAPTION tracking loop returns. + auto weakSelfDrag = get_weak(); DragRegion().PointerPressed([](auto&&, auto&&) { OutputDebugStringA("[DragRegion] PointerPressed\n"); }); - DragRegion().PointerReleased([](auto&&, auto&&) { + DragRegion().PointerReleased([weakSelfDrag](auto&&, auto&&) { OutputDebugStringA("[DragRegion] PointerReleased\n"); + auto self = weakSelfDrag.get(); + if (!self) return; + auto dq = self->DispatcherQueue(); + if (!dq) return; + dq.TryEnqueue([weakSelfDrag]() { + auto self = weakSelfDrag.get(); + if (!self) return; + try { + if (auto* tab = self->ActiveTab()) { + bool result = tab->Focus(); + OutputDebugStringA(result + ? "[DragRegion] deferred focus restore -> true\n" + : "[DragRegion] deferred focus restore -> false\n"); + } + } catch (winrt::hresult_error const&) {} + }); }); // Pointer / keyboard / IME routing all live on From bb45bda6bfbecbf300b1d035b627f44d544e28a2 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:20:24 +0900 Subject: [PATCH 26/36] Remove diagnostic logging used to chase the title-bar focus bug 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 include in both TUs. --- GhosttyWin32App/MainWindow.xaml.cpp | 147 +++++++---------------- GhosttyWin32App/TerminalControl.xaml.cpp | 13 -- 2 files changed, 42 insertions(+), 118 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 3be6f67..7c592b8 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -145,47 +144,34 @@ namespace winrt::GhosttyWin32::implementation if (!self) return; try { using State = winrt::Microsoft::UI::Xaml::WindowActivationState; - auto state = args.WindowActivationState(); - OutputDebugStringA(state == State::Deactivated - ? "[Activated] Deactivated\n" - : (state == State::CodeActivated - ? "[Activated] CodeActivated\n" - : "[Activated] PointerActivated\n")); - if (state == State::Deactivated) { + if (args.WindowActivationState() == State::Deactivated) { if (auto* tc = self->ActiveControl()) { tc->NotifyImeFocusLeave(); } // Spurious-deactivation recovery, deferred. // The Win32 title-bar tracking modal loop // DefWindowProc runs for HTCAPTION clicks - // briefly steals the foreground window - // (for tracking proxies / system menu probes), - // so a synchronous GetForegroundWindow() check - // here can read a transient non-our-HWND - // value and misclassify a spurious deactivation - // as a genuine one. Bounce the check through - // the dispatcher so it runs after the modal - // loop returns and foreground state has - // settled. - // - // If by then our HWND is still the foreground - // window, the Deactivated event was a Win32 - // tracking-loop artifact and we self-Activate - // to re-enter the activation path and trigger - // the deferred-Focus restore. Genuine - // deactivation leaves the foreground on the - // other app, so the deferred check skips and - // the window stays properly backgrounded. + // briefly steals foreground for tracking + // proxies, so a synchronous + // GetForegroundWindow() check here reads a + // transient non-our-HWND value and + // misclassifies the spurious deactivation as + // genuine. Bouncing through the dispatcher + // delays the check until after the modal loop + // returns and foreground state settles. If by + // then our HWND is still foreground, we + // self-Activate so the activated branch of + // this same handler re-runs and queues the + // focus restore. Genuine deactivation leaves + // foreground on the other app, so the check + // skips re-activation and the window properly + // backgrounds. auto dq = self->DispatcherQueue(); if (dq) { dq.TryEnqueue([weakActivated]() { auto self = weakActivated.get(); if (!self || !self->m_hwnd) return; - bool stillForeground = (GetForegroundWindow() == self->m_hwnd); - OutputDebugStringA(stillForeground - ? "[Activated] deferred check: spurious deactivation, re-Activating\n" - : "[Activated] deferred check: genuine deactivation, leaving alone\n"); - if (stillForeground) { + if (GetForegroundWindow() == self->m_hwnd) { try { self->Activate(); } catch (winrt::hresult_error const&) {} } @@ -198,14 +184,14 @@ namespace winrt::GhosttyWin32::implementation // exactly one focusable TerminalControl, but with // multiple panes WinUI's default-focus pass races // with us and sometimes lands focus on a different - // TabStop (a sibling pane, or the TabView header). - // Deferring through the DispatcherQueue at Low - // priority puts our Focus call after every default- - // focus assignment XAML schedules for this + // TabStop (a sibling pane, the TabView header, + // etc.). Deferring through the DispatcherQueue at + // Low priority puts our Focus call after every + // default-focus assignment XAML schedules for this // activation, so the last write wins. Same trick - // as the SelectionChanged path which has always - // worked because the SelectedItem assignment is - // already on the dispatcher queue. + // as the SelectionChanged path, which is naturally + // last because SelectedItem assignment is itself + // dispatcher-scheduled. auto dq = self->DispatcherQueue(); if (!dq) return; dq.TryEnqueue( @@ -214,18 +200,13 @@ namespace winrt::GhosttyWin32::implementation auto self = weakActivated.get(); if (!self) return; try { - bool focused = false; if (auto* tab = self->ActiveTab()) { - focused = tab->Focus(); + tab->Focus(); } - OutputDebugStringA(focused - ? "[Activated] deferred Focus -> true\n" - : "[Activated] deferred Focus -> false\n"); if (auto* tc = self->ActiveControl()) { tc->NotifyImeFocusEnter(); } } catch (winrt::hresult_error const&) { - OutputDebugStringA("[Activated] deferred Focus threw\n"); } }); } catch (winrt::hresult_error const&) { @@ -235,32 +216,18 @@ namespace winrt::GhosttyWin32::implementation auto tv = TabView(); SetTitleBar(DragRegion()); - // Title-bar click focus loss, observed behaviour: - // - // * Click DragRegion → PointerPressed / PointerReleased - // fire on XAML, then TerminalControl LostFocus fires - // immediately. Focus moves into limbo (not onto any - // TerminalControl), keyboard input dies. - // * Activated state transitions to Deactivated some time - // later (often after several clicks), but by then - // GetForegroundWindow() reports something other than - // our HWND (the OS HTCAPTION tracking proxy briefly - // owns foreground), so the spurious-deactivation - // check in the Activated handler treats it as a real - // deactivation and skips recovery. - // - // Direct recovery from PointerReleased is more reliable: - // we know the click went through XAML, we know the user - // wants the window to keep its focus, and we don't need - // to disambiguate spurious vs genuine deactivation. Defer - // the Focus call through the dispatcher so it runs after - // the DefWindowProc HTCAPTION tracking loop returns. + // Title-bar click focus restore. Clicking the DragRegion + // immediately knocks focus off the active TerminalControl + // (Win32 HTCAPTION click handling moves XAML logical focus + // into limbo), and the subsequent Activated state goes + // Deactivated long enough that the foreground check in + // the activation handler reports a "genuine" deactivation + // and skips recovery. Bouncing the Focus call through the + // dispatcher from PointerReleased restores focus right + // after the click completes, no matter how the activation + // state ends up. auto weakSelfDrag = get_weak(); - DragRegion().PointerPressed([](auto&&, auto&&) { - OutputDebugStringA("[DragRegion] PointerPressed\n"); - }); DragRegion().PointerReleased([weakSelfDrag](auto&&, auto&&) { - OutputDebugStringA("[DragRegion] PointerReleased\n"); auto self = weakSelfDrag.get(); if (!self) return; auto dq = self->DispatcherQueue(); @@ -270,10 +237,7 @@ namespace winrt::GhosttyWin32::implementation if (!self) return; try { if (auto* tab = self->ActiveTab()) { - bool result = tab->Focus(); - OutputDebugStringA(result - ? "[DragRegion] deferred focus restore -> true\n" - : "[DragRegion] deferred focus restore -> false\n"); + tab->Focus(); } } catch (winrt::hresult_error const&) {} }); @@ -641,17 +605,6 @@ namespace winrt::GhosttyWin32::implementation return true; } - if (action.tag == GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM - || action.tag == GHOSTTY_ACTION_EQUALIZE_SPLITS - || action.tag == GHOSTTY_ACTION_RESIZE_SPLIT - || action.tag == GHOSTTY_ACTION_GOTO_SPLIT - || action.tag == GHOSTTY_ACTION_NEW_SPLIT) { - char buf[96]; - std::snprintf(buf, sizeof(buf), - "[action_cb] tag=%d (split family)\n", static_cast(action.tag)); - OutputDebugStringA(buf); - } - // Zoom the source pane to fill the entire tab. A second // press unzooms back to the regular split layout. if (action.tag == GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM @@ -1158,20 +1111,17 @@ namespace winrt::GhosttyWin32::implementation void MainWindow::EqualizeSplitsForSurface(ghostty_surface_t surface) { - OutputDebugStringA("[Equalize] enter\n"); - if (!surface) { OutputDebugStringA("[Equalize] no surface\n"); return; } + if (!surface) return; auto* tab = m_tabs.FindBySurface(surface); - if (!tab) { OutputDebugStringA("[Equalize] no tab\n"); return; } + if (!tab) return; auto* panelImpl = winrt::get_self(tab->Panel()); - if (!panelImpl) { OutputDebugStringA("[Equalize] no panelImpl\n"); return; } + if (!panelImpl) return; panelImpl->EqualizeAll(); - OutputDebugStringA("[Equalize] EqualizeAll done\n"); } void MainWindow::ToggleSplitZoomForSurface(ghostty_surface_t surface) { - OutputDebugStringA("[Zoom] enter\n"); - if (!surface) { OutputDebugStringA("[Zoom] no surface\n"); return; } + if (!surface) return; auto* tab = m_tabs.FindBySurface(surface); if (!tab) return; auto* panelImpl = winrt::get_self(tab->Panel()); @@ -1204,13 +1154,7 @@ namespace winrt::GhosttyWin32::implementation void MainWindow::GotoSplitFromAction(ghostty_surface_t surface, ghostty_action_goto_split_e direction) { - { - char buf[64]; - std::snprintf(buf, sizeof(buf), - "[Goto] enter dir=%d\n", static_cast(direction)); - OutputDebugStringA(buf); - } - if (!surface) { OutputDebugStringA("[Goto] no surface\n"); return; } + if (!surface) return; auto* tab = m_tabs.FindBySurface(surface); if (!tab) return; auto* panelImpl = winrt::get_self(tab->Panel()); @@ -1254,14 +1198,7 @@ namespace winrt::GhosttyWin32::implementation void MainWindow::ResizeSplitFromAction(ghostty_surface_t surface, ghostty_action_resize_split_s resize) { - { - char buf[64]; - std::snprintf(buf, sizeof(buf), - "[Resize] enter dir=%d amount=%u\n", - static_cast(resize.direction), static_cast(resize.amount)); - OutputDebugStringA(buf); - } - if (!surface) { OutputDebugStringA("[Resize] no surface\n"); return; } + if (!surface) return; auto* tab = m_tabs.FindBySurface(surface); if (!tab) return; auto* panelImpl = winrt::get_self(tab->Panel()); diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index d7c9010..7eee5e8 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -3,7 +3,6 @@ #include "Clipboard.h" #include "Encoding.h" #include "KeyModifiers.h" -#include #if __has_include("TerminalControl.g.cpp") #include "TerminalControl.g.cpp" #endif @@ -78,12 +77,6 @@ namespace winrt::GhosttyWin32::implementation GotFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); if (!self) return; - { - char buf[64]; - std::snprintf(buf, sizeof(buf), "[TC] GotFocus surface=%p\n", - static_cast(self->m_surface)); - OutputDebugStringA(buf); - } if (self->m_editContext) self->m_editContext.NotifyFocusEnter(); self->ShowFocusBorder(true); }); @@ -91,12 +84,6 @@ namespace winrt::GhosttyWin32::implementation LostFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); if (!self) return; - { - char buf[64]; - std::snprintf(buf, sizeof(buf), "[TC] LostFocus surface=%p\n", - static_cast(self->m_surface)); - OutputDebugStringA(buf); - } if (self->m_editContext) self->m_editContext.NotifyFocusLeave(); self->ShowFocusBorder(false); }); From 469ba6ec6df37289c38bfc1dbe73b6d494c79b09 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:22:09 +0900 Subject: [PATCH 27/36] Auto-hide focus border 1.5s after the latest focus change 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. --- GhosttyWin32App/TerminalControl.xaml.cpp | 19 +++++++++++++++++++ GhosttyWin32App/TerminalControl.xaml.h | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index 7eee5e8..d6e3b14 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -324,9 +324,28 @@ namespace winrt::GhosttyWin32::implementation // with the terminal palette. border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( winrt::Windows::UI::Color{ 255, 0, 120, 215 })); + // Lazy-init the auto-hide timer the first time the border + // goes visible. Capturing get_weak() and resolving inside + // the Tick handler keeps the timer-held lambda safe across + // TerminalControl destruction. + if (!m_focusBorderTimer) { + m_focusBorderTimer = winrt::Microsoft::UI::Xaml::DispatcherTimer{}; + m_focusBorderTimer.Interval(std::chrono::milliseconds(1500)); + auto weakSelf = get_weak(); + m_focusBorderTimer.Tick([weakSelf](auto&&, auto&&) { + if (auto self = weakSelf.get()) { + self->ShowFocusBorder(false); + } + }); + } + // Restart so the border stays visible for the full + // interval after the most recent focus change. + m_focusBorderTimer.Stop(); + m_focusBorderTimer.Start(); } else { border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( winrt::Windows::UI::Color{ 0, 0, 0, 0 })); + if (m_focusBorderTimer) m_focusBorderTimer.Stop(); } } diff --git a/GhosttyWin32App/TerminalControl.xaml.h b/GhosttyWin32App/TerminalControl.xaml.h index 7bec395..9d5d63d 100644 --- a/GhosttyWin32App/TerminalControl.xaml.h +++ b/GhosttyWin32App/TerminalControl.xaml.h @@ -113,6 +113,14 @@ namespace winrt::GhosttyWin32::implementation // BorderThickness of 1 so toggling the brush doesn't force a // relayout / terminal grid reflow. Called from the GotFocus / // LostFocus handlers wired up in the constructor. + // + // Visible flashes are momentary: passing true also (re)starts + // m_focusBorderTimer, which fires after a short interval and + // hides the border again. This keeps the indicator transient + // — useful for "you just landed on this pane" feedback after + // a click / keyboard navigation, but doesn't leave a + // permanent frame around the outer edges of every active + // pane during normal typing. void ShowFocusBorder(bool visible); private: @@ -144,6 +152,12 @@ namespace winrt::GhosttyWin32::implementation // activation. ImeBuffer m_ime; winrt::Windows::UI::Text::Core::CoreTextEditContext m_editContext{ nullptr }; + + // Auto-hide timer for the focus border. Created lazily on + // first ShowFocusBorder(true); subsequent visible calls reset + // the interval so the border stays up for the full duration + // after the most recent focus change. + winrt::Microsoft::UI::Xaml::DispatcherTimer m_focusBorderTimer{ nullptr }; }; } From 710737793783c082660886b1f30f3e36f5eebedf Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:24:38 +0900 Subject: [PATCH 28/36] Pane chrome: 2 DIP splitter line, no outer focus border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/SplitPanel.h | 9 +++-- GhosttyWin32App/TerminalControl.xaml | 18 ++------- GhosttyWin32App/TerminalControl.xaml.cpp | 48 ++---------------------- GhosttyWin32App/TerminalControl.xaml.h | 21 ----------- 4 files changed, 12 insertions(+), 84 deletions(-) diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index 52b0238..fd7e53c 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -95,10 +95,11 @@ struct SplitPanel : SplitPanelT { Windows::Foundation::Size MeasureOverride(Windows::Foundation::Size availableSize); Windows::Foundation::Size ArrangeOverride(Windows::Foundation::Size finalSize); - // Width of the draggable splitter strip in DIPs. Wide enough to - // hit reliably with mouse, narrow enough that it doesn't visually - // dominate the split — matches Windows Terminal's GridSplitter. - static constexpr double kSplitterThickness = 6.0; + // Width of the splitter strip in DIPs. Visible as a thin line + // between panes — narrow enough to avoid taking visible gap + // space between pane content, wide enough to land a click on + // for drag-resize. + static constexpr double kSplitterThickness = 2.0; private: // Recursive measure — caps each subtree at its share of `available` diff --git a/GhosttyWin32App/TerminalControl.xaml b/GhosttyWin32App/TerminalControl.xaml index 6c6efa5..15987ee 100644 --- a/GhosttyWin32App/TerminalControl.xaml +++ b/GhosttyWin32App/TerminalControl.xaml @@ -9,19 +9,7 @@ IsTabStop="True" AllowFocusOnInteraction="True" IsHitTestVisible="True"> - - - - + diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index d6e3b14..f0b979e 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -76,16 +76,14 @@ namespace winrt::GhosttyWin32::implementation // for that case. GotFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); - if (!self) return; - if (self->m_editContext) self->m_editContext.NotifyFocusEnter(); - self->ShowFocusBorder(true); + if (!self || !self->m_editContext) return; + self->m_editContext.NotifyFocusEnter(); }); LostFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); - if (!self) return; - if (self->m_editContext) self->m_editContext.NotifyFocusLeave(); - self->ShowFocusBorder(false); + if (!self || !self->m_editContext) return; + self->m_editContext.NotifyFocusLeave(); }); PointerMoved([weakSelf](auto&&, muxi::PointerRoutedEventArgs const& args) { @@ -311,44 +309,6 @@ namespace winrt::GhosttyWin32::implementation ProtectedCursor(winrt::Microsoft::UI::Input::InputSystemCursor::Create(mapped)); } - void TerminalControl::ShowFocusBorder(bool visible) - { - auto border = FocusBorder(); - if (!border) return; - if (visible) { - // Hard-coded accent — bright enough to be obvious against - // typical dark / light terminal backgrounds without bleeding - // visual noise into the cell area. Using the system accent - // brush would require a ThemeResource lookup that varies - // with the user's Windows accent colour and could clash - // with the terminal palette. - border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( - winrt::Windows::UI::Color{ 255, 0, 120, 215 })); - // Lazy-init the auto-hide timer the first time the border - // goes visible. Capturing get_weak() and resolving inside - // the Tick handler keeps the timer-held lambda safe across - // TerminalControl destruction. - if (!m_focusBorderTimer) { - m_focusBorderTimer = winrt::Microsoft::UI::Xaml::DispatcherTimer{}; - m_focusBorderTimer.Interval(std::chrono::milliseconds(1500)); - auto weakSelf = get_weak(); - m_focusBorderTimer.Tick([weakSelf](auto&&, auto&&) { - if (auto self = weakSelf.get()) { - self->ShowFocusBorder(false); - } - }); - } - // Restart so the border stays visible for the full - // interval after the most recent focus change. - m_focusBorderTimer.Stop(); - m_focusBorderTimer.Start(); - } else { - border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( - winrt::Windows::UI::Color{ 0, 0, 0, 0 })); - if (m_focusBorderTimer) m_focusBorderTimer.Stop(); - } - } - TerminalControl::~TerminalControl() { // Belt-and-suspenders: Tab's destructor normally calls Detach diff --git a/GhosttyWin32App/TerminalControl.xaml.h b/GhosttyWin32App/TerminalControl.xaml.h index 9d5d63d..0c78957 100644 --- a/GhosttyWin32App/TerminalControl.xaml.h +++ b/GhosttyWin32App/TerminalControl.xaml.h @@ -108,21 +108,6 @@ namespace winrt::GhosttyWin32::implementation // borders, etc. void SetCursorShape(ghostty_action_mouse_shape_e shape); - // Flip the focus border between visible (accent colour) and - // hidden (transparent). The Border element keeps a constant - // BorderThickness of 1 so toggling the brush doesn't force a - // relayout / terminal grid reflow. Called from the GotFocus / - // LostFocus handlers wired up in the constructor. - // - // Visible flashes are momentary: passing true also (re)starts - // m_focusBorderTimer, which fires after a short interval and - // hides the border again. This keeps the indicator transient - // — useful for "you just landed on this pane" feedback after - // a click / keyboard navigation, but doesn't leave a - // permanent frame around the outer edges of every active - // pane during normal typing. - void ShowFocusBorder(bool visible); - private: // Builds the per-control CoreTextEditContext and wires its // seven event handlers (TextRequested / SelectionRequested / @@ -152,12 +137,6 @@ namespace winrt::GhosttyWin32::implementation // activation. ImeBuffer m_ime; winrt::Windows::UI::Text::Core::CoreTextEditContext m_editContext{ nullptr }; - - // Auto-hide timer for the focus border. Created lazily on - // first ShowFocusBorder(true); subsequent visible calls reset - // the interval so the border stays up for the full duration - // after the most recent focus change. - winrt::Microsoft::UI::Xaml::DispatcherTimer m_focusBorderTimer{ nullptr }; }; } From 3679ee2f3c69b53739cccfbaa5d64658bb11b7b5 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:29:01 +0900 Subject: [PATCH 29/36] Bring back the 1.5 s focus-border flash as an overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GhosttyWin32App/TerminalControl.xaml | 23 ++++++++++-- GhosttyWin32App/TerminalControl.xaml.cpp | 48 ++++++++++++++++++++++-- GhosttyWin32App/TerminalControl.xaml.h | 16 ++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/GhosttyWin32App/TerminalControl.xaml b/GhosttyWin32App/TerminalControl.xaml index 15987ee..55eaef1 100644 --- a/GhosttyWin32App/TerminalControl.xaml +++ b/GhosttyWin32App/TerminalControl.xaml @@ -9,7 +9,24 @@ IsTabStop="True" AllowFocusOnInteraction="True" IsHitTestVisible="True"> - + + + + + diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index f0b979e..d6e3b14 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -76,14 +76,16 @@ namespace winrt::GhosttyWin32::implementation // for that case. GotFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); - if (!self || !self->m_editContext) return; - self->m_editContext.NotifyFocusEnter(); + if (!self) return; + if (self->m_editContext) self->m_editContext.NotifyFocusEnter(); + self->ShowFocusBorder(true); }); LostFocus([weakSelf](auto&&, auto&&) { auto self = weakSelf.get(); - if (!self || !self->m_editContext) return; - self->m_editContext.NotifyFocusLeave(); + if (!self) return; + if (self->m_editContext) self->m_editContext.NotifyFocusLeave(); + self->ShowFocusBorder(false); }); PointerMoved([weakSelf](auto&&, muxi::PointerRoutedEventArgs const& args) { @@ -309,6 +311,44 @@ namespace winrt::GhosttyWin32::implementation ProtectedCursor(winrt::Microsoft::UI::Input::InputSystemCursor::Create(mapped)); } + void TerminalControl::ShowFocusBorder(bool visible) + { + auto border = FocusBorder(); + if (!border) return; + if (visible) { + // Hard-coded accent — bright enough to be obvious against + // typical dark / light terminal backgrounds without bleeding + // visual noise into the cell area. Using the system accent + // brush would require a ThemeResource lookup that varies + // with the user's Windows accent colour and could clash + // with the terminal palette. + border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( + winrt::Windows::UI::Color{ 255, 0, 120, 215 })); + // Lazy-init the auto-hide timer the first time the border + // goes visible. Capturing get_weak() and resolving inside + // the Tick handler keeps the timer-held lambda safe across + // TerminalControl destruction. + if (!m_focusBorderTimer) { + m_focusBorderTimer = winrt::Microsoft::UI::Xaml::DispatcherTimer{}; + m_focusBorderTimer.Interval(std::chrono::milliseconds(1500)); + auto weakSelf = get_weak(); + m_focusBorderTimer.Tick([weakSelf](auto&&, auto&&) { + if (auto self = weakSelf.get()) { + self->ShowFocusBorder(false); + } + }); + } + // Restart so the border stays visible for the full + // interval after the most recent focus change. + m_focusBorderTimer.Stop(); + m_focusBorderTimer.Start(); + } else { + border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( + winrt::Windows::UI::Color{ 0, 0, 0, 0 })); + if (m_focusBorderTimer) m_focusBorderTimer.Stop(); + } + } + TerminalControl::~TerminalControl() { // Belt-and-suspenders: Tab's destructor normally calls Detach diff --git a/GhosttyWin32App/TerminalControl.xaml.h b/GhosttyWin32App/TerminalControl.xaml.h index 0c78957..3389e28 100644 --- a/GhosttyWin32App/TerminalControl.xaml.h +++ b/GhosttyWin32App/TerminalControl.xaml.h @@ -108,6 +108,16 @@ namespace winrt::GhosttyWin32::implementation // borders, etc. void SetCursorShape(ghostty_action_mouse_shape_e shape); + // Flash the focus border for ~1.5 s. Called from GotFocus to + // give a brief visual "you landed here" cue when focus moves + // between panes. visible=false stops the timer + clears the + // brush immediately (LostFocus path) so the previously-active + // pane's border doesn't linger past the focus change. The + // border is an overlay sibling in the XAML so the flash + // doesn't reserve permanent layout space — see + // TerminalControl.xaml. + void ShowFocusBorder(bool visible); + private: // Builds the per-control CoreTextEditContext and wires its // seven event handlers (TextRequested / SelectionRequested / @@ -137,6 +147,12 @@ namespace winrt::GhosttyWin32::implementation // activation. ImeBuffer m_ime; winrt::Windows::UI::Text::Core::CoreTextEditContext m_editContext{ nullptr }; + + // Auto-hide timer for the focus border. Created lazily on + // first ShowFocusBorder(true); subsequent visible calls reset + // the interval so the border stays visible for the full + // 1.5 s after the most recent focus change. + winrt::Microsoft::UI::Xaml::DispatcherTimer m_focusBorderTimer{ nullptr }; }; } From 003f168a54755861c56456a2de67cb66ada13ee7 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:33:10 +0900 Subject: [PATCH 30/36] Extract focus-border colours + hide delay into named constants 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. --- GhosttyWin32App/TerminalControl.xaml.cpp | 33 +++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index d6e3b14..605a4be 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -9,6 +9,27 @@ namespace winrt::GhosttyWin32::implementation { + namespace { + // Focus-border palette. Brush colours are flipped in + // ShowFocusBorder() between these two values; the constant + // names make the call sites self-documenting and put the + // visual tuning knobs in one place. + // + // kFocusBorderAccent — Windows-blue-ish, ARGB. Saturated + // enough to stand out against most terminal palettes + // (foreground text + background fill) without bleeding into + // the cell area when the border flashes. + // kFocusBorderHidden — fully transparent, restores the + // no-chrome resting state. + constexpr winrt::Windows::UI::Color kFocusBorderAccent{ 255, 0, 120, 215 }; + constexpr winrt::Windows::UI::Color kFocusBorderHidden{ 0, 0, 0, 0 }; + + // How long the accent stays visible after the most recent + // GotFocus. Subsequent focus changes restart the timer, so + // rapid pane hops keep the border up through the sequence. + constexpr auto kFocusBorderHideDelay = std::chrono::milliseconds(1500); + } + TerminalControl::TerminalControl() { InitializeComponent(); @@ -316,21 +337,15 @@ namespace winrt::GhosttyWin32::implementation auto border = FocusBorder(); if (!border) return; if (visible) { - // Hard-coded accent — bright enough to be obvious against - // typical dark / light terminal backgrounds without bleeding - // visual noise into the cell area. Using the system accent - // brush would require a ThemeResource lookup that varies - // with the user's Windows accent colour and could clash - // with the terminal palette. border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( - winrt::Windows::UI::Color{ 255, 0, 120, 215 })); + kFocusBorderAccent)); // Lazy-init the auto-hide timer the first time the border // goes visible. Capturing get_weak() and resolving inside // the Tick handler keeps the timer-held lambda safe across // TerminalControl destruction. if (!m_focusBorderTimer) { m_focusBorderTimer = winrt::Microsoft::UI::Xaml::DispatcherTimer{}; - m_focusBorderTimer.Interval(std::chrono::milliseconds(1500)); + m_focusBorderTimer.Interval(kFocusBorderHideDelay); auto weakSelf = get_weak(); m_focusBorderTimer.Tick([weakSelf](auto&&, auto&&) { if (auto self = weakSelf.get()) { @@ -344,7 +359,7 @@ namespace winrt::GhosttyWin32::implementation m_focusBorderTimer.Start(); } else { border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( - winrt::Windows::UI::Color{ 0, 0, 0, 0 })); + kFocusBorderHidden)); if (m_focusBorderTimer) m_focusBorderTimer.Stop(); } } From b874ee6351bb9fb71e820f6b6c6c6973619d0317 Mon Sep 17 00:00:00 2001 From: i999rri Date: Thu, 21 May 2026 02:40:09 +0900 Subject: [PATCH 31/36] RESIZE_SPLIT: arrow direction == boundary direction 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. --- GhosttyWin32App/MainWindow.xaml.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 7c592b8..6c8a752 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -1233,20 +1233,24 @@ namespace winrt::GhosttyWin32::implementation } if (!child || !node || node->IsLeaf()) return; - bool activeIsFirst = (node->First() == child); - auto rect = node->ArrangedRect(); float extent = (needOrient == SplitOrientation::Horizontal) ? rect.Width : rect.Height; float useable = std::max(1.0f, extent - static_cast(implementation::SplitPanel::kSplitterThickness)); double deltaRatio = static_cast(resize.amount) / useable; - // RIGHT / DOWN push the boundary in the +axis direction. - // For the first-child side that's an increase in ratio; for - // the second-child side it's a decrease. + // Arrow direction == direction the boundary moves, regardless + // of which side of the split the active pane is on. + // * RIGHT / DOWN move the boundary toward +axis → ratio + // grows (first child gets larger). + // * LEFT / UP move the boundary toward -axis → ratio shrinks. + // The previous "flip the sign when the active pane is the + // second child" logic was a tmux-style "grow the active pane + // in the arrow direction" rule that surprised the user when + // pressing LEFT from the right-hand pane moved the boundary + // right instead of left. bool increase = (resize.direction == GHOSTTY_RESIZE_SPLIT_RIGHT || resize.direction == GHOSTTY_RESIZE_SPLIT_DOWN); - if (!activeIsFirst) increase = !increase; node->SetRatio(node->Ratio() + (increase ? deltaRatio : -deltaRatio)); panelImpl->InvalidateMeasure(); From cce29c522cddd8d40db221571d5d9e41d3ac2591 Mon Sep 17 00:00:00 2001 From: i999rri Date: Sat, 23 May 2026 14:48:13 +0900 Subject: [PATCH 32/36] Track active surface via TerminalControl focus events 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) --- GhosttyWin32App/MainWindow.xaml.cpp | 21 ++++++++++++++++++- GhosttyWin32App/MainWindow.xaml.h | 20 ++++++++++++++++++ GhosttyWin32App/TabFactory.h | 26 +++++++++++++++++++----- GhosttyWin32App/TerminalControl.xaml.cpp | 8 ++++++++ GhosttyWin32App/TerminalControl.xaml.h | 15 ++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 6c8a752..2e86d44 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -443,6 +443,11 @@ namespace winrt::GhosttyWin32::implementation return tab ? tab->ActiveControl() : nullptr; } + void MainWindow::NotifySurfaceFocused(ghostty_surface_t surface) noexcept + { + m_activeSurface = surface; + } + void MainWindow::InitGhostty() { ghostty_runtime_config_s rtConfig{}; @@ -751,7 +756,15 @@ namespace winrt::GhosttyWin32::implementation m_ghostty = GhosttyApp::Create(rtConfig); if (m_ghostty && m_hwnd) { - m_tabFactory = std::make_unique(m_ghostty->Handle(), m_hwnd, m_paneIds); + // Capture by raw `this`: MainWindow outlives every + // TerminalControl it owns (the controls are destroyed + // through Tabs, which is a MainWindow member), so the + // lambda staying alive on the factory is safe. + auto onLeafFocused = [this](ghostty_surface_t surface) noexcept { + NotifySurfaceFocused(surface); + }; + m_tabFactory = std::make_unique( + m_ghostty->Handle(), m_hwnd, m_paneIds, std::move(onLeafFocused)); } } @@ -1268,6 +1281,12 @@ namespace winrt::GhosttyWin32::implementation // synchronously, before the Pane node holding the // TerminalControl is destroyed. if (auto* tc = Tab::LeafToTerminalControl(*leaf)) { + // Clear m_activeSurface if it pointed at the surface we're + // about to free — the focused-surface cache must never + // outlive the underlying ghostty_surface_t. The next + // TerminalControl::GotFocus on the retargeted sibling (or + // a new tab) will refill the slot. + if (tc->Surface() == m_activeSurface) m_activeSurface = nullptr; tc->Detach(); } diff --git a/GhosttyWin32App/MainWindow.xaml.h b/GhosttyWin32App/MainWindow.xaml.h index 30a93df..56076ee 100644 --- a/GhosttyWin32App/MainWindow.xaml.h +++ b/GhosttyWin32App/MainWindow.xaml.h @@ -33,6 +33,22 @@ namespace winrt::GhosttyWin32::implementation void OnCloseClick(winrt::Windows::Foundation::IInspectable const&, winrt::Microsoft::UI::Xaml::RoutedEventArgs const&); + // Called by every TerminalControl when it receives keyboard + // focus (wired by TabFactory). Updates m_activeSurface so any + // future caller — APP-target action handlers, multi-window + // focus delivery, IPC / scripting bridges — can ask "which + // surface is the user looking at right now" without a + // separate tree walk. Public because TerminalControl needs to + // reach in via the host-supplied callback. UI thread only. + void NotifySurfaceFocused(ghostty_surface_t surface) noexcept; + + // Last surface to receive keyboard focus inside this window. + // Null until the first focus delivery (typically the very + // first tab's TerminalControl GotFocus right after launch). + // Stays valid across alt-tab — we only clear it when the + // surface itself is torn down. + ghostty_surface_t GetActiveSurface() const noexcept { return m_activeSurface; } + private: void InitGhostty(); void CreateTab(); @@ -86,6 +102,10 @@ namespace winrt::GhosttyWin32::implementation HWND m_hwnd = nullptr; PaneIdAllocator m_paneIds; Tabs m_tabs; + // Focus-tracked active surface. Set by NotifySurfaceFocused + // when a TerminalControl gains focus, cleared when the + // matching surface is torn down through CloseSurfaceByPaneId. + ghostty_surface_t m_activeSurface = nullptr; // Constructed once ghostty is initialized — needs the app handle // and HWND, neither available until InitGhostty has run. std::unique_ptr m_tabFactory; diff --git a/GhosttyWin32App/TabFactory.h b/GhosttyWin32App/TabFactory.h index 5137dcc..075b98a 100644 --- a/GhosttyWin32App/TabFactory.h +++ b/GhosttyWin32App/TabFactory.h @@ -19,17 +19,25 @@ namespace winrt::GhosttyWin32::implementation { // Builds Tabs. Holds the cross-cutting context (ghostty app handle, the -// HWND for DPI/initial-size, and the PaneIdAllocator that produces fresh -// per-leaf IDs) so callers don't have to thread those through every -// Make() call. +// HWND for DPI/initial-size, the PaneIdAllocator that produces fresh +// per-leaf IDs, and an optional "leaf gained focus" callback that +// every TerminalControl this factory creates will fire) so callers +// don't have to thread those through every Make() call. +// +// The focus callback is the one piece of MainWindow-side context that +// needs to reach into each leaf without each leaf knowing about +// MainWindow directly. Wiring it here keeps the dependency direction +// host -> control (the inverse would be a layering violation). // // Stateless beyond the injected references — no mutable state of its // own. ID counter mutation lives in PaneIdAllocator; the factory only // borrows it. class TabFactory { public: - TabFactory(ghostty_app_t app, HWND hwnd, PaneIdAllocator& idAllocator) noexcept - : m_app(app), m_hwnd(hwnd), m_idAllocator(idAllocator) {} + TabFactory(ghostty_app_t app, HWND hwnd, PaneIdAllocator& idAllocator, + std::function onLeafFocused = {}) noexcept + : m_app(app), m_hwnd(hwnd), m_idAllocator(idAllocator), + m_onLeafFocused(std::move(onLeafFocused)) {} TabFactory(const TabFactory&) = delete; TabFactory& operator=(const TabFactory&) = delete; @@ -186,6 +194,11 @@ class TabFactory { // and closing the handle. controlImpl->Attach(m_app, surface, handle, m_hwnd, attach); + // Wire the host-supplied focus callback so the control can + // notify MainWindow whenever it gains keyboard focus without + // taking a hard dependency on MainWindow. + if (m_onLeafFocused) controlImpl->SetOnFocused(m_onLeafFocused); + return Pane::MakeLeaf(control, paneId); } @@ -209,6 +222,9 @@ class TabFactory { ghostty_app_t m_app; HWND m_hwnd; PaneIdAllocator& m_idAllocator; + // Optional. Fires with the leaf's ghostty_surface_t whenever the + // leaf's TerminalControl receives keyboard focus. + std::function m_onLeafFocused; }; } // namespace winrt::GhosttyWin32::implementation diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index 605a4be..2d68326 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -100,6 +100,14 @@ namespace winrt::GhosttyWin32::implementation if (!self) return; if (self->m_editContext) self->m_editContext.NotifyFocusEnter(); self->ShowFocusBorder(true); + // Surface-level focus event for the host. Mirrors the + // upstream getActiveSurface pattern (#62): the host uses + // this to track "currently focused surface" without us + // reaching into MainWindow globals from inside the + // control. + if (self->m_onFocused && self->m_surface) { + self->m_onFocused(self->m_surface); + } }); LostFocus([weakSelf](auto&&, auto&&) { diff --git a/GhosttyWin32App/TerminalControl.xaml.h b/GhosttyWin32App/TerminalControl.xaml.h index 3389e28..e0cc5c2 100644 --- a/GhosttyWin32App/TerminalControl.xaml.h +++ b/GhosttyWin32App/TerminalControl.xaml.h @@ -118,6 +118,17 @@ namespace winrt::GhosttyWin32::implementation // TerminalControl.xaml. void ShowFocusBorder(bool visible); + // Set the callback that fires when this control receives + // keyboard focus. Passed the underlying ghostty surface so + // the host can update its "currently focused surface" + // tracking without reaching into MainWindow globals from + // here. The callback is invoked on the UI thread (XAML + // GotFocus delivery path). Setting an empty function clears + // the registration. + void SetOnFocused(std::function cb) noexcept { + m_onFocused = std::move(cb); + } + private: // Builds the per-control CoreTextEditContext and wires its // seven event handlers (TextRequested / SelectionRequested / @@ -153,6 +164,10 @@ namespace winrt::GhosttyWin32::implementation // the interval so the border stays visible for the full // 1.5 s after the most recent focus change. winrt::Microsoft::UI::Xaml::DispatcherTimer m_focusBorderTimer{ nullptr }; + + // Notify-on-focus callback registered by the factory that built + // this control. See SetOnFocused(). + std::function m_onFocused; }; } From 20ebb9a08f58a39bbeaa7373a1165dc881d6d246 Mon Sep 17 00:00:00 2001 From: i999rri Date: Sat, 23 May 2026 14:52:04 +0900 Subject: [PATCH 33/36] Replace focus border with upstream-style dim overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GhosttyWin32App/GhosttyApp.h | 5 ++ GhosttyWin32App/MainWindow.xaml.cpp | 6 +- GhosttyWin32App/TabFactory.h | 27 ++++++- GhosttyWin32App/TerminalControl.xaml | 26 +++---- GhosttyWin32App/TerminalControl.xaml.cpp | 90 ++++++++++-------------- GhosttyWin32App/TerminalControl.xaml.h | 40 ++++++----- 6 files changed, 111 insertions(+), 83 deletions(-) diff --git a/GhosttyWin32App/GhosttyApp.h b/GhosttyWin32App/GhosttyApp.h index 464907c..b6ad439 100644 --- a/GhosttyWin32App/GhosttyApp.h +++ b/GhosttyWin32App/GhosttyApp.h @@ -64,6 +64,11 @@ class GhosttyApp { } ghostty_app_t Handle() const noexcept { return m_app; } + // Borrow the parsed config. Callers use ghostty_config_get against + // this handle for read-only lookups (e.g. unfocused-split-opacity); + // the GhosttyApp keeps ownership and frees it in the destructor + // after the app handle is released. + ghostty_config_t ConfigHandle() const noexcept { return m_config; } void Tick() noexcept { if (m_app) ghostty_app_tick(m_app); } private: diff --git a/GhosttyWin32App/MainWindow.xaml.cpp b/GhosttyWin32App/MainWindow.xaml.cpp index 2e86d44..5d0d0f1 100644 --- a/GhosttyWin32App/MainWindow.xaml.cpp +++ b/GhosttyWin32App/MainWindow.xaml.cpp @@ -764,7 +764,11 @@ namespace winrt::GhosttyWin32::implementation NotifySurfaceFocused(surface); }; m_tabFactory = std::make_unique( - m_ghostty->Handle(), m_hwnd, m_paneIds, std::move(onLeafFocused)); + m_ghostty->Handle(), + m_ghostty->ConfigHandle(), + m_hwnd, + m_paneIds, + std::move(onLeafFocused)); } } diff --git a/GhosttyWin32App/TabFactory.h b/GhosttyWin32App/TabFactory.h index 075b98a..dece45b 100644 --- a/GhosttyWin32App/TabFactory.h +++ b/GhosttyWin32App/TabFactory.h @@ -34,9 +34,10 @@ namespace winrt::GhosttyWin32::implementation { // borrows it. class TabFactory { public: - TabFactory(ghostty_app_t app, HWND hwnd, PaneIdAllocator& idAllocator, + TabFactory(ghostty_app_t app, ghostty_config_t config, HWND hwnd, + PaneIdAllocator& idAllocator, std::function onLeafFocused = {}) noexcept - : m_app(app), m_hwnd(hwnd), m_idAllocator(idAllocator), + : m_app(app), m_config(config), m_hwnd(hwnd), m_idAllocator(idAllocator), m_onLeafFocused(std::move(onLeafFocused)) {} TabFactory(const TabFactory&) = delete; @@ -199,6 +200,27 @@ class TabFactory { // taking a hard dependency on MainWindow. if (m_onLeafFocused) controlImpl->SetOnFocused(m_onLeafFocused); + // Resolve the unfocused-split appearance from ghostty config + // and stamp it onto the control. unfocused-split-opacity is + // ghostty's "how visible the unfocused side should be" (0.7 + // by default), so the overlay alpha is 1 - that. fill falls + // back to the terminal background, matching upstream. + double opacity = 0.7; + ghostty_config_color_s fill{}; + bool gotFill = false; + if (m_config) { + ghostty_config_get(m_config, &opacity, "unfocused-split-opacity", sizeof(opacity)); + gotFill = ghostty_config_get(m_config, &fill, + "unfocused-split-fill", sizeof(fill)); + if (!gotFill) { + gotFill = ghostty_config_get(m_config, &fill, + "background", sizeof(fill)); + } + } + if (!gotFill) { fill.r = 0; fill.g = 0; fill.b = 0; } + winrt::Windows::UI::Color overlayFill{ 255, fill.r, fill.g, fill.b }; + controlImpl->SetUnfocusedAppearance(1.0 - opacity, overlayFill); + return Pane::MakeLeaf(control, paneId); } @@ -220,6 +242,7 @@ class TabFactory { } ghostty_app_t m_app; + ghostty_config_t m_config; HWND m_hwnd; PaneIdAllocator& m_idAllocator; // Optional. Fires with the leaf's ghostty_surface_t whenever the diff --git a/GhosttyWin32App/TerminalControl.xaml b/GhosttyWin32App/TerminalControl.xaml index 55eaef1..0f6fddc 100644 --- a/GhosttyWin32App/TerminalControl.xaml +++ b/GhosttyWin32App/TerminalControl.xaml @@ -10,23 +10,23 @@ AllowFocusOnInteraction="True" IsHitTestVisible="True"> - + diff --git a/GhosttyWin32App/TerminalControl.xaml.cpp b/GhosttyWin32App/TerminalControl.xaml.cpp index 2d68326..d448e4f 100644 --- a/GhosttyWin32App/TerminalControl.xaml.cpp +++ b/GhosttyWin32App/TerminalControl.xaml.cpp @@ -9,27 +9,6 @@ namespace winrt::GhosttyWin32::implementation { - namespace { - // Focus-border palette. Brush colours are flipped in - // ShowFocusBorder() between these two values; the constant - // names make the call sites self-documenting and put the - // visual tuning knobs in one place. - // - // kFocusBorderAccent — Windows-blue-ish, ARGB. Saturated - // enough to stand out against most terminal palettes - // (foreground text + background fill) without bleeding into - // the cell area when the border flashes. - // kFocusBorderHidden — fully transparent, restores the - // no-chrome resting state. - constexpr winrt::Windows::UI::Color kFocusBorderAccent{ 255, 0, 120, 215 }; - constexpr winrt::Windows::UI::Color kFocusBorderHidden{ 0, 0, 0, 0 }; - - // How long the accent stays visible after the most recent - // GotFocus. Subsequent focus changes restart the timer, so - // rapid pane hops keep the border up through the sequence. - constexpr auto kFocusBorderHideDelay = std::chrono::milliseconds(1500); - } - TerminalControl::TerminalControl() { InitializeComponent(); @@ -99,7 +78,7 @@ namespace winrt::GhosttyWin32::implementation auto self = weakSelf.get(); if (!self) return; if (self->m_editContext) self->m_editContext.NotifyFocusEnter(); - self->ShowFocusBorder(true); + self->ApplyFocusVisual(true); // Surface-level focus event for the host. Mirrors the // upstream getActiveSurface pattern (#62): the host uses // this to track "currently focused surface" without us @@ -114,7 +93,7 @@ namespace winrt::GhosttyWin32::implementation auto self = weakSelf.get(); if (!self) return; if (self->m_editContext) self->m_editContext.NotifyFocusLeave(); - self->ShowFocusBorder(false); + self->ApplyFocusVisual(false); }); PointerMoved([weakSelf](auto&&, muxi::PointerRoutedEventArgs const& args) { @@ -340,36 +319,45 @@ namespace winrt::GhosttyWin32::implementation ProtectedCursor(winrt::Microsoft::UI::Input::InputSystemCursor::Create(mapped)); } - void TerminalControl::ShowFocusBorder(bool visible) + void TerminalControl::SetUnfocusedAppearance(double overlayOpacity, + winrt::Windows::UI::Color overlayFill) noexcept { - auto border = FocusBorder(); - if (!border) return; - if (visible) { - border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( - kFocusBorderAccent)); - // Lazy-init the auto-hide timer the first time the border - // goes visible. Capturing get_weak() and resolving inside - // the Tick handler keeps the timer-held lambda safe across - // TerminalControl destruction. - if (!m_focusBorderTimer) { - m_focusBorderTimer = winrt::Microsoft::UI::Xaml::DispatcherTimer{}; - m_focusBorderTimer.Interval(kFocusBorderHideDelay); - auto weakSelf = get_weak(); - m_focusBorderTimer.Tick([weakSelf](auto&&, auto&&) { - if (auto self = weakSelf.get()) { - self->ShowFocusBorder(false); - } - }); - } - // Restart so the border stays visible for the full - // interval after the most recent focus change. - m_focusBorderTimer.Stop(); - m_focusBorderTimer.Start(); - } else { - border.BorderBrush(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush( - kFocusBorderHidden)); - if (m_focusBorderTimer) m_focusBorderTimer.Stop(); + m_unfocusedOpacity = overlayOpacity; + // Build the brush eagerly so ApplyFocusVisual is allocation- + // free on every focus toggle. Recreate (rather than mutate + // the existing brush's Color) so config reloads pick up the + // new value with a single assignment. + m_unfocusedFillBrush = winrt::Microsoft::UI::Xaml::Media::SolidColorBrush(overlayFill); + // If we're currently unfocused, refresh the live overlay so a + // reload doesn't wait until the next focus toggle to take + // effect. + if (auto dim = UnfocusedDim(); + dim && dim.Visibility() == winrt::Microsoft::UI::Xaml::Visibility::Visible) + { + dim.Fill(m_unfocusedFillBrush); + dim.Opacity(m_unfocusedOpacity); + } + } + + void TerminalControl::ApplyFocusVisual(bool focused) + { + auto dim = UnfocusedDim(); + if (!dim) return; + if (focused) { + dim.Visibility(winrt::Microsoft::UI::Xaml::Visibility::Collapsed); + return; + } + // Lazy fall-back: if the factory never called + // SetUnfocusedAppearance (e.g. config-less unit test paths) + // we still want something visible. Black @ 30 % matches the + // upstream default of 0.7 opacity over a dark background. + if (!m_unfocusedFillBrush) { + winrt::Windows::UI::Color fallback{ 255, 0, 0, 0 }; + m_unfocusedFillBrush = winrt::Microsoft::UI::Xaml::Media::SolidColorBrush(fallback); } + dim.Fill(m_unfocusedFillBrush); + dim.Opacity(m_unfocusedOpacity); + dim.Visibility(winrt::Microsoft::UI::Xaml::Visibility::Visible); } TerminalControl::~TerminalControl() diff --git a/GhosttyWin32App/TerminalControl.xaml.h b/GhosttyWin32App/TerminalControl.xaml.h index e0cc5c2..3ed82f6 100644 --- a/GhosttyWin32App/TerminalControl.xaml.h +++ b/GhosttyWin32App/TerminalControl.xaml.h @@ -108,16 +108,6 @@ namespace winrt::GhosttyWin32::implementation // borders, etc. void SetCursorShape(ghostty_action_mouse_shape_e shape); - // Flash the focus border for ~1.5 s. Called from GotFocus to - // give a brief visual "you landed here" cue when focus moves - // between panes. visible=false stops the timer + clears the - // brush immediately (LostFocus path) so the previously-active - // pane's border doesn't linger past the focus change. The - // border is an overlay sibling in the XAML so the flash - // doesn't reserve permanent layout space — see - // TerminalControl.xaml. - void ShowFocusBorder(bool visible); - // Set the callback that fires when this control receives // keyboard focus. Passed the underlying ghostty surface so // the host can update its "currently focused surface" @@ -129,6 +119,23 @@ namespace winrt::GhosttyWin32::implementation m_onFocused = std::move(cb); } + // Apply the resolved unfocused-split appearance from config + // (fill = the dim-overlay colour, opacity = the alpha at + // which to draw it). Stored locally because the overlay is + // re-shown every time focus is lost, not just on first + // attach. The factory calls this right after Attach so the + // initial unfocused appearance (other tabs / siblings) is + // already correct before any focus events fire. + void SetUnfocusedAppearance(double overlayOpacity, + winrt::Windows::UI::Color overlayFill) noexcept; + + // Toggle the unfocused-dim overlay. true = focused (overlay + // hidden, full brightness); false = unfocused (overlay shown + // with the cached fill / opacity). The XAML element stays + // hit-test transparent in both states so pointer routing + // doesn't change with focus. + void ApplyFocusVisual(bool focused); + private: // Builds the per-control CoreTextEditContext and wires its // seven event handlers (TextRequested / SelectionRequested / @@ -159,15 +166,16 @@ namespace winrt::GhosttyWin32::implementation ImeBuffer m_ime; winrt::Windows::UI::Text::Core::CoreTextEditContext m_editContext{ nullptr }; - // Auto-hide timer for the focus border. Created lazily on - // first ShowFocusBorder(true); subsequent visible calls reset - // the interval so the border stays visible for the full - // 1.5 s after the most recent focus change. - winrt::Microsoft::UI::Xaml::DispatcherTimer m_focusBorderTimer{ nullptr }; - // Notify-on-focus callback registered by the factory that built // this control. See SetOnFocused(). std::function m_onFocused; + + // Cached unfocused-split overlay parameters resolved from + // ghostty config. See SetUnfocusedAppearance(). The + // SolidColorBrush is reused across hide/show transitions to + // avoid reallocating per focus event. + double m_unfocusedOpacity = 0.3; + winrt::Microsoft::UI::Xaml::Media::SolidColorBrush m_unfocusedFillBrush{ nullptr }; }; } From 34ee6889dea2ffbb35d786cca1acb7c55a99c525 Mon Sep 17 00:00:00 2001 From: i999rri Date: Sat, 23 May 2026 15:48:30 +0900 Subject: [PATCH 34/36] Halve splitter thickness to 1 DIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GhosttyWin32App/SplitPanel.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index fd7e53c..ee9381a 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -95,11 +95,11 @@ struct SplitPanel : SplitPanelT { Windows::Foundation::Size MeasureOverride(Windows::Foundation::Size availableSize); Windows::Foundation::Size ArrangeOverride(Windows::Foundation::Size finalSize); - // Width of the splitter strip in DIPs. Visible as a thin line - // between panes — narrow enough to avoid taking visible gap - // space between pane content, wide enough to land a click on - // for drag-resize. - static constexpr double kSplitterThickness = 2.0; + // Width of the splitter strip in DIPs. Doubles as the click + // hit-target for drag-resize — at 1 DIP the click area is + // small but precise; bump if drag becomes too finicky in + // practice. + static constexpr double kSplitterThickness = 1.0; private: // Recursive measure — caps each subtree at its share of `available` From f969f2987e0fea275970b916e01b0feec288fd3e Mon Sep 17 00:00:00 2001 From: i999rri Date: Sat, 23 May 2026 15:58:47 +0900 Subject: [PATCH 35/36] Drive splitter color from ghostty split-divider-color config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GhosttyWin32App/SplitPanel.cpp | 30 ++++++++++++++++++---- GhosttyWin32App/SplitPanel.h | 14 ++++++++++ GhosttyWin32App/TabFactory.h | 47 +++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/GhosttyWin32App/SplitPanel.cpp b/GhosttyWin32App/SplitPanel.cpp index 0dd241e..e586ee8 100644 --- a/GhosttyWin32App/SplitPanel.cpp +++ b/GhosttyWin32App/SplitPanel.cpp @@ -106,6 +106,21 @@ void SplitPanel::UpdateChildVisibility() { } } +void SplitPanel::SetDividerColor(winrt::Windows::UI::Color color) noexcept { + using namespace winrt::Microsoft::UI::Xaml::Controls; + using namespace winrt::Microsoft::UI::Xaml::Media; + m_dividerBrush = SolidColorBrush(color); + // Repaint every existing splitter so a reload-driven colour + // change shows up without waiting for a tree rebuild. New + // splitters created after this point pick the brush up via + // MakeSplitter's check on m_dividerBrush. + for (auto const& entry : m_splitters) { + if (auto border = entry.element.try_as()) { + border.Background(m_dividerBrush); + } + } +} + void SplitPanel::EqualizeAll() { // m_splitters already holds one entry per internal node, so reuse // it instead of re-walking the tree. The vector is rebuilt on @@ -163,11 +178,16 @@ Microsoft::UI::Xaml::Controls::Border SplitPanel::MakeSplitter(Pane* node) { using namespace winrt::Microsoft::UI::Xaml::Media; Border border{}; - // Semi-transparent gray, visible on both light- and dark-themed - // terminal backgrounds without dominating. Refined theming can - // come later; the priority here is "the user can see it and grab - // it" rather than "it matches the palette". - border.Background(SolidColorBrush(winrt::Windows::UI::Color{ 96, 128, 128, 128 })); + // Prefer the SetDividerColor-supplied brush (TabFactory resolves + // it from ghostty config) over the built-in fallback. Fallback + // is a semi-transparent gray, visible on both light- and dark- + // themed terminal backgrounds without dominating — used only + // when the factory hasn't called SetDividerColor yet. + if (m_dividerBrush) { + border.Background(m_dividerBrush); + } else { + border.Background(SolidColorBrush(winrt::Windows::UI::Color{ 96, 128, 128, 128 })); + } // No resize cursor for now — ProtectedCursor is protected on // UIElement and Border is sealed, so we can't set the per-element diff --git a/GhosttyWin32App/SplitPanel.h b/GhosttyWin32App/SplitPanel.h index ee9381a..9e9dc4f 100644 --- a/GhosttyWin32App/SplitPanel.h +++ b/GhosttyWin32App/SplitPanel.h @@ -101,6 +101,14 @@ struct SplitPanel : SplitPanelT { // practice. static constexpr double kSplitterThickness = 1.0; + // Set the brush used to paint the splitter strip. Called once + // by the factory right after the SplitPanel is constructed + // (before any tree exists), and re-callable later if a config + // reload changes split-divider-color. Refreshes the Background + // of any already-laid-out splitter Borders so the change shows + // up without rebuilding the tree. + void SetDividerColor(winrt::Windows::UI::Color color) noexcept; + private: // Recursive measure — caps each subtree at its share of `available` // along the split axis. Called by MeasureOverride. @@ -154,6 +162,12 @@ struct SplitPanel : SplitPanelT { std::unique_ptr m_root; std::vector m_splitters; + // Cached brush handed to every splitter Border. Set by + // SetDividerColor; if null, MakeSplitter falls back to a + // neutral semi-transparent gray so brand-new SplitPanels are + // still visible before the factory hooks up the config-derived + // colour. + winrt::Microsoft::UI::Xaml::Media::SolidColorBrush m_dividerBrush{ nullptr }; // Set while a splitter drag is in progress (PointerPressed → // PointerReleased / CaptureLost). Identifies which internal node's // ratio is being updated by PointerMoved. diff --git a/GhosttyWin32App/TabFactory.h b/GhosttyWin32App/TabFactory.h index dece45b..f6c752e 100644 --- a/GhosttyWin32App/TabFactory.h +++ b/GhosttyWin32App/TabFactory.h @@ -38,7 +38,8 @@ class TabFactory { PaneIdAllocator& idAllocator, std::function onLeafFocused = {}) noexcept : m_app(app), m_config(config), m_hwnd(hwnd), m_idAllocator(idAllocator), - m_onLeafFocused(std::move(onLeafFocused)) {} + m_onLeafFocused(std::move(onLeafFocused)), + m_dividerColor(ResolveDividerColor(config)) {} TabFactory(const TabFactory&) = delete; TabFactory& operator=(const TabFactory&) = delete; @@ -88,6 +89,10 @@ class TabFactory { DetachLeaf(*leaf); return nullptr; } + // Apply the divider colour before any tree exists so the + // first splitter Border built by SetRoot already paints + // with the correct brush. + splitPanelImpl->SetDividerColor(m_dividerColor); splitPanelImpl->SetRoot(std::move(leaf)); item.Content(splitPanel); @@ -225,6 +230,40 @@ class TabFactory { } private: + // Resolve split-divider-color from config the way upstream does + // (macOS Ghostty.Config.swift `splitDividerColor`): prefer the + // user-set `split-divider-color`; otherwise derive from the + // background by darkening it — 8% if the background is light, + // 40% if it's dark. Falls back to a neutral mid-grey if config + // is null or even `background` isn't readable (shouldn't happen + // in normal startup but keeps the brush valid). + static winrt::Windows::UI::Color ResolveDividerColor(ghostty_config_t config) noexcept { + ghostty_config_color_s c{ 128, 128, 128 }; + bool resolved = false; + if (config) { + constexpr const char* keyDivider = "split-divider-color"; + resolved = ghostty_config_get(config, &c, keyDivider, sizeof(c)); + if (!resolved) { + ghostty_config_color_s bg{}; + if (ghostty_config_get(config, &bg, "background", sizeof(bg))) { + const bool light = (static_cast(bg.r) + + static_cast(bg.g) + + static_cast(bg.b)) / 3 > 128; + const double factor = light ? 0.08 : 0.40; + c.r = static_cast(bg.r * (1.0 - factor)); + c.g = static_cast(bg.g * (1.0 - factor)); + c.b = static_cast(bg.b * (1.0 - factor)); + resolved = true; + } + } + } + // Use alpha=255: the divider is a 1 DIP opaque hairline. + // Letting the brush be translucent would let the colour + // beneath bleed through, which on a dark background reads + // as "blurry edge" rather than "clean separator". + return winrt::Windows::UI::Color{ 255, c.r, c.g, c.b }; + } + // Synchronously detach every TerminalControl under `node` so the // surface / DComp handle don't leak when an error path discards a // partially-constructed tree. Mirrors Tab::DetachAllLeaves but is @@ -248,6 +287,12 @@ class TabFactory { // Optional. Fires with the leaf's ghostty_surface_t whenever the // leaf's TerminalControl receives keyboard focus. std::function m_onLeafFocused; + // Resolved once in the ctor (config is read-only-here) and + // stamped onto every SplitPanel built by Make. Reload-time + // updates would require re-running ResolveDividerColor and + // pushing the new colour into existing SplitPanels via + // SetDividerColor — wired but not yet triggered by anyone. + winrt::Windows::UI::Color m_dividerColor; }; } // namespace winrt::GhosttyWin32::implementation From fc6f214136e6978e9d323169a69bf5cd7383f211 Mon Sep 17 00:00:00 2001 From: i999rri Date: Sat, 23 May 2026 16:16:16 +0900 Subject: [PATCH 36/36] ghostty_config_get: pass key length, not output buffer size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- GhosttyWin32App/TabFactory.h | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/GhosttyWin32App/TabFactory.h b/GhosttyWin32App/TabFactory.h index f6c752e..97d05c9 100644 --- a/GhosttyWin32App/TabFactory.h +++ b/GhosttyWin32App/TabFactory.h @@ -210,16 +210,28 @@ class TabFactory { // ghostty's "how visible the unfocused side should be" (0.7 // by default), so the overlay alpha is 1 - that. fill falls // back to the terminal background, matching upstream. + // + // ghostty_config_get's 4th parameter is the LENGTH OF THE + // KEY STRING, not the size of the output buffer (see upstream + // Ghostty.Config.swift which passes key.lengthOfBytes). We + // were passing sizeof(out) which made every lookup miss on a + // 2-3 char prefix of the key. The constexpr-string-literal + // sizeof minus the null terminator is the smallest way to + // compute the key length without a runtime strlen. double opacity = 0.7; ghostty_config_color_s fill{}; bool gotFill = false; if (m_config) { - ghostty_config_get(m_config, &opacity, "unfocused-split-opacity", sizeof(opacity)); + ghostty_config_get(m_config, &opacity, + "unfocused-split-opacity", + sizeof("unfocused-split-opacity") - 1); gotFill = ghostty_config_get(m_config, &fill, - "unfocused-split-fill", sizeof(fill)); + "unfocused-split-fill", + sizeof("unfocused-split-fill") - 1); if (!gotFill) { gotFill = ghostty_config_get(m_config, &fill, - "background", sizeof(fill)); + "background", + sizeof("background") - 1); } } if (!gotFill) { fill.r = 0; fill.g = 0; fill.b = 0; } @@ -238,14 +250,22 @@ class TabFactory { // is null or even `background` isn't readable (shouldn't happen // in normal startup but keeps the brush valid). static winrt::Windows::UI::Color ResolveDividerColor(ghostty_config_t config) noexcept { + // ghostty_config_get's 4th argument is the length of the key + // string (see upstream Ghostty.Config.swift), not the size of + // the output buffer. Passing sizeof(out) silently truncates + // the key and the lookup fails for everything longer than + // a couple of characters. ghostty_config_color_s c{ 128, 128, 128 }; bool resolved = false; if (config) { - constexpr const char* keyDivider = "split-divider-color"; - resolved = ghostty_config_get(config, &c, keyDivider, sizeof(c)); + resolved = ghostty_config_get(config, &c, + "split-divider-color", + sizeof("split-divider-color") - 1); if (!resolved) { ghostty_config_color_s bg{}; - if (ghostty_config_get(config, &bg, "background", sizeof(bg))) { + if (ghostty_config_get(config, &bg, + "background", + sizeof("background") - 1)) { const bool light = (static_cast(bg.r) + static_cast(bg.g) + static_cast(bg.b)) / 3 > 128;