<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/test_move_pane.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_move_pane.py?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/test_move_pane.py?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-17T12:36:59Z</updated>
<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: drag a pane onto a tab</title>
<updated>2026-08-16T17:31:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-16T17:31:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d8e6e1525ace2a68a060e60365178f5dfa016a4b'/>
<id>urn:sha1:d8e6e1525ace2a68a060e60365178f5dfa016a4b</id>
<content type='text'>
Drop it on a tab in the strip and it moves into that tab; drop it on the `+` and it
gets one of its own. The fourth verb of the same drag machine, and it cost almost
nothing because it is the third one's target read the other way round: the strip was
already a row of drop targets, and a pane in your hand was already a thing being
carried somewhere.

The strip draws the drop states itself. `ptr_on` reports nothing during a drag -- on
purpose, so hover feedback does not fight a gesture -- so a tab cannot find out it is
under the pointer the usual way. It uses the two states the panes already use for
this exact question: every tab the pane does not live in is a candidate in the drop
colour, and the one under the pointer is filled. These are all targets; that is the
one you are on. The tab the pane already lives in does not offer itself, because
dropping there is the one refusal a drag can reach -- and it says "it is already in
that tab" rather than looking like a drag that failed to take.

Leaving the strip takes the offer back, the same way dragging a tab off the strip is
not a cancel but simply not a place to put one. Releasing over a pane still swaps
the two, which has a test of its own: the strip is a new kind of destination, not a
replacement for the old one.

A toast says where the pane went, and here it matters more than for the keys: the
pane lands in a tab you are not looking at, so without a word the screen just shows
one fewer pane.

Verified: `tests/test_move_pane.py` at 52 checks. The new ones drive real mouse
reports -- press on a title, motion onto a tab hit, release -- for the move, the `+`,
the three highlight states (candidate, filled, and the pane's own tab declining),
taking the offer back by dragging off the strip, the refusal, and that pane-onto-pane
still swaps.
</content>
</entry>
<entry>
<title>sl0ppty: `C-a &gt;` sends this pane to the next tab</title>
<updated>2026-08-16T17:25:52Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-16T17:25:52Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=503b309187eaf0bc7282dd09e93c695dba11abb5'/>
<id>urn:sha1:503b309187eaf0bc7282dd09e93c695dba11abb5</id>
<content type='text'>
    C-a &gt;   push it to the tab after      C-a b   into a tab of its own
    C-a &lt;   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.

`&gt;` and `&lt;` are the shifted period and comma, and the defaults bind both halves --
a terminal does not report shift for punctuation, so `&gt;` 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 `&gt;` 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.
</content>
</entry>
<entry>
<title>sl0ppty: a pane can change tabs</title>
<updated>2026-08-16T17:13:28Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-16T17:13:28Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c58d873b409c7a166a64ceabf178692b0b4467c2'/>
<id>urn:sha1:c58d873b409c7a166a64ceabf178692b0b4467c2</id>
<content type='text'>
    sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2}'
    sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2,"dir":"rows"}'
    sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":0,"name":"logs"}'

The mechanism only. There is no key and no gesture for it yet -- that is the next
conversation, and guessing at it now would mean building the wrong one twice.

**The pane keeps running.** Measured, not assumed: `pid=3062557` before the move
and `pid=3062557` after, in a different tab, with its scrollback intact and still
answering what it is sent. That is the whole reason to move a pane rather than open
one where you wanted it and close this one.

It is tree surgery and it reuses the surgery that was already here. `place_beside`
is the second half of `split_node_with` -- everything after "make the leaf" --
lifted out so that a leaf which already exists somewhere else can be grafted the
same way, flat sibling or new split, on either side. It takes the tab rather than
`cur(a)` now, because replacing a root has to land on the right one and `cur(a)` was
only right while the sole caller was splitting the pane you were looking at.
`detach_leaf` is `close_leaf` without the funeral.

Three decisions worth naming:

**The destination is a tab id, not an index.** A tab whose last pane leaves has
nothing to be, so it is removed -- and every index after it moves. An id survives
that; an index quietly means a different tab. The move re-finds the destination by
id *after* the removal for exactly this reason, and there is a test that empties a
tab and checks the pane landed where it was sent.

**A move drops what the old tab thought about the pane** -- a zoom naming it, and
its minimised flag. Both are the tab's opinion and not the pane's: carried across,
the first zooms a pane that has left and the second files the arrival in a strip
nobody asked for.

**It does not follow the pane.** Which tab you are looking at is a question about
intent, and this is the mechanism; `move-pane` answers with the tab id so a caller
can select it if that is what it meant. "Into a tab of its own" is its own call
rather than new-tab-then-move, because a pane that is the only thing in its tab
would otherwise have its tab removed from under the new one, and the order those
two happen in is precisely the bug.

Verified: `tests/test_move_pane.py`, 25 checks -- the pane still running and still
talking afterwards, placement beside and under the destination's focus, focus
landing on the arrival, the emptied tab, a tab of its own, every refusal (no such
pane, no such tab, already there, already alone), the dropped zoom and minimise,
and after three moves a layout with no overlaps that dumps and rebuilds as itself.
</content>
</entry>
</feed>
