Skip to content

gtk: fix invisible splits and focus being lost - #12698

Merged
jcollie merged 4 commits into
ghostty-org:mainfrom
dkinzler:11193-fix-splits
May 18, 2026
Merged

jcollie merged 4 commits into
ghostty-org:mainfrom
dkinzler:11193-fix-splits

Conversation

@dkinzler

Copy link
Copy Markdown
Contributor

Fixes #11193 where terminal surfaces might not appear and focus might be lost when creating multiple nested splits.

These bugs are caused by GTK initially allocating a tiny width/height to deeply nested splits. For a split with a tiny size, the split ratio will be set inaccurately e.g. to 1 which means that the right/bottom child of the split is invisible. If that child is the terminal surface that should have the focus, it will lose it. In the current implementation the split ratio can be set at most once, which means the inaccurate ratio never gets corrected and a surface (or an entire sub-tree of the layout) will stay invisible.

The following explains the current implementation and bug in more detail, it is a bit long, but I hope it will make it easier to review this PR.

Current Implementation

A split layout is a tree, in code represented by datastruct/SplitTree, where inner nodes are splits and leafs are terminal surface. A split can be either horizontal or vertical, and has a ratio that defines how its space should be divided among the 2 children.
The counterpart in the GTK UI is the apprt/gtk/class/SplitTree widget whose onRebuild/buildTree functions build a widget tree that has the same structure as the datastruct/SplitTree. The widget tree consists of a SplitTreeSplit widget for every split and a Surface widget for every terminal surface.

A SplitTreeSplit widget wraps a gtk.Paned widget, which displays its two children with a divider in between, either horizontally or vertically. How much space each child gets is determined by 3 properties. min_position is always 0 in our case, max_position corresponds to the width/height (for horizontal/vertical splits) of the widget and position is where the divider should be. So position is equivalent to the width/height of the left/top child and thereby also determines the width/height of the right/bottom child. SplitTreeSplit listens for changes in the 3 properties. If there is one, the propPosition, propMinPosition or propMaxPosition function gets triggered and an idle callback for the onIdle function is added.

We need to make sure that the widget tree and the datastruct/SplitTree stay in sync.

If we e.g. create a new split or close a surface, the structure of the split tree changes. In that case gtk/class/SplitTree.onRebuild will completely rebuild the widget tree (the Surface widgets are actually reused) to match the new tree structure. If we resize a split (i.e. change the split ratio) via action/keybind, we also completely rebuild the widget tree.

Additionally we need to make sure that for every SplitTreeSplit/gtk.Paned the position divided by max_position matches the ratio of the corresponding split node in our datastruct/SplitTree. There are two ways the current implementation keeps these ratios in sync, both are handled by the SplitTreeSplit.onIdle function.

  1. Initially when the widget tree is built, GTK allocates each widget a size. Specifically it also sets the position and max_position properties of each gtk.Paned widget, which will trigger the SplitTreeSplit.onIdle function to run. GTK will not necessarily set position correctly, it is the task of onIdle to make sure that the UI matches the layout defined by the datastruct/SplitTree. onIdle checks if position/max_position matches the ratio that the split should have and if not calls gtk.Paned.setPosition to update it. This can only happen once for each split since onIdle checks if the position was set previously. The idea is that we should only ever need to set the position once, because gtk.Paned will automatically keep its current ratio whenever its size/max-position changes (if the setPosition function has been called before). A size change can happen e.g. because the entire window was resized or because an ancestor split changed its split ratio.
  2. The user can manually change the ratio between the two children of a split by dragging the divider between them in the UI. When that happens the position property in gtk.Paned changes and eventually the SplitTreeSplit.onIdle function gets called. Since setPosition should have already been called when the widget was initially sized, we should fall through to the second case and write the current ratio back to the datastructure/SplitTree.

The problem with SplitTreeSplit.onIdle is that sometimes the split ratio cannot be set accurately given the current size of the gtk.Paned widget. Because onIdle can only set the position/ratio once, any previous inaccuracy can never get corrected.

For example with many nested vertical splits, GTK might initially allocate a gtk.Paned widget a height of 1. It will have max_position=1 and position=1. When onIdle runs the current ratio of position/max_position = 1 is different from the desired ratio of e.g. 0.5. But a ratio of 0.5 cannot be set, the position can only be 0 or 1 corresponding to a ratio of 0 or 1. The position will then get set as 1 and can't be changed later. Even when the split later gets a larger height, it will keep the ratio of 1 and the bottom child will stay invisible. When the surface that should be focused initially becomes invisible it loses focus and the focus will never be restored. That is exactly what happens in the first screencast in the issue description (#11193).

Another problem with onIdle is that the setPosition call in onIdle will trigger another idle callback where the position change is sometimes wrongly interpreted as a manual update and written back to the tree. Also sometimes the initial ratio in a gtk.Paned can already be correct, in which case position will not get set. The next manual position update is then not detected as a manual update.

Changes

SplitTreeSplit.onIdle is now able to set the split position every time the widget is resized, an inaccurate initial ratio will be corrected. To be able to distinguish a widget resize from a manual position update by the user, we keep track of whether max-position, position or both properties changed. If only max-position or both properties changed, then the widget was resized. If just position changed it is a manual update. This is kind of hacky but works. To verify I checked the source code for gtk.Paned, see the comment in the code on onIdle.

SplitTreeSplit no longer listens to changes in min-position, that should always be 0 (because we use the default resize/shrink properties for gtk.Paned) and there is already an assert in onIdle that checks that.

It can still happen that a surface initially gets allocated width/height 0 and loses focus. The only reliable way to detect when we can restore focus, is to listen to the map/unmap signals exposed by gtk.Widget. The Surface widget now listens to these signals on its GlArea child (because that is where we want to put focus) and stores the current state in the new mapped property. The SplitTree widget listens to changes in that property: when surfaces become mapped, an idle callback for the new onRestoreFocus function is added, which will check whether the last focused surface is mapped and if so restore focus to it.

Other possible solutions

Alternatively we could try to only set the split ratio once the split has its correct final size, but I think it's hard to detect that reliably. Or we could try to prevent the splits/surfaces from becoming invisible in the first place by e.g. setting a minimal widget size. But then you won't get the exactly correct layout and sometimes you do want a surface to be tiny or invisible e.g. you can drag the divider in a split all the way to one side.

The ideal solution would probably be to write a custom version of gtk.Paned where you can provide the desired ratio on widget creation. Then everything will be sized correctly from the start, focus will not be lost. In terms of performance it would probably be better as well, because right now there can be multiple rounds of resizes until every split/surface has its correct size. I have played around with this a bit, it is definitely possible. But you would have to implement the divider widget, logic for layouting, handling gestures and co. That is a bigger undertaking.

Testing

Tested by creating/modifying deeply nested layouts, dragging split dividers and resizing splits via keybind. Checked that ratios are maintained when the window is resized and tested that it works with zoom. Tested locally with hyprland and in a VM with KDE.

All the bugs described in the issue should be fixed.

AI Disclosure

Discovered the bug and wrote all code/comments by myself. Used AI in researching various internals of GTK.

dkinzler added 4 commits May 12, 2026 14:13
The cause of these bugs is that GTK can initially allocate
a split/surface a width/height of 0 which causes it to
get unmapped and lose focus. Additionally the split ratio is
only set once but not accurately for tiny splits, which can keep
a surface invisible even when the split gets resized later.

To fix these problems the split ratio is always checked and
possibly corrected when a split gets resized. Changes in a split
ratio caused by the user dragging the divider are detected
separately using an event controller. If a surface loses focus
we restore it once the surface becomes mapped again.
max-position and position properties. Listening to drag events directly
did not work that well.
@dkinzler
dkinzler requested a review from a team as a code owner May 15, 2026 18:56
@ghostty-bot ghostty-bot Bot added the gtk Issues exclusive to GTK which are not OS-specific label May 15, 2026
@jcollie
jcollie merged commit 4814aee into ghostty-org:main May 18, 2026
91 checks passed
jcollie added a commit that referenced this pull request May 18, 2026
Fixes `SplitTree.resize` not rescaling the split ratio to be relative to
the size of the split. Added a unit test for resizing a nested split.

Previously the new ratio was incorrectly calculated relative to the
entire grid. As a consequence resizing a nested split in the GTK app
would cause unexpected size changes like large jumps. E.g. in the
following recording the window has height ~1000px and the resize was
done using a keybind for `resize_split:up,10`. The change is much larger
than 10 pixels.



https://github.com/user-attachments/assets/ba375ddf-5b2f-45e4-8b12-69021ef2f8a8



Note that even with this fix, resizing by a small amount like 10 pixels
might not work at all (depending on window size and layout), because of
the same bug causing #11193 (see my PR #12698). Initially an inaccurate
split ratio will be set and eventually written back to the split tree
datastructure. That incorrect split ratio will be the same before and
after the small resize, so nothing actually changes in the UI.

The split tree implementation for the macOS app already calculates the
ratios correctly.

AI Disclosure
No AI was used, the bug was discovered and all code written by myself.
@jcollie

jcollie commented May 18, 2026

Copy link
Copy Markdown
Member

Thanks! This combined with 12699 works quite well in my testing.

@pktpls

pktpls commented May 18, 2026

Copy link
Copy Markdown

I love you both!! Confirmed fixed!

mitchellh pushed a commit that referenced this pull request May 22, 2026
Calls core_surface.occlusionCallback(visible) from the existing
glareaMap/glareaUnmap handlers (added in #12698) so the renderer
thread learns when a surface is off-screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jcollie added a commit that referenced this pull request Aug 3, 2026
This PR improves the way splits/surfaces are sized in the GTK app, which
eliminates flickering and slightly improves performance.

Fixes #13328, #12709, #11187.
Related #8208 (closed) but some later comments mention flickering issues
persisting.
Builds on top of the changes in #12698.

Previously an idle callback was used to sync the split ratio between the
GTK widget tree and the split tree data structure that represents the
split layout. The widget tree contains a `SplitTreeSplit` widget, which
wraps a `GtkPaned` widget, for every split. During size allocation a
`GtkPaned` widget first computes the initial position of the divider and
thereby the size for its two children. We get notified of that position
(and the max possible position) via the `propPosition/propMaxPosition`
callbacks in `SplitTreeSplit` and set up an idle callback (the `onIdle`
function) to update the position if it does not match the desired split
ratio. Since the initial position is often not correct, especially in
nested layouts or if the ratio is not 0.5, a surface will first be shown
with the wrong size for a few frames until the idle callback runs and
corrects the sizing. In nested layouts it might take multiple rounds of
size allocation and idle callbacks until every surface gets the correct
size. This causes flickering as widgets eventually snap to another size,
which is especially noticeable if the layout changes quickly e.g. when
resizing a split using keybinds.

To fix this, the divider position will now be corrected directly from
the `propMaxPosition` callback, which runs during GTK size allocation,
right after a `GtkPaned` computes the initial position and right before
it uses the position to allocate sizes for its two children. With this
change every surface will be sized correctly during the first round of
size allocation.
The idle callback is still used to update the ratio in the split tree
when a split is resized by manually dragging the divider in the UI. The
logic to sync the split ratio was moved to the new `syncSplitRatio`
function which is called from both `propMaxPosition` and `onIdle`.

This is kind of hacky, but I reviewed the GTK source code in detail to
verify that this is safe (see the various code comments for more
details). I also tested extensively on both Hyprland and KDE Plasma:
creating deeply nested layouts, resizing with both keybinds and dragging
dividers by hand, with multiple tabs, resizing the entire window,
resizing entire subtrees to 0 and back. Everything seems to work fine.

For performance testing I used sysprof which can also collect GTK stats.
When creating/deleting/resizing splits I can measure a slight but
consistent increase in GTK FPS (+5 to 10) on my system. Other than that
CPU usage and FPS seem to be the same before and after. I guess this
makes sense, while we added a bit of work to the GTK loop during size
allocation, we avoid surfaces being resized.

For the flickering, here's a side-by-side comparison. Left is before the
changes, right is after.


https://github.com/user-attachments/assets/2a4f0b4b-e113-49b5-b0d7-d9e507a5a4ff

AI Disclosure: no AI was used.
@mitchellh mitchellh added this to the 1.4.0 milestone Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gtk Issues exclusive to GTK which are not OS-specific

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GTK: weird state making tiny splits

4 participants