<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/test_layout.py, branch master</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/tests/test_layout.py?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/test_layout.py?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-18T13:24:25Z</updated>
<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: a pane has to clear the floor in both directions</title>
<updated>2026-08-14T15:01:47Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T15:01:47Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=954989dc58c6f98593cc97cb1cc43a3833cb8d22'/>
<id>urn:sha1:954989dc58c6f98593cc97cb1cc43a3833cb8d22</id>
<content type='text'>
Two panes stacked on top of each other could be squeezed to four columns wide
and nothing collapsed, at any width.

The fit check lived only on split nodes, and a split node only ever asks about
the dimension it divides: a row split asks about height and never about width.
A leaf was asked nothing at all. So for a stacked layout there was no node in
the tree responsible for noticing that its panes had become unusably narrow —
the check existed, it was just looking the other way.

A leaf is the only node that can see both of its own sides, so it now checks
both. The floor is per pane and the same in every arrangement, which is what
made this a hole rather than a missing feature.

That immediately made resizing dangerous, which is the more interesting half.
Dragging a divider one cell too far could push a pane under the floor and
collapse the entire tab into a list — a drastic answer to a nudge, and it
looked like the session falling over rather than a limit being reached. So the
transfer stops at the floor instead, the way every other resizable thing stops.

The clamp went into transfer_weight rather than into the caller, because the
mouse reaches it through drag_edge and the keyboard through resize_focus, and
my first attempt clamped only the mouse. A limit that one of two paths respects
is worse than none: it looks fixed until you use the other hand.

It also has to round the weight *up*. The weight that buys exactly `floor_`
cells is a fraction, and rounding it down buys one cell fewer — which is
precisely the cell that puts the pane under the floor. The first version
rounded down and collapsed anyway, one drag later than before.

Two tests changed with real content rather than arithmetic: one used a
seventy-six by eight screen whose panes were four rows tall, which is under the
floor and should always have collapsed, and one asserted a squeezed pane stayed
"at least 3 columns" when it now stops at 24 exactly.
</content>
</entry>
<entry>
<title>sl0ppty: a floor under splitting, and an arrow saying which way</title>
<updated>2026-08-14T12:45:49Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T12:45:49Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=037c3550f075ff78e680e5a1f1508d4c292ffcb1'/>
<id>urn:sha1:037c3550f075ff78e680e5a1f1508d4c292ffcb1</id>
<content type='text'>
Two changes to the same affordance.

A border stops being a button below `min_split cols=32 rows=8`. This is a
different question from min_pane, which is where the layout gives up and
collapses a pane: two twenty-five column panes technically fit and are not
worth having, so the number that decides whether to *offer* a split is larger
than the one that decides whether a pane can survive. It is clamped to never
go below min_pane, because refusing to offer what the layout would collapse
anyway is the floor under the floor.

The test is the layout's own arithmetic rather than an approximation of it:
the same floor, the same gap, and the same child count the split would create
— which is one more sibling when the parent already splits that way and two
when it does not. The guide's standing claim is that drawing and the layout
cannot disagree, and a guide promising a split that immediately collapsed
would be exactly that.

The keyboard obeys the same floor, with a toast. A rule only the mouse
followed would mean the same session splits or refuses depending on how you
asked, and whether there is room cannot depend on that. The control API and
layout files are *not* bound by it: a script asking for a pane is declaring
what it wants rather than being offered something, and D6's responsive
collapse is what catches it there. That distinction was not free — app_split_pane
went through the same helper as the keybinding, so the API was silently bound
too until a test noticed, and the interactive path is now its own function.

The guide also gained an arrow on the dashed line, pointing at the half the
new pane will take. The line already said where the boundary lands and left
which side to be inferred from which border you happened to be touching.

Pointers for left and right rather than the matching triangles: U+25C0 and
U+25B6 carry emoji presentation and are widely rendered double-width, while
screen_text books every chrome glyph as exactly one cell — so one that drew as
two would shift the rest of the row. U+25B2 is already the scroll indicator,
so the vertical pair is known good here.

Raising a floor invalidates every test that quietly relied on the old one, and
nine of them did: sessions sized for a world where a sixty-column terminal
could be split into columns. They are sized for the rule now, and the two that
were really about *layout* collapse rather than the affordance now split over
the API, which is the path that still means "do it anyway".
</content>
</entry>
<entry>
<title>sl0ptty: the border is the button — click an edge to split toward it</title>
<updated>2026-08-13T22:21:56Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T22:21:56Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=f2f3d678495e37d2eee2d26f0f704f2accf451e1'/>
<id>urn:sha1:f2f3d678495e37d2eee2d26f0f704f2accf451e1</id>
<content type='text'>
The per-pane '+' is gone. It was one glyph for a verb with four directions, so
it always split into columns; it taxed every frame three columns of title
forever; and there were N of them on screen for one verb.

Clicking a pane's border now splits toward that border: the side you click is
the side the new pane appears on. Hovering arms the edge (it goes heavy) and
draws a dashed line exactly where the new boundary would land, so the gesture
explains itself and an idle frame stays quiet.

  ╭──────────────────── shell ─────────────────────╮
  ┃                        ╎                       │
  ┃                        ╎                       │     hovering the left
  ╰────────────────────────────────────────────────╯     border

  │╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌│     hovering the bottom
  ╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯     border

The top border keeps its other job: a click splits upward, a drag moves the
pane. A press that never moves is a click, which is a distinction the drag
machine already had to make.

The guide has to be painted after the pane's content, not with the frame: the
dashed line crosses the terminal's own cells, and the first version was drawn
underneath them and was invisible.

Two old tests changed rather than the code, both because the gesture set is
better now: 'beside the split button' is a drag handle, and 'dropping a pane
on itself does nothing' is a click, which splits. The replacement asserts what
actually needs asserting — a drag that wanders and returns changes nothing.

387 checks green (360 headless + 27 live).
</content>
</entry>
<entry>
<title>sl0ptty: M8 — pane sizes as weights, resize and drag-to-reorder</title>
<updated>2026-08-13T21:34:23Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T21:34:23Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=9158b04ffdb15d2b163ffda81c82509a5c2ea00b'/>
<id>urn:sha1:9158b04ffdb15d2b163ffda81c82509a5c2ea00b</id>
<content type='text'>
Sizes are weights now, so an even split is equal weights and resizing is not a
special case of anything: the layout pass is still the same pure function of
the tree and the rect, and a resized layout survives a collapse/expand cycle
without anyone writing code for that.

Keyboard: C-a shift-H/J/K/L (and shift+arrows) move the boundary in the
direction pressed, whichever side of it the focused pane is on, which is what
a person means by wider. A pane can be squeezed but never squeezed out.

Mouse: one drag machine, two verbs, both starting from the hit list so neither
can disagree with what is on screen.

  - a frame's top row is a drag handle: drop a pane on another to swap them,
    with the drop target highlighted while you drag;
  - the gap between two panes is the boundary: drag it to resize.

The gap was dead space until now, and its hit rect is derived from the rects
the children were just given rather than recomputed from the config — the same
rule that keeps the split button honest.

Any keystroke ends a drag. A release can go missing (the pointer leaves the
terminal, a client detaches mid-drag) and a mouse wedged in a state the user
cannot see is the worst outcome available.

One test needed updating rather than the code: the frame's top row is now the
drag handle, so the cell beside the split button reports title: rather than
focus:. That is the intent, and the hit list said so immediately.

304 checks green (277 headless + 27 live).
</content>
</entry>
<entry>
<title>sl0ptty: M5 — responsive collapse, status bar, pane finder</title>
<updated>2026-08-13T21:14:29Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T21:14:29Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=a194e3f84299c943447c43f5d48d953a095cd4ca'/>
<id>urn:sha1:a194e3f84299c943447c43f5d48d953a095cd4ca</id>
<content type='text'>
D6 as promised: layout(tree, rect) recomputed every frame, and a split that
cannot give every child a floor (24 cols / 6 rows) collapses *itself* into a
stack — one expanded child, every sibling a one-row header. No swap layouts,
no remembered position, no synthesized anything.

Three properties fall out of the design rather than being coded:
- the expanded child is the one holding focus, so focusing a collapsed pane
  expands it: from the finder, by clicking its header, or by walking into it;
- a hidden pane is never resized, so its program does not reflow off-screen;
- a narrow/wide cycle returns the exact rects it started with, and a pane
  added afterwards is not stacked.

That last one is the fork's 'panes stacked after a responsive collapse/expand
cycle' bug, asserted directly. It cannot regress here without the layout
function growing memory it does not have.

A collapsed header shows the pane's title and its OSC 5577 status, so a
stacked agent is still legible — the monitored strip, without a 'rail' mode.

Below the size where headers fit, a node shows only the focused subtree: at
12x4 you get one pane instead of rects that do not fit on the screen. Every
size from 200x60 down to 12x4 is asserted for overlap and containment.

Status bar: tabs, a +tab button, live pane count, and the held prefix shown as
a mode. The button is '+tab', not '+', because the frame already has a '+' for
splitting — two verbs sharing a glyph is a UI bug the fork shipped, noticed,
and wrote down; the collision showed up here as two hit-list tests failing.

Pane finder (C-a f): substring filter over title, purpose and tab name across
every tab, keyboard and mouse, painted last so its rows win the hit list. It
is how you reach a pane the screen has collapsed away, which is tested.

Deferred to M6: drag-to-reorder, which wants the same drag-state machine as
pane resizing and should be built with it.

213 checks green (186 headless + 27 live).
</content>
</entry>
<entry>
<title>sl0ptty: M1 — layout tree, splits, focus, frames, and the hit list in use</title>
<updated>2026-08-13T20:35:57Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T20:35:57Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d2940b9643496ed898450f8bd2c640aeb6f8b7a1'/>
<id>urn:sha1:d2940b9643496ed898450f8bd2c640aeb6f8b7a1</id>
<content type='text'>
app_t owns the tree, focus, the prefix and what a key means; the interactive
client and the headless driver are now both thin front ends over it, so there
is exactly one implementation of every command.

Layout is the pure function D6 promised: layout(tree, rect) recomputed every
frame, remainder spread across children so nothing drifts, and splitting in a
direction that already exists grows that split rather than nesting — three
splits give three equal columns, not 1/2 + 1/4 + 1/4.

Chrome: rounded frames, aspect-corrected gap, centred titles. The split button
is budgeted BEFORE the title and hit-tested from the rect it was drawn in,
which is the fix the fork needed four attempts to land.

Every painted element registers (rect, action) as it is painted; a click is a
lookup. Mouse into a pane is translated to pane-local coordinates.

Three bugs the harness caught, none of which unit tests would have:
- one click on the split button split twice: press *and* release both fired.
  Chrome now acts on press; forwarding to a pane still gets every event.
- callbacks were registered as &amp;fn, but the option is pointer-typed, so the
  stored 'callback' was a stack address. Any OSC 2 or DSR jumped into a dead
  frame. Now a hard segfault turned into two regression tests.
- a keystroke that changed nothing still emitted a cursor hide/show pair.
  The cursor is hidden lazily now, so an idle frame costs zero bytes.

96 checks green in 6s (40 unit, 23 screen, 33 layout), plus 9 live.
</content>
</entry>
</feed>
