diff options
| author | user <user@clank> | 2026-08-16 19:25:52 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-16 19:25:52 +0200 |
| commit | 503b309187eaf0bc7282dd09e93c695dba11abb5 (patch) | |
| tree | 420112d1b9ea8416375b7f0cd0b4777ee8137599 | |
| parent | sl0ppty: a pane can change tabs (diff) | |
| download | slosh-503b309187eaf0bc7282dd09e93c695dba11abb5.tar.gz slosh-503b309187eaf0bc7282dd09e93c695dba11abb5.tar.xz slosh-503b309187eaf0bc7282dd09e93c695dba11abb5.zip | |
sl0ppty: `C-a >` sends this pane to the next tab
C-a > push it to the tab after C-a b into a tab of its own
C-a < push it to the tab before
Three actions rather than three special cases in the key handler, which is what
puts them in the cheatsheet and the palette without another line of code -- and
the palette is where you find a thing you did not know to look for.
You do not follow the pane, and a toast says where it went: "moved to tab 2 (api)".
A key you might press twice needs to say what it did, and following would take you
away from what you were doing. The tab's *name* is read before the move and its
*number* after: emptying this tab removes it and shifts everything along, so the
number read first is not the number anybody will see. When the tab you were in is
the one that goes, you arrive in the survivor because there is nowhere to stay.
`>` and `<` are the shifted period and comma, and the defaults bind both halves --
a terminal does not report shift for punctuation, so `>` arrives as a bare period
the way `?` arrives as a bare slash. The config loader already did this for a chord
you write by hand; the defaults had to do it themselves. The cost is that `.` and
`,` are spoken for, which is the cost `/` already pays for `?`. `b` is for break
out, the word tmux taught everybody for this.
**Two things the tests caught, both mine.**
`panes` reported a pane's tab as an index while every command that takes a tab
wants an id -- and my own test conflated them, passing only because the two agree
until a tab is removed, which is half of what these keys do. There is now a
`tab_id` beside the `tab`: one is where it sits in the strip, the other is what you
pass back.
And three more rows overflowed the cheatsheet: `modal_frame` clamps the box to the
screen, so at 56x44 the `session` group was simply not drawn. The sheet now says
`+7 more, in docs/keys.md` when it cannot show everything -- counted by walking the
rows it skipped, because arithmetic on two uneven columns claimed six were hidden
on a screen showing all of them. A list that quietly stops is worse than a short
one: the reader cannot tell that the key they wanted was on it. Reflowing to two
columns when the height forces it would be better than a footnote, and is not this
commit.
Verified: `tests/test_move_pane.py` at 38 checks -- the keys in both directions,
breaking out, the refusal when there is only one tab, pushing the last pane out and
landing in the survivor, and that the sheet prints `>` rather than the bare period
it also answers to while the palette finds it by label. `tests/test_help.py` gained
the short-terminal case and lost an assumption about its own fixture height.
| -rw-r--r-- | config/config.kdl | 9 | ||||
| -rw-r--r-- | docs/keys.md | 8 | ||||
| -rw-r--r-- | src/app.c | 75 | ||||
| -rw-r--r-- | src/config.c | 24 | ||||
| -rw-r--r-- | src/config.h | 3 | ||||
| -rw-r--r-- | tests/test_help.py | 5 | ||||
| -rwxr-xr-x | tests/test_move_pane.py | 133 |
7 files changed, 244 insertions, 13 deletions
diff --git a/config/config.kdl b/config/config.kdl index 9ee458a..afacd47 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -703,7 +703,7 @@ theme { // // Actions: split-cols split-rows close-pane focus-left focus-right focus-up // focus-down focus-next resize-left resize-right resize-up resize-down equalize -// rotate-layout clear-shaders +// rotate-layout clear-shaders pane-to-next-tab pane-to-prev-tab pane-to-new-tab // scroll-up scroll-down scroll-page-up scroll-page-down scroll-top // scroll-bottom zoom minimize rerun new-tab next-tab prev-tab select-tab-1..9 finder // palette help detach quit @@ -770,6 +770,13 @@ keys { // usually fixes that. bind "space" "rotate-layout" + // Push the focused pane one tab along, or out into a tab of its own. `>` and + // `<` are the shifted period and comma; a terminal does not report the shift + // for punctuation, so `.` and `,` answer to them too -- the same deal `/` + // already has with `?`. + bind ">" "pane-to-next-tab" + bind "<" "pane-to-prev-tab" + bind "b" "pane-to-new-tab" bind "c" "new-tab" // Tab cycles tabs, the way every other tabbed thing does. `n` and `p` // used to, and are free again: `p` is worth more as the palette, which is diff --git a/docs/keys.md b/docs/keys.md index a5befad..74d6ceb 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -21,6 +21,8 @@ you can type into. | `m` | minimise it into the strip along the bottom | | `x` | close this pane | | `r` | run a finished pane's command again | +| `>` `<` | push this pane to the next / previous tab (a toast says where it went) | +| `b` | break this pane out into a tab of its own | | `c` | new tab | | `Tab` `shift+Tab` | next / previous tab | | `1`…`9` | go to that tab | @@ -95,13 +97,17 @@ being bound at all. | group | actions | |---|---| -| panes | `split-cols` `split-rows` `close-pane` `rerun` `zoom` `minimize` `rotate-layout` `clear-shaders` | +| panes | `split-cols` `split-rows` `close-pane` `rerun` `zoom` `minimize` `rotate-layout` `clear-shaders` `pane-to-next-tab` `pane-to-prev-tab` `pane-to-new-tab` | | focus | `focus-left` `focus-right` `focus-up` `focus-down` `focus-next` `finder` | | size | `resize-left` `resize-right` `resize-up` `resize-down` `equalize` | | tabs | `new-tab` `next-tab` `prev-tab` `select-tab-1` … `select-tab-9` | | scroll | `scroll-up` `scroll-down` `scroll-page-up` `scroll-page-down` `scroll-top` `scroll-bottom` | | session | `palette` `help` `edit-config` `detach` `quit` `literal-prefix` | +`>` and `<` are the shifted period and comma. A terminal does not report the shift +for punctuation, so `.` and `,` answer to them too — the same deal `/` has with +`?`, and the reason the defaults bind both halves. + `scroll-up` and `scroll-down` have no default key — the wheel does that job — which is exactly the case the palette exists for. So does `clear-shaders`, which undoes whatever the program in a pane painted on it @@ -4115,7 +4115,23 @@ static void draw_help(app_t *a, screen_t *s) { if (right_n && !rows[n - 1].label) right_n--; uint16_t lines = (uint16_t)(left_n > right_n ? left_n : right_n); - rect_t in = modal_frame(a, s, (uint16_t)(body + 6), (uint16_t)(lines + 6), + /* A terminal too short for the whole sheet gets as much of it as fits and a line + * saying how much it did not. `modal_frame` clamps the box to the screen, so + * without this the rows past the bottom are simply not drawn -- and a list that + * quietly stops is worse than a short one, because the reader has no way to know + * the key they are looking for is on it. */ + uint16_t room = (uint16_t)(s->rows > 7 ? s->rows - 7 : 1); + uint16_t shown = lines > room ? room : lines; + /* Counted, not calculated: the two columns are not the same length and a blank + * spacer is not a row anybody is missing. Arithmetic on `lines` claimed six rows + * were hidden on a screen showing all of them. */ + size_t hidden = 0; + for (size_t i = 0; i < n; i++) { + size_t row = (two && i >= split) ? i - split : i; + if (row >= shown && rows[i].label) hidden++; + } + + rect_t in = modal_frame(a, s, (uint16_t)(body + 6), (uint16_t)(shown + 6 + (hidden ? 1 : 0)), "keys", "closehelp"); uint16_t x1 = (uint16_t)(in.x + in.w); /* the right border */ @@ -4130,6 +4146,7 @@ static void draw_help(app_t *a, screen_t *s) { for (size_t i = 0; i < n; i++) { bool right = two && i >= split; size_t row = right ? i - split : i; + if (row >= shown) continue; /* past the bottom: counted, not drawn */ uint16_t ry = (uint16_t)(in.y + 3 + row); if (ry >= in.y + in.h) continue; if (!rows[i].label) continue; /* the blank row: it occupies, it draws not */ @@ -4151,6 +4168,14 @@ static void draw_help(app_t *a, screen_t *s) { MODAL_FG, MODAL_BG, 0); } + /* Said inside the box, where the rows stopped, rather than left to be noticed. */ + if (hidden) { + char more[64]; + snprintf(more, sizeof more, "+%zu more, in docs/keys.md", hidden); + help_text(s, (uint16_t)(in.x + 2), (uint16_t)(in.y + 3 + shown), more, in.w, + HINT_C, MODAL_BG, 0); + } + const char *foot = " any key closes this "; if (in.w > cells(foot)) screen_text(s, (uint16_t)(in.x + (in.w - cells(foot)) / 2), @@ -4397,6 +4422,37 @@ static void draw_picker(app_t *a, screen_t *s) { /* Defined with the other action handling, further down: a picker row that * says "run:" means one of these, and running it is that code's business. */ +/* Push the focused pane one tab along, and say where it went. + * + * You do not follow it -- which is the right default for a key you might press + * twice, and useless without a word about where the thing landed. The name is read + * *before* the move and the index *after*: emptying this tab removes it and shifts + * everything after, so the number you would have printed first is not the number + * anybody will see. */ +static bool push_pane_a_tab(app_t *a, bool forward) { + if (a->ntabs < 2) { + app_toast(a, "only one tab to move it to"); + return true; + } + size_t to = (a->cur + (forward ? 1 : a->ntabs - 1)) % a->ntabs; + uint32_t tid = a->tabs[to].id; + char name[64]; + snprintf(name, sizeof name, "%s", a->tabs[to].name); + + if (!app_move_pane_to_tab(a, 0, tid, false)) { + app_toast(a, "cannot move it there"); + return true; + } + size_t at = 0; + for (size_t i = 0; i < a->ntabs; i++) + if (a->tabs[i].id == tid) at = i; + char msg[128]; + if (name[0]) snprintf(msg, sizeof msg, "moved to tab %zu (%s)", at + 1, name); + else snprintf(msg, sizeof msg, "moved to tab %zu", at + 1); + app_toast(a, msg); + return true; +} + static bool run_action(app_t *a, action_t act); /* Do what a row says it does. One entry point for the keyboard and the mouse, @@ -5099,6 +5155,15 @@ static bool run_action(app_t *a, action_t act) { app_toast(a, app_clear_pane_shaders(a, 0) ? "shaders cleared" : "no shaders on this pane"); return true; + case ACT_PANE_TO_NEXT_TAB: + case ACT_PANE_TO_PREV_TAB: + return push_pane_a_tab(a, act == ACT_PANE_TO_NEXT_TAB); + case ACT_PANE_TO_NEW_TAB: { + uint32_t made = app_move_pane_to_new_tab(a, 0, ""); + app_toast(a, made ? "into a tab of its own" + : "it is the only pane in this tab"); + return true; + } case ACT_EQUALIZE: if (!app_equalize_splits(a)) app_toast(a, "nothing to even out"); return true; @@ -5537,7 +5602,13 @@ static void panes_cb(node_t *n, void *ud) { } json_str(j, "purpose", n->purpose, strlen(n->purpose)); json_bool(j, "purpose_declared", n->purpose_locked); - json_int(j, "tab", (long long)tab_of(pj->a, n) + 1); + /* Both, because they answer different questions and only one of them is stable: + * `tab` is where it sits in the strip, which is what a person reads, and `tab_id` + * is what every command that takes a tab wants. They coincide until a tab is + * removed, which is exactly when a script that guessed would be wrong. */ + size_t ti = tab_of(pj->a, n); + json_int(j, "tab", (long long)ti + 1); + json_int(j, "tab_id", (long long)pj->a->tabs[ti].id); const char *t = pane_title(n->pane); json_str(j, "title", t ? t : "", t ? strlen(t) : 0); json_obj_close(j); diff --git a/src/config.c b/src/config.c index 52188ed..d0cd347 100644 --- a/src/config.c +++ b/src/config.c @@ -25,6 +25,9 @@ static const struct { {"resize-up", ACT_RESIZE_UP}, {"resize-down", ACT_RESIZE_DOWN}, {"equalize", ACT_EQUALIZE}, {"clear-shaders", ACT_CLEAR_SHADERS}, + {"pane-to-next-tab", ACT_PANE_TO_NEXT_TAB}, + {"pane-to-prev-tab", ACT_PANE_TO_PREV_TAB}, + {"pane-to-new-tab", ACT_PANE_TO_NEW_TAB}, {"rotate-layout", ACT_ROTATE_LAYOUT}, {"scroll-up", ACT_SCROLL_UP}, {"scroll-down", ACT_SCROLL_DOWN}, {"scroll-page-up", ACT_SCROLL_PAGE_UP}, @@ -860,6 +863,21 @@ void config_defaults(config_t *c) { * that reaches for the right thing here. Four presses come back round, which * is what makes a key this easy to hit the right choice rather than a hazard. */ bind_add(c, GHOSTTY_KEY_SPACE, 0, ACT_ROTATE_LAYOUT, false); + /* `>` and `<` are the shifted period and comma, which is how the sheet prints + * them and how anybody would type them. Pushing a pane one tab along is the + * cheap version of moving it: no picker, no drag, and the toast says where it + * went since you do not follow it. */ + bind_add(c, GHOSTTY_KEY_PERIOD, MOD_SHIFT, ACT_PANE_TO_NEXT_TAB, false); + bind_add(c, GHOSTTY_KEY_COMMA, MOD_SHIFT, ACT_PANE_TO_PREV_TAB, false); + /* ...and without the shift, because a terminal does not report one for + * punctuation: `>` arrives as a bare period, the way `?` arrives as a bare + * slash. The config loader binds both halves for the same reason when you write + * one of these by hand; the defaults have to do it themselves. The cost is that + * `.` and `,` are spoken for, which is the cost `/` already pays for `?`. */ + bind_add(c, GHOSTTY_KEY_PERIOD, 0, ACT_PANE_TO_NEXT_TAB, false); + bind_add(c, GHOSTTY_KEY_COMMA, 0, ACT_PANE_TO_PREV_TAB, false); + /* `b` for break out, which is the word tmux taught everybody for this. */ + bind_add(c, GHOSTTY_KEY_B, 0, ACT_PANE_TO_NEW_TAB, false); bind_add(c, GHOSTTY_KEY_C, 0, ACT_NEW_TAB, false); /* Cycling tabs is on tab/shift+tab, not on n/p: `p` is the palette, which is * pressed far more often than "the tab before this one" and had the only @@ -927,6 +945,12 @@ static const struct { /* 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"}, + /* Under `panes`: what moves is the pane, and the tab it lands in is where it + * lands. A reader looking for "how do I get this thing out of here" looks up + * the thing, not the place. */ + {ACT_PANE_TO_NEXT_TAB, "panes", "push it to the tab after"}, + {ACT_PANE_TO_PREV_TAB, "panes", "push it to the tab before"}, + {ACT_PANE_TO_NEW_TAB, "panes", "into a tab of its own"}, {ACT_ROTATE_LAYOUT, "size", "turn the layout a quarter"}, {ACT_NEW_TAB, "tabs", "new tab"}, diff --git a/src/config.h b/src/config.h index 062291e..16c4218 100644 --- a/src/config.h +++ b/src/config.h @@ -71,6 +71,9 @@ typedef enum { ACT_RESIZE_DOWN, ACT_EQUALIZE, ACT_CLEAR_SHADERS, + ACT_PANE_TO_NEXT_TAB, + ACT_PANE_TO_PREV_TAB, + ACT_PANE_TO_NEW_TAB, ACT_DETACH, ACT_QUIT, ACT_HELP, diff --git a/tests/test_help.py b/tests/test_help.py index d4300bd..765bff2 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -78,7 +78,10 @@ def modal_top(snap): def test_sections_are_separated_by_a_blank_line(): """Five short lists rather than one wall of rows. The line above a heading is blank -- except the first, which the caption already separates.""" - with Session(SH, cols=56, rows=44) as s: # narrow: one column, easy to read + # Narrow, so one column and easy to read -- and tall enough for all of it, + # because this is about the spacing between groups and not about what a short + # terminal does with the overflow (which is checked below). + with Session(SH, cols=56, rows=56) as s: s.settle() snap = open_help(s) text = [l for l in snap.text if "│" in l] diff --git a/tests/test_move_pane.py b/tests/test_move_pane.py index d77e18b..fda17ab 100755 --- a/tests/test_move_pane.py +++ b/tests/test_move_pane.py @@ -26,10 +26,14 @@ def cfg(text): def by_tab(s): - """Which panes are in which tab, by id.""" + """Which panes are in which tab, by tab **id**. + + `tab_id`, not `tab`: the latter is where the tab sits in the strip, and the two + stop agreeing the moment a tab is removed -- which is half of what this file is + about.""" out = {t["id"]: [] for t in s.tabs()} for p in s.panes(): - out.setdefault(p["tab"], []).append(p["id"]) + out.setdefault(p["tab_id"], []).append(p["id"]) return {k: sorted(v) for k, v in out.items()} @@ -89,7 +93,7 @@ def test_it_lands_beside_the_destination_focus(): s.api("select-tab", id=tabs[1]) s.settle(60) rects = {p["id"]: (p["x"], p["y"], p["w"], p["h"]) for p in s.panes() - if p["tab"] == tabs[1]} + if p["tab_id"] == tabs[1]} check("both panes are in the destination", len(rects) == 2, str(rects)) check("side by side, the arrival on the right", rects[mover][0] > rects[host][0], str(rects)) @@ -101,7 +105,7 @@ def test_it_lands_beside_the_destination_focus(): s.api("select-tab", id=tabs[0]) s.settle(60) rects = {p["id"]: (p["x"], p["y"], p["w"], p["h"]) for p in s.panes() - if p["tab"] == tabs[0]} + if p["tab_id"] == tabs[0]} check("`rows` puts it underneath", rects[mover][1] > min(r[1] for r in rects.values()), str(rects)) @@ -194,8 +198,8 @@ def test_a_move_drops_what_the_old_tab_thought(): pane = [p for p in s.panes() if p["id"] == mover][0] check("it arrives laid out, not filed away", pane["h"] > 1, str(pane)) check("and the tab it left is not zoomed onto a pane that has gone", - all(p["h"] > 1 for p in s.panes() if p["tab"] == tabs[0]), - str([p for p in s.panes() if p["tab"] == tabs[0]])) + all(p["h"] > 1 for p in s.panes() if p["tab_id"] == tabs[0]), + str([p for p in s.panes() if p["tab_id"] == tabs[0]])) def test_the_layout_survives_a_move(): @@ -208,14 +212,14 @@ def test_the_layout_survives_a_move(): s.settle(40) for target in (tabs[1], tabs[0], tabs[1]): - movable = [p["id"] for p in s.panes() if p["tab"] != target] + movable = [p["id"] for p in s.panes() if p["tab_id"] != target] if not movable: continue s.api("move-pane", id=movable[0], tab=target) s.settle(60) rs = [(p["x"], p["y"], p["w"], p["h"]) for p in s.panes() - if p["tab"] == [t["id"] for t in s.tabs() if t.get("active")][0]] + if p["tab_id"] == [t["id"] for t in s.tabs() if t.get("active")][0]] for i in range(len(rs)): for j in range(i + 1, len(rs)): ax, ay, aw, ah = rs[i] @@ -237,6 +241,114 @@ def test_the_layout_survives_a_move(): os.unlink(path) +def test_the_keys_push_a_pane_a_tab_along(): + """`C-a >` and `C-a <`, and a word about where it went -- you do not follow it, + and a key that might have done nothing is a key you press again.""" + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + s.api("select-tab", id=tabs[0]) + s.settle(40) + watching = [t["id"] for t in s.tabs() if t.get("active")] + moved = [p["id"] for p in s.panes() + if p["tab_id"] == tabs[0] and p["focused"]][0] + + s.key(">") + s.settle(60) + check("`C-a >` moves the focused pane to the next tab", + moved in by_tab(s)[tabs[1]], str(by_tab(s))) + check("and says where it went", "moved to tab" in s.snapshot().screen(), + repr(s.snapshot().screen()[-120:])) + check("without following it", + [t["id"] for t in s.tabs() if t.get("active")] == watching, + str([t["id"] for t in s.tabs() if t.get("active")])) + + s.api("select-tab", id=tabs[1]) + s.api("focus", id=moved) + s.settle(40) + s.key("<") + s.settle(60) + check("`C-a <` sends it back the other way", + moved in by_tab(s)[tabs[0]], str(by_tab(s))) + + +def test_the_key_for_a_tab_of_its_own(): + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + s.api("select-tab", id=tabs[0]) + s.settle(40) + moved = [p["id"] for p in s.panes() + if p["tab_id"] == tabs[0] and p["focused"]][0] + + s.key("b") + s.settle(60) + made = [t["id"] for t in s.tabs() if t["id"] not in tabs] + check("`C-a b` breaks it out into a new tab", len(made) == 1, str(s.tabs())) + check("with the pane in it", by_tab(s).get(made[0]) == [moved], + str(by_tab(s))) + check("and says so", "tab of its own" in s.snapshot().screen(), + repr(s.snapshot().screen()[-120:])) + + +def test_one_tab_has_nowhere_to_push_to(): + """Refused with a word rather than silently: the only tab is the one it is in.""" + with Session(ECHO, cols=80, rows=14) as s: + s.settle() + s.key("\\\\") + s.settle(40) + before = by_tab(s) + s.key(">") + s.settle(60) + check("nothing moved", by_tab(s) == before, "%s -> %s" % (before, by_tab(s))) + check("and it says why", "only one tab" in s.snapshot().screen(), + repr(s.snapshot().screen()[-120:])) + + +def test_pushing_the_last_pane_out_takes_the_tab_with_it(): + """The tab you were looking at can be the one that goes. There is nowhere to + stay, so you arrive in the tab that survived -- and the toast reads the + destination's number *after* the removal, which is the number on screen.""" + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + s.api("select-tab", id=tabs[1]) # the tab with one pane in it + s.settle(40) + s.key(">") + s.settle(60) + check("the tab it emptied is gone", + [t["id"] for t in s.tabs()] == [tabs[0]], str(s.tabs())) + check("all three panes are in the survivor", + len(by_tab(s)[tabs[0]]) == 3, str(by_tab(s))) + check("and that is what we are looking at", + [t["id"] for t in s.tabs() if t.get("active")] == [tabs[0]], + str(s.tabs())) + + +def test_the_sheet_and_the_palette_know_about_them(): + """The palette comes free with an action, which is the reason to make these + actions rather than special cases in the key handler. And the cheatsheet has to + print `>`, not the bare period it also answers to -- what you would type.""" + with Session(ECHO, cols=100, rows=30) as s: + s.settle() + s.key("?") + s.settle(60) + sheet = s.snapshot().screen() + check("the sheet lists pushing a pane along", "tab after" in sheet, + repr([l.strip() for l in s.snapshot().text if "tab after" in l])) + check("under the key you would type", ">" in sheet, "") + s.send("\\x1b") + s.settle(40) + + s.key("p") + s.settle(40) + s.send("tab of its own") + s.settle(60) + pal = s.snapshot().screen() + check("and the palette finds it by its label", "tab of its own" in pal, + repr([l.strip() for l in s.snapshot().text if "own" in l])) + + if __name__ == "__main__": test_a_pane_moves_and_keeps_running() test_it_lands_beside_the_destination_focus() @@ -245,4 +357,9 @@ if __name__ == "__main__": test_the_refusals() test_a_move_drops_what_the_old_tab_thought() test_the_layout_survives_a_move() + test_the_keys_push_a_pane_a_tab_along() + test_the_key_for_a_tab_of_its_own() + test_one_tab_has_nowhere_to_push_to() + test_pushing_the_last_pane_out_takes_the_tab_with_it() + test_the_sheet_and_the_palette_know_about_them() sys.exit(report()) |
