diff options
| author | user <user@clank> | 2026-08-20 23:22:33 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-20 23:22:33 +0200 |
| commit | b33e8c13ff7981effc7ec2735e6aeaaaf1705af7 (patch) | |
| tree | 4933eb476b5d09e9d8131986c84bb966bca317a2 /src | |
| parent | fuzz: libFuzzer harness for all six parser surfaces (diff) | |
| download | slosh-b33e8c13ff7981effc7ec2735e6aeaaaf1705af7.tar.gz slosh-b33e8c13ff7981effc7ec2735e6aeaaaf1705af7.tar.xz slosh-b33e8c13ff7981effc7ec2735e6aeaaaf1705af7.zip | |
slosh: floating panes and the drop grammar
A pane can float: lifted out of the layout, drawn over the tiled panes and
under the modals, freely moved and shaped. The model is a flag and a rect on
the leaf, exactly like `minimized` -- the seat in the tree is kept, layout
skips it, the siblings absorb its share, and un-floating returns it home.
The drawn rect is the wanted one clamped to the tab every frame, never
written back, so a terminal squeeze round-trips exactly; an explicit move or
resize *does* write back, because the hand is the author of the intent.
Floating pops the pane to the centre: the jump is what says it happened.
Painted after everything tiled, so the hit list -- searched backwards --
resolves an overlap to the float you can see: the z-order and the click
order are the same list. It casts a shadow (float_shadow, one number), and
`floating` joins the states table above `unfocused`, so a float is never
dimmed as merely-unfocused.
The hands: the title moves it, any other border resizes it (a bottom corner
on both axes; the grabbed edge pins at walls and floors, never sliding the
window), H J K L move a focused float, the =/+ key grows it about its
centre, `-` shrinks it (its split was a refusal anyway), and hovering shows
which edges a grab would take, from the same derivation the press uses.
Equalize moved to `0` to free the key; `C-a f` floats (finder went to `s`,
where it reads as search); `C-a F` opens a new floating shell -- the
throwaway terminal; the function row f1..f12 became nameable in configs
along the way. Guards throughout: the last tiled pane cannot float or
minimize away, closing it lands the top float, splits are refused with
their own words, floats are not swap targets.
Dropping got a grammar: while a pane is carried, the pane under the pointer
subdivides into a swap centre and four edge bands meaning "insert me beside
this pane, on this side" -- detach_leaf + place_beside, the surgery
cross-tab moves already had. Bands are thirds (the first map of quarters
showed the swap owning half of each axis), the hovered band fills the half
the drop would hand over, the release resolves against the painted frame's
hits so it cannot disagree with the fill, and a drop between siblings along
the axis is a reorder that always fits -- refusing it was refusing the most
ordinary drag there is.
Also: rename-pane/rename-tab as palette actions (renaming was a
double-click and nothing else, the one verb the palette could not reach),
and C-u clears the rename editor like it clears the picker's query.
Design log in PLAN-FLOATING-PANES.md; to fold into DESIGN.md as D22/M10-11.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 198 | ||||
| -rw-r--r-- | src/app_draw.c | 262 | ||||
| -rw-r--r-- | src/app_layout.c | 390 | ||||
| -rw-r--r-- | src/app_ui.c | 20 | ||||
| -rw-r--r-- | src/cmd.c | 38 | ||||
| -rw-r--r-- | src/config.c | 70 |
6 files changed, 933 insertions, 45 deletions
@@ -327,14 +327,31 @@ static const char *hint_for(app_t *a, const char *action) { if (strncmp(action, "scrollbottom:", 13) == 0) return "back to the bottom"; if (strncmp(action, "panetitle:", 10) == 0) return "double-click to rename \u00b7 drag to move"; - if (strncmp(action, "title:", 6) == 0) + if (strncmp(action, "title:", 6) == 0) { + /* A float's top row only moves it; the click-to-split half of the + * handle's promise is exactly what a float does not offer. */ + node_t *n = pane_by_id(a, id); + if (n && n->floating) return "drag to move"; /* The handle says both things it can do; the rest of the row only drags. */ return strchr(action + 6, ':') ? "drag to move \u00b7 click to split up" : "drag to move"; - /* The rim gets no caption on purpose: it is the part of the edge that does - * nothing, and the guide it arms is already pointing at the part that does. */ - if (strncmp(action, "brim:", 5) == 0) return NULL; - if (strncmp(action, "border:", 7) == 0) { + } + if (strncmp(action, "brim:", 5) == 0 || strncmp(action, "border:", 7) == 0) { + /* On a float the whole border is the resize grab, corners both ways — + * read from the same derivation the press uses, so the caption and the + * drag cannot disagree. */ + node_t *n = pane_by_id(a, id); + if (n && n->floating) { + if (!a->ptr_valid) return "drag to resize"; + uint8_t e = float_edges_at(n, a->ptr_x, a->ptr_y); + return (e & (FEDGE_L | FEDGE_R)) && (e & (FEDGE_T | FEDGE_B)) + ? "drag to resize both ways" + : "drag to resize"; + } + /* The rim gets no caption on purpose: it is the part of the edge that + * does nothing, and the guide it arms is already pointing at the part + * that does. */ + if (action[1] == 'r') return NULL; /* brim: */ const char *side = strrchr(action, ':'); switch (side && side[1] ? side[1] : 0) { case 'l': return "click to split left"; @@ -902,6 +919,34 @@ static bool hover_focus_allowed(const app_t *a) { a->drag.kind == DRAG_NONE; } +/* Grab a float's edge: which edges follow the pointer is derived from where + * the press landed on the rect painted this frame, so a bottom corner is two + * edges and resizes on both axes — no corner target to register, because the + * geometry that was just painted already says which corner it is. The same + * derivation the hint and the guide read (float_edges_at), so the promise + * and the grab cannot disagree. */ +static void float_resize_press(app_t *a, node_t *n, const input_event_t *ev) { + uint8_t e = float_edges_at(n, ev->mx, ev->my); + if (!e) return; + a->drag.kind = DRAG_FLOAT_RESIZE; + a->drag.src = n->id; + a->drag.fedges = e; + a->drag.moved = false; + a->drag.x = ev->mx; + a->drag.y = ev->my; + /* Grabbing a float raises it, like every window you have ever held. */ + app_focus_pane(a, n->id); +} + +/* During a title drag, is this pane somewhere the drop could land? A float + * is not: swapping tree seats with a pane that is not *in* its seat would + * rearrange something invisible. 0 clears the highlight as well as the drop, + * so hovering a float mid-drag promises nothing. */ +static uint32_t swap_target(app_t *a, uint32_t id) { + node_t *n = pane_by_id(a, id); + return n && n->floating ? 0 : id; +} + static void do_action(app_t *a, const char *action, const input_event_t *ev) { bool on_name = strncmp(action, "panetitle:", 10) == 0; if (on_name || strncmp(action, "title:", 6) == 0) { @@ -925,6 +970,20 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { a->name_click_id = id; a->name_click_kind = RENAME_PANE; } + /* A float's top row moves it — its own verb, because everything a + * title drag means on release (swap, split up) is what a float must + * not do. The rename double-click above still wins, so a float's name + * is still a name. */ + node_t *fn = pane_by_id(a, id); + if (fn && fn->floating) { + a->drag.kind = DRAG_FLOAT_MOVE; + a->drag.src = id; + a->drag.moved = false; + a->drag.x = ev->mx; + a->drag.y = ev->my; + app_focus_pane(a, id); + return; + } /* The top border is both a drag handle and an edge, and whether this is a * move or a split-upward is decided by whether the pointer moves. Which * side it takes is decided by *where* on the row it was pressed: only the @@ -934,6 +993,7 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { a->drag.kind = DRAG_TITLE; a->drag.src = id; a->drag.target = id; + a->drag.drop_side = 0; a->drag.x = ev->mx; a->drag.y = ev->my; a->drag.moved = false; @@ -947,13 +1007,29 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { * Deliberately inert and deliberately explicit -- this is the whole point of * the handle, and a silent fall-through to whatever comes next would be a * thin place for it to live. Starting no drag also means the release path - * finds nothing to split, which is what stops the accidents. */ - if (strncmp(action, "brim:", 5) == 0) return; + * finds nothing to split, which is what stops the accidents. + * + * On a float the whole border is a resize grab instead: a float has no + * guide to arm and no split to guard, and the rim being inert was about + * accidents that rearrange a layout — resizing the thing you grabbed is + * the accident-free reading of the same gesture. */ + if (strncmp(action, "brim:", 5) == 0) { + uint32_t id = (uint32_t)strtoul(action + 5, NULL, 10); + node_t *fn = pane_by_id(a, id); + if (fn && fn->floating && ev->maction == MOUSE_PRESS) + float_resize_press(a, fn, ev); + return; + } if (strncmp(action, "border:", 7) == 0) { uint32_t id = (uint32_t)strtoul(action + 7, NULL, 10); const char *colon = strchr(action + 7, ':'); char side = colon && colon[1] ? colon[1] : 'r'; if (ev->maction == MOUSE_PRESS) { + node_t *fn = pane_by_id(a, id); + if (fn && fn->floating) { + float_resize_press(a, fn, ev); + return; + } a->drag.kind = DRAG_BORDER; a->drag.src = id; a->drag.side = side; @@ -1168,11 +1244,31 @@ bool run_action(app_t *a, action_t act) { switch (act) { case ACT_SPLIT: split_focus_auto(a); return true; case ACT_SPLIT_COLS: split_focus_ui(a, SPLIT_COLS); return true; - case ACT_SPLIT_ROWS: split_focus_ui(a, SPLIT_ROWS); return true; + case ACT_SPLIT_ROWS: + /* On a focused float, `-` shrinks it: the split it usually means is a + * refusal there, and minus meaning smaller is the reading the hand + * expects. The pair's other half, grow, has the =/+ key to itself. */ + if (focus_float_grow(a, -1)) return true; + split_focus_ui(a, SPLIT_ROWS); + return true; case ACT_ZOOM: app_toggle_zoom(a, 0); return true; case ACT_MINIMIZE: if (!app_minimize(a, 0)) app_toast(a, "nothing else to show"); return true; + case ACT_FLOAT: + /* The refusal is the guard: floating the last tiled pane would leave + * the overlay nothing to be over. */ + if (!app_toggle_float(a, 0)) app_toast(a, "nothing to float over"); + return true; + case ACT_NEW_FLOAT: + if (!app_new_float(a)) app_toast(a, "cannot open a pane"); + return true; + case ACT_FLOAT_GROW: + if (!focus_float_grow(a, 1)) app_toast(a, "nothing floating to grow"); + return true; + case ACT_FLOAT_SHRINK: + if (!focus_float_grow(a, -1)) app_toast(a, "nothing floating to shrink"); + return true; case ACT_CLOSE_PANE: if (cur(a)->focus) close_leaf(a, cur(a)->focus); return true; @@ -1181,6 +1277,19 @@ bool run_action(app_t *a, action_t act) { * than to the space one would occupy. */ if (cur(a)->focus) purpose_begin(a, cur(a)->focus->id); return true; + case ACT_RENAME_PANE: + if (cur(a)->focus) rename_begin(a, cur(a)->focus->id); + return true; + case ACT_RENAME_TAB: + /* The editor draws in the tab's own cell in the strip; with the strip + * off there is nowhere to type, and an invisible editor holding the + * keyboard is the wedged state this program does not do. */ + if (!CFG.status_bar) { + app_toast(a, "no tab strip to rename in"); + return true; + } + rename_tab_begin(a, cur(a)->id); + return true; case ACT_RERUN: /* The keyboard's half of a dead pane's [re-run] button. Refused on a * live pane rather than restarting it: "run it again" would mean @@ -1220,10 +1329,21 @@ bool run_action(app_t *a, action_t act) { } 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; + /* On a focused float the same keys move the float: it has no boundary to + * move, and moving the thing itself is the same verb aimed at what is + * there. */ + case ACT_RESIZE_LEFT: + if (!focus_float_move(a, -1, 0)) resize_focus(a, -1, 0); + return true; + case ACT_RESIZE_RIGHT: + if (!focus_float_move(a, 1, 0)) resize_focus(a, 1, 0); + return true; + case ACT_RESIZE_UP: + if (!focus_float_move(a, 0, -1)) resize_focus(a, 0, -1); + return true; + case ACT_RESIZE_DOWN: + if (!focus_float_move(a, 0, 1)) resize_focus(a, 0, 1); + return true; case ACT_CLEAR_SHADERS: /* Says which of the two happened. "Nothing to undo" and "undone" look * identical on a pane that was never painted, and a key that might have @@ -1366,6 +1486,7 @@ void app_event(app_t *a, const input_event_t *ev) { } a->drag.kind = DRAG_NONE; a->drag.src = a->drag.target = 0; + a->drag.drop_side = 0; } if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) { @@ -1465,6 +1586,16 @@ void app_event(app_t *a, const input_event_t *ev) { ? (int)ev->mx - (int)a->drag.x : (int)ev->my - (int)a->drag.y; drag_edge(a, sp, a->drag.edge, cells); + } else if (a->drag.kind == DRAG_FLOAT_MOVE) { + node_t *n = pane_by_id(a, a->drag.src); + if (n) + float_move(a, n, (int)ev->mx - (int)a->drag.x, + (int)ev->my - (int)a->drag.y); + } else if (a->drag.kind == DRAG_FLOAT_RESIZE) { + node_t *n = pane_by_id(a, a->drag.src); + if (n) + float_resize(a, n, a->drag.fedges, (int)ev->mx - (int)a->drag.x, + (int)ev->my - (int)a->drag.y); } else if (a->drag.kind == DRAG_TAB) { /* Reordered as you drag, rather than dropped at the end: the * strip is the only thing that could show an insertion point, and @@ -1486,25 +1617,43 @@ void app_event(app_t *a, const input_event_t *ev) { a->drag.tab_target = (uint32_t)strtoul(action + 4, NULL, 10); a->drag.new_tab_target = false; a->drag.target = 0; + a->drag.drop_side = 0; } else if (a->drag.kind == DRAG_TITLE && action && strcmp(action, "newtab") == 0) { /* The button that makes a tab, used as somewhere to put one pane. */ a->drag.new_tab_target = true; a->drag.tab_target = 0; a->drag.target = 0; + a->drag.drop_side = 0; + } else if (a->drag.kind == DRAG_TITLE && action && + strncmp(action, "drop:", 5) == 0) { + /* A drop zone: insert beside that pane, on that side. The zone + * was registered by the paint the pointer is over, so it exists + * exactly where the fill said it would. */ + a->drag.target = (uint32_t)strtoul(action + 5, NULL, 10); + const char *sep = strrchr(action, ':'); + a->drag.drop_side = sep && sep[1] ? sep[1] : 0; + a->drag.tab_target = 0; + a->drag.new_tab_target = false; } else if (action && strncmp(action, "title:", 6) == 0) { - a->drag.target = (uint32_t)strtoul(action + 6, NULL, 10); + a->drag.target = + swap_target(a, (uint32_t)strtoul(action + 6, NULL, 10)); a->drag.tab_target = 0; a->drag.new_tab_target = false; + a->drag.drop_side = 0; } else if (action && strncmp(action, "panetitle:", 10) == 0) { /* Dropping onto a pane's name is dropping onto that pane. */ - a->drag.target = (uint32_t)strtoul(action + 10, NULL, 10); + a->drag.target = + swap_target(a, (uint32_t)strtoul(action + 10, NULL, 10)); a->drag.tab_target = 0; a->drag.new_tab_target = false; + a->drag.drop_side = 0; } else if (action && strncmp(action, "pane:", 5) == 0) { - a->drag.target = (uint32_t)strtoul(action + 5, NULL, 10); + a->drag.target = + swap_target(a, (uint32_t)strtoul(action + 5, NULL, 10)); a->drag.tab_target = 0; a->drag.new_tab_target = false; + a->drag.drop_side = 0; } a->drag.x = ev->mx; a->drag.y = ev->my; @@ -1521,7 +1670,9 @@ void app_event(app_t *a, const input_event_t *ev) { /* The guide already declined to offer this, so the click that * the guide would have explained must decline too — otherwise * the border silently does something it just said it would not. */ - if (!split_fits(n, dir)) { + if (n->floating) { /* its own words: the room is not the problem */ + app_toast(a, "a floating pane cannot be split"); + } else if (!split_fits(n, dir)) { app_toast(a, dir == SPLIT_COLS ? "no room to split across" : "no room to split down"); } else { @@ -1549,13 +1700,27 @@ void app_event(app_t *a, const input_event_t *ev) { (a->drag.tab_target || a->drag.new_tab_target)) { /* Dropped on the strip: the pane changes tab rather than places. */ drop_pane_on_strip(a); + } else if (a->drag.kind == DRAG_TITLE && a->drag.moved && action && + strncmp(action, "drop:", 5) == 0) { + /* A zone inserts beside its pane, on its side: the drop grammar. + * Resolved from the painted frame's hits at the moment of + * release, not from a side remembered off the last motion — + * the zones are painted one frame behind the pointer, and a + * remembered answer could disagree with the fill on screen. + * Derive from what the user saw; same lesson as the guide. */ + uint32_t dst = (uint32_t)strtoul(action + 5, NULL, 10); + const char *sep = strrchr(action, ':'); + if (dst != a->drag.src && sep && sep[1]) + insert_pane_beside(a, a->drag.src, dst, sep[1]); } else if (a->drag.kind == DRAG_TITLE && a->drag.target != a->drag.src) { + /* The centre is the swap it always was. */ swap_panes(a, a->drag.src, a->drag.target); } a->drag.kind = DRAG_NONE; a->drag.src = a->drag.target = a->drag.tab_target = 0; a->drag.new_tab_target = false; + a->drag.drop_side = 0; } break; /* a drag owns the mouse until the button comes up */ } @@ -1618,6 +1783,7 @@ static void panes_cb(node_t *n, void *ud) { json_int(j, "content_h", n->content.h); json_bool(j, "focused", n == cur(pj->a)->focus); json_bool(j, "hidden", n->hidden); + json_bool(j, "floating", n->floating); json_bool(j, "suspended", pane_suspended(n->pane)); /* A dead pane is still a pane, so tooling has to be able to tell: `alive` * false with a status is one whose program is over and which is waiting to diff --git a/src/app_draw.c b/src/app_draw.c index 7c2a93c..d96ceab 100644 --- a/src/app_draw.c +++ b/src/app_draw.c @@ -362,6 +362,179 @@ static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) { } } +/* The drop zones: while a pane is being carried, the pane under the pointer + * subdivides into a centre — today's swap, keeping the biggest target — and + * four edge bands that mean "insert me beside this pane, on this side", + * which is what turns dragging into re-layout. Bands are rects registered in + * the hit list as they are painted, so the promise on screen and the drop on + * release are the same rects; the corners go to the upright bands because + * they are registered last, and the centre needs no entry at all — a + * pointer that lands in no band falls through to the pane's own hit, which + * is the swap it always was. + * + * Zones materialise only under the pointer: four bands of chrome on every + * candidate during every drag would be noise, and the drop highlight has + * always been a fact about the pane you are on. A side whose insert would + * not fit (the same floor split_fits holds a border click to) simply never + * registers, so the preview cannot promise what the drop cannot deliver. + * The hovered band fills the half of the pane the drop would hand over — + * where the pane will *be*, not where the button is. */ +static void draw_drop_zones(app_t *a, screen_t *s, node_t *n) { + if (a->drag.kind != DRAG_TITLE || !a->drag.moved) return; + if (n->id == a->drag.src || n->floating) return; + if (!a->ptr_valid) return; + rect_t r = n->rect; + if (r.w < 3 || r.h < 3) return; + if (a->ptr_x < r.x || a->ptr_x >= r.x + r.w || a->ptr_y < r.y || + a->ptr_y >= r.y + r.h) + return; + + /* A third of each dimension per band, so the centre is the remaining + * third: big enough to hit without aiming, small enough that the bands — + * the half of this feature you steer by — own most of the pane. It began + * as quarters, and the first map of the result showed the swap owning + * half of each axis: a target that big reads as dead space when you are + * mid-drag hunting for a band. */ + uint16_t bw = r.w / 3 ? (uint16_t)(r.w / 3) : 1; + uint16_t bh = r.h / 3 ? (uint16_t)(r.h / 3) : 1; + + /* Whether a side is offered. A drop beside a *sibling along that axis* is + * a reorder — the dragged pane leaves the split it would rejoin, so the + * child count never changes and there is nothing to fit; refusing it was + * refusing the most ordinary drag there is, rearranging a row. Everything + * else is a real insert and answers to the same floor a border click + * does. (Still conservative across splits: a drop whose *source* leaving + * would have made the room is not offered, because the offer would need + * the speculative layout to prove.) */ + node_t *src = pane_by_id(a, a->drag.src); + bool sib = src && src->parent && src->parent == n->parent; + bool rows_ok = + (sib && n->parent->dir == SPLIT_ROWS) || split_fits(n, SPLIT_ROWS); + bool cols_ok = + (sib && n->parent->dir == SPLIT_COLS) || split_fits(n, SPLIT_COLS); + + char action[48]; + /* Horizontal bands first, upright bands after: the corners resolve to a + * column split, which is the axis a cell's shape makes roomier. */ + if (rows_ok) { + snprintf(action, sizeof action, "drop:%u:t", n->id); + hit_add(&s->hits, r.x, r.y, r.w, bh, action); + snprintf(action, sizeof action, "drop:%u:b", n->id); + hit_add(&s->hits, r.x, (uint16_t)(r.y + r.h - bh), r.w, bh, action); + } + if (cols_ok) { + snprintf(action, sizeof action, "drop:%u:l", n->id); + hit_add(&s->hits, r.x, r.y, bw, r.h, action); + snprintf(action, sizeof action, "drop:%u:r", n->id); + hit_add(&s->hits, (uint16_t)(r.x + r.w - bw), r.y, bw, r.h, action); + } + + /* The fill, read back off the hits just registered so it cannot disagree + * with what a release here would do. */ + const char *over = hit_test(&s->hits, a->ptr_x, a->ptr_y); + char want[16]; + snprintf(want, sizeof want, "drop:%u:", n->id); + if (!over || strncmp(over, want, strlen(want)) != 0) return; + char side = over[strlen(want)]; + rect_t half = r; + switch (side) { + case 'l': half.w = (uint16_t)(r.w / 2); break; + case 'r': + half.w = (uint16_t)(r.w / 2); + half.x = (uint16_t)(r.x + r.w - half.w); + break; + case 't': half.h = (uint16_t)(r.h / 2); break; + default: + half.h = (uint16_t)(r.h / 2); + half.y = (uint16_t)(r.y + r.h - half.h); + break; + } + shader_t fill; + if (!shader_make(&fill, "tint", DROP_C, 110)) return; + shade_ctx_t base = { + .now_ms = now_ms_(), + .default_fg = CFG.default_fg, + .default_bg = CFG.default_bg, + }; + shade_apply(s, &fill, 1, half, NULL, &base); +} + +/* The float's answer to the split guide: hovering its border shows, in the + * resize colour, which edges a grab there would move — one lit edge for a + * side, two meeting at the pointer for a corner — and the pair stays lit + * while the drag holds them. Without this the border was a working handle + * that never said so, which is a control you have to already know about. + * + * The edges are read from float_edges_at, the same derivation the press and + * the hint use, and each cell is painted only if the hit list says the + * border still owns it — so the buttons and the epitaph on the bottom row + * stay legible, the same question the split guide asks. Armed on the same + * dwell, for the same reason: a pointer crossing a float on its way + * somewhere else should not make the frame flash. */ +static void draw_float_guide(app_t *a, screen_t *s, node_t *leaf) { + if (!leaf->floating || leaf->collapsed || leaf->hidden) return; + rect_t r = leaf->rect; + if (r.w < 3 || r.h < 3) return; + + uint8_t e = 0; + bool active = a->drag.kind == DRAG_FLOAT_RESIZE && a->drag.src == leaf->id; + if (active) { + e = a->drag.fedges; + } else if (a->drag.kind == DRAG_NONE && a->ptr_valid) { + if (now_ms_() - a->ptr_still_since < CFG.hover_delay_ms) return; + const char *action = hit_test(&s->hits, a->ptr_x, a->ptr_y); + if (!action) return; + char want[24]; + snprintf(want, sizeof want, "border:%u:", leaf->id); + bool mine = strncmp(action, want, strlen(want)) == 0; + if (!mine) { + snprintf(want, sizeof want, "brim:%u:", leaf->id); + mine = strncmp(action, want, strlen(want)) == 0; + } + if (!mine) return; + e = float_edges_at(leaf, a->ptr_x, a->ptr_y); + } + if (!e) return; + + uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1); + uint16_t attrs = ATTR_BOLD; + + /* A cell is painted only if the border owns it: brim or handle, either + * name is the edge. */ + char rim[24], handle[24]; + for (int side = 0; side < 3; side++) { + char c = "lrb"[side]; + uint8_t bit = c == 'l' ? FEDGE_L : c == 'r' ? FEDGE_R : FEDGE_B; + if (!(e & bit)) continue; + snprintf(rim, sizeof rim, "brim:%u:%c", leaf->id, c); + snprintf(handle, sizeof handle, "border:%u:%c", leaf->id, c); + if (c == 'b') { + for (uint16_t x = r.x; x <= x1; x++) { + const char *own = hit_test(&s->hits, x, y1); + if (own && (strcmp(own, rim) == 0 || strcmp(own, handle) == 0)) + screen_text(s, x, y1, "\u2501", RESIZE_C, NO_COLOR, attrs); + } + } else { + uint16_t bx = c == 'l' ? r.x : x1; + for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { + const char *own = hit_test(&s->hits, bx, y); + if (own && (strcmp(own, rim) == 0 || strcmp(own, handle) == 0)) + screen_text(s, bx, y, "\u2503", RESIZE_C, NO_COLOR, attrs); + } + } + } + + /* The double arrow at the grab point, hover only — mid-drag the pointer + * has left the edge, and the lit edges following the hand are the + * feedback. A corner gets none: two edges meeting at the pointer already + * say "both ways", and no single glyph does. */ + bool corner = (e & (FEDGE_L | FEDGE_R)) && (e & (FEDGE_T | FEDGE_B)); + if (!active && !corner) + screen_text(s, a->ptr_x, a->ptr_y, + (e & (FEDGE_L | FEDGE_R)) ? "\u21d4" : "\u21d5", RESIZE_C, + NO_COLOR, ATTR_BOLD); +} + 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; @@ -378,8 +551,8 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { * the greying subtle. The pane actually under the pointer still takes the * drop_target highlight on top of the dashes: these are all targets, that is * the one you are on. */ - bool drag_target = - a->drag.kind == DRAG_TITLE && a->drag.moved && a->drag.src != leaf->id; + bool drag_target = a->drag.kind == DRAG_TITLE && a->drag.moved && + a->drag.src != leaf->id && !leaf->floating; const char *hbar = drag_target ? "\u2504" : "\u2500"; const char *vbar = drag_target ? "\u2506" : "\u2502"; @@ -967,7 +1140,11 @@ static pane_state_t pane_state_of(app_t *a, node_t *n) { * click would otherwise flash the whole session on its way to nothing. */ if (a->drag.kind == DRAG_TITLE && a->drag.moved) { if (n->id == a->drag.src) return PSTATE_DRAGGING; - if (n->id == a->drag.target) return PSTATE_DROP_HOVER; + /* drop_hover is the swap's promise — the whole pane trades places. A + * pointer in one of its drop *zones* promises something narrower, and + * the zone's own fill says it; the pane stays an ordinary candidate so + * the two promises cannot show at once. */ + if (n->id == a->drag.target && !a->drag.drop_side) return PSTATE_DROP_HOVER; return PSTATE_DROP_TARGET; } if (!pane_alive(n->pane) && !pane_suspended(n->pane)) return PSTATE_DEAD; @@ -979,6 +1156,12 @@ static pane_state_t pane_state_of(app_t *a, node_t *n) { * at it answers the bell before anything is drawn. */ if (pane_bell(n->pane)) return PSTATE_BELL; if (pane_scrolled(n->pane)) return PSTATE_SCROLLED; + /* Above `unfocused`, deliberately: a float is never dimmed by + * dim_unfocused. The thing on top of the stack reading at full strength is + * what keeps it lifted off the page — the same reason `dragging` keeps no + * default — and the frame colour still tells focus. Ships no chain; the + * shadow does the telling apart. */ + if (n->floating) return PSTATE_FLOATING; if (n != cur(a)->focus) return PSTATE_UNFOCUSED; return PSTATE_COUNT; } @@ -1216,6 +1399,8 @@ static void draw_cb(node_t *n, void *ud) { shade_leaf(d->a, d->s, n); shade_chrome(d->a, d->s, n, n->rect, &n->content); draw_split_guide(d->a, d->s, n); + draw_float_guide(d->a, d->s, n); + draw_drop_zones(d->a, d->s, n); } struct bellsearch { @@ -1592,6 +1777,10 @@ void draw_node(app_t *a, screen_t *s, node_t *n) { return; } if (n->kind == NODE_LEAF) { + /* A float is painted by draw_floats, after everything tiled, so its + * cells and its hits go on top. A *collapsed* float took the branch + * above: in a flattened tab it is a row like everyone else. */ + if (n->floating) return; struct draw d = {a, s}; draw_cb(n, &d); return; @@ -1612,6 +1801,73 @@ void draw_node(app_t *a, screen_t *s, node_t *n) { for (size_t i = 0; i < n->nkids; i++) draw_node(a, s, n->kids[i]); } +/* The cell of shade a float casts on whatever it covers: `gap_aspect` + * columns beside and one row below, offset so the light reads as coming from + * the top left. The same dim pass the scrim is — a shadow is a colour pass + * with everything in its rects — run before the float itself is painted, so + * it falls on the composited cells underneath, lower floats included. The + * two rects meet without overlapping, because a corner dimmed twice reads as + * a stain rather than a shadow. */ +static void cast_shadow(app_t *a, screen_t *s, rect_t r) { + if (!CFG.float_shadow) return; + shader_t dim; + if (!shader_make(&dim, "dim", (color_t){0}, CFG.float_shadow)) return; + shade_ctx_t base = { + .now_ms = now_ms_(), + .default_fg = CFG.default_fg, + .default_bg = CFG.default_bg, + }; + uint16_t off = CFG.gap_aspect ? CFG.gap_aspect : 2; + shade_apply(s, &dim, 1, + (rect_t){(uint16_t)(r.x + r.w), (uint16_t)(r.y + 1), off, + (uint16_t)(r.h - 1)}, + NULL, &base); + shade_apply(s, &dim, 1, + (rect_t){(uint16_t)(r.x + off), (uint16_t)(r.y + r.h), r.w, 1}, + NULL, &base); +} + +/* The floating panes, over everything tiled and under the modals. + * + * Painted in ascending raise order, so the hit list — searched backwards — + * resolves an overlap to the float you can see. The z-order and the click + * order are the same list, which is the "one geometry" rule doing the work: + * there is no routing code to disagree with the paint. + * + * Each float is drawn by the same draw_cb as a tiled pane: same frame, same + * buttons, same shader passes. What differs is when, which is the whole + * feature. */ +void draw_floats(app_t *a, screen_t *s) { + node_t *fl[64]; + size_t nf = collect_floating(cur(a)->root, fl, 64, 0); + /* Insertion sort by raise stamp: n is small and the order is nearly + * stable frame to frame. */ + for (size_t i = 1; i < nf; i++) { + node_t *k = fl[i]; + size_t j = i; + while (j > 0 && fl[j - 1]->raised > k->raised) { + fl[j] = fl[j - 1]; + j--; + } + fl[j] = k; + } + for (size_t i = 0; i < nf; i++) { + node_t *n = fl[i]; + /* A flattened tab listed it as a row, and a zoomed tab hid it: either + * way this pass has nothing to add. */ + if (n->collapsed || n->hidden) continue; + /* The cursor a tiled pane parked under this float must not glow through + * it. Its own cursor is set by its own compose below, when focused. */ + if (s->cursor_visible && s->cursor_x >= n->rect.x && + s->cursor_x < n->rect.x + n->rect.w && s->cursor_y >= n->rect.y && + s->cursor_y < n->rect.y + n->rect.h) + s->cursor_visible = false; + cast_shadow(a, s, n->rect); + struct draw d = {a, s}; + draw_cb(n, &d); + } +} + /* ---- kitty graphics ------------------------------------------------------ */ struct gfx_ctx { diff --git a/src/app_layout.c b/src/app_layout.c index 5af2dec..d2fc0b1 100644 --- a/src/app_layout.c +++ b/src/app_layout.c @@ -33,10 +33,11 @@ size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k) { return k; } -/* Does anything under here still want space in the layout? */ +/* Does anything under here still want space in the layout? A floating pane + * does not: it is placed over the layout, not by it. */ static bool subtree_live(node_t *n) { if (!n) return false; - if (n->kind == NODE_LEAF) return !n->minimized; + if (n->kind == NODE_LEAF) return !n->minimized && !n->floating; for (size_t i = 0; i < n->nkids; i++) if (subtree_live(n->kids[i])) return true; return false; @@ -53,6 +54,19 @@ size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k) { return k; } +/* The floats, in tree order; the draw pass sorts them by raise stamp. A + * minimised float is in the strip, not on screen, so it is not collected. */ +size_t collect_floating(node_t *n, node_t **out, size_t cap, size_t k) { + if (!n || k >= cap) return k; + if (n->kind == NODE_LEAF) { + if (n->floating && !n->minimized) out[k++] = n; + return k; + } + for (size_t i = 0; i < n->nkids; i++) + k = collect_floating(n->kids[i], out, cap, k); + return k; +} + size_t count_leaves(node_t *n) { if (!n) return 0; if (n->kind == NODE_LEAF) return 1; @@ -98,6 +112,11 @@ typedef struct { node_t *focus; bool apply; /* false during the probe */ bool overflow; /* some node could not give its children their floor */ + /* Lay out leaves the tree normally skips. The flatten paths set it because + * down there every pane is a row and being minimised or floating is not a + * different thing to be; the float placement sets it because placing a + * float *is* laying out a leaf the tree skipped. */ + bool place_hidden; } layout_ctx_t; static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx); @@ -126,6 +145,7 @@ static void layout_stack(node_t *n, rect_t r, layout_ctx_t *ctx) { node_t *leaves[64]; size_t nl = collect_leaves(n, leaves, 64, 0); if (!nl) return; + ctx->place_hidden = true; /* a list holds every pane, floats included */ size_t expanded = 0; for (size_t i = 0; i < nl; i++) @@ -152,6 +172,7 @@ static void layout_solo(node_t *n, rect_t r, layout_ctx_t *ctx) { node_t *leaves[64]; size_t nl = collect_leaves(n, leaves, 64, 0); if (!nl) return; + ctx->place_hidden = true; /* the shown pane may be a float (zoomed, say) */ size_t expanded = 0; for (size_t i = 0; i < nl; i++) @@ -164,11 +185,13 @@ static void layout_solo(node_t *n, rect_t r, layout_ctx_t *ctx) { } static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { - /* Minimised panes are placed by the strip, not by the tree. Returning before - * touching the rect leaves the strip's placement standing, and returning - * before the floor check is right too: a pane that is one row by request is - * not a pane that failed to fit. */ - if (n->kind == NODE_LEAF && n->minimized) return; + /* Minimised panes are placed by the strip and floating ones by the float + * pass, not by the tree. Returning before touching the rect leaves that + * placement standing, and returning before the floor check is right too: a + * pane that is one row by request is not a pane that failed to fit. */ + if (n->kind == NODE_LEAF && (n->minimized || n->floating) && + !ctx->place_hidden) + return; n->rect = r; n->collapsed = false; @@ -281,6 +304,76 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { node_t *pane_by_id(app_t *a, uint32_t id); /* defined below */ size_t tab_of(app_t *a, node_t *n); +/* Clamp a wanted rect into the tab's area. Derived every frame from the + * intent and the area, never written back: a float squeezed by a small + * terminal is exactly where you put it when the room comes back — the same + * property the weights promise across a collapse/expand cycle. */ +static rect_t float_clamp(rect_t want, rect_t area) { + rect_t r = want; + if (!r.w || !r.h) { /* never placed: centred, two thirds of the area */ + r.w = (uint16_t)(area.w * 2 / 3); + r.h = (uint16_t)(area.h * 2 / 3); + r.x = (uint16_t)(area.x + (area.w - r.w) / 2); + r.y = (uint16_t)(area.y + (area.h - r.h) / 2); + } + /* The same floor the tree holds a leaf's whole rect to. */ + if (r.w < MIN_PANE_COLS) r.w = MIN_PANE_COLS; + if (r.h < MIN_PANE_ROWS) r.h = MIN_PANE_ROWS; + if (r.w > area.w) r.w = area.w; + if (r.h > area.h) r.h = area.h; + if (r.x < area.x) r.x = area.x; + if (r.y < area.y) r.y = area.y; + if (r.x + r.w > area.x + area.w) r.x = (uint16_t)(area.x + area.w - r.w); + if (r.y + r.h > area.y + area.h) r.y = (uint16_t)(area.y + area.h - r.h); + return r; +} + +/* Give every float its rect for this frame. Reuses the leaf branch of + * layout_node — frame, padding, content, resize — rather than a second copy + * of that arithmetic, with a ctx of its own so a float below the floor + * cannot flatten the tab: the clamp is the answer down here, not the list. */ +static void place_floats(app_t *a, rect_t area) { + node_t *fl[64]; + size_t nf = collect_floating(cur(a)->root, fl, 64, 0); + layout_ctx_t ctx = { + .focus = cur(a)->focus, .apply = true, .place_hidden = true}; + for (size_t i = 0; i < nf; i++) + layout_node(fl[i], float_clamp(fl[i]->float_rect, area), &ctx); +} + +/* No live tiled pane under this root? The float guard refuses to *make* that + * state, but closing the last tiled pane, or moving it to another tab, can + * still reach it — and a tab that is only overlays is overlays over nothing. + * So the top float lands. Same shape as the minimised-focus rule below: + * checked once, here, rather than at each place the tree can lose a leaf. */ +static void ensure_tiled(app_t *a) { + node_t *leaves[64]; + size_t nl = collect_leaves(cur(a)->root, leaves, 64, 0); + node_t *top = NULL; + for (size_t i = 0; i < nl; i++) { + if (!leaves[i]->minimized && !leaves[i]->floating) return; /* all is well */ + if (leaves[i]->floating && !leaves[i]->minimized && + (!top || leaves[i]->raised > top->raised)) + top = leaves[i]; + } + if (top) top->floating = false; +} + +/* The rect a tab lays out into: the screen minus the strip, the status line + * and the outer gap. Factored so the float edits can ask the same question + * the layout answers, rather than a second copy of this arithmetic. */ +static rect_t tab_area(app_t *a) { + uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap; + uint16_t top = (uint16_t)(gy + STRIP_ROWS); + return (rect_t){.x = gx, + .y = top, + .w = + (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols), + .h = (uint16_t)(a->rows > top + gy + LINE_ROWS + ? a->rows - top - gy - LINE_ROWS + : 1)}; +} + void layout(app_t *a) { if (!a->ntabs) return; /* Re-derived below, like everything else about a layout pass: whether this @@ -288,23 +381,25 @@ void layout(app_t *a) { * able to ask what its edit did — a turn that flattens the tab is a turn * nobody asked for. */ a->flattened = false; - uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap; - uint16_t top = (uint16_t)(gy + STRIP_ROWS); - rect_t r = {.x = gx, - .y = top, - .w = (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols), - .h = (uint16_t)(a->rows > top + gy + LINE_ROWS - ? a->rows - top - gy - LINE_ROWS - : 1)}; + rect_t r = tab_area(a); node_t *root = cur(a)->root; if (!root) return; + ensure_tiled(a); + /* Focusing a minimised pane is how you get it back, and it is checked here * rather than in the several places focus can move: any route that ends with * this pane focused restores it, including ones that do not exist yet. */ if (cur(a)->focus && cur(a)->focus->minimized) cur(a)->focus->minimized = false; + /* Focusing a float raises it. Stamped here for the same reason the rule + * above lives here: every route to "this float is focused" raises it, and + * the guard keeps the counter still while it is already on top. */ + if (cur(a)->focus && cur(a)->focus->floating && + cur(a)->focus->raised != a->raise_seq) + cur(a)->focus->raised = ++a->raise_seq; + /* A zoomed pane is the whole tab. Reuses the solo path rather than adding a * third arrangement: "show this one and nothing else" is a thing the layout * already knew how to do, it just used to be a last resort rather than a @@ -362,6 +457,9 @@ void layout(app_t *a) { /* They are drawn by the bar, not as panes: no rect of their own. */ for (size_t i = 0; i < nmin; i++) collapse_leaf(mins[i], (rect_t){r.x, r.y, 0, 0}); + /* Floats go over the whole tab area, min bar included: the bar owns no + * global verbs, and a float can be moved off it. */ + place_floats(a, r); return; } a->flattened = true; @@ -473,7 +571,7 @@ static void place_beside(app_t *a, tab_t *t, node_t *leaf, node_t *node, static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, bool before, const char *const *argv, const char *label) { - if (!leaf) return NULL; + if (!leaf || leaf->floating) return NULL; /* nowhere beside a float to be */ /* A new pane opens where the pane it came out of *is*, not where the session * was started. Splitting inside a project and landing in whatever directory * the server happened to be launched from is wrong on its own, and it made a @@ -499,6 +597,11 @@ static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, * that immediately collapsed, and the guide's whole claim is that drawing and * the layout cannot disagree. */ bool split_fits(node_t *leaf, split_dir_t dir) { + /* A split is a statement about the tree's arrangement, and a float is + * precisely not arranged. False here reaches everything that offers a + * split — the guide, the handle's click, the keyboard's floor check — so + * a float's border never promises what the tree cannot deliver. */ + if (leaf->floating) return false; /* Two different questions, and the larger answer wins: min_pane is where the * layout gives up, min_split is where splitting stopped being worth * offering. A pane can clear the first and still be too small to be worth @@ -552,6 +655,10 @@ void split_focus(app_t *a, split_dir_t dir) { void split_focus_ui(app_t *a, split_dir_t dir) { node_t *n = cur(a)->focus; if (!n) return; + if (n->floating) { /* its own words: the room is not the problem */ + app_toast(a, "a floating pane cannot be split"); + return; + } if (!split_fits(n, dir)) { app_toast(a, dir == SPLIT_COLS ? "no room to split across" : "no room to split down"); @@ -585,6 +692,10 @@ static split_dir_t preferred_dir(const node_t *leaf) { void split_focus_auto(app_t *a) { node_t *n = cur(a)->focus; if (!n) return; + if (n->floating) { + app_toast(a, "a floating pane cannot be split"); + return; + } split_dir_t want = preferred_dir(n); split_dir_t other = want == SPLIT_COLS ? SPLIT_ROWS : SPLIT_COLS; split_dir_t dir; @@ -872,12 +983,14 @@ bool app_minimize(app_t *a, uint32_t id) { /* Refused if it would leave nothing on screen. A tab showing no panes at all * has no way back that is discoverable from the tab, and "everything is put - * away" is not a state worth being able to reach by accident. */ + * away" is not a state worth being able to reach by accident. A float does + * not count as something to show: it needs a tiled backdrop under it, the + * same invariant app_toggle_float holds. */ node_t *next = NULL; node_t *leaves[64]; size_t nl = collect_leaves(a->tabs[ti].root, leaves, 64, 0); for (size_t i = 0; i < nl; i++) - if (leaves[i] != n && !leaves[i]->minimized) { + if (leaves[i] != n && !leaves[i]->minimized && !leaves[i]->floating) { next = leaves[i]; break; } @@ -911,6 +1024,209 @@ bool app_pane_zoomed(app_t *a, uint32_t id) { return a->ntabs && cur(a)->zoom == id && id != 0; } +/* Lift a pane out of the layout, or put it back in the seat it kept. + * + * Floating on puts it in the centre of the tab, always: the pop to the + * middle is what says the float *happened* — a pane lifted in place looks + * like a pane whose neighbours flinched. The size is the one remembered + * from the last time a hand shaped it, or the pane's own, a cell in from + * where it stood; only the position is opinionated, because the position is + * the announcement. Floating off keeps the size for next time. */ +bool app_toggle_float(app_t *a, uint32_t id) { + node_t *n = id ? pane_by_id(a, id) : (a->ntabs ? cur(a)->focus : NULL); + if (!n || n->kind != NODE_LEAF) return false; + size_t ti = tab_of(a, n); + if (ti == (size_t)-1) return false; + + if (n->floating) { + n->floating = false; + } else { + /* The backdrop must keep a pane: a tab that is only an overlay is an + * overlay over nothing. Same guard as minimising, tightened to tiled + * panes — another float is not something to float over. */ + node_t *leaves[64]; + size_t nl = collect_leaves(a->tabs[ti].root, leaves, 64, 0); + bool backed = false; + for (size_t i = 0; i < nl; i++) + if (leaves[i] != n && !leaves[i]->minimized && !leaves[i]->floating) + backed = true; + if (!backed) return false; + + n->minimized = false; /* a minimised pane floated is a pane brought back */ + n->floating = true; + n->raised = ++a->raise_seq; + uint16_t w = n->float_rect.w, h = n->float_rect.h; + if (!w && n->rect.w > 4 && n->rect.h > 4) { + w = (uint16_t)(n->rect.w - 2); + h = (uint16_t)(n->rect.h - 2); + } + if (w) { + rect_t area = tab_area(a); + if (w > area.w) w = area.w; + if (h > area.h) h = area.h; + n->float_rect = (rect_t){(uint16_t)(area.x + (area.w - w) / 2), + (uint16_t)(area.y + (area.h - h) / 2), w, h}; + } + /* w == 0 (a pane too small to measure): float_clamp's own centred + * default answers the same question at two thirds of the area. */ + } + /* Either way you are now looking at it, and it holds the keyboard: the + * pane that just changed planes is the one the verb was about. */ + a->cur = ti; + a->tabs[ti].focus = n; + layout(a); + return true; +} + +bool app_pane_floating(app_t *a, uint32_t id) { + node_t *n = id ? pane_by_id(a, id) : (a->ntabs ? cur(a)->focus : NULL); + return n && n->floating; +} + +/* An explicit move or resize is the user authoring the intent, so unlike a + * terminal resize it *does* write back: the delta is applied to the rect as + * drawn — what you grabbed is what moves, even when an older intent is + * currently clamped — and after the layout has clamped the result, the + * clamped answer becomes the intent. Where you see it is where it stays. + * + * Refused while the float is not being drawn as a float: a flattened tab + * shows it as a row and a zoomed tab hides it, and writing either of those + * rects back as intent would destroy the place it returns to. */ +static bool float_editable(app_t *a, node_t *n) { + return n && n->floating && !n->minimized && !n->hidden && !n->collapsed && + !a->flattened && !cur(a)->zoom; +} + +void float_move(app_t *a, node_t *n, int dx, int dy) { + if (!float_editable(a, n) || (!dx && !dy)) return; + int x = (int)n->rect.x + dx, y = (int)n->rect.y + dy; + n->float_rect = (rect_t){(uint16_t)(x < 0 ? 0 : x), (uint16_t)(y < 0 ? 0 : y), + n->rect.w, n->rect.h}; + layout(a); + if (float_editable(a, n)) n->float_rect = n->rect; +} + +/* Only the grabbed edges move. Each one stops at the area's wall and at the + * floor, and the opposite edge stays exactly where it is — the general + * clamp would keep the *size* by sliding the rect, which turns "drag the + * right edge past the wall" into the whole window walking left. An edge you + * are holding pinning at the wall is what a hand expects. */ +void float_resize(app_t *a, node_t *n, unsigned edges, int dx, int dy) { + if (!float_editable(a, n) || !edges || (!dx && !dy)) return; + rect_t area = tab_area(a); + int left = n->rect.x, top = n->rect.y; + int right = n->rect.x + n->rect.w, bottom = n->rect.y + n->rect.h; + int ax1 = area.x + area.w, ay1 = area.y + area.h; + if (edges & FEDGE_L) { + left += dx; + if (left < area.x) left = area.x; + if (left > right - MIN_PANE_COLS) left = right - MIN_PANE_COLS; + } + if (edges & FEDGE_R) { + right += dx; + if (right > ax1) right = ax1; + if (right < left + MIN_PANE_COLS) right = left + MIN_PANE_COLS; + } + if (edges & FEDGE_T) { + top += dy; + if (top < area.y) top = area.y; + if (top > bottom - MIN_PANE_ROWS) top = bottom - MIN_PANE_ROWS; + } + if (edges & FEDGE_B) { + bottom += dy; + if (bottom > ay1) bottom = ay1; + if (bottom < top + MIN_PANE_ROWS) bottom = top + MIN_PANE_ROWS; + } + if (left < 0) left = 0; + if (top < 0) top = 0; + n->float_rect = (rect_t){(uint16_t)left, (uint16_t)top, + (uint16_t)(right - left), (uint16_t)(bottom - top)}; + layout(a); + if (float_editable(a, n)) n->float_rect = n->rect; +} + +/* Which edges of a float a grab at (mx, my) would move: the cell on the + * left border is the left edge, a bottom corner is two edges at once. One + * derivation shared by the press, the hover hint and the guide, so what is + * promised, what is painted and what the drag does cannot disagree — the + * hit-list rule, applied to geometry. */ +uint8_t float_edges_at(const node_t *n, uint16_t mx, uint16_t my) { + rect_t r = n->rect; + uint8_t e = 0; + if (mx <= r.x) + e |= FEDGE_L; + else if (mx >= (uint16_t)(r.x + r.w - 1)) + e |= FEDGE_R; + if (my <= r.y) + e |= FEDGE_T; + else if (my >= (uint16_t)(r.y + r.h - 1)) + e |= FEDGE_B; + return e; +} + +/* The keyboard's move, one gap-aspect-square step per press, so a nudge + * looks the same distance on both axes. False when the focus is not a + * float, which hands the keys back to the boundary they usually move. */ +bool focus_float_move(app_t *a, int dx, int dy) { + node_t *n = a->ntabs ? cur(a)->focus : NULL; + if (!n || !n->floating) return false; + int aspect = CFG.gap_aspect ? CFG.gap_aspect : 2; + float_move(a, n, dx * aspect, dy); + return true; +} + +/* Grow or shrink a focused float about its own centre, one gap-aspect + * square per side per press: `+` makes it bigger in every direction, `-` + * smaller, and the pane stays where you were looking. Pinned by the area + * and floored like every other float edit; the centre only drifts when a + * wall or the floor stops one side and not the other. False when the focus + * is not a float. */ +bool focus_float_grow(app_t *a, int sign) { + node_t *n = a->ntabs ? cur(a)->focus : NULL; + if (!n || !n->floating) return false; + if (!float_editable(a, n)) return true; /* a float, just not editable now */ + rect_t area = tab_area(a); + int aspect = CFG.gap_aspect ? CFG.gap_aspect : 2; + int w = (int)n->rect.w + sign * 2 * aspect; + int h = (int)n->rect.h + sign * 2; + if (w < MIN_PANE_COLS) w = MIN_PANE_COLS; + if (w > area.w) w = area.w; + if (h < MIN_PANE_ROWS) h = MIN_PANE_ROWS; + if (h > area.h) h = area.h; + int x = (int)n->rect.x + ((int)n->rect.w - w) / 2; + int y = (int)n->rect.y + ((int)n->rect.h - h) / 2; + if (x < area.x) x = area.x; + if (y < area.y) y = area.y; + if (x + w > area.x + area.w) x = area.x + area.w - w; + if (y + h > area.y + area.h) y = area.y + area.h - h; + n->float_rect = (rect_t){(uint16_t)x, (uint16_t)y, (uint16_t)w, (uint16_t)h}; + layout(a); + if (float_editable(a, n)) n->float_rect = n->rect; + return true; +} + +/* A new floating shell: the throwaway terminal. The pane is real — it has a + * seat in the tree beside the focused pane, so un-floating it lands + * somewhere sensible and closing it is `close_leaf` like anyone — it simply + * starts life lifted, centred at the never-placed default size. A shell + * closes when you type `exit` (D14), which is what makes it throwaway + * without any new machinery saying so. */ +uint32_t app_new_float(app_t *a) { + node_t *beside = a->ntabs ? cur(a)->focus : NULL; + if (!beside || beside->kind != NODE_LEAF) return 0; + char cwdbuf[4096]; + const char *cwd = live_cwd(beside->pane, cwdbuf, sizeof cwdbuf); + node_t *fresh = leaf_new_ex(a, default_argv(a), cwd, false, ""); + if (!fresh) return 0; + place_beside(a, cur(a), beside, fresh, preferred_dir(beside), false); + fresh->floating = true; + fresh->raised = ++a->raise_seq; + /* float_rect stays zero: the centred two-thirds default. */ + cur(a)->focus = fresh; + layout(a); + return fresh->id; +} + void app_set_session(app_t *a, const char *name) { snprintf(a->session, sizeof a->session, "%s", name ? name : ""); } @@ -928,6 +1244,9 @@ bool app_focus_pane(app_t *a, uint32_t id) { bool app_split_pane(app_t *a, uint32_t id, bool rows) { if (id && !app_focus_pane(a, id)) return false; + /* Refused, not quietly ignored: a script declaring it wants a pane beside + * a float would otherwise get an ok and no pane. */ + if (cur(a)->focus && cur(a)->focus->floating) return false; split_focus(a, rows ? SPLIT_ROWS : SPLIT_COLS); return true; } @@ -950,9 +1269,11 @@ static bool detach_leaf(app_t *a, node_t *leaf, size_t *from, bool *emptied) { /* Nothing about this pane should still say where it used to be. A zoom is the * tab's opinion about one of its panes, and a minimised pane is one this tab put * away; carried across, the first would zoom a pane that has left and the second - * would land the arrival in a strip nobody asked for. */ + * would land the arrival in a strip nobody asked for. A float is one this tab + * lifted, and it would arrive hovering over a layout that never saw it. */ if (t->zoom == leaf->id) t->zoom = 0; leaf->minimized = false; + leaf->floating = false; node_t *p = leaf->parent; if (!p) { @@ -1258,7 +1579,10 @@ void resize_focus(app_t *a, int dx, int dy) { static size_t equalize_node(node_t *n) { if (n->kind == NODE_LEAF) { n->weight = WEIGHT_UNIT; - return n->minimized ? 0 : 1; + /* A float is out of the layout like a minimised pane: counting one would + * hand its share to a pane the tree is not showing. Its own weight is + * still evened, because that is the share it lands back into. */ + return n->minimized || n->floating ? 0 : 1; } size_t vis = 0; for (size_t i = 0; i < n->nkids; i++) vis += equalize_node(n->kids[i]); @@ -1365,6 +1689,32 @@ void rotate_layout_ui(app_t *a) { app_toast(a, one_pane ? "nothing to turn" : "no room to turn it"); } +/* Land a dragged pane beside the target, on the side its drop zone named: + * the same detach-and-place surgery moving a pane to another tab does, + * aimed within the tab. A flat join through place_beside, exactly like a + * border-click split — three drops into a row are three even columns, not a + * bisection cascade — and `0` evens the shares as ever. Both panes must be + * in the same tab, which is the only place a drop zone can be painted: only + * the current tab is composed. */ +bool insert_pane_beside(app_t *a, uint32_t src_id, uint32_t dst_id, char side) { + node_t *src = pane_by_id(a, src_id), *dst = pane_by_id(a, dst_id); + if (!src || !dst || src == dst || dst->floating) return false; + size_t ti = tab_of(a, src); + if (ti == (size_t)-1 || tab_of(a, dst) != ti) return false; + + size_t from; + bool emptied = false; + if (!detach_leaf(a, src, &from, &emptied)) return false; + /* Two leaves in one tree share a root split, so detaching one cannot + * empty the tab; the guard is here for the day that stops being true. */ + if (emptied) return false; + place_beside(a, &a->tabs[ti], dst, src, side_dir(side), + side == 'l' || side == 't'); + a->tabs[ti].focus = src; /* your hand was on it; it keeps the keyboard */ + layout(a); + return true; +} + /* Reordering is a swap of two leaves, in place: their positions, weights and * parents trade, and everything else about them is untouched. */ bool swap_panes(app_t *a, uint32_t id_a, uint32_t id_b) { diff --git a/src/app_ui.c b/src/app_ui.c index ec4a6ef..d9e231c 100644 --- a/src/app_ui.c +++ b/src/app_ui.c @@ -950,6 +950,17 @@ bool rename_key(app_t *a, const input_event_t *ev) { if (!a->renaming) return false; if (ev->kind != EV_KEY || ev->action == KEY_RELEASE) return true; + /* The same C-u the picker's query field has: these are the program's two + * places you type into, and a key that clears one and is swallowed by the + * other is a key you cannot trust. It earns its place here twice over, + * because a rename seeds with the current label — an edit, usually — and + * "replace it outright" is otherwise a backspace per character. */ + if ((ev->mods & MOD_CTRL) && + (ev->key == GHOSTTY_KEY_U || ev->unshifted == 'u')) { + a->rename_buf[0] = 0; + return true; + } + switch (ev->key) { case GHOSTTY_KEY_ESCAPE: rename_end(a, false); return true; case GHOSTTY_KEY_ENTER: rename_end(a, true); return true; @@ -996,6 +1007,15 @@ void app_compose(app_t *a, screen_t *s) { draw_node(a, s, cur(a)->root); draw_corners(a, s); draw_min_bar(a, s); + /* The floats, over everything tiled and under the modals below: painted + * later is on top, and the hit list resolves clicks the same way, so the + * z-order and the click order cannot disagree. Before the status line, + * which cannot overlap them (the clamp keeps floats off it) but reads the + * hit list for its hover hint — drawn first, it would ask about a frame + * whose hits did not exist yet, and a float's border would hint as the + * pane underneath. The order also keeps a float's shadow off the status + * row on a gap-less config, since the row is painted after the shade. */ + draw_floats(a, s); draw_status_line(a, s); /* The modals, on top of everything: each is the only thing that can be * interacted with while it is up, so it owns the topmost hits. The scrim @@ -120,6 +120,22 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, free(txt); return j.buf; } + /* The emitter's own output: what a client's terminal would be sent for + * this frame. The model and the bytes are two different things, and + * asserting on the model while the bytes are wrong is a lesson this + * session has already paid for once (the graphics cmd says the same). + * Rendering updates the diff state, so a second call is the delta — + * which is itself worth testing. */ + if (strcmp(fmt, "bytes") == 0) { + screen_render(s); + json_t j; + json_init(&j); + json_obj_open(&j, NULL); + json_bool(&j, "ok", true); + json_str(&j, "bytes", s->out ? s->out : "", s->out_len); + json_obj_close(&j); + return j.buf; + } return jok_raw("screen", screen_dump_json(s)); } if (strcmp(cmd, "deadline") == 0) { @@ -156,9 +172,11 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, return jok_int(NULL, 0); } if (strcmp(cmd, "split") == 0) { + uint32_t id = (uint32_t)jv_geti(req, "id", 0); bool rows = strcmp(jv_gets(req, "dir", "cols"), "rows") == 0; - if (!app_split_pane(a, (uint32_t)jv_geti(req, "id", 0), rows)) - return jerr("no such pane"); + if (!app_split_pane(a, id, rows)) + return jerr(app_pane_floating(a, id) ? "cannot split a floating pane" + : "no such pane"); return jok_int("id", app_focused_pane_id(a)); } if (strcmp(cmd, "focus") == 0) { @@ -166,6 +184,22 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, return jerr("no such pane"); return jok_int("id", app_focused_pane_id(a)); } + /* The throwaway terminal, scripted: a fresh shell floating over the + * current tab. */ + if (strcmp(cmd, "new-float") == 0) { + uint32_t id = app_new_float(a); + if (!id) return jerr("cannot open a pane"); + return jok_int("id", id); + } + /* Toggle, like the button and the palette entry: M10c grows placement + * arguments here, this is the same one verb everything else runs. */ + if (strcmp(cmd, "float") == 0) { + uint32_t id = (uint32_t)jv_geti(req, "id", 0); + /* Two refusals share the word: no such pane, and no tiled pane left to + * float over. */ + if (!app_toggle_float(a, id)) return jerr("cannot float that"); + return jok_int("floating", app_pane_floating(a, id) ? 1 : 0); + } if (strcmp(cmd, "edit-config") == 0) { return app_edit_config(a) ? jok_int("id", app_focused_pane_id(a)) : jerr("no room for another pane"); diff --git a/src/config.c b/src/config.c index 6060479..f1361ef 100644 --- a/src/config.c +++ b/src/config.c @@ -22,7 +22,13 @@ static const struct { {"rerun", ACT_RERUN}, {"zoom", ACT_ZOOM}, {"minimize", ACT_MINIMIZE}, + {"float", ACT_FLOAT}, + {"new-float", ACT_NEW_FLOAT}, + {"float-grow", ACT_FLOAT_GROW}, + {"float-shrink", ACT_FLOAT_SHRINK}, {"set-purpose", ACT_SET_PURPOSE}, + {"rename-pane", ACT_RENAME_PANE}, + {"rename-tab", ACT_RENAME_TAB}, {"focus-left", ACT_FOCUS_LEFT}, {"focus-right", ACT_FOCUS_RIGHT}, {"focus-up", ACT_FOCUS_UP}, @@ -83,6 +89,21 @@ static const struct { {"slash", GHOSTTY_KEY_SLASH}, {"comma", GHOSTTY_KEY_COMMA}, {"period", GHOSTTY_KEY_PERIOD}, + /* The function row. The decoder has always understood these (legacy + * CSI ~-forms, SS3 and kitty alike); the config simply had no name for + * them, so nothing could be bound there. */ + {"f1", GHOSTTY_KEY_F1}, + {"f2", GHOSTTY_KEY_F2}, + {"f3", GHOSTTY_KEY_F3}, + {"f4", GHOSTTY_KEY_F4}, + {"f5", GHOSTTY_KEY_F5}, + {"f6", GHOSTTY_KEY_F6}, + {"f7", GHOSTTY_KEY_F7}, + {"f8", GHOSTTY_KEY_F8}, + {"f9", GHOSTTY_KEY_F9}, + {"f10", GHOSTTY_KEY_F10}, + {"f11", GHOSTTY_KEY_F11}, + {"f12", GHOSTTY_KEY_F12}, }; /* A character to a key, and whether typing it needs shift. @@ -277,8 +298,8 @@ size_t config_messages(const config_t *c, const char **out, size_t max) { } static const char *const PSTATE_NAMES[PSTATE_COUNT] = { - "dragging", "drop_hover", "drop_target", "dead", - "suspended", "bell", "scrolled", "unfocused", + "dragging", "drop_hover", "drop_target", "dead", "suspended", + "bell", "scrolled", "floating", "unfocused", }; const char *pane_state_name(pane_state_t s) { @@ -846,6 +867,7 @@ void config_defaults(config_t *c) { * At 60 white text lands on #c3c3c3, which reads as "not this one" without * reading as "not available". */ c->dim_unfocused = 60; + c->float_shadow = 110; c->min_pane_cols = 24; c->min_pane_rows = 6; c->min_split_cols = 32; @@ -1033,6 +1055,11 @@ void config_defaults(config_t *c) { bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM, false); bind_add(c, GHOSTTY_KEY_E, 0, ACT_EDIT_CONFIG, false); bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE, false); + /* `f` is float, not finder. Both wanted the letter; the tie went to the + * verb -- f is what "float" sounds like, a float toggle is pressed in a + * flow (lift, look, put back) where a reach hurts, and the finder reads + * just as naturally as *search* on `s`. */ + bind_add(c, GHOSTTY_KEY_F, 0, ACT_FLOAT, false); bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT, false); bind_add(c, GHOSTTY_KEY_L, 0, ACT_FOCUS_RIGHT, false); bind_add(c, GHOSTTY_KEY_K, 0, ACT_FOCUS_UP, false); @@ -1050,7 +1077,23 @@ void config_defaults(config_t *c) { bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, MOD_SHIFT, ACT_RESIZE_RIGHT, false); bind_add(c, GHOSTTY_KEY_ARROW_UP, MOD_SHIFT, ACT_RESIZE_UP, false); bind_add(c, GHOSTTY_KEY_ARROW_DOWN, MOD_SHIFT, ACT_RESIZE_DOWN, false); - bind_add(c, GHOSTTY_KEY_EQUAL, 0, ACT_EQUALIZE, false); + /* The =/+ key grows a focused float and `-` shrinks one (through the + * split-rows case, where a float's split is a refusal anyway). Grow is + * bound with and without shift, because whether `+` arrives shifted + * depends on the outer terminal -- the same deal `?` has with `/` -- and + * that ambiguity is also why equalize moved instead of sharing the key: + * two verbs on one key survive only until a terminal drops the shift. + * Equalize lands on `0`, the digit row it already lives on: 1..9 pick a + * tab, 0 resets the shares. */ + bind_add(c, GHOSTTY_KEY_EQUAL, 0, ACT_FLOAT_GROW, false); + bind_add(c, GHOSTTY_KEY_EQUAL, MOD_SHIFT, ACT_FLOAT_GROW, false); + bind_add(c, GHOSTTY_KEY_DIGIT_0, 0, ACT_EQUALIZE, false); + /* Shifted beside `f`, the same verb on a fresh thing: `f` floats this + * pane, `F` opens a new floating shell -- the throwaway terminal. (It + * spent a day on F12, the IDE key for the same idea, and came back: a + * chord of the leader and a letter is what every other verb here costs, + * and the function row is a reach the home row is not.) */ + bind_add(c, GHOSTTY_KEY_F, MOD_SHIFT, ACT_NEW_FLOAT, false); /* The leader and the space bar: the biggest key on the keyboard, no modifier, * and the one tmux already spends on cycling layouts — so the hand that knows * that reaches for the right thing here. Four presses come back round, which @@ -1088,7 +1131,7 @@ void config_defaults(config_t *c) { * same shifted-pair shape as `p`/`P`, on the letter people reach for. */ bind_add(c, GHOSTTY_KEY_W, 0, ACT_WORKSPACES, false); bind_add(c, GHOSTTY_KEY_W, MOD_SHIFT, ACT_SAVE_WORKSPACE, false); - bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER, false); + bind_add(c, GHOSTTY_KEY_S, 0, ACT_FINDER, false); bind_add(c, GHOSTTY_KEY_P, 0, ACT_PALETTE, false); /* Shifted, beside the palette on the same letter: `p` runs a command, `P` * tags this pane. A purpose was reachable only from a layout or the socket, @@ -1137,10 +1180,17 @@ static const struct { {ACT_RERUN, "panes", "run a finished pane again"}, {ACT_ZOOM, "panes", "fill the tab with it"}, {ACT_MINIMIZE, "panes", "put it away in the strip"}, + {ACT_FLOAT, "panes", "float it above the layout"}, + {ACT_NEW_FLOAT, "panes", "a new floating shell"}, /* Under `panes` because a purpose is a fact about this pane, and the label * says "tag" rather than "set purpose": the word people arrive with is the * one for what it is *for*, which is finding the pane again later. */ {ACT_SET_PURPOSE, "panes", "tag it with a purpose"}, + /* Renaming was a double-click and nothing else, which made it the one + * verb the palette could not reach — exactly what the palette exists to + * prevent. Unbound by default: the double-click is the fast path, and an + * action with no key is the palette's whole reason to list it. */ + {ACT_RENAME_PANE, "panes", "rename this pane"}, {ACT_FOCUS_LEFT, "focus", "go left"}, {ACT_FOCUS_RIGHT, "focus", "go right"}, @@ -1156,6 +1206,8 @@ static const struct { /* Short enough to fit the palette's label column, which truncates at 26 — * "give every pane an even share" read as "give every pane an even sh". */ {ACT_EQUALIZE, "size", "even out every split"}, + {ACT_FLOAT_GROW, "size", "grow a floating pane"}, + {ACT_FLOAT_SHRINK, "size", "shrink a floating pane"}, /* Under `panes`, not `size`: it undoes what the program in the pane did to * the pane, which is a fact about that pane and not about the layout. */ {ACT_CLEAR_SHADERS, "panes", "clear this pane's shaders"}, @@ -1168,6 +1220,7 @@ static const struct { {ACT_ROTATE_LAYOUT, "size", "turn the layout a quarter"}, {ACT_NEW_TAB, "tabs", "new tab"}, + {ACT_RENAME_TAB, "tabs", "rename this tab"}, {ACT_CLOSE_TAB, "tabs", "close this tab"}, {ACT_NEXT_TAB, "tabs", "next tab"}, {ACT_PREV_TAB, "tabs", "previous tab"}, @@ -1562,6 +1615,10 @@ char *config_render(const config_t *c) { "dim_unfocused %u // ...and how far the panes you are not in\n", c->dim_unfocused); cb_add(&b, + "float_shadow %u // the shade a floating pane casts; 0 for " + "none\n", + c->float_shadow); + cb_add(&b, "keep_dead \"%s\" // which dead panes stay: commands, all, none\n", c->keep_dead == KEEP_DEAD_ALL ? "all" : c->keep_dead == KEEP_DEAD_NONE ? "none" @@ -1772,6 +1829,7 @@ static const char *const KNOWN_TOP[] = { "double_click_ms", "word_separators", "editor", + "float_shadow", "focus_follows_mouse", "gap", "gap_aspect", @@ -2114,6 +2172,10 @@ static bool load_into(config_t *c, const char *path, int depth, char *err, long v = kdl_arg_int(kdl_child(root, "modal_scrim"), 0, c->modal_scrim); c->modal_scrim = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v); } + { + long v = kdl_arg_int(kdl_child(root, "float_shadow"), 0, c->float_shadow); + c->float_shadow = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v); + } const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL); if (sh) { |
