diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:34:23 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:34:23 +0200 |
| commit | 9158b04ffdb15d2b163ffda81c82509a5c2ea00b (patch) | |
| tree | c26a94e11ba66f744666dfd8f518cea9756f4d88 | |
| parent | sl0ptty: M7 — layout files and suspended panes (diff) | |
| download | slosh-9158b04ffdb15d2b163ffda81c82509a5c2ea00b.tar.gz slosh-9158b04ffdb15d2b163ffda81c82509a5c2ea00b.tar.xz slosh-9158b04ffdb15d2b163ffda81c82509a5c2ea00b.zip | |
sl0ptty: M8 — pane sizes as weights, resize and drag-to-reorder
Sizes are weights now, so an even split is equal weights and resizing is not a
special case of anything: the layout pass is still the same pure function of
the tree and the rect, and a resized layout survives a collapse/expand cycle
without anyone writing code for that.
Keyboard: C-a shift-H/J/K/L (and shift+arrows) move the boundary in the
direction pressed, whichever side of it the focused pane is on, which is what
a person means by wider. A pane can be squeezed but never squeezed out.
Mouse: one drag machine, two verbs, both starting from the hit list so neither
can disagree with what is on screen.
- a frame's top row is a drag handle: drop a pane on another to swap them,
with the drop target highlighted while you drag;
- the gap between two panes is the boundary: drag it to resize.
The gap was dead space until now, and its hit rect is derived from the rects
the children were just given rather than recomputed from the config — the same
rule that keeps the split button honest.
Any keystroke ends a drag. A release can go missing (the pointer leaves the
terminal, a client detaches mid-drag) and a mouse wedged in a state the user
cannot see is the worst outcome available.
One test needed updating rather than the code: the frame's top row is now the
drag handle, so the cell beside the split button reports title: rather than
focus:. That is the intent, and the hit list said so immediately.
304 checks green (277 headless + 27 live).
| -rw-r--r-- | DESIGN.md | 22 | ||||
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | config/config.kdl | 12 | ||||
| -rw-r--r-- | src/app.c | 249 | ||||
| -rw-r--r-- | src/config.c | 10 | ||||
| -rw-r--r-- | src/config.h | 4 | ||||
| -rw-r--r-- | tests/test_drag.py | 220 | ||||
| -rw-r--r-- | tests/test_layout.py | 3 |
9 files changed, 516 insertions, 11 deletions
@@ -247,6 +247,28 @@ Frame pacing: pty reads are coalesced and painted on a timerfd at a cap - **M6** — config file: geometry, theme, keybindings, live reload ✅ - **M7** — layout files, suspended panes, `apply-layout` ✅ (what `sl0ppi up` is ported onto) +- **M8** — pane sizes as weights: keyboard resize, drag the gap to move a + boundary, drag a title to swap two panes ✅ + +### One drag machine, two verbs (M8) + +Pane sizes are **weights**, so an even split is simply equal weights and +resizing is not a special case of anything: the layout pass stays the same +pure function of the tree and the rect, and a resized layout survives a +collapse/expand cycle for free. + +Both mouse verbs start from the hit list, so neither can disagree with what is +on screen: + +| target | painted by | drag does | +|---|---|---| +| a frame's top row | `draw_frame` | swap this pane with the one you drop it on | +| the gap between two children | `draw_node`, from the rects they were just given | move that boundary | + +The drop target is highlighted while dragging, and **any keystroke ends a +drag** — a release can go missing (the pointer leaves the terminal, the client +detaches mid-drag) and a mouse wedged in an invisible state is the worst +possible outcome. M4 landed: the pi extensions' exact byte patterns are an acceptance test (`tests/test_osc5577.py::test_pi_extension_compat`), including `buttons` with @@ -58,7 +58,7 @@ test: $(BIN) $(TEST_BIN) $(KDL_TEST) ## unit + headless checks (fast) @echo cd tests && python3 test_screen.py && python3 test_layout.py && python3 test_tabs.py && python3 test_osc5577.py && \ python3 test_responsive.py && python3 test_finder.py && \ - python3 test_config.py && python3 test_layout_files.py + python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py test-live: $(BIN) ## the checks that need a real tty (slow) python3 tests/live_m0.py @@ -47,12 +47,15 @@ sl0ptty cmd '{"cmd":"panes"}' | `C-a h j k l` or arrows | move focus | | `C-a c` / `C-a n` `C-a p` / `C-a 1..9` | new tab / cycle / select | | `C-a f` | find a pane by title, purpose or tab | +| `C-a H J K L` or shift+arrows | move the boundary (the pane grows or shrinks accordingly) | | `C-a x` / `C-a d` / `C-a q` | close pane / detach / quit | | `C-a C-a` | send a literal `C-a` | The mouse works throughout: click a pane to focus it, a tab to switch, `+` on a frame to split, `+tab` to open one, a collapsed header to expand it, and a -pane's own buttons to talk to the program inside it. +pane's own buttons to talk to the program inside it. **Drag a pane's title bar +onto another pane to swap them, or drag the gap between two panes to move the +boundary.** Configuration is [`config/config.kdl`](config/config.kdl), which documents every setting by being the defaults. Copy it to `~/.config/sl0ptty/config.kdl`. diff --git a/config/config.kdl b/config/config.kdl index 98c3b72..9395c7a 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -55,8 +55,9 @@ theme { // pageup/pagedown/delete/insert/backslash/minus/slash/comma/period. // // Actions: split-cols split-rows close-pane focus-left focus-right focus-up -// focus-down focus-next new-tab next-tab prev-tab select-tab-1..9 finder -// detach quit literal-prefix none +// focus-down focus-next resize-left resize-right resize-up resize-down +// new-tab next-tab prev-tab select-tab-1..9 finder detach quit +// literal-prefix none keys { prefix "ctrl+a" @@ -75,6 +76,13 @@ keys { bind "down" "focus-down" bind "o" "focus-next" + // The boundary moves in the direction you press, whichever side of it the + // focused pane is on. + bind "shift+h" "resize-left" + bind "shift+l" "resize-right" + bind "shift+k" "resize-up" + bind "shift+j" "resize-down" + bind "c" "new-tab" bind "n" "next-tab" bind "p" "prev-tab" @@ -51,6 +51,10 @@ bool app_reload_config(char *err, size_t errcap) { return true; } +#define WEIGHT_UNIT 1000 +#define WEIGHT_MIN 150 /* a pane can be squeezed, not squeezed out */ +#define WEIGHT_STEP 120 /* one keyboard nudge */ + #define MIN_PANE_COLS (CFG.min_pane_cols) #define MIN_PANE_ROWS (CFG.min_pane_rows) #define FRAME_FOCUS (CFG.frame_focus) @@ -69,6 +73,10 @@ struct node { node_t *parent; rect_t rect; /* recomputed every layout pass; never trusted between them */ + /* Share of the parent split, in arbitrary units. Even splits are simply + * equal weights, so resizing is not a special case of anything. */ + int weight; + /* leaf */ pane_t *pane; uint32_t id; @@ -107,6 +115,18 @@ struct app { bool prefix; bool quit; bool detach; + /* One drag machine, two verbs: a title drags a pane onto another to swap + * them, a gap between panes drags the boundary. Both are started by a press + * on something the hit list says is draggable, so neither can disagree with + * what is on screen. */ + struct { + enum { DRAG_NONE, DRAG_TITLE, DRAG_EDGE } kind; + uint32_t src; /* pane being dragged, or the split being resized */ + uint32_t target; /* pane under the pointer, for the drop highlight */ + size_t edge; /* which boundary of that split */ + uint16_t x, y; /* where the pointer was at the last event */ + } drag; + /* the pane finder overlay: tabs stop being navigation past about six */ bool finder; char query[64]; @@ -149,6 +169,7 @@ static node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd, if (!p) return NULL; node_t *n = calloc(1, sizeof *n); n->kind = NODE_LEAF; + n->weight = WEIGHT_UNIT; n->pane = p; n->id = ++a->next_id; pane_set_osc_handler(p, on_pane_osc, a); @@ -160,6 +181,7 @@ static node_t *leaf_new(app_t *a) { if (!p) return NULL; node_t *n = calloc(1, sizeof *n); n->kind = NODE_LEAF; + n->weight = WEIGHT_UNIT; n->pane = p; n->id = ++a->next_id; pane_set_osc_handler(p, on_pane_osc, a); @@ -415,16 +437,39 @@ static void layout_node(node_t *n, rect_t r, node_t *focus) { uint16_t gaps = (uint16_t)(gap * (k - 1)); uint16_t avail = total > gaps ? (uint16_t)(total - gaps) : (uint16_t)k; - uint16_t each = (uint16_t)(avail / k); - if (each == 0) { /* below one cell each, nothing sensible is left */ + if (avail / k == 0) { /* below one cell each, nothing sensible is left */ layout_solo(n, r, focus); return; } - uint16_t extra = (uint16_t)(avail % k); /* spread the remainder, no drift */ + + /* Sizes are proportional to weights; the remainder goes to the widest + * children, largest first, so nothing drifts and nothing rounds to zero. */ + long total_weight = 0; + for (size_t i = 0; i < k; i++) total_weight += n->kids[i]->weight; + if (total_weight <= 0) total_weight = 1; + + uint16_t sizes[64]; + uint16_t used = 0; + for (size_t i = 0; i < k && i < 64; i++) { + long want = (long)avail * n->kids[i]->weight / total_weight; + if (want < 1) want = 1; + sizes[i] = (uint16_t)want; + used = (uint16_t)(used + sizes[i]); + } + for (size_t i = 0; used < avail && i < k; i = (i + 1) % k) { + sizes[i]++; + used++; + } + for (size_t i = 0; used > avail && i < k; i = (i + 1) % k) { + if (sizes[i] > 1) { + sizes[i]--; + used--; + } + } uint16_t pos = n->dir == SPLIT_COLS ? r.x : r.y; for (size_t i = 0; i < k; i++) { - uint16_t size = (uint16_t)(each + (i < extra ? 1 : 0)); + uint16_t size = sizes[i]; rect_t cr = n->dir == SPLIT_COLS ? (rect_t){.x = pos, .y = r.y, .w = size, .h = r.h} : (rect_t){.x = r.x, .y = pos, .w = r.w, .h = size}; @@ -488,6 +533,9 @@ static void split_focus(app_t *a, split_dir_t dir) { } else { node_t *sp = calloc(1, sizeof *sp); sp->kind = NODE_SPLIT; + sp->id = ++a->next_id; + sp->weight = leaf->weight; /* the new split inherits the pane's share */ + leaf->weight = WEIGHT_UNIT; sp->dir = dir; sp->nkids = 2; sp->kids = malloc(2 * sizeof *sp->kids); @@ -719,6 +767,8 @@ static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd) { if (kids) { node_t *sp = calloc(1, sizeof *sp); sp->kind = NODE_SPLIT; + sp->id = ++a->next_id; + sp->weight = WEIGHT_UNIT; sp->dir = strcmp(kdl_prop(node, "split", "cols"), "rows") == 0 ? SPLIT_ROWS : SPLIT_COLS; for (size_t i = 0; i < node->nkids; i++) { @@ -835,6 +885,68 @@ bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err, return ok; } +/* ---- resizing and reordering -------------------------------------------- */ + +static size_t index_in_parent(node_t *n) { + for (size_t i = 0; i < n->parent->nkids; i++) + if (n->parent->kids[i] == n) return i; + return 0; +} + +/* Move weight between two adjacent siblings, refusing to squeeze either out. */ +static void transfer_weight(node_t *from, node_t *to, int amount) { + if (from->weight - amount < WEIGHT_MIN) amount = from->weight - WEIGHT_MIN; + if (amount <= 0) return; + from->weight -= amount; + to->weight += amount; +} + +/* Move the boundary next to the focused pane in the given direction. The pane + * left of a boundary grows when it moves right — which is what a person means + * by "wider", whichever side of it they are on. */ +static void resize_focus(app_t *a, int dx, int dy) { + node_t *n = cur(a)->focus; + if (!n) return; + split_dir_t want = dx ? SPLIT_COLS : SPLIT_ROWS; + while (n->parent && !(n->parent->dir == want && n->parent->nkids >= 2)) + n = n->parent; + if (!n->parent) return; /* nothing to resize against, in that direction */ + + node_t *p = n->parent; + size_t i = index_in_parent(n); + int dir = dx ? dx : dy; + + if (i + 1 < p->nkids) { + if (dir > 0) transfer_weight(p->kids[i + 1], n, WEIGHT_STEP); + else transfer_weight(n, p->kids[i + 1], WEIGHT_STEP); + } else if (i > 0) { + if (dir > 0) transfer_weight(n, p->kids[i - 1], WEIGHT_STEP); + else transfer_weight(p->kids[i - 1], n, WEIGHT_STEP); + } + layout(a); +} + +/* Reordering is a swap of two leaves, in place: their positions, weights and + * parents trade, and everything else about them is untouched. */ +static bool swap_panes(app_t *a, uint32_t id_a, uint32_t id_b) { + if (id_a == id_b) return false; + node_t *x = pane_by_id(a, id_a), *y = pane_by_id(a, id_b); + if (!x || !y || !x->parent || !y->parent) return false; + + node_t *px = x->parent, *py = y->parent; + size_t ix = index_in_parent(x), iy = index_in_parent(y); + int wx = x->weight, wy = y->weight; + + px->kids[ix] = y; + py->kids[iy] = x; + y->parent = px; + x->parent = py; + y->weight = wx; + x->weight = wy; + layout(a); + return true; +} + /* ---- focus -------------------------------------------------------------- */ struct dirsearch { @@ -935,8 +1047,10 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { rect_t r = leaf->rect; if (r.w < 3 || r.h < 3) return; bool focused = leaf == cur(a)->focus; - color_t fg = focused ? FRAME_FOCUS : FRAME_IDLE; - uint16_t attrs = 0; + bool drop_target = a->drag.kind == DRAG_TITLE && a->drag.target == leaf->id && + a->drag.src != leaf->id; + color_t fg = drop_target ? BTN_BG : (focused ? FRAME_FOCUS : FRAME_IDLE); + uint16_t attrs = drop_target ? ATTR_BOLD : 0; const char *tl = CFG.rounded ? "╭" : "┌", *tr = CFG.rounded ? "╮" : "┐"; const char *bl = CFG.rounded ? "╰" : "└", *br = CFG.rounded ? "╯" : "┘"; @@ -955,6 +1069,14 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { screen_text(s, x1, y, "│", fg, NO_COLOR, attrs); } + /* The frame's top row is the drag handle. Registered before the split + * button, which is painted after and therefore wins its own cell. */ + { + char action[48]; + snprintf(action, sizeof action, "title:%u", leaf->id); + hit_add(&s->hits, r.x, r.y, r.w, 1, action); + } + /* The split button is budgeted BEFORE the title, and hit-tested from the * same rect it is drawn in. Both halves of that sentence are scar tissue * from the sl0ppi fork, where a title long enough to fill the frame ate the @@ -1119,6 +1241,28 @@ static void draw_node(app_t *a, screen_t *s, node_t *n) { draw_cb(n, &d); return; } + + /* The gap between two children is the boundary you can drag. It is drawn as + * nothing, but it is a real target, derived from the rects the children were + * just given rather than recomputed from the config. */ + for (size_t i = 0; i + 1 < n->nkids; i++) { + rect_t a_r = n->kids[i]->rect, b_r = n->kids[i + 1]->rect; + if (!a_r.w || !b_r.w) continue; + rect_t gapr; + if (n->dir == SPLIT_COLS) { + uint16_t x0 = (uint16_t)(a_r.x + a_r.w); + if (b_r.x <= x0) continue; + gapr = (rect_t){x0, a_r.y, (uint16_t)(b_r.x - x0), a_r.h}; + } else { + uint16_t y0 = (uint16_t)(a_r.y + a_r.h); + if (b_r.y <= y0) continue; + gapr = (rect_t){a_r.x, y0, a_r.w, (uint16_t)(b_r.y - y0)}; + } + char action[48]; + snprintf(action, sizeof action, "edge:%u:%zu", n->id, i); + hit_add(&s->hits, gapr.x, gapr.y, gapr.w, gapr.h, action); + } + for (size_t i = 0; i < n->nkids; i++) draw_node(a, s, n->kids[i]); } @@ -1272,7 +1416,63 @@ static node_t *by_id(app_t *a, uint32_t id) { return b.found; } +static node_t *split_by_id(app_t *a, uint32_t id) { + /* splits are not leaves, so walk the trees rather than the leaf walker */ + for (size_t t = 0; t < a->ntabs; t++) { + node_t *stack[64]; + size_t n = 0; + if (a->tabs[t].root) stack[n++] = a->tabs[t].root; + while (n) { + node_t *cur_ = stack[--n]; + if (cur_->kind == NODE_SPLIT) { + if (cur_->id == id) return cur_; + for (size_t i = 0; i < cur_->nkids && n < 64; i++) + stack[n++] = cur_->kids[i]; + } + } + } + return NULL; +} + +/* Drag a boundary by `cells`, in the split's own direction. */ +static void drag_edge(app_t *a, node_t *sp, size_t i, int cells) { + if (!sp || i + 1 >= sp->nkids || cells == 0) return; + uint16_t span = sp->dir == SPLIT_COLS ? sp->rect.w : sp->rect.h; + if (!span) return; + + long total = 0; + for (size_t k = 0; k < sp->nkids; k++) total += sp->kids[k]->weight; + int amount = (int)((long)labs(cells) * total / (span ? span : 1)); + if (amount <= 0) amount = 1; + + if (cells > 0) transfer_weight(sp->kids[i + 1], sp->kids[i], amount); + else transfer_weight(sp->kids[i], sp->kids[i + 1], amount); + layout(a); +} + static void do_action(app_t *a, const char *action, const input_event_t *ev) { + if (strncmp(action, "title:", 6) == 0) { + uint32_t id = (uint32_t)strtoul(action + 6, NULL, 10); + if (ev->maction == MOUSE_PRESS) { + a->drag.kind = DRAG_TITLE; + a->drag.src = id; + a->drag.target = id; + a->drag.x = ev->mx; + a->drag.y = ev->my; + app_focus_pane(a, id); + } + return; + } + if (strncmp(action, "edge:", 5) == 0) { + if (ev->maction != MOUSE_PRESS) return; + a->drag.kind = DRAG_EDGE; + a->drag.src = (uint32_t)strtoul(action + 5, NULL, 10); + const char *colon = strchr(action + 5, ':'); + a->drag.edge = colon ? strtoul(colon + 1, NULL, 10) : 0; + a->drag.x = ev->mx; + a->drag.y = ev->my; + return; + } if (strncmp(action, "btn:", 4) == 0) { if (ev->maction != MOUSE_PRESS) return; uint32_t id = (uint32_t)strtoul(action + 4, NULL, 10); @@ -1348,6 +1548,10 @@ static bool prefix_command(app_t *a, const input_event_t *ev) { case ACT_NEW_TAB: app_new_tab(a, ""); return true; case ACT_NEXT_TAB: app_cycle_tab(a, 1); return true; case ACT_PREV_TAB: app_cycle_tab(a, -1); return true; + case ACT_RESIZE_LEFT: resize_focus(a, -1, 0); return true; + case ACT_RESIZE_RIGHT: resize_focus(a, 1, 0); return true; + case ACT_RESIZE_UP: resize_focus(a, 0, -1); return true; + case ACT_RESIZE_DOWN: resize_focus(a, 0, 1); return true; case ACT_FINDER: a->finder = true; a->query[0] = 0; @@ -1369,6 +1573,14 @@ void app_event(app_t *a, const input_event_t *ev) { return; } + /* A release can go missing (the pointer leaves the terminal, the client + * detaches mid-drag). Any keystroke ends a drag, so the mouse can never be + * left wedged in a state the user cannot see. */ + if (ev->kind == EV_KEY && a->drag.kind != DRAG_NONE) { + a->drag.kind = DRAG_NONE; + a->drag.src = a->drag.target = 0; + } + if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) { uint16_t mods = ev->mods & (MOD_SHIFT | MOD_CTRL | MOD_ALT | MOD_SUPER); bool is_prefix = ev->key == CFG.prefix_key && mods == CFG.prefix_mods; @@ -1405,6 +1617,31 @@ void app_event(app_t *a, const input_event_t *ev) { * the list was filled by the pass that painted what the user clicked. */ if (!a->painted) break; const char *action = hit_test(&a->painted->hits, ev->mx, ev->my); + + if (a->drag.kind != DRAG_NONE) { + if (ev->maction == MOUSE_MOTION) { + if (a->drag.kind == DRAG_EDGE) { + node_t *sp = split_by_id(a, a->drag.src); + int cells = sp && sp->dir == SPLIT_COLS + ? (int)ev->mx - (int)a->drag.x + : (int)ev->my - (int)a->drag.y; + drag_edge(a, sp, a->drag.edge, cells); + } else if (action && strncmp(action, "title:", 6) == 0) { + a->drag.target = (uint32_t)strtoul(action + 6, NULL, 10); + } else if (action && strncmp(action, "pane:", 5) == 0) { + a->drag.target = (uint32_t)strtoul(action + 5, NULL, 10); + } + a->drag.x = ev->mx; + a->drag.y = ev->my; + } else if (ev->maction == MOUSE_RELEASE) { + if (a->drag.kind == DRAG_TITLE && a->drag.target != a->drag.src) + swap_panes(a, a->drag.src, a->drag.target); + a->drag.kind = DRAG_NONE; + a->drag.src = a->drag.target = 0; + } + break; /* a drag owns the mouse until the button comes up */ + } + if (action) do_action(a, action, ev); break; } diff --git a/src/config.c b/src/config.c index e518c3b..bda5bdb 100644 --- a/src/config.c +++ b/src/config.c @@ -17,6 +17,8 @@ static const struct { {"close-pane", ACT_CLOSE_PANE}, {"focus-left", ACT_FOCUS_LEFT}, {"focus-right", ACT_FOCUS_RIGHT}, {"focus-up", ACT_FOCUS_UP}, {"focus-down", ACT_FOCUS_DOWN}, {"focus-next", ACT_FOCUS_NEXT}, + {"resize-left", ACT_RESIZE_LEFT}, {"resize-right", ACT_RESIZE_RIGHT}, + {"resize-up", ACT_RESIZE_UP}, {"resize-down", ACT_RESIZE_DOWN}, {"new-tab", ACT_NEW_TAB}, {"next-tab", ACT_NEXT_TAB}, {"prev-tab", ACT_PREV_TAB}, {"finder", ACT_FINDER}, {"detach", ACT_DETACH}, {"quit", ACT_QUIT}, @@ -143,6 +145,14 @@ void config_defaults(config_t *c) { bind_add(c, GHOSTTY_KEY_ARROW_UP, 0, ACT_FOCUS_UP); bind_add(c, GHOSTTY_KEY_ARROW_DOWN, 0, ACT_FOCUS_DOWN); bind_add(c, GHOSTTY_KEY_O, 0, ACT_FOCUS_NEXT); + bind_add(c, GHOSTTY_KEY_H, MOD_SHIFT, ACT_RESIZE_LEFT); + bind_add(c, GHOSTTY_KEY_L, MOD_SHIFT, ACT_RESIZE_RIGHT); + bind_add(c, GHOSTTY_KEY_K, MOD_SHIFT, ACT_RESIZE_UP); + bind_add(c, GHOSTTY_KEY_J, MOD_SHIFT, ACT_RESIZE_DOWN); + bind_add(c, GHOSTTY_KEY_ARROW_LEFT, MOD_SHIFT, ACT_RESIZE_LEFT); + bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, MOD_SHIFT, ACT_RESIZE_RIGHT); + bind_add(c, GHOSTTY_KEY_ARROW_UP, MOD_SHIFT, ACT_RESIZE_UP); + bind_add(c, GHOSTTY_KEY_ARROW_DOWN, MOD_SHIFT, ACT_RESIZE_DOWN); bind_add(c, GHOSTTY_KEY_C, 0, ACT_NEW_TAB); bind_add(c, GHOSTTY_KEY_N, 0, ACT_NEXT_TAB); bind_add(c, GHOSTTY_KEY_P, 0, ACT_PREV_TAB); diff --git a/src/config.h b/src/config.h index 81d9a00..852da88 100644 --- a/src/config.h +++ b/src/config.h @@ -27,6 +27,10 @@ typedef enum { ACT_NEXT_TAB, ACT_PREV_TAB, ACT_FINDER, + ACT_RESIZE_LEFT, + ACT_RESIZE_RIGHT, + ACT_RESIZE_UP, + ACT_RESIZE_DOWN, ACT_DETACH, ACT_QUIT, ACT_LITERAL_PREFIX, diff --git a/tests/test_drag.py b/tests/test_drag.py new file mode 100644 index 0000000..8dfbe8c --- /dev/null +++ b/tests/test_drag.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""M8: pane resizing and drag-to-reorder — one drag machine, two verbs. + +Sizes are weights, so resizing is not a special case of anything: an even +split is equal weights, and every layout pass is still the same pure function +of the tree and the rect. +""" +import sys + +from harness import Session, check, report + +SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat'] + + +def widths(s): + return [p["w"] for p in s.panes()] + + +def test_keyboard_resize(): + with Session(SH, cols=80, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + before = widths(s) + check("an even split starts even", abs(before[0] - before[1]) <= 1, + str(before)) + + s.send(r"\x01L") # C-a shift-L: move the boundary right + s.settle() + after = widths(s) + check("resize moves the boundary right", + after[0] > before[0] and after[1] < before[1], f"{before} -> {after}") + + s.send(r"\x01H") + s.settle() + check("and back left again", widths(s) == before, f"{before} -> {widths(s)}") + + for _ in range(20): + s.send(r"\x01L") + s.settle() + squeezed = widths(s) + check("a pane can be squeezed but not squeezed out", squeezed[1] >= 3, + str(squeezed)) + check("the total is still the screen", + sum(squeezed) + 2 + 4 == 80, str(squeezed)) + + +def test_vertical_resize(): + with Session(SH, cols=80, rows=20) as s: + s.settle() + s.key("-") + s.settle() + before = [p["h"] for p in s.panes()] + s.send(r"\x01J") + s.settle() + after = [p["h"] for p in s.panes()] + check("resize works in rows too", after[0] > before[0], + f"{before} -> {after}") + + s.send(r"\x01L") # no column split anywhere above this pane + s.settle() + check("resizing against a boundary that does not exist is harmless", + [p["h"] for p in s.panes()] == after and s.api("alive")["alive"], + str(s.panes())) + + +def test_resize_survives_a_collapse_cycle(): + with Session(SH, cols=100, rows=20) as s: + s.settle() + s.key("\\\\") + s.settle() + s.send(r"\x01L") + s.send(r"\x01L") + s.settle() + resized = widths(s) + + s.resize(40, 12) # collapses + s.settle() + s.resize(100, 20) # and back + s.settle() + check("weights survive a collapse/expand cycle", widths(s) == resized, + f"{resized} -> {widths(s)}") + + +def test_edge_drag(): + with Session(SH, cols=80, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + panes = s.panes() + snap = s.snapshot() + + gap_x = panes[0]["x"] + panes[0]["w"] + gap_y = panes[0]["y"] + 2 + action = snap.hit_at(gap_x, gap_y) + check("the gap between panes is a drag target", + action and action.startswith("edge:"), str(action)) + + before = widths(s) + # press in the gap, move right, release + s.send(rf"\e[<0;{gap_x + 1};{gap_y + 1}M") + s.send(rf"\e[<32;{gap_x + 9};{gap_y + 1}M") + s.send(rf"\e[<0;{gap_x + 9};{gap_y + 1}m") + s.settle() + after = widths(s) + check("dragging the gap right widens the left pane", + after[0] > before[0] and after[1] < before[1], + f"{before} -> {after}") + + # and back + s.send(rf"\e[<0;{after[0] + 3};{gap_y + 1}M") + s.send(rf"\e[<32;{after[0] - 5};{gap_y + 1}M") + s.send(rf"\e[<0;{after[0] - 5};{gap_y + 1}m") + s.settle() + check("dragging it back narrows it again", widths(s)[0] < after[0], + f"{after} -> {widths(s)}") + + +def test_title_drag_reorders(): + with Session(SH, cols=90, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + left, right = s.panes() + # write into each so we can see them move + s.api("focus", id=left["id"]) + s.raw("LEFTMARK") + s.api("focus", id=right["id"]) + s.raw("RIGHTMARK") + s.settle() + + snap = s.snapshot() + check("a pane title row is a drag handle", + snap.hit_at(left["x"] + 4, left["y"]) == f"title:{left['id']}", + str(snap.hit_at(left["x"] + 4, left["y"]))) + + # drag the left pane's title onto the right pane + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send(rf"\e[<32;{right['x'] + 5};{right['y'] + 3}M") + s.settle(60) + s.send(rf"\e[<0;{right['x'] + 5};{right['y'] + 3}m") + s.settle() + + panes = {p["id"]: p for p in s.panes()} + check("the dragged pane took the target's place", + panes[left["id"]]["x"] > panes[right["id"]]["x"], + f"{panes[left['id']]['x']} vs {panes[right['id']]['x']}") + + snap = s.snapshot() + check("and its content came with it", + "LEFTMARK" in snap.pane_text(panes[left["id"]]), + repr(snap.pane_text(panes[left["id"]]))) + check("the other pane's content did too", + "RIGHTMARK" in snap.pane_text(panes[right["id"]]), + repr(snap.pane_text(panes[right["id"]]))) + + +def test_drag_edge_cases(): + with Session(SH, cols=90, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + before = [(p["id"], p["x"]) for p in s.panes()] + left = s.panes()[0] + + # a drag that ends where it started must change nothing + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}m") + s.settle() + check("dropping a pane on itself does nothing", + [(p["id"], p["x"]) for p in s.panes()] == before, str(s.panes())) + + # a drag released over nothing (the gap ring) must not swap or crash + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send(r"\e[<32;1;1M") + s.send(r"\e[<0;1;1m") + s.settle() + check("dropping outside any pane is harmless", + [(p["id"], p["x"]) for p in s.panes()] == before + and s.api("alive")["alive"], str(s.panes())) + + # a press whose release never arrives must not wedge the mouse + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send("k") # any keystroke ends a drag + s.settle(60) + check("a keystroke ends an abandoned drag", s.api("alive")["alive"]) + snap = s.snapshot() + pos = snap.line(left["y"]).find("+") + if pos >= 0: + s.click(pos, left["y"]) + s.settle() + check("the mouse still works after a drag", len(s.panes()) == 3, + str(len(s.panes()))) + + +def test_drop_target_is_visible(): + with Session(SH, cols=90, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + left, right = s.panes() + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send(rf"\e[<32;{right['x'] + 5};{right['y'] + 3}M") + s.settle(60) + snap = s.snapshot() + run = snap.style_at(right["x"], right["y"]) + check("the drop target is highlighted while dragging", + run and run["fg"] == "#ff5fd7" and "bold" in run["attrs"], str(run)) + s.send(rf"\e[<0;{right['x'] + 5};{right['y'] + 3}m") + + +if __name__ == "__main__": + test_keyboard_resize() + test_vertical_resize() + test_resize_survives_a_collapse_cycle() + test_edge_drag() + test_title_drag_reorders() + test_drag_edge_cases() + test_drop_target_is_visible() + sys.exit(report()) diff --git a/tests/test_layout.py b/tests/test_layout.py index 083ec97..97744d3 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -162,8 +162,9 @@ def test_hit_list(): x, y = pos check("the click target is exactly where the glyph is", snap.hit_at(x, y) == f"split:{pane['id']}", str(snap.hit_at(x, y))) + # the rest of the top row is the drag handle, not the button check("beside the button is not the button", - snap.hit_at(x - 1, y) == f"focus:{pane['id']}", + snap.hit_at(x - 1, y) == f"title:{pane['id']}", str(snap.hit_at(x - 1, y))) s.click(x, y) |
