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
This commit is contained in:
Leah Amelia Chen
2026-01-22 01:39:53 +08:00
parent 9e30f70f23
commit 1d053bd6ea
6 changed files with 381 additions and 12 deletions

View File

@@ -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
\\ */
\\

View File

@@ -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()) {

View File

@@ -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, &.{

View File

@@ -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
*/

View File

@@ -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 {

View File

@@ -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,