From e59e27f8bd7610f82ca66c3f0971e6e88713e06c Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Tue, 12 May 2026 14:13:10 +0200 Subject: [PATCH 1/4] Fix nested splits disappearing and focus being lost. 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. --- src/apprt/gtk/class/split_tree.zig | 272 +++++++++++++++------- src/apprt/gtk/class/surface.zig | 53 ++++- src/apprt/gtk/ui/1.2/surface.blp | 2 + src/apprt/gtk/ui/1.5/split-tree-split.blp | 7 +- 4 files changed, 251 insertions(+), 83 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 311fbd8a6..33b1530fa 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -158,6 +158,12 @@ pub const SplitTree = extern struct { /// used to debounce updates. rebuild_source: ?c_uint = null, + // The source that we use to restore focus. With enough splits, some + // surfaces are initially allocated a width/height of 0 which causes + // them to get unmapped and lose focus. We can reliably restore focus + // to the last focused surface only once it is mapped again. + restore_focus_source: ?c_uint = null, + /// Used to store state about a pending surface close for the /// close dialog. pending_close: ?Surface.Tree.Node.Handle, @@ -415,6 +421,13 @@ pub const SplitTree = extern struct { self, .{ .detail = "focused" }, ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceMapped, + self, + .{ .detail = "mapped" }, + ); } } @@ -571,6 +584,12 @@ pub const SplitTree = extern struct { } priv.rebuild_source = null; } + if (priv.restore_focus_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove restore_focus source", .{}); + } + priv.restore_focus_source = null; + } gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -766,6 +785,23 @@ pub const SplitTree = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } + fn propSurfaceMapped( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + if (!surface.getMapped()) return; + + // We could add the idle callback only if this is actually the last focused + // surface. But we can avoid that check because usually all the surfaces get + // mapped at once, so the idle callback will run only once anyway. + const priv = self.private(); + if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd( + onRestoreFocus, + self, + ); + } + fn propTree( self: *Self, _: *gobject.ParamSpec, @@ -779,14 +815,20 @@ pub const SplitTree = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec); - // If we were planning a rebuild, always remove that so we can - // start from a clean slate. + // If we were planning a rebuild or focus restore, always remove + // that so we can start from a clean slate. if (priv.rebuild_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove rebuild source", .{}); } priv.rebuild_source = null; } + if (priv.restore_focus_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove restore_focus source", .{}); + } + priv.restore_focus_source = null; + } // If we transitioned to an empty tree, clear immediately instead of // waiting for an idle callback. Delaying teardown can keep the last @@ -842,6 +884,26 @@ pub const SplitTree = extern struct { return 0; } + fn onRestoreFocus(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always mark our source as null since we're done. + const priv = self.private(); + priv.restore_focus_source = null; + + // If we have a last-focused surface and it is mapped, restore focus to it. + // Depending on the available size, the surface might already have focus + // because it never got unmapped. In that case grabbing focus will have no + // effect. + if (priv.last_focused.get()) |v| { + defer v.unref(); + if (v.getMapped()) { + v.grabFocus(); + } + } + return 0; + } + /// Builds the widget tree associated with a surface split tree. /// /// Returned widgets are expected to be attached to a parent by the caller. @@ -905,7 +967,6 @@ pub const SplitTree = extern struct { defer left.deinit(); const right = self.buildTree(tree, s.right); defer right.deinit(); - break :split .initNew(SplitTreeSplit.new( current, &s, @@ -1041,8 +1102,11 @@ const SplitTreeSplit = extern struct { /// Assumed to be correct. handle: Surface.Tree.Node.Handle, - /// Source to handle repositioning the split when properties change. - idle: ?c_uint = null, + /// Source to handle repositioning the split when its size changes. + idle_max_pos: ?c_uint = null, + /// Source to write back the updated split ratio to the split tree + /// when the user manually drags the divider. + idle_drag: ?c_uint = null, // Template bindings paned: *gtk.Paned, @@ -1083,21 +1147,13 @@ const SplitTreeSplit = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - fn refresh(self: *Self) void { - const priv = self.private(); - if (priv.idle == null) priv.idle = glib.idleAdd( - onIdle, - self, - ); - } - - fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + fn onIdleMaxPos(ud: ?*anyopaque) callconv(.c) c_int { const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); const priv = self.private(); const paned = priv.paned; - // Our idle source is always over - priv.idle = null; + // Our idle source is always over. + priv.idle_max_pos = null; // Get our split. This is the most dangerous part of this entire // widget. We assume that this widget is always a child of a @@ -1110,42 +1166,10 @@ const SplitTreeSplit = extern struct { const tree = split_tree.getTree() orelse return 0; const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - // Current, min, and max positions as pixels. - const pos = paned.getPosition(); - const min = min: { - var val = gobject.ext.Value.new(c_int); - defer val.unset(); - gobject.Object.getProperty( - paned.as(gobject.Object), - "min-position", - &val, - ); - break :min gobject.ext.Value.get(&val, c_int); + const pos, const max = positions: { + const p = self.getPanedPositions(); + break :positions .{ p.pos, p.max }; }; - const max = max: { - var val = gobject.ext.Value.new(c_int); - defer val.unset(); - gobject.Object.getProperty( - paned.as(gobject.Object), - "max-position", - &val, - ); - break :max gobject.ext.Value.get(&val, c_int); - }; - const pos_set: bool = max: { - var val = gobject.ext.Value.new(c_int); - defer val.unset(); - gobject.Object.getProperty( - paned.as(gobject.Object), - "position-set", - &val, - ); - break :max gobject.ext.Value.get(&val, c_int) != 0; - }; - - // We don't actually use min, but we don't expect this to ever - // be non-zero, so let's add an assert to ensure that. - assert(min == 0); // If our max is zero then we can't do any math. I don't know // if this is possible but I suspect it can be if you make a nested @@ -1172,51 +1196,134 @@ const SplitTreeSplit = extern struct { return 0; } - // If we're out of bounds, then we need to either set the position - // to what we expect OR update our expected ratio. + // Note that if max is small, it might not be possible to accurately + // set the desired ratio. E.g. with max=2 you can only set ratios + // of 0, 0.5 and 1. + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + return 0; + } - // If we've never set the position, then we set it to the desired. - if (!pos_set) { - const desired_pos: c_int = desired_pos: { - const max_f64: f64 = @floatFromInt(max); - break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); - }; - paned.setPosition(desired_pos); + fn onIdleDrag(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + + // Our idle source is always over. + priv.idle_drag = null; + + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse return 0; + const tree = split_tree.getTree() orelse return 0; + const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; + + const pos, const max = positions: { + const p = self.getPanedPositions(); + break :positions .{ p.pos, p.max }; + }; + + if (max == 0) return 0; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; + }; + const old_ratio: f64 = @floatCast(split.ratio); + + // If our ratio is close enough to the old ratio, then + // we ignore the update. + if (std.math.approxEqAbs( + f64, + current_ratio, + old_ratio, + 0.001, + )) { return 0; } - // If we've set the position, then this is a manual human update - // and we need to write our update back to the tree. + // Write our update back to the tree. tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); return 0; } + const PanedPositions = struct { + pos: c_int, + min: c_int, + max: c_int, + }; + + // Returns the current, min, and max positions of the gtk.Paned + // this widget wraps. + fn getPanedPositions(self: *Self) PanedPositions { + const priv = self.private(); + const paned = priv.paned; + + const pos = paned.getPosition(); + const min = min: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "min-position", + &val, + ); + break :min gobject.ext.Value.get(&val, c_int); + }; + const max = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "max-position", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int); + }; + + // We don't actually use min, but we don't expect this to ever + // be non-zero, so let's add an assert to ensure that. + assert(min == 0); + + return .{ + .pos = pos, + .min = min, + .max = max, + }; + } + //--------------------------------------------------------------- // Signal handlers - fn propPosition( - _: *gtk.Paned, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - self.refresh(); - } - fn propMaxPosition( _: *gtk.Paned, _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { - self.refresh(); + const priv = self.private(); + if (priv.idle_max_pos == null) priv.idle_max_pos = glib.idleAdd( + onIdleMaxPos, + self, + ); } - fn propMinPosition( - _: *gtk.Paned, - _: *gobject.ParamSpec, + fn onDragEnd( + _: *gtk.GestureDrag, + _: f64, + _: f64, self: *Self, ) callconv(.c) void { - self.refresh(); + const priv = self.private(); + if (priv.idle_drag == null) priv.idle_drag = glib.idleAdd( + onIdleDrag, + self, + ); } //--------------------------------------------------------------- @@ -1224,11 +1331,17 @@ const SplitTreeSplit = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.idle) |v| { + if (priv.idle_max_pos) |v| { if (glib.Source.remove(v) == 0) { - log.warn("unable to remove idle source", .{}); + log.warn("unable to remove idle_max_pos source", .{}); } - priv.idle = null; + priv.idle_max_pos = null; + } + if (priv.idle_drag) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle_drag source", .{}); + } + priv.idle_drag = null; } gtk.Widget.disposeTemplate( @@ -1275,8 +1388,7 @@ const SplitTreeSplit = extern struct { // Template Callbacks class.bindTemplateCallback("notify_max_position", &propMaxPosition); - class.bindTemplateCallback("notify_min_position", &propMinPosition); - class.bindTemplateCallback("notify_position", &propPosition); + class.bindTemplateCallback("on_drag_end", &onDragEnd); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 179c779d7..2e05d7b12 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -169,6 +169,24 @@ pub const Surface = extern struct { ); }; + pub const mapped = struct { + pub const name = "mapped"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "mapped", + ), + }, + ); + }; + pub const @"min-size" = struct { pub const name = "min-size"; const impl = gobject.ext.defineProperty( @@ -592,11 +610,15 @@ pub const Surface = extern struct { /// focus events. focused: bool = true, + /// Whether the GLArea widget is mapped. Some operations like grabbing + /// focus only work if a widget is mapped. + mapped: bool = false, + /// Whether this surface is "zoomed" or not. A zoomed surface /// shows up taking the full bounds of a split view. zoom: bool = false, - /// The GLAarea that renders the actual surface. This is a binding + /// The GLArea that renders the actual surface. This is a binding /// to the template so it doesn't have to be unrefed manually. gl_area: *gtk.GLArea, @@ -1768,6 +1790,7 @@ pub const Surface = extern struct { priv.mouse_shape = .text; priv.mouse_hidden = false; priv.focused = true; + priv.mapped = false; priv.size = .{ .width = 0, .height = 0 }; priv.vadj_signal_group = null; @@ -2019,6 +2042,11 @@ pub const Surface = extern struct { return self.private().focused; } + /// Returns true if the GLArea of this surface is mapped. + pub fn getMapped(self: *Self) bool { + return self.private().mapped; + } + /// Change the configuration for this surface. pub fn setConfig(self: *Self, config: *Config) void { const priv = self.private(); @@ -3250,6 +3278,26 @@ pub const Surface = extern struct { priv.im_context.as(gtk.IMContext).setClientWidget(null); } + fn glareaMap( + _: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { + self.updateMapped(true); + } + + fn glareaUnmap( + _: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { + self.updateMapped(false); + } + + fn updateMapped(self: *Self, mapped: bool) void { + const priv = self.private(); + priv.mapped = mapped; + self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec); + } + fn glareaRender( _: *gtk.GLArea, _: *gdk.GLContext, @@ -3560,6 +3608,8 @@ pub const Surface = extern struct { class.bindTemplateCallback("drop", &dtDrop); class.bindTemplateCallback("gl_realize", &glareaRealize); class.bindTemplateCallback("gl_unrealize", &glareaUnrealize); + class.bindTemplateCallback("gl_map", &glareaMap); + class.bindTemplateCallback("gl_unmap", &glareaUnmap); class.bindTemplateCallback("gl_render", &glareaRender); class.bindTemplateCallback("gl_resize", &glareaResize); class.bindTemplateCallback("im_preedit_start", &imPreeditStart); @@ -3592,6 +3642,7 @@ pub const Surface = extern struct { properties.@"error".impl, properties.@"font-size-request".impl, properties.focused.impl, + properties.mapped.impl, properties.@"key-sequence".impl, properties.@"key-table".impl, properties.@"min-size".impl, diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 794ea1801..2f01c48ce 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -23,6 +23,8 @@ Overlay terminal_page { GLArea gl_area { realize => $gl_realize(); unrealize => $gl_unrealize(); + map => $gl_map(); + unmap => $gl_unmap(); render => $gl_render(); resize => $gl_resize(); hexpand: true; diff --git a/src/apprt/gtk/ui/1.5/split-tree-split.blp b/src/apprt/gtk/ui/1.5/split-tree-split.blp index 182919f4e..82dd79e69 100644 --- a/src/apprt/gtk/ui/1.5/split-tree-split.blp +++ b/src/apprt/gtk/ui/1.5/split-tree-split.blp @@ -13,8 +13,11 @@ template $GhosttySplitTreeSplit: Adw.Bin { Adw.Bin { Paned paned { notify::max-position => $notify_max_position(); - notify::min-position => $notify_min_position(); - notify::position => $notify_position(); + + GestureDrag { + drag-end => $on_drag_end(); + } } + } } From 54a38e8134b8418d1d8d5293c2881b48a7274689 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Thu, 14 May 2026 15:45:29 +0200 Subject: [PATCH 2/4] Distinguish resize and manual update using a combination of max-position and position properties. Listening to drag events directly did not work that well. --- src/apprt/gtk/class/split_tree.zig | 218 +++++++++------------- src/apprt/gtk/ui/1.5/split-tree-split.blp | 6 +- 2 files changed, 90 insertions(+), 134 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 33b1530fa..43383720d 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -1102,11 +1102,14 @@ const SplitTreeSplit = extern struct { /// Assumed to be correct. handle: Surface.Tree.Node.Handle, - /// Source to handle repositioning the split when its size changes. - idle_max_pos: ?c_uint = null, - /// Source to write back the updated split ratio to the split tree - /// when the user manually drags the divider. - idle_drag: ?c_uint = null, + /// Source to handle repositioning the split when properties change. + idle: ?c_uint = null, + + /// Whether the max-position/position property of the gtk.Paned widget + /// changed. We use these to distinguish between a resize and the user + /// manually moving the split divider. See the "on-idle" function. + max_changed: bool = false, + pos_changed: bool = false, // Template bindings paned: *gtk.Paned, @@ -1147,13 +1150,37 @@ const SplitTreeSplit = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - fn onIdleMaxPos(ud: ?*anyopaque) callconv(.c) c_int { + // We need to keep the split ratios from the tree datastructure and + // widget tree in sync. Using the max-position and position properties + // of the gtk.Paned widget, we can distinguish a resize from a manual + // update (e.g. the user dragging the divider).If max-position changes, + // we always have a widget resize. Usually position will change as well + // but it might not if the size change is small enough. If only position + // changes, we have a manual human update. + // + // This is a hack, it relies on the timing of property notifcations. + // From looking at the GTK source code, it should not be possible that we + // erroneously interpret a position change from a resize as a manual update. + // When a gtk.Paned is resized, internally the gtk_paned_calc_position function + // will change both max-position and position and synchronously call our + // propMaxPosition and propPosition functions. I.e. when the widget is resized, + // it should not be possible for onIdle to run before we have been notified of + // both property changes. + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); const priv = self.private(); const paned = priv.paned; - // Our idle source is always over. - priv.idle_max_pos = null; + // Clear source and fields at the end. Otherwise if setPosition is called + // below, propPosition is triggered and would add another idle callback + // before this one is finished. + defer priv.idle = null; + defer priv.max_changed = false; + defer priv.pos_changed = false; + + if (!priv.max_changed and !priv.pos_changed) { + return 0; + } // Get our split. This is the most dangerous part of this entire // widget. We assume that this widget is always a child of a @@ -1166,105 +1193,7 @@ const SplitTreeSplit = extern struct { const tree = split_tree.getTree() orelse return 0; const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - const pos, const max = positions: { - const p = self.getPanedPositions(); - break :positions .{ p.pos, p.max }; - }; - - // If our max is zero then we can't do any math. I don't know - // if this is possible but I suspect it can be if you make a nested - // split completely minimized. - if (max == 0) return 0; - - // Determine our current ratio. - const current_ratio: f64 = ratio: { - const pos_f64: f64 = @floatFromInt(pos); - const max_f64: f64 = @floatFromInt(max); - break :ratio pos_f64 / max_f64; - }; - const desired_ratio: f64 = @floatCast(split.ratio); - - // If our ratio is close enough to our desired ratio, then - // we ignore the update. This is to avoid constant split updates - // for lossy floating point math. - if (std.math.approxEqAbs( - f64, - current_ratio, - desired_ratio, - 0.001, - )) { - return 0; - } - - // Note that if max is small, it might not be possible to accurately - // set the desired ratio. E.g. with max=2 you can only set ratios - // of 0, 0.5 and 1. - const desired_pos: c_int = desired_pos: { - const max_f64: f64 = @floatFromInt(max); - break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); - }; - paned.setPosition(desired_pos); - return 0; - } - - fn onIdleDrag(ud: ?*anyopaque) callconv(.c) c_int { - const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); - const priv = self.private(); - - // Our idle source is always over. - priv.idle_drag = null; - - const split_tree = ext.getAncestor( - SplitTree, - self.as(gtk.Widget), - ) orelse return 0; - const tree = split_tree.getTree() orelse return 0; - const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - - const pos, const max = positions: { - const p = self.getPanedPositions(); - break :positions .{ p.pos, p.max }; - }; - - if (max == 0) return 0; - - // Determine our current ratio. - const current_ratio: f64 = ratio: { - const pos_f64: f64 = @floatFromInt(pos); - const max_f64: f64 = @floatFromInt(max); - break :ratio pos_f64 / max_f64; - }; - const old_ratio: f64 = @floatCast(split.ratio); - - // If our ratio is close enough to the old ratio, then - // we ignore the update. - if (std.math.approxEqAbs( - f64, - current_ratio, - old_ratio, - 0.001, - )) { - return 0; - } - - // Write our update back to the tree. - tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); - - return 0; - } - - const PanedPositions = struct { - pos: c_int, - min: c_int, - max: c_int, - }; - - // Returns the current, min, and max positions of the gtk.Paned - // this widget wraps. - fn getPanedPositions(self: *Self) PanedPositions { - const priv = self.private(); - const paned = priv.paned; - + // Current, min, and max positions as pixels. const pos = paned.getPosition(); const min = min: { var val = gobject.ext.Value.new(c_int); @@ -1291,11 +1220,47 @@ const SplitTreeSplit = extern struct { // be non-zero, so let's add an assert to ensure that. assert(min == 0); - return .{ - .pos = pos, - .min = min, - .max = max, + // If our max is zero then we can't do any math. I don't know + // if this is possible but I suspect it can be if you make a nested + // split completely minimized. + if (max == 0) return 0; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; }; + const desired_ratio: f64 = @floatCast(split.ratio); + + // If our ratio is close enough to our desired ratio, then + // we ignore the update. This is to avoid constant split updates + // for lossy floating point math. + if (std.math.approxEqAbs( + f64, + current_ratio, + desired_ratio, + 0.001, + )) { + return 0; + } + + if (priv.max_changed) { + // Widget got resized, update position to match desired ratio. + // Note that if max-position is small, it might not be possible + // to accurately set the desired ratio. E.g. with max-position=2 + // you can only have ratios 0, 0.5 and 1. + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + } else { + // If only position changed, this is a manual human update and + // we need to write our update back to the tree. + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + } + return 0; } //--------------------------------------------------------------- @@ -1307,21 +1272,22 @@ const SplitTreeSplit = extern struct { self: *Self, ) callconv(.c) void { const priv = self.private(); - if (priv.idle_max_pos == null) priv.idle_max_pos = glib.idleAdd( - onIdleMaxPos, + priv.max_changed = true; + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, self, ); } - fn onDragEnd( - _: *gtk.GestureDrag, - _: f64, - _: f64, + fn propPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { const priv = self.private(); - if (priv.idle_drag == null) priv.idle_drag = glib.idleAdd( - onIdleDrag, + priv.pos_changed = true; + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, self, ); } @@ -1331,17 +1297,11 @@ const SplitTreeSplit = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.idle_max_pos) |v| { + if (priv.idle) |v| { if (glib.Source.remove(v) == 0) { - log.warn("unable to remove idle_max_pos source", .{}); + log.warn("unable to remove idle source", .{}); } - priv.idle_max_pos = null; - } - if (priv.idle_drag) |v| { - if (glib.Source.remove(v) == 0) { - log.warn("unable to remove idle_drag source", .{}); - } - priv.idle_drag = null; + priv.idle = null; } gtk.Widget.disposeTemplate( @@ -1388,7 +1348,7 @@ const SplitTreeSplit = extern struct { // Template Callbacks class.bindTemplateCallback("notify_max_position", &propMaxPosition); - class.bindTemplateCallback("on_drag_end", &onDragEnd); + class.bindTemplateCallback("notify_position", &propPosition); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/ui/1.5/split-tree-split.blp b/src/apprt/gtk/ui/1.5/split-tree-split.blp index 82dd79e69..521665d18 100644 --- a/src/apprt/gtk/ui/1.5/split-tree-split.blp +++ b/src/apprt/gtk/ui/1.5/split-tree-split.blp @@ -13,11 +13,7 @@ template $GhosttySplitTreeSplit: Adw.Bin { Adw.Bin { Paned paned { notify::max-position => $notify_max_position(); - - GestureDrag { - drag-end => $on_drag_end(); - } + notify::position => $notify_position(); } - } } From 93d1142ada9336c3e33e23bd6343aa1366265bbd Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Fri, 15 May 2026 17:20:57 +0200 Subject: [PATCH 3/4] small formatting changes --- src/apprt/gtk/class/split_tree.zig | 44 ++++++++++++++++-------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 43383720d..56796975d 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -158,10 +158,11 @@ pub const SplitTree = extern struct { /// used to debounce updates. rebuild_source: ?c_uint = null, - // The source that we use to restore focus. With enough splits, some - // surfaces are initially allocated a width/height of 0 which causes - // them to get unmapped and lose focus. We can reliably restore focus - // to the last focused surface only once it is mapped again. + /// The source that we use to restore focus. With enough nested + /// splits, some surfaces might initially be allocated a width or + /// height of 0 which causes them to get unmapped and lose focus. + /// We can reliably restore focus to the last focused surface only + /// once it is mapped again. restore_focus_source: ?c_uint = null, /// Used to store state about a pending surface close for the @@ -792,9 +793,10 @@ pub const SplitTree = extern struct { ) callconv(.c) void { if (!surface.getMapped()) return; - // We could add the idle callback only if this is actually the last focused - // surface. But we can avoid that check because usually all the surfaces get - // mapped at once, so the idle callback will run only once anyway. + // We could add the idle callback only if this is actually the last + // focused surface. But we can avoid that check because usually all + // the surfaces get mapped at once, so the idle callback will run + // only once anyway. const priv = self.private(); if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd( onRestoreFocus, @@ -891,10 +893,10 @@ pub const SplitTree = extern struct { const priv = self.private(); priv.restore_focus_source = null; - // If we have a last-focused surface and it is mapped, restore focus to it. - // Depending on the available size, the surface might already have focus - // because it never got unmapped. In that case grabbing focus will have no - // effect. + // If we have a last-focused surface and it is mapped, restore focus + // to it. Depending on the available size, the surface might already + // have focus because it never got unmapped. In that case grabbing + // focus will have no effect. if (priv.last_focused.get()) |v| { defer v.unref(); if (v.getMapped()) { @@ -1159,21 +1161,21 @@ const SplitTreeSplit = extern struct { // changes, we have a manual human update. // // This is a hack, it relies on the timing of property notifcations. - // From looking at the GTK source code, it should not be possible that we - // erroneously interpret a position change from a resize as a manual update. - // When a gtk.Paned is resized, internally the gtk_paned_calc_position function - // will change both max-position and position and synchronously call our - // propMaxPosition and propPosition functions. I.e. when the widget is resized, - // it should not be possible for onIdle to run before we have been notified of - // both property changes. + // From looking at the GTK source code, it should not be possible that + // we interpret a position change from a resize as a manual update. + // When a gtk.Paned is resized, internally the gtk_paned_calc_position + // function will change both max-position and position and synchronously + // call our propMaxPosition and propPosition functions. I.e. when the + // widget is resized, it should not be possible for onIdle to run before + // we have been notified of both property changes. fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); const priv = self.private(); const paned = priv.paned; - // Clear source and fields at the end. Otherwise if setPosition is called - // below, propPosition is triggered and would add another idle callback - // before this one is finished. + // Clear source and fields at the end. Otherwise if setPosition is + // called below, propPosition is triggered and would add another + // idle callback before this one is finished. defer priv.idle = null; defer priv.max_changed = false; defer priv.pos_changed = false; From 9f72eb9d7ca05fb1e7dfd1f9eb0395ed77205d13 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Fri, 15 May 2026 17:52:48 +0200 Subject: [PATCH 4/4] added back accidentally deleted empty line --- src/apprt/gtk/class/split_tree.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 56796975d..5aef53d2e 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -969,6 +969,7 @@ pub const SplitTree = extern struct { defer left.deinit(); const right = self.buildTree(tree, s.right); defer right.deinit(); + break :split .initNew(SplitTreeSplit.new( current, &s,