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..473ae3daf 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -387,6 +387,74 @@ pub const SplitTree = extern struct { return true; } + /// 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, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + const target_tree = self.getTree() orelse return; + + // This really shouldn't fail, but just in case + const target_handle = target_tree.locate(target) orelse { + log.warn("target is not placed in a split tree", .{}); + return; + }; + + // Try to find the source within the current tree. + // If it exists, then it's a local move; otherwise the logic gets more + // complicated + const source_handle = target_tree.locate(source); + + // First add the source into this tree. + // We have to do this no matter what + var branch = try Surface.Tree.init(alloc, source); + defer branch.deinit(); + + var after_split = try target_tree.split( + alloc, + target_handle, + dir, + 0.5, + &branch, + ); + defer after_split.deinit(); + + if (source_handle) |handle| { + // Happy path: source and target are located within the same tree. + // In that case, we just remove the existing source from this tree + + var after_remove = try after_split.remove(alloc, handle); + defer after_remove.deinit(); + + self.setTree(&after_remove); + } else { + // :( Cross-tree moves are a bit more complicated. + + // TODO: Find a better way to access the split tree from here + const source_tree_widget = ext.getAncestor( + SplitTree, + source.as(gtk.Widget), + ) orelse { + log.warn("source is not placed in a split tree", .{}); + return; + }; + const source_tree = source_tree_widget.getTree() orelse return; + + // Remove the source from its own tree + const handle = source_tree.locate(source) orelse return; + var new_source_tree = try source_tree.remove(alloc, handle); + defer new_source_tree.deinit(); + + // Finally, set the final tree structures for both tree widgets + source_tree_widget.setTree(&new_source_tree); + self.setTree(&after_split); + } + } + fn disconnectSurfaceHandlers(self: *Self) void { const tree = self.getTree() orelse return; var it = tree.iterator(); @@ -705,17 +773,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..b9f896c93 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,164 @@ 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); + + // The only error that could happen here is an OOM, + // and in that case we're already milliseconds away from crashing, so... + st.moveSplit(from, self, dir) catch 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 +3887,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 +3932,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/config-errors-dialog.blp b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp index 845909eb3..77c71edee 100644 --- a/src/apprt/gtk/ui/1.2/config-errors-dialog.blp +++ b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp @@ -4,7 +4,9 @@ using Adw 1; template $GhosttyConfigErrorsDialog: $GhosttyDialog { heading: _("Configuration Errors"); - body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); + body: _( + "One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors." + ); responses [ ignore: _("Ignore"), diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 6523d4149..8ab2e3109 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -46,7 +46,12 @@ template $GhosttySearchOverlay: Adw.Bin { "dim-label", ] - label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; + label: bind $match_label_closure( + template.has-search-selected, + template.search-selected, + template.has-search-total, + template.search-total + ) as ; width-chars: 6; xalign: 1.0; } diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 2ae0a347e..b7d07cb3e 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 { @@ -96,7 +97,9 @@ Overlay terminal_page { // TODO: the tooltip doesn't actually work, but keep it here for now so // that we can get the tooltip text translated. has-tooltip: true; - tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application."); + tooltip-text: _( + "This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application." + ); halign: end; valign: start; spacing: 6; @@ -205,7 +208,11 @@ Overlay terminal_page { // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface Revealer { - reveal-child: bind $should_unfocused_split_be_shown(search_overlay.active, template.focused, template.is-split) as ; + reveal-child: bind $should_unfocused_split_be_shown( + search_overlay.active, + template.focused, + template.is-split + ) as ; transition-duration: 0; // This is all necessary so that the Revealer itself doesn't override // any input events from the other overlays. Namely, if you don't have @@ -221,10 +228,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/apprt/gtk/ui/1.5/tab.blp b/src/apprt/gtk/ui/1.5/tab.blp index 55f2e7ef4..21a30bec7 100644 --- a/src/apprt/gtk/ui/1.5/tab.blp +++ b/src/apprt/gtk/ui/1.5/tab.blp @@ -8,7 +8,14 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; + title: bind $computed_title( + template.config, + split_tree.active-surface as <$GhosttySurface>.title, + split_tree.active-surface as <$GhosttySurface>.title-override, + template.title-override, + split_tree.is-zoomed, + split_tree.active-surface as <$GhosttySurface>.bell-ringing + ) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index b66a93093..7c5c107db 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -47,7 +47,10 @@ template $GhosttyWindow: Adw.ApplicationWindow { // elements of a property chain are also subscribed to for changes. // This one long, ugly line saves us from manually building up this // massive notify chain in code. - subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as ; + subtitle: bind $computed_subtitle( + template.config, + tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd + ) as ; }; [start] 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,