From b50e2f41085b4771df3875462c7ed3cacc515967 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 14 Aug 2026 21:21:13 -0400 Subject: [PATCH] Add tab re-ordering. --- README.md | 18 +++- hooks/playpen-status.sh | 75 ++++++++++--- src/Window.zig | 228 +++++++++++++++++++++++++++++++++++++++- src/style.css | 30 ++++++ 4 files changed, 332 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5ecde7f..0dd59f6 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ just the VT core. ``` main.zig AdwApplication startup appearance.zig the colour scheme: preference -> libadwaita, CSS, palette -Window.zig sidebar + GtkStack of views, tab management, shortcuts +Window.zig sidebar + GtkStack of views, tab management, reordering, shortcuts View.zig one tab's content: its panes, their layout, and drag handling Layout.zig the split tree: nodes, rearranging, GtkPaned materialization Pane.zig content plus its header, drag source, and drop target @@ -168,6 +168,19 @@ preview. Cancelling a drag puts the pane back: only the dragged pane ever moves, so the rest of the tree is unchanged and re-inserting it beside its original sibling restores the original shape. +**Tabs reorder the same way.** Dragging a sidebar row moves the tab, and the +rows shuffle under the pointer as you go rather than waiting for the drop; a +copy of the row travels with the cursor while the one being moved stays dimmed +where it currently sits. Dropping in the space below the last row puts the tab +at the end, and a drag that ends anywhere else — over a pane, off the window — +is a cancel, which puts the tab back at the index it started from. + +The order is the tab list itself, which is the order everything else already +reads: `Ctrl+1`..`Ctrl+9`, next/previous tab, and the startup list that "use +current tabs" captures. The sidebar follows it through a sort function rather +than by moving rows around, since taking a row out of a `GtkListBox` to put it +back elsewhere would drop the selection and the focus with it. + ## Layouts A **layout** is a saved tab: an arrangement of panes, each with a directory and @@ -364,7 +377,8 @@ in principle, but a terminal grid is small. - Resize reflows the grid and notifies the child - Window title (OSC 0/2) becomes the pane header and tab label; the tab shows its pane count once a view holds more than one -- Tabs: create, close, switch; closing the last one closes the window +- Tabs: create, close, switch, and reorder by dragging a sidebar row, with the + rows shuffling live as you drag; closing the last one closes the window - Multiple panes per tab in an arbitrary split tree, rearranged by keyboard or by dragging a pane's header, with draggable dividers between them; closing the last pane in a view closes its tab diff --git a/hooks/playpen-status.sh b/hooks/playpen-status.sh index 091e08d..35e0969 100755 --- a/hooks/playpen-status.sh +++ b/hooks/playpen-status.sh @@ -26,6 +26,59 @@ set -u state=${1:-idle} +# --------------------------------------------------------------------------- +# Read the event. +# +# Claude Code hands the hook its event as JSON on stdin. Both of the states +# that care about it read it here, before anything has been written, because +# the payload decides what — if anything — this call should report at all. +# +# Guarded on a tty so that running this by hand still works: stdin is then the +# keyboard, and `cat` would sit there waiting for a payload nobody is going to +# type. +payload="" +if [ "$state" = busy ] || [ "$state" = input ]; then + [ -t 0 ] || payload=$(cat 2>/dev/null) || payload="" +fi + +# Pull a string field out of the payload. jq when it is there, and otherwise a +# sed fallback that gives up on a value containing an escaped quote — the +# common case handled badly rather than the rare case handled wrongly. Both +# callers have a safe default for the empty answer. +json_str() { + [ -n "$payload" ] || return 0 + if command -v jq >/dev/null 2>&1; then + printf '%s' "$payload" | jq -r --arg k "$1" '.[$k] // ""' 2>/dev/null + else + printf '%s' "$payload" \ + | sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' + fi +} + +# The Notification event is not one event. Claude Code raises it for permission +# prompts, for elicitations, for auth results, for a subagent finishing — and +# for `idle_prompt`, a 60-second timer that fires whenever a session has been +# sitting with nothing running. Reporting every one of them as "blocked on +# you" is what makes a finished, cleared session light up amber a minute after +# you have walked away from it. +# +# So report the kind, not the event. An absent field means a caller that isn't +# Claude Code, which keeps the argument's plain meaning. +if [ "$state" = input ]; then + case $(json_str notification_type) in + permission_prompt | elicitation_dialog | agent_needs_input | "") ;; + + # The opposite claim: the session is sitting idle. Worth saying, because it + # clears a stale amber left by a prompt that was answered somewhere else. + idle_prompt) state=idle ;; + + # Informational — auth results, a completed elicitation, a subagent + # finishing while the session itself is still working. None of them change + # whether the pane wants you, so leave it showing whatever it shows. + *) exit 0 ;; + esac +fi + # --------------------------------------------------------------------------- # Find the pty to write to. # @@ -114,25 +167,15 @@ printf '\033]9;4;%s\007' "$code" > "$tty_target" 2>/dev/null || true # Only a starting turn carries a task worth naming. The other states leave the # title alone so the shell's own title comes back when the session ends. [ "$state" = busy ] || exit 0 - -payload=$(cat 2>/dev/null) || exit 0 [ -n "$payload" ] || exit 0 -if command -v jq >/dev/null 2>&1; then - title=$(printf '%s' "$payload" | jq -r '.prompt // ""' 2>/dev/null) || title="" -else - # No jq — pull the field out directly. This gives up on a prompt whose first - # line contains an escaped quote, which is the common case handled badly - # rather than the rare case handled wrongly: a missed title costs nothing - # because the tab just keeps the name it already had. - title=$(printf '%s' "$payload" \ - | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +title=$(json_str prompt) - # Still JSON at this point, so a newline is the two characters \ and n - # rather than an actual line break. `head` below would not split on it and - # the whole prompt would arrive as one long title with \n sitting in it. - title=${title%%\\n*} -fi +# Without jq the title is still JSON at this point, so a newline is the two +# characters \ and n rather than an actual line break. `head` below would not +# split on it and the whole prompt would arrive as one long title with \n +# sitting in it. Harmless on the jq path, which has already unescaped them. +title=${title%%\\n*} # First line only, and no control characters: an OSC string ends at the first # BEL or ESC, so anything of that sort in a prompt would truncate the sequence diff --git a/src/Window.zig b/src/Window.zig index a41159e..2e74c7a 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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 diff --git a/src/style.css b/src/style.css index 3fd9b7e..c7d073e 100644 --- a/src/style.css +++ b/src/style.css @@ -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;