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
+16 -2
View File
@@ -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
+59 -16
View File
@@ -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
+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;