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
+30
View File
@@ -174,6 +174,36 @@ button.playpen-header-button:hover,
background-color: @pp_row_selected;
}
/* The row being dragged, left dimmed and accented where it currently sits. As
with a pane there is no separate drop indicator: the sidebar reorders live
during the drag, so the list itself is the preview and this only has to say
which row is the one on the move.
Last of the row rules, so it wins over both the selection and the state
washes. While you are moving a tab, where it is going is the only thing you
are looking for. */
.playpen-list > row.dragging {
opacity: 0.65;
background-color: alpha(@pp_accent_strong, 0.18);
box-shadow: inset 3px 0 0 @pp_accent_strong;
}
/* The copy of that row travelling with the pointer, which GTK draws into a
surface of its own. What it paints is the row's *box* — the icon, the label
and the close button — and a row's background belongs to the row above that
box, so without this the label would float on nothing. Given the selected
row's background it reads as the row lifted off the list, which is what it
is.
Qualified by a class the window puts on this drag's icon, because the `dnd`
node belongs to every drag icon in the app: unqualified, this would also
repaint the one a pane drag carries. */
dnd.playpen-tab-drag {
background-color: @pp_row_selected;
color: @pp_text;
border-radius: 8px;
}
/* Keep the close button unobtrusive until the row is hovered or current. */
.playpen-close {
opacity: 0;