Add tab re-ordering.

This commit is contained in:
Greyson Parrelli
2026-08-14 21:21:13 -04:00
parent 695417a601
commit b50e2f4108
4 changed files with 332 additions and 19 deletions
+227 -1
View File
@@ -42,10 +42,18 @@ window: *adw.ApplicationWindow,
stack: *gtk.Stack,
/// One row per tab, in the same order as `tabs`.
///
/// Kept in that order by a sort function rather than by moving rows around:
/// `tabs` is the single source of truth for tab order, and re-sorting is the
/// only way to reorder a `GtkListBox` without taking a row out of it, which
/// would drop the selection and the focus along with it.
list: *gtk.ListBox,
tabs: std.ArrayListUnmanaged(*Tab) = .empty,
/// State tracked for the duration of a row drag, null when none is in flight.
drag: ?Drag = null,
/// Monotonic counter so every tab gets a distinct GtkStack page name.
next_id: u32 = 0,
@@ -87,6 +95,24 @@ const Source = struct {
values: []Settings.Value,
};
/// State tracked while a sidebar row is being dragged to a new position.
///
/// Shaped like the pane drag in `View`, and for the same reason: the reorder is
/// applied as the pointer moves rather than on the drop, so the sidebar under
/// the cursor is always the order you will get. That means a cancelled drag has
/// something to undo, which is what `origin` is for.
const Drag = struct {
tab: *Tab,
/// Where the tab sat in `tabs` when the drag began, so a cancelled drag can
/// put it back.
origin: usize,
/// Set once a drop has been accepted; a drag that ends without this was
/// cancelled, and the preview has to be undone.
committed: bool = false,
};
/// A single tab: a view of one or more panes, plus the sidebar row that
/// selects it.
const Tab = struct {
@@ -216,6 +242,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
self.list.setSelectionMode(.single);
self.list.as(gtk.Widget).addCssClass("navigation-sidebar");
self.list.as(gtk.Widget).addCssClass("playpen-list");
self.list.setSortFunc(&sortRows, self, null);
_ = gtk.ListBox.signals.row_selected.connect(
self.list,
*Window,
@@ -224,6 +251,12 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
.{},
);
// One drop target for the whole list rather than one per row, so that the
// gaps between rows and the empty space under the last one are part of it:
// a drag to the bottom of the sidebar should land there, not be cancelled
// for having missed every row by a few pixels.
self.installRowDropTarget();
const scroller = gtk.ScrolledWindow.new();
scroller.setPolicy(.never, .automatic);
scroller.as(gtk.Widget).setVexpand(1);
@@ -388,6 +421,7 @@ fn newTabEmpty(self: *Window) !*Tab {
tab.row.setChild(row_box.as(gtk.Widget));
self.buildRowMenu(tab, row_box);
self.buildRename(tab, row_box);
installRowDragSource(tab, row_box);
self.list.append(tab.row.as(gtk.Widget));
_ = self.stack.addNamed(view.widget(), tab.pageName());
@@ -1102,6 +1136,187 @@ fn indexOf(self: *Window, tab: *Tab) ?usize {
return null;
}
// -------------------------------------------------------------------------
// Reordering rows
//
// Dragging a row up or down the sidebar moves the tab, which is the order
// everything else reads: `Ctrl+1`..`Ctrl+9`, next/previous tab, and the startup
// list captured from "use current tabs" all go through `tabs`. So the drag has
// only one thing to change, and the sidebar follows it.
//
// The payload is the same fixed string the pane drag uses, and both drop targets
// accept plain strings, so a pane dragged over the sidebar reaches this code and
// a row dragged over a pane reaches that one. Neither can do anything with the
// other: each checks its *own* drag state first and refuses when there is none,
// which is what keeps the two kinds of drag from crossing.
const row_drag_payload = "playpen-tab";
/// Where a row sits in `tabs`, which is the order the sidebar is sorted by.
///
/// A row GTK asks about before its tab has been recorded sorts last. That
/// happens once per tab: a new row goes into the list — which sorts it — a
/// moment before `tabs` learns about it, and last is where it belongs anyway.
fn orderOf(self: *Window, row: *gtk.ListBoxRow) usize {
for (self.tabs.items, 0..) |tab, i| if (tab.row == row) return i;
return std.math.maxInt(usize);
}
fn sortRows(a: *gtk.ListBoxRow, b: *gtk.ListBoxRow, data: ?*anyopaque) callconv(.c) c_int {
const self: *Window = @ptrCast(@alignCast(data.?));
const ia = self.orderOf(a);
const ib = self.orderOf(b);
if (ia < ib) return -1;
if (ia > ib) return 1;
return 0;
}
/// Put the tab at `from` at index `to`, sliding the tabs between them along, and
/// re-sort the sidebar to match.
fn moveTab(self: *Window, from: usize, to: usize) void {
if (from == to) return;
const tab = self.tabs.orderedRemove(from);
// Capacity is guaranteed: the element being put back was just taken out of
// this same list.
self.tabs.insertAssumeCapacity(to, tab);
self.list.invalidateSort();
}
/// Which slot a drop at `y` — in list coordinates — would put the dragged tab
/// in, as an index into `tabs` with that tab taken out.
///
/// Counts the rows the pointer has passed the midpoint of, skipping the dragged
/// row itself: its own position is what is being decided, and measuring against
/// where it currently sits is what would make the order oscillate as the rows
/// move out from under the cursor. Points above the first row and below the last
/// fall out as the first and last slot without needing a case of their own.
fn slotAt(self: *Window, dragged: *Tab, y: f64) usize {
const list = self.list.as(gtk.Widget);
var slot: usize = 0;
for (self.tabs.items) |tab| {
if (tab == dragged) continue;
const row = tab.row.as(gtk.Widget);
var rx: f64 = 0;
var ry: f64 = 0;
if (list.translateCoordinates(row, 0, y, &rx, &ry) == 0) continue;
const height: f64 = @floatFromInt(row.getHeight());
if (ry < height / 2) break;
slot += 1;
}
return slot;
}
fn installRowDragSource(tab: *Tab, anchor: *gtk.Box) void {
const source = gtk.DragSource.new();
source.setActions(.{ .move = true });
_ = gtk.DragSource.signals.prepare.connect(source, *Tab, &onRowDragPrepare, tab, .{});
_ = gtk.DragSource.signals.drag_begin.connect(source, *Tab, &onRowDragBegin, tab, .{});
_ = gtk.DragSource.signals.drag_end.connect(source, *Tab, &onRowDragEnd, tab, .{});
// Left to itself GTK draws the payload as the drag icon, which would put the
// literal string "playpen-tab" under the cursor. The row is what is being
// carried, so the row is what should be drawn: a paintable of it follows the
// pointer while the real one stays dimmed in place.
const ghost = gtk.WidgetPaintable.new(anchor.as(gtk.Widget));
defer ghost.as(gobject.Object).unref();
source.setIcon(ghost.as(gdk.Paintable), 0, 0);
// On the row's box rather than the row, so the close button keeps its own
// presses: a controller on a child claims the gesture before this one sees
// it. Clicking to select a tab still works either way — a drag source only
// takes the sequence once the pointer has moved past the drag threshold.
anchor.as(gtk.Widget).addController(source.as(gtk.EventController));
}
fn installRowDropTarget(self: *Window) void {
const target = gtk.DropTarget.new(gobject.ext.types.string, .{ .move = true });
_ = gtk.DropTarget.signals.motion.connect(target, *Window, &onRowDropMotion, self, .{});
_ = gtk.DropTarget.signals.drop.connect(target, *Window, &onRowDrop, self, .{});
self.list.as(gtk.Widget).addController(target.as(gtk.EventController));
}
fn onRowDragPrepare(
_: *gtk.DragSource,
_: f64,
_: f64,
tab: *Tab,
) callconv(.c) ?*gdk.ContentProvider {
const self = tab.window;
// A lone tab has nothing to be reordered against, so there is no preview to
// show and no drop that could change anything. Refusing the drag is better
// than starting one that can only ever be cancelled.
if (self.tabs.items.len < 2) return null;
self.drag = .{ .tab = tab, .origin = self.indexOf(tab) orelse return null };
tab.row.as(gtk.Widget).addCssClass("dragging");
var value = gobject.ext.Value.newFrom(@as([*:0]const u8, row_drag_payload));
return gdk.ContentProvider.newForValue(&value);
}
/// Style the surface GTK carries the row in.
///
/// It is styled from here rather than by its `dnd` node, which every drag icon
/// in the app shares: a rule on that would restyle the pane drag's icon too, and
/// a pane's drag icon has nothing to do with this one.
fn onRowDragBegin(_: *gtk.DragSource, drag: *gdk.Drag, _: *Tab) callconv(.c) void {
const icon = gtk.DragIcon.getForDrag(drag);
icon.as(gtk.Widget).addCssClass("playpen-tab-drag");
}
/// Move the dragged row to where a drop at this point would leave it.
fn previewRowDrag(self: *Window, y: f64) void {
const drag = self.drag orelse return;
const from = self.indexOf(drag.tab) orelse return;
self.moveTab(from, self.slotAt(drag.tab, y));
}
fn onRowDropMotion(_: *gtk.DropTarget, _: f64, y: f64, self: *Window) callconv(.c) gdk.DragAction {
// A pane being dragged over the sidebar, not a row: it carries the same kind
// of payload, but there is no row drag for it to be part of.
if (self.drag == null) return .{};
self.previewRowDrag(y);
return .{ .move = true };
}
fn onRowDrop(
_: *gtk.DropTarget,
_: *gobject.Value,
_: f64,
y: f64,
self: *Window,
) callconv(.c) c_int {
if (self.drag == null) return 0;
// The preview has usually already applied this, but a drop without any
// intervening motion still needs the move performed.
self.previewRowDrag(y);
self.drag.?.committed = true;
return 1;
}
/// End of a drag. If no drop was accepted, put the row back where it started.
fn onRowDragEnd(_: *gtk.DragSource, _: *gdk.Drag, _: c_int, tab: *Tab) callconv(.c) void {
const self = tab.window;
const drag = self.drag orelse return;
self.drag = null;
tab.row.as(gtk.Widget).removeCssClass("dragging");
if (drag.committed) return;
// Only the dragged tab ever moved, so the rest of the list still has its
// original order and putting this one back at its original index restores
// the arrangement the drag started from.
const now = self.indexOf(drag.tab) orelse return;
self.moveTab(now, drag.origin);
}
/// Take a tab out of the window and free it, with no view about what should be
/// selected next or whether anything is left.
///
@@ -1112,6 +1327,14 @@ fn indexOf(self: *Window, tab: *Tab) ?usize {
fn discardTab(self: *Window, tab: *Tab) void {
const index = self.indexOf(tab) orelse return;
// A tab closing mid-drag invalidates the recorded origin, since the indices
// after this one all shift down — and if it is the dragged tab itself there
// is nothing left to put anywhere. Either way the drag has to end without
// trying to undo itself.
if (self.drag) |drag| {
if (drag.tab == tab) self.drag = null else self.drag.?.committed = true;
}
self.stack.remove(tab.view.widget());
// The dialog holds this tab as an opaque pointer, so it has to go first.
@@ -1303,8 +1526,11 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
self.closing = true;
// Before anything is freed: a scheme change arriving mid-teardown would
// otherwise walk a tab list we are about to destroy.
// otherwise walk a tab list we are about to destroy. The sort function reads
// that same list, and holds this window as its user data, so it goes now for
// the same reason.
appearance.clearOnChanged();
self.list.setSortFunc(null, null, null);
// The settings page holds this window's layout store and a pointer back to
// the window itself. It is modal, so you cannot close the window underneath