<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/test_split_ux.py, branch docs-new</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/tests/test_split_ux.py?h=docs-new</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/test_split_ux.py?h=docs-new'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-18T14:20:37Z</updated>
<entry>
<title>sl0ppty: aim the top handle at the middle, and stop ruling through the row</title>
<updated>2026-08-18T14:20:37Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-18T14:20:37Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=13967ce08d440a850665c81d407e710c1c77394d'/>
<id>urn:sha1:13967ce08d440a850665c81d407e710c1c77394d</id>
<content type='text'>
Two faults that fed each other, and only the second one made the first visible.

The handle went to the **widest** free run of the top row. A centred title leaves
two runs of nearly the same width, so a one-cell difference nobody can see threw
the handle out toward a corner: on a 116-column pane whose row middle is 60, it
sat at 85. Placement should be stated in terms of where the thing wants to be,
not of the leftovers it is allowed to occupy -- so it now aims at the middle of
the row and slides into the nearest run that is free. Nothing in the way: centred,
like the other three sides. A title holding the middle: hard against the title,
where the eye already is. That same pane now puts it at 64.

The armed edge also ruled the heavy line straight across the whole border, over
the pane's name, its buttons, its scroll indicator and a dead pane's epitaph. The
row went unreadable exactly while the pointer was on it -- and the title the
handle had just been placed beside was wiped, which is what turned a merely-off
position into an apparently random one. Each cell of the edge is now painted only
if the hit list says the edge still owns it: the same question the handle asks,
and the one the click will ask.

The epitaph had to start claiming its cells to take part. It answers
`panestatus:` and does nothing when clicked, because an epitaph is not a button;
what it buys is that ` exited: status 3 ` survives a hover over the border it
lives on, next to `[re-run]` and `[close]`, which already survived because they
were buttons.

Verified: `make retest`, **1877 checks measured**, all green. Four new: the handle
is centred on the row when the row is empty, sits against the title when the
title holds the middle, and the armed top edge leaves both the name and the three
buttons legible.

Live on a 116x36 pane, hovering the far left of the top row -- deliberately the
rim, so this is the first stage doing all of it:

  ╭━━━━━━━━━━━━━━━━━━━ sl0ppty ███▲███━━━━━━━━━━━━━━━━━▬ □ ✕ ╮

and on a dead pane, hovering the bottom handle:

  ╰ exited: status 3 ━━━━━━━━━━███▼███━━━━━━━━[re-run]━[close]━╯
</content>
</entry>
<entry>
<title>sl0ppty: the split arrow belongs on the button</title>
<updated>2026-08-18T14:10:10Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-18T14:10:10Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=14dad660036e34a899a406f09195f790bb60709c'/>
<id>urn:sha1:14dad660036e34a899a406f09195f790bb60709c</id>
<content type='text'>
The arrow rode the middle of the dashed boundary line, which put the two halves
of one message in two places: the handle said *press here* and a glyph out in the
middle of the pane said *this is what happens*. It also meant the direction was
unreadable until you were already on the target, since the dashed line is stage
two of the hover.

So the arrow moves into the handle and the boundary line goes back to being a
plain boundary -- which is the honest shape for it anyway: it is the *edge* of the
pane you are about to make, and an edge has no middle worth marking. The handle
is now one object that explains itself, and because the handle is drawn from the
first stage, brushing any part of an edge tells you where its button is and which
way that button splits, without previewing a boundary nobody asked about.

Knocked out with ATTR_INVERSE rather than drawn in a second colour: the bar is
solid `guide`, so a `guide` glyph on top of it would have been invisible, and
inverting gets contrast that follows whatever colour the theme sets for no new
knob. Along the handle the arrow sits in the middle, where the pointer is aiming.
Across it -- a distinction only the upright pair has, being the two that grow a
second cell -- it sits on the edge's own cell rather than the one over the
content, because it marks a boundary and the boundary lands on the frame.

Verified: `make retest`, **1873 checks measured** (`test_split_ux.py` 100 of
them), all green. The test that pinned the arrow to the dashed line is now the
test that pins it to the handle, on all four sides, plus one that it is legible
from the first stage with no boundary drawn.

Checked live on a 116x36 pane: `◄█` at column 2 and `█►` at column 117 on the
middle row of their bars, `━━━███▼███━━━` on the bottom border's single row, and
-- the case that shows why this is worth it -- hovering the *far left* of the top
row draws `███▲███` at column 85, where the handle actually is, having dodged a
centred title.

A note on the two commits before this one: their messages quote 1801 and 1807
checks, which were carried over from earlier in the session rather than measured.
The figure was 1865 at that point. The code and the tests were right; the numbers
in those two messages were not.
</content>
</entry>
<entry>
<title>sl0ppty: a horizontal handle gets heavier, not taller</title>
<updated>2026-08-18T13:49:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-18T13:49:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=0e48544210c58e55ebc17d399b390c6c3bba9492'/>
<id>urn:sha1:0e48544210c58e55ebc17d399b390c6c3bba9492</id>
<content type='text'>
The armed handle grew by one cell into the pane on all four sides, which is one
change on two axes and therefore two different results. A cell is about twice as
tall as it is wide -- the fact `gap_aspect` exists for, and the one the split
picker measures its longer side by -- so the extra column on a left or right
edge read as a thicker line, and the extra row on a top or bottom edge read as a
wall. It also spent a row of somebody's output to say something the glyph was
already saying: `█` is the heaviest thing one cell can draw, and against `━` it
needs no help.

So only the upright sides widen now. A horizontal handle stays on its own row.
That also removes its claim on a content cell, which is why the inner-strip hit
is registered on those two sides only -- the paint and the hit still describe the
same rect, which is the whole reason the guide registers what it thickens.

Verified: `make retest`, 1807 checks, all green -- six new, pinning the asymmetry
in both directions: the top and bottom handles occupy exactly the row their
border is on, the left and right ones exactly two columns, and the inner column
of each upright handle hit-tests as that border rather than as the pane.

Measured on a live session rather than argued: hovering the bottom border of a
116x36 pane puts seven block cells on row 37 and no others, inside the `━` rule;
hovering the left border puts them on rows 16-22 across columns 2 and 3.
</content>
</entry>
<entry>
<title>sl0ppty: the middle of a border is the button, not all of it</title>
<updated>2026-08-18T13:24:25Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-18T13:24:25Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=8231838e92e5a31e85f53f8efa1b2a1e407e7cd1'/>
<id>urn:sha1:8231838e92e5a31e85f53f8efa1b2a1e407e7cd1</id>
<content type='text'>
Clicking a pane's border to split toward it is the right gesture and was the
wrong target. A border is the longest thing on the screen and it sits between
two things you did mean to click -- a title, a gap, the pane beyond it -- so
reaching a cell too far changed the layout, and the top row was worse than the
sides: clicking a pane's header to focus it split it upward.

So the target shrank. Each side carries a *handle* of three to seven cells in the
middle, and only the handle splits; the rest of the edge is the *rim*, which
arms the guide and does nothing when pressed. Three is the floor the pane
buttons already settled on for the reason written there -- a one-cell target is
a thing you miss with a mouse -- and seven is the ceiling, because a third of a
tall pane's border is most of that border again and brings the accidents back
with it.

Hover now answers two questions in two stages, because they are two questions.
Anywhere on an edge, the edge goes heavy and the handle thickens by a cell into
the pane: that says where the button is, which is the thing you can no longer
infer. On the handle, the dashed boundary and the arrow as well: that says what
the click will do. The hint follows the same split, so sweeping an edge neither
draws a boundary nor offers a split you cannot get from where the pointer is.

Three things this had to get right.

**The thickened handle is registered, not just painted.** The guide adds the
hit for the inner strip in the frame that draws it, and reads the outer rect
back from the hit list rather than recomputing it, so there is one opinion about
where the handle is (D6). A handle you can see and cannot click would be worse
than either mistake alone.

**The top row cannot put its handle in the middle.** The buttons own its right
and a centred title -- the default -- owns exactly the middle. The title winning
is the existing rule, and it is what keeps a double-click rename from splitting
twice on its way, so the handle is placed last, in the widest run of cells the
row has left and nearest the middle among equals. A row with nothing left offers
no upward split; its other three edges are unaffected. Each handle cell is then
painted only if the hit list still agrees it belongs to the handle, so a block
never lands on a pane's name.

**The row keeps the drag and loses the split.** `title:&lt;id&gt;` takes no side now
and `title:&lt;id&gt;:t` -- the handle -- carries one, which is why the press path
reads the side out of the action instead of assuming it. Press-and-hold skips
the dwell on a handle, where a press means something, rather than anywhere on an
edge.

Verified: `make retest`, 1801 checks, all green. Nine of them are new or
rewritten: the two-stage guide, the caption belonging to the handle, the rim
splitting nothing on any of the four sides, the top row's handle still splitting
upward on a titled pane, and the rim of the top row not. Tests read the handle's
cell out of the hit list (`Snapshot.handle`/`.rim`) rather than deriving the
geometry, so a test cannot agree with a broken formula.

Also checked by hand on a live session, which is where the placement rule earned
itself: hovering the rim draws the heavy edge and the block and no dashed line,
hovering the handle adds the line and the arrow, three clicks on pane headers
split nothing, and on a pane titled `sl0ppty` the top handle lands to the right
of the title and left of the buttons -- `drag to move · click to split up` at
x=42, `drag to move` either side of it.
</content>
</entry>
<entry>
<title>sl0ppty: ruff format the Python</title>
<updated>2026-08-17T12:36:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T12:36:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c0022776d0912042819e4ea330cf7491a8196b45'/>
<id>urn:sha1:c0022776d0912042819e4ea330cf7491a8196b45</id>
<content type='text'>
The bumpy one, again. Every `.py` outside `vendor/` plus the three `contrib/`
scripts that are Python with no extension: 60 files, 6,714 lines rewritten, no
behaviour changed. Nothing here is worth reading -- the next commit adds it to
`.git-blame-ignore-revs` so `git blame` walks past it.

It is a bigger reformat than the C was, 60% of the lines against 17%, and for a
reason worth writing down rather than tuning away: ruff and black break a call that
does not fit onto one argument per line, where this code aligned the continuation
under the opening paren. The test suite is thousands of `check("some prose", cond,
detail)` calls, so nearly every one of them changed shape and the suite grew 3,308
lines. Line length was the one lever that moved it -- 120 columns would have been a
40% reformat and no growth at all -- and it was left at 88, because picking an
unusual width to dodge a one-time diff is paying forever to avoid paying once.

Import sorting came with it. Two files had `import os, pty, uuid, select, struct,
sys, termios, time, fcntl, signal` on one line and now have ten.

Verified after, not before, because a formatter that alters behaviour would be
worse than none:

- `make retest`, 1801 checks green -- and this reformats the suite that does the
  checking, including `harness.py`, which every one of those files imports.
- `make test-live`, the tty tests the fast suite skips. They matter here more than
  usual: `live_input.py` and `live_m0.py` are two of the files whose comma-imports
  were split, and nothing in the fast suite would have noticed if that broke them.
- All 60 files still compile, `make fmt` twice in a row changes nothing the second
  time, and `ruff check --select I` has nothing left to say.
</content>
</entry>
<entry>
<title>sl0ppty: `C-a Enter` splits the longer way</title>
<updated>2026-08-16T18:34:29Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-16T18:34:29Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=967d8521c2b021457f33bf87ef762100a5e2a74c'/>
<id>urn:sha1:967d8521c2b021457f33bf87ef762100a5e2a74c</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>sl0ppty: the new-tab button is a bare +, and yours to choose</title>
<updated>2026-08-14T20:26:30Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T20:26:30Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=83f569c162e94609f35a33eb126af4244aa5f68f'/>
<id>urn:sha1:83f569c162e94609f35a33eb126af4244aa5f68f</id>
<content type='text'>
It read "+tab" because of this, in the code, right above it:

    Not a bare "+": the frame already has one, for splitting a pane. Two
    verbs that look identical is a UI bug the fork shipped and noticed.

That was true when it was written and has not been since f2f3d67, which
removed the frame's "+" when the border became the button. The collision it
was avoiding no longer exists, and the word was left explaining itself to
nobody -- while every other affordance in this UI is a bare mark: _ # x on a
frame, and a hint under the pointer for anyone who wants the word.

So: "+", padded to three cells, because their own comment applies -- a
one-cell target is a thing you miss with a mouse.

Also configurable, as `newtab_mark`, which it should have been already: it
was the only mark here that was not (zoom_mark, zoom_on_mark, close_mark,
min_mark, bell_mark all are). Try "[+]" or "new" if you disagree; a session
picks it up on save.

Four tests were asserting the literal "+tab". Three of them only needed the
new string, but two were using it to *locate* the button -- bar.index("+tab")
-- which is exactly the mistake of asserting something incidental: what the
button says is now config, and a test that spells it out fails the day
somebody changes their mind. They look it up through the hit list now, which
is what they meant.

test_no_more_plus keeps the invariant that makes the bare mark safe, and now
says so: there is exactly one "+" in the UI, so it means one thing. If one
ever returns to a frame, that button needs its word back.
</content>
</entry>
<entry>
<title>sl0ppty: zoom a pane, and give the frame buttons for it</title>
<updated>2026-08-14T17:39:32Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T17:39:32Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=277869e7621b057554f6c02c478c4786392e0aa4'/>
<id>urn:sha1:277869e7621b057554f6c02c478c4786392e0aa4</id>
<content type='text'>
A pane can now fill its tab and go back again, by C-a z or by a button in the
frame's top-right corner. The others keep running; they are just not drawn.

Zoom is intent rather than derived state, so it is stored like focus is: on the
tab, one per tab, because zooming a pane in one tab has nothing to say about
another. Everything else about it falls out of that. It survives the layout
being recomputed because it is an input to the layout rather than a product of
it. It cannot outlive the pane it names, because the id is checked against the
tree every pass and dropped if the pane has gone. And it needed no new
arrangement: "show this one and nothing else" is what layout_solo already did
for a tab too small to divide, and zoom is the same shape asked for on purpose
rather than as a last resort.

The frame gets buttons back, having had its split button removed once already
on the grounds that one glyph cannot mean four directions. Zoom and close are
two verbs a single glyph *can* mean, which is the difference; splitting is
still done by clicking an edge, where the edge encodes the direction. They cost
six columns of every frame, which is a real tax and why pane_buttons turns them
off — the keybindings are untouched by that, because the setting is about the
affordance and not the verb.

A test in the split-UX suite had been asserting that the frame spends nothing
on buttons. It now asserts the weaker thing with them on and the original,
stronger thing with them off, which says what actually changed rather than
lowering a number until it passed.

One test of mine was wrong in an instructive way: it counted visible panes
across every tab, and panes in a tab that is not on screen were never laid out,
so they carry whatever rect and hidden flag they had last time. The comment on
those fields says exactly that. It counts the current tab now.
</content>
</entry>
<entry>
<title>sl0ppty: a status line along the bottom</title>
<updated>2026-08-14T14:38:26Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T14:38:26Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=6548577400ab6833b9424de056c6da6079ed6ff4'/>
<id>urn:sha1:6548577400ab6833b9424de056c6da6079ed6ff4</id>
<content type='text'>
The strip along the top answers "which tab" and "how many panes" — questions
about what you could switch to. Nothing answered "what am I looking at", and
three of the states a pane can be in were discoverable only by being surprised
by them.

So the bottom line is about the focused pane and deliberately repeats nothing
from the top: the session it lives in, the tab, what the pane calls itself
(its purpose if it declared one, otherwise its title), and on the right, the
state worth knowing — scrolled back and how far, on an alternate screen, not
started yet, or exited. Right side budgeted first, the same rule the tab strip
and the pane frame already use, so a long name cannot push the state off the
end.

The session name had to be plumbed in: the server knows it because it opened
the socket under that name, and the app had no reason to until now.

Reserving a row is not free and three tests noticed, which is the correct
number to notice. Two were arithmetic — a chrome budget written as rows-5 and a
six-row session whose pane no longer had room for a frame at all. The third was
more useful: test_tiny_panes_have_no_targets asserted "no border hit that is
one row tall and under four wide", which is a description of the shape a
collapsed header used to have rather than of the thing being tested, and it
started catching the perfectly legitimate side border of a pane that was merely
short. It now says what it means — no split targets on a *header* row — and is
both stricter and immune to the layout getting a row shorter again.
</content>
</entry>
<entry>
<title>sl0ppty: the test cycle was 80s, it is now 5.5s</title>
<updated>2026-08-14T13:16:06Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T13:16:06Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c20b226d44af64ac476173361dad9676dbc74043'/>
<id>urn:sha1:c20b226d44af64ac476173361dad9676dbc74043</id>
<content type='text'>
Measured before changing anything, because the obvious suspect was wrong: the
build is not slow. zig caches objects, so even a clean rebuild is ~0.15s and an
incremental one is 0.08s. The C unit tests are 0.00s. All eighty seconds were
in the python suite, and almost all of that was waiting on durations that were
guesses.

Four things, in order of what they bought.

Run the python tests in parallel. They are separate processes that share
nothing — script mode opens no socket — so each is now its own make target with
a stamp, and `make -j` runs them at once. 78s -&gt; 21s for free. The stamps are
worth as much as the parallelism day to day: an untouched test whose binary has
not changed does not run again, so `make test` after editing one test file is
1.1s and after editing nothing is 0.02s. The binary is a prerequisite of every
stamp, so changing the code still re-runs everything.

Then stop guessing at durations. `settle N` is the only thing that reads a
pane's pty, so it is how you wait for a program's output — but `send` and
`snapshot` are already synchronous with the app, which means a settle after a
mouse event, waiting to assert on our own chrome, is waiting for nothing at
all. Those are removed. Where a program's output really is the thing being
waited for, harness.until()/until_text() polls in 10ms steps and returns the
instant it lands: measured, that is one poll, against the 200-300ms those calls
used to donate. The default settle drops 120ms -&gt; 20ms on the same reasoning.

Three effects are gated on a real clock rather than on anything observable —
the hover dwell, and a toast's lifetime. Those genuinely have to wait, so the
tests that are not *about* the timing now configure the clock down instead
(hover_delay_ms 20, toast_ms 150) and the two tests that are about it keep the
real defaults, because a dwell test that cannot observe not-yet-elapsed proves
nothing.

Two tests turned out to be passing by accident, which is the useful kind of
thing a speedup shakes loose: both asserted the resize hint was gone after a
drop while the pointer was still sitting on the handle. It was only gone
because the dwell had not re-elapsed — resting on a handle *should* re-arm the
hint. They now move off the handle first and assert the thing they meant.

Unrelated but found on the way: the Makefile tracked no header dependencies, so
editing a .h rebuilt nothing and the only safe move was `make clean`. -MMD -MP
fixes it; touching shader.h now rebuilds exactly the six objects that include
it. That one was a correctness bug wearing a performance costume.

.fastfeedback/SPEED.md records the measurements and how to write a fast test
here, so the next person does not rediscover that settle-after-a-click is free
to delete.
</content>
</entry>
</feed>
