Add tab re-ordering.
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user