From 1d053bd6ea28d9109fa352862588e421c2f2767f Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 22 Jan 2026 01:39:53 +0800 Subject: [PATCH] gtk: implement drag-to-move for splits One major todo is moving splits across different split trees (i.e. moving across tabs and windows), but that would involve a lot more logic. This MVP version works for now. GTK version of #10090 Closes #10224 --- src/apprt/gtk/class/application.zig | 32 +++++ src/apprt/gtk/class/split_tree.zig | 54 ++++++-- src/apprt/gtk/class/surface.zig | 194 ++++++++++++++++++++++++++++ src/apprt/gtk/css/style.css | 54 +++++++- src/apprt/gtk/ui/1.2/surface.blp | 47 +++++++ src/datastruct/split_tree.zig | 12 ++ 6 files changed, 381 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 1241c7340..55d7f4055 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1028,6 +1028,38 @@ pub const Application = extern struct { \\} \\ \\/* + \\ * Drag and Drop Overlay + \\ */ + \\.drop-overlay.drop-left { + \\ background: linear-gradient( + \\ to left, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-right { + \\ background: linear-gradient( + \\ to right, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-top { + \\ background: linear-gradient( + \\ to top, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-bottom { + \\ background: linear-gradient( + \\ to bottom, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\ + \\/* \\ * Splits \\ */ \\ diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 13f0baf15..36d92ad47 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -387,6 +387,47 @@ pub const SplitTree = extern struct { return true; } + pub const MoveSplitError = Allocator.Error || error{ + SourceNotFound, + TargetNotFound, + }; + + /// Move the source split onto the target split in a given direction. + /// The target split must be located within this tree. + pub fn moveSplit( + self: *Self, + source: *Surface, + target: *Surface, + dir: Surface.Tree.Split.Direction, + ) MoveSplitError!void { + const alloc = Application.default().allocator(); + const tree = self.getTree() orelse return; + + const source_handle = tree.locate(source) orelse return error.SourceNotFound; + + // This really shouldn't fail, but just in case + const target_handle = tree.locate(target) orelse return error.TargetNotFound; + + // TODO: is it perhaps possible to condense all of this + // into one atomic operation? + var branch = try Surface.Tree.init(alloc, source); + defer branch.deinit(); + + var after_split = try tree.split( + alloc, + target_handle, + dir, + 0.5, + &branch, + ); + defer after_split.deinit(); + + var after_remove = try after_split.remove(alloc, source_handle); + defer after_remove.deinit(); + + self.setTree(&after_remove); + } + fn disconnectSurfaceHandlers(self: *Self) void { const tree = self.getTree() orelse return; var it = tree.iterator(); @@ -705,17 +746,8 @@ pub const SplitTree = extern struct { // Find the surface in the tree to verify this is valid and // set our pending close handle. - priv.pending_close = handle: { - const tree = self.getTree() orelse return; - var it = tree.iterator(); - while (it.next()) |entry| { - if (entry.view == surface) { - break :handle entry.handle; - } - } - - return; - }; + const tree = self.getTree() orelse return; + priv.pending_close = tree.locate(surface) orelse return; // If we don't need to confirm then just close immediately. if (!core.needsConfirmQuit()) { diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index d165418de..8d0231acb 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -34,6 +34,7 @@ const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig") const TitleDialog = @import("title_dialog.zig").TitleDialog; const Window = @import("window.zig").Window; const InspectorWindow = @import("inspector_window.zig").InspectorWindow; +const SplitTree = @import("split_tree.zig").SplitTree; const i18n = @import("../../../os/i18n.zig"); const media = @import("../media.zig"); const global = @import("../../../global.zig"); @@ -715,6 +716,9 @@ pub const Surface = extern struct { child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, drop_target: *gtk.DropTarget, + surface_drop_target: *gtk.DropTarget, + drag_handle: *gtk.Widget, + drop_overlay: *gtk.Widget, progress_bar_overlay: *gtk.ProgressBar, error_page: *adw.StatusPage, terminal_page: *gtk.Overlay, @@ -1830,8 +1834,19 @@ pub const Surface = extern struct { }; priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len); + // Also have to set up the surface drop target to accept other surfaces + // (in particular, their surface IDs) + var surface_drop_target_types = [_]gobject.Type{ + gobject.ext.types.uint64, + }; + priv.surface_drop_target.setGtypes( + &surface_drop_target_types, + surface_drop_target_types.len, + ); + // Setup properties we can't set from our Blueprint file. self.as(gtk.Widget).setCursorFromName("text"); + priv.drag_handle.setCursorFromName("grab"); // Initialize our config self.propConfig(undefined, null); @@ -3675,6 +3690,176 @@ pub const Surface = extern struct { }; } + fn surfaceDragPrepare( + src: *gtk.DragSource, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) *gdk.ContentProvider { + // TODO: Use a static content provider once we make `self.core()` + // available immediately when constructing the surface widget + _ = src; + _ = x; + _ = y; + var val = gobject.ext.Value.newFrom(self.core().?.id); + return gdk.ContentProvider.newForValue(&val); + } + + fn surfaceDragBegin( + src: *gtk.DragSource, + _: *gdk.Drag, + self: *Self, + ) callconv(.c) void { + // The scale of the preview + const preview_scale: f32 = 0.2; + + // Snapshot the entire surface widget as the icon preview + const paintable = gtk.WidgetPaintable.new(self.as(gtk.Widget)); + defer paintable.unref(); + + // Center the preview + const width = self.as(gtk.Widget).getWidth(); + const height = self.as(gtk.Widget).getHeight(); + const mid_x = @as(f32, @floatFromInt(width)) / 2; + const mid_y = @as(f32, @floatFromInt(height)) / 2; + + // Create a snapshot to render the scaled paintable + const snap = gtk.Snapshot.new(); + snap.scale(preview_scale, preview_scale); + paintable.as(gdk.Paintable).snapshot( + snap.as(gdk.Snapshot), + @floatFromInt(width), + @floatFromInt(height), + ); + + if (snap.freeToPaintable(null)) |scaled| { + defer scaled.unref(); + src.setIcon( + scaled, + @intFromFloat(mid_x * preview_scale), + @intFromFloat(mid_y * preview_scale), + ); + } else { + // The scaling process somehow failed. + // Use the original paintable as a fail-safe + log.warn("preview scaling failed - falling back to original paintable", .{}); + src.setIcon( + paintable.as(gdk.Paintable), + @intFromFloat(mid_x), + @intFromFloat(mid_y), + ); + } + } + + fn surfaceDrop( + _: *gtk.DropTarget, + v: *const gobject.Value, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) void { + const dropped_id = v.getUint64(); + const dropped = self.core().?.app.findSurfaceByID(dropped_id) orelse return; + const from = dropped.rt_surface.gobj(); + + // TODO: Find a better way to access the split tree from here + const st = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse { + log.warn("surface is not placed in a split tree", .{}); + return; + }; + + const dir = self.calcDropDirection(x, y); + + st.moveSplit(from, self, dir) catch |err| { + switch (err) { + error.OutOfMemory => log.warn("out of memory", .{}), + // FIXME: implement this! + error.SourceNotFound => log.debug( + "cross-tree drag-and-drop not yet supported", + .{}, + ), + error.TargetNotFound => log.warn( + "can't seem to find surface in tree - this shouldn't happen!", + .{}, + ), + } + return; + }; + + // Clean up overlay state + self.setDropOverlayDirection(null); + } + + fn surfaceDropLeave( + _: *gtk.DropTarget, + self: *Self, + ) callconv(.c) void { + // Hide overlay + self.setDropOverlayDirection(null); + } + + fn surfaceDropMotion( + _: *gtk.DropTarget, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) gdk.DragAction { + // Recalculate the drop region + const dir = self.calcDropDirection(x, y); + self.setDropOverlayDirection(dir); + return .{ .move = true }; + } + + fn propDropValue( + tgt: *gtk.DropTarget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // Reject the drop if we're dropping a surface onto itself. + // Note that we cannot implement this via the `accept` signal, + // since the decision of whether to accept or deny a drop is dependent + // on the payload (i.e. the surface being dropped). This is + // well-documented in GTK docs. + + const core_surface = self.core() orelse return; + const value = tgt.getValue() orelse return; + const surface_id = value.getUint64(); + if (core_surface.id == surface_id) tgt.reject(); + } + + fn setDropOverlayDirection(self: *Self, dir: ?Tree.Split.Direction) void { + const priv = self.private(); + inline for (&.{ "drop-top", "drop-left", "drop-right", "drop-bottom" }) |c| { + priv.drop_overlay.removeCssClass(c); + } + + if (dir) |d| priv.drop_overlay.addCssClass(switch (d) { + .up => "drop-top", + .left => "drop-left", + .right => "drop-right", + .down => "drop-bottom", + }); + } + + fn calcDropDirection(self: *Self, x: f64, y: f64) Tree.Split.Direction { + const width: f64 = @floatFromInt(self.as(gtk.Widget).getWidth()); + const height: f64 = @floatFromInt(self.as(gtk.Widget).getHeight()); + + const l_dist = x / width; + const t_dist = y / height; + const r_dist = 1 - l_dist; + const b_dist = 1 - t_dist; + const min = @min(l_dist, t_dist, r_dist, b_dist); + + if (min == l_dist) return .left; + if (min == r_dist) return .right; + if (min == t_dist) return .up; + return .down; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3714,6 +3899,9 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("key_state_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); + class.bindTemplateChildPrivate("surface_drop_target", .{}); + class.bindTemplateChildPrivate("drag_handle", .{}); + class.bindTemplateChildPrivate("drop_overlay", .{}); class.bindTemplateChildPrivate("im_context", .{}); // Template Callbacks @@ -3756,6 +3944,12 @@ pub const Surface = extern struct { class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("search_next_match", &searchNextMatch); class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); + class.bindTemplateCallback("surface_drag_prepare", &surfaceDragPrepare); + class.bindTemplateCallback("surface_drag_begin", &surfaceDragBegin); + class.bindTemplateCallback("surface_drop", &surfaceDrop); + class.bindTemplateCallback("surface_drop_leave", &surfaceDropLeave); + class.bindTemplateCallback("surface_drop_motion", &surfaceDropMotion); + class.bindTemplateCallback("notify_drop_value", &propDropValue); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 9c0f115f1..045d9906f 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -127,7 +127,7 @@ label.resize-overlay { } .surface .bell-overlay { - border-color: rgba(58, 148, 74, 0.5); + border-color: rgba(53, 132, 228, 0.5); /* after GTK 4.16 is a requirement, switch to the following: */ /* background-color: color-mix(in srgb, var(--accent-color), transparent 50%); */ border-width: 3px; @@ -144,6 +144,58 @@ label.resize-overlay { outline-style: solid; outline-width: 1px; } +/* + * Drag and drop overlay + */ +.drag-handle { + background-color: rgba(0, 0, 0, 0.15); + opacity: 0; + transition: 0.1s opacity; +} +.drag-handle:hover { + opacity: 1; +} +.drop-overlay.drop-left { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to left, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to left, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-right { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to right, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to right, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-top { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to top, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to top, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-bottom { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to bottom, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to bottom, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} + /* * Command Palette */ diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 2ae0a347e..d0e028e35 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -1,4 +1,5 @@ using Gtk 4.0; +using Gdk 4.0; using Adw 1; Adw.StatusPage error_page { @@ -221,10 +222,56 @@ Overlay terminal_page { } } + [overlay] + CenterBox drag_handle { + halign: fill; + valign: start; + + styles [ + "drag-handle", + ] + + [center] + Image { + icon-name: "view-more-horizontal-symbolic"; + } + + DragSource surface_drag_source { + actions: move; + prepare => $surface_drag_prepare(); + drag-begin => $surface_drag_begin(); + } + } + + [overlay] + DrawingArea drop_overlay { + halign: fill; + valign: fill; + can-focus: false; + can-target: false; + focusable: false; + + styles [ + "drop-overlay", + ] + } + DropTarget drop_target { drop => $drop(); actions: copy | move; } + + DropTarget surface_drop_target { + // Enabling preload is required since that lets us know + // which surface is currently being dragged, and to turn off + // the drop overlay if the source and target are the same + preload: true; + leave => $surface_drop_leave(); + motion => $surface_drop_motion(); + drop => $surface_drop(); + notify::value => $notify_drop_value(); + actions: move; + } } template $GhosttySurface: Adw.Bin { diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 1dc924ca7..012f107f3 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -183,6 +183,18 @@ pub fn SplitTree(comptime V: type) type { }; } + /// Find the handle within this tree that corresponds + /// to the given view. Uses pointer equality. + pub fn locate(self: *const Self, view: *const View) ?Node.Handle { + var it = self.iterator(); + while (it.next()) |entry| { + if (entry.view == view) { + return entry.handle; + } + } + return null; + } + /// An iterator over all the views in the tree. pub fn iterator( self: *const Self,