diff options
| author | user <user@clank> | 2026-08-16 20:34:29 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-16 20:34:29 +0200 |
| commit | 967d8521c2b021457f33bf87ef762100a5e2a74c (patch) | |
| tree | e1d0d4b7214e2595c37d06d70d4590cfaf6e26af | |
| parent | sl0ppty: layouts are `*.layout.kdl`, and each loader says which it got (diff) | |
| download | slosh-967d8521c2b021457f33bf87ef762100a5e2a74c.tar.gz slosh-967d8521c2b021457f33bf87ef762100a5e2a74c.tar.xz slosh-967d8521c2b021457f33bf87ef762100a5e2a74c.zip | |
sl0ppty: `C-a Enter` splits the longer way
One key for "I want a pane and I have no opinion about where". It cuts across the
pane's longer side, so the two halves come out as square as the pane allows, and
pressing it four times on a 100x30 terminal tiles the tab the way you would have
done it by hand: a column, then rows inside the new column, then rows again, then
a word saying there is no room left.
"Longer" is a question about what you can *see*. A cell is about twice as tall as
it is wide, so 80x24 is a wide pane (80 against 48) while 40x24 is not (40 against
48). `gap_aspect` is already this program's answer to how many columns a row is
worth, so that is the number used rather than a second constant meaning the same
thing -- and a config that says cells are square gets a square answer. There is a
test at 74x44, the size where the two readings disagree: square cells split it into
columns, the default splits it into rows.
The fallback is the other half of "sensible". A pane that is wide but ten rows tall
wants cutting across; if `split_fits` says that will not fit, the other axis is
tried before giving up. And it says which way it went -- `split right` or `split
down`, the same two words the border click already uses -- because one key that can
do two things has to tell you which it did.
**`split_fits` had a hole this action made obvious.** It only ever asked about the
dimension being divided, so a pane four rows tall on a short terminal "had room" for
two columns: the width was fine, and the two panes it produced were immediately
collapsed into a list by the layout. The border was offering that, and both split
keys were doing it. It now also requires the axis it does *not* divide to clear the
floor, because splitting cannot improve that one -- the same hole `layout_node`
closed at the leaf, one level up. On a 100x8 terminal all three now say "no room to
split" instead of undoing the arrangement.
Verified: nine checks in `tests/test_split_ux.py` -- the axis chosen for wide and
for tall panes, the aspect deciding at 74x44, both fallback directions, the refusal
and that the explicit keys refuse the same way, and that it appears on the
cheatsheet under `enter` and in the palette by name. The existing 65 checks about
what the border offers still pass, which is what I was most worried about.
| -rw-r--r-- | config/config.kdl | 5 | ||||
| -rw-r--r-- | docs/keys.md | 5 | ||||
| -rw-r--r-- | docs/panes.md | 13 | ||||
| -rw-r--r-- | src/app.c | 53 | ||||
| -rw-r--r-- | src/config.c | 7 | ||||
| -rw-r--r-- | src/config.h | 1 | ||||
| -rw-r--r-- | tests/test_split_ux.py | 142 |
7 files changed, 219 insertions, 7 deletions
diff --git a/config/config.kdl b/config/config.kdl index afacd47..49d75e2 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -701,7 +701,7 @@ theme { // // `sl0ppty --check` reads this file and says what it could not honour. // -// Actions: split-cols split-rows close-pane focus-left focus-right focus-up +// Actions: split 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 pane-to-next-tab pane-to-prev-tab pane-to-new-tab // scroll-up scroll-down scroll-page-up scroll-page-down scroll-top @@ -729,6 +729,9 @@ keys { // Pressing it twice always sends the key itself, whichever key it is. prefix "ctrl+a" + // Enter splits whichever way there is room for, preferring the longer side; + // the next two are for when you mean a particular axis. + bind "enter" "split" bind "\\" "split-cols" bind "-" "split-rows" bind "x" "close-pane" diff --git a/docs/keys.md b/docs/keys.md index 648568a..47c419b 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -14,7 +14,8 @@ it did not — `+7 more, in docs/keys.md`, which is the table below. | key | does | |---|---| -| `\` `-` | split into columns / rows | +| `Enter` | split whichever way there is more room — across the longer side, and the other axis if that will not fit | +| `\` `-` | split into columns / rows, when you mean one | | `h` `j` `k` `l`, or arrows | move focus | | `o` | the next pane | | `H` `J` `K` `L`, or shift+arrows | move the boundary between panes | @@ -100,7 +101,7 @@ being bound at all. | group | actions | |---|---| -| 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` | +| panes | `split` `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` | diff --git a/docs/panes.md b/docs/panes.md index 95bcdc5..f6f9297 100644 --- a/docs/panes.md +++ b/docs/panes.md @@ -5,8 +5,12 @@ looking at is drawn. ## Arranging -- **Split** with `C-a \` and `C-a -`. Splitting the same direction again gives - three equal shares rather than 1/2 + 1/4 + 1/4. +- **Split** with `C-a Enter` and it picks the axis: across the pane's longer side, + measured as you see it (a cell is about twice as tall as it is wide, which is + what `gap_aspect` says), falling back to the other axis when that one will not + fit and saying which way it went. `C-a \` and `C-a -` when you mean one. + Splitting the same direction again gives three equal shares rather than + 1/2 + 1/4 + 1/4. - **Resize** with `C-a H J K L`: the boundary moves the way you press, whichever side of it you are on. Sizes are *weights*, so a resized layout survives the window changing size. @@ -81,7 +85,10 @@ recomputed from the tree every frame, and the list is what that function returns when the room runs out. `min_pane` is where that starts happening; `min_split` is the smaller pane a -split is willing to *create*. +split is willing to *create*. A split is refused when either floor would be +broken — including the axis it does not divide, since splitting cannot improve +that one: a pane already too short for two rows has no room for two columns +either, whatever its width says. ## Text, images, bells @@ -1593,7 +1593,18 @@ static bool split_fits(node_t *leaf, split_dir_t dir) { k = 2; total = dir == SPLIT_COLS ? leaf->rect.w : leaf->rect.h; } - return total >= (uint16_t)(k * floor_ + gap * (k - 1)); + if (total < (uint16_t)(k * floor_ + gap * (k - 1))) return false; + + /* And the axis this split does *not* divide has to already clear the floor, + * because splitting will not improve it. A pane four rows tall on a short + * terminal has room for two columns by every measure this function used to take, + * and produces two panes the layout immediately collapses into a list -- so the + * border was offering, and the keys were doing, something that could only undo + * the arrangement. The same hole `layout_node` closed at the leaf: the check on a + * split only ever asked about the dimension that split divides. */ + uint16_t across = dir == SPLIT_COLS ? leaf->rect.h : leaf->rect.w; + uint16_t across_floor = dir == SPLIT_COLS ? MIN_PANE_ROWS : MIN_PANE_COLS; + return across >= across_floor; } static split_dir_t side_dir(char side) { @@ -1624,6 +1635,45 @@ static void split_focus_ui(app_t *a, split_dir_t dir) { split_node(a, n, dir, false); } +/* Which way this pane wants to be cut: across its longer side, so the two halves + * come out as square as the pane allows. + * + * "Longer" is a question about what you can *see*, and a cell is about twice as + * tall as it is wide -- so 80x24 is wider than it is tall (80 against 48) while + * 40x24 is not (40 against 48). `gap_aspect` is already this program's answer to + * how many columns a row is worth, so it is the number used here rather than a + * second constant meaning the same thing: a config that says cells are square gets + * a square answer. */ +static split_dir_t preferred_dir(const node_t *leaf) { + uint16_t aspect = CFG.gap_aspect ? CFG.gap_aspect : 1; + return (uint32_t)leaf->rect.w >= (uint32_t)leaf->rect.h * aspect ? SPLIT_COLS + : SPLIT_ROWS; +} + +/* Split whichever way there is room for, preferring the longer side. + * + * The fallback is the point: a pane that is wide but six rows tall wants cutting + * across, and if that will not fit `split_fits` says so and the other axis is tried + * before giving up. Refusing while a split was available would be the worse answer. + * It does not pick quietly either -- one key that can do two things has to say + * which it did. */ +static void split_focus_auto(app_t *a) { + node_t *n = cur(a)->focus; + if (!n) return; + split_dir_t want = preferred_dir(n); + split_dir_t other = want == SPLIT_COLS ? SPLIT_ROWS : SPLIT_COLS; + split_dir_t dir; + if (split_fits(n, want)) dir = want; + else if (split_fits(n, other)) dir = other; + else { + app_toast(a, "no room to split"); + return; + } + split_node(a, n, dir, false); + /* The same two words the border click uses for the same two outcomes. */ + app_toast(a, dir == SPLIT_COLS ? "split right" : "split down"); +} + static node_t *first_leaf(node_t *n) { while (n && n->kind == NODE_SPLIT) n = n->kids[0]; return n; @@ -5191,6 +5241,7 @@ static bool run_action(app_t *a, action_t act) { return true; } 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_ZOOM: app_toggle_zoom(a, 0); return true; diff --git a/src/config.c b/src/config.c index d540dd5..70d0fae 100644 --- a/src/config.c +++ b/src/config.c @@ -15,6 +15,7 @@ static const struct { const char *name; action_t action; } ACTIONS[] = { + {"split", ACT_SPLIT}, {"split-cols", ACT_SPLIT_COLS}, {"split-rows", ACT_SPLIT_ROWS}, {"close-pane", ACT_CLOSE_PANE}, {"rerun", ACT_RERUN}, {"zoom", ACT_ZOOM}, @@ -827,6 +828,9 @@ void config_defaults(config_t *c) { c->prefix_key = GHOSTTY_KEY_A; c->prefix_mods = MOD_CTRL; + /* Enter: the key you press when you want *a* pane and have no opinion about + * where. The two explicit splits are one key away when you do. */ + bind_add(c, GHOSTTY_KEY_ENTER, 0, ACT_SPLIT, false); bind_add(c, GHOSTTY_KEY_BACKSLASH, 0, ACT_SPLIT_COLS, false); bind_add(c, GHOSTTY_KEY_MINUS, 0, ACT_SPLIT_ROWS, false); bind_add(c, GHOSTTY_KEY_X, 0, ACT_CLOSE_PANE, false); @@ -921,6 +925,9 @@ static const struct { const char *group; const char *label; } ACTION_HELP[] = { + /* First in the group: the one to reach for, with the two explicit ones after + * it for when you mean a particular axis. */ + {ACT_SPLIT, "panes", "split the longer way"}, {ACT_SPLIT_COLS, "panes", "split into columns"}, {ACT_SPLIT_ROWS, "panes", "split into rows"}, {ACT_CLOSE_PANE, "panes", "close this pane"}, diff --git a/src/config.h b/src/config.h index 365559c..8ef4f9e 100644 --- a/src/config.h +++ b/src/config.h @@ -42,6 +42,7 @@ const char *pane_state_name(pane_state_t s); typedef enum { ACT_NONE = 0, + ACT_SPLIT, ACT_SPLIT_COLS, ACT_SPLIT_ROWS, ACT_CLOSE_PANE, diff --git a/tests/test_split_ux.py b/tests/test_split_ux.py index e1b9942..398ca7e 100644 --- a/tests/test_split_ux.py +++ b/tests/test_split_ux.py @@ -402,6 +402,143 @@ def test_the_arrow_sits_on_the_new_boundary(): pos is not None and pos[0] == p["x"] + p["w"] // 2, str(pos)) +# ---- `C-a Enter`: split whichever way there is room for ----------------------- + + +def geometry(s): + return [(p["x"], p["y"], p["w"], p["h"]) + for p in sorted(s.panes(), key=lambda q: (q["y"], q["x"]))] + + +def toast_line(s): + got = [l.strip() for l in s.snapshot().text + if "split right" in l or "split down" in l or "no room" in l] + return got[-1] if got else "" + + +def test_it_splits_across_the_longer_side(): + """Longer as you *see* it: a cell is about twice as tall as it is wide, so a + 100x24 terminal is a wide pane and wants cutting into columns, while 60x40 is a + tall one and wants rows. `gap_aspect` is the number that says so.""" + with Session(SH, cols=100, rows=24) as s: + s.settle() + s.key(r"\r") + s.settle(60) + g = geometry(s) + check("a wide pane splits into columns", len(g) == 2 and g[0][1] == g[1][1], + str(g)) + check("side by side, evenly", g[0][2] == g[1][2], str(g)) + check("and it says which way it went", toast_line(s) == "split right", + repr(toast_line(s))) + + with Session(SH, cols=60, rows=40) as s: + s.settle() + s.key(r"\r") + s.settle(60) + g = geometry(s) + check("a tall pane splits into rows", len(g) == 2 and g[0][0] == g[1][0], + str(g)) + check("one above the other", g[1][1] > g[0][1], str(g)) + check("and says so", toast_line(s) == "split down", repr(toast_line(s))) + + +def test_the_aspect_is_the_configured_one(): + """`gap_aspect` is already this program's answer to how many columns a row is + worth, so it is the number that decides "longer" -- one knob, one answer, rather + than a second constant meaning the same thing. + + 74x44 is the case that proves it: the pane is about 72x40, which is wider than it + is tall in *cells* and taller than it is wide once a row counts double. Square + cells split it into columns; the default splits it into rows. + """ + square = cfg("gap_aspect 1\n") + with Session(SH, cols=74, rows=44, config=square) as s: + s.settle() + s.key(r"\r") + s.settle(60) + g = geometry(s) + check("with square cells, 72x40 splits into columns", + len(g) == 2 and g[0][1] == g[1][1], str(g)) + with Session(SH, cols=74, rows=44) as s: + s.settle() + s.key(r"\r") + s.settle(60) + g = geometry(s) + check("...and with the default aspect, the same pane splits into rows", + len(g) == 2 and g[0][0] == g[1][0], str(g)) + os.unlink(square) + + +def test_it_falls_back_to_the_axis_that_fits(): + """The point of the action: on a pane that is wide but short the longer side is + across, and if that does not fit the other one is tried before giving up.""" + # 150x14: the pane is 146x10 -- wide, so columns are preferred, and two columns + # of 73 clear `min_split cols=32`. Rows would not (10 rows cannot make two of 8). + with Session(SH, cols=150, rows=14) as s: + s.settle() + s.key(r"\r") + s.settle(60) + g = geometry(s) + check("wide and short splits into columns", g[0][1] == g[1][1], str(g)) + + # 40x50: the pane is 36x46 -- tall, so rows, and 46 makes two of 8 easily while + # 36 columns cannot make two of 32. + with Session(SH, cols=40, rows=50) as s: + s.settle() + s.key(r"\r") + s.settle(60) + g = geometry(s) + check("narrow and tall splits into rows", g[0][0] == g[1][0], str(g)) + check("...even though columns is what a naive cell count would pick", + g[1][1] > g[0][1], str(g)) + + +def test_no_room_says_so_rather_than_collapsing_the_tab(): + """A pane already under the floor cannot be split into two panes worth having by + either axis -- and `split_fits` used to say yes, because it only asked about the + dimension it was dividing. The answer was two panes the layout immediately + collapsed into a list, from an action that was offered.""" + with Session(SH, cols=100, rows=8) as s: + s.settle() + before = geometry(s) + s.key(r"\r") + s.settle(60) + check("nothing was split", geometry(s) == before, + "%s -> %s" % (before, geometry(s))) + check("and it says why", toast_line(s) == "no room to split", + repr(toast_line(s))) + + # The explicit keys answer the same way, because whether there is room cannot + # depend on which key you asked with. + with Session(SH, cols=100, rows=8) as s: + s.settle() + before = geometry(s) + s.key("\\\\") + s.settle(60) + check("`C-a \\` refuses it too", geometry(s) == before, str(geometry(s))) + check("...in its own words", "no room to split across" in s.snapshot().screen(), + repr(s.snapshot().screen()[-100:])) + + +def test_it_is_on_the_sheet_and_in_the_palette(): + with Session(SH, cols=100, rows=30) as s: + s.settle() + s.send(r"\x01?") + s.settle(60) + sheet = s.snapshot().screen() + check("the sheet lists it", "split the longer way" in sheet, + repr([l.strip() for l in s.snapshot().text if "longer" in l])) + check("under Enter", "enter" in sheet.lower(), "") + s.send(r"\e") + s.settle(40) + s.send(r"\x01p") + s.settle(40) + s.send("longer") + s.settle(60) + check("and the palette finds it", "split the longer way" in s.snapshot().screen(), + repr([l.strip() for l in s.snapshot().text if "longer" in l])) + + if __name__ == "__main__": test_no_more_plus() test_each_border_splits_toward_itself() @@ -418,4 +555,9 @@ if __name__ == "__main__": test_the_keyboard_obeys_the_same_floor() test_the_guide_says_which_way_it_goes() test_the_arrow_sits_on_the_new_boundary() + test_it_splits_across_the_longer_side() + test_the_aspect_is_the_configured_one() + test_it_falls_back_to_the_axis_that_fits() + test_no_room_says_so_rather_than_collapsing_the_tab() + test_it_is_on_the_sheet_and_in_the_palette() sys.exit(report()) |
