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(); + } } + } }