<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/test_clipboard.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_clipboard.py?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/test_clipboard.py?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-27T13:09:02Z</updated>
<entry>
<title>selection: drag past an edge scrolls the viewport under it</title>
<updated>2026-08-27T13:09:02Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-27T13:09:02Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2ee881452aa4b19701a071f0333a15265e3b314e'/>
<id>urn:sha1:2ee881452aa4b19701a071f0333a15265e3b314e</id>
<content type='text'>
Text taller than the viewport was not selectable in one gesture: the
drag stopped dead at the pane's border. Two faults, one visible and one
waiting. The visible one: DRAG_SELECT motion subtracted the content
origin in uint16_t, so a pointer above or left of the pane underflowed,
the grid lookup failed, and the selection froze. The waiting one: the
anchor was a pair of *viewport* coordinates re-resolved on every extend,
so the moment anything scrolled mid-drag the anchor would have drifted
with the viewport, re-anchoring to whatever slid under the press.

The anchor is now a tracked grid ref -- the thing lib-vt documents as
being for 'long-lived anchors such as selections' -- so it follows its
cell across scrolling, pruning and reflow, and only the near end of the
selection moves. If a program flooding output prunes the anchored row
out of scrollback entirely, the selection keeps what it had; there is no
cell left to anchor a new answer to.

Motion clamps into the content rect (a pointer past an edge is pointing
at the nearest cell), and holding past the top or bottom edge scrolls on
a clock rather than on motion, because a resting pointer sends no events
and 'keep going' is exactly what resting it there means. app_tick() does
the step, app_next_deadline_ms() wakes the poll loop for it -- the same
mechanism toasts and hover guides already use -- and both front ends
call the tick, so the behaviour exists under the server and the headless
harness alike. Rows per step is how far past the edge the pointer sits:
one row past creeps, further hurries, so the hand already dragging is
also the throttle. The metronome is select_scroll_ms, a knob so the test
can configure the clock down instead of sleeping.

The test drags to one row above the pane and simply holds: the viewport
walks to the top of scrollback (clamping makes the endpoint exact), the
copy starts at a line that was never on screen with the press, and still
ends at the pressed row -- that last check is the tracked anchor's.
</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 wrapped line is one line when you copy it</title>
<updated>2026-08-17T12:02:54Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T12:02:54Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d90aadfc636cf6b655ed5e5488944b5385b451ec'/>
<id>urn:sha1:d90aadfc636cf6b655ed5e5488944b5385b451ec</id>
<content type='text'>
Select a line too long for the pane and the clipboard came back with a `\n` in
the middle of it, at whatever column the pane happened to end. Paste that into a
shell and it runs half a path.

`pane_selection_text` set `trim` and left `unwrap` at whatever a zeroed struct
gives, which is false. lib-vt's header names the exact trio the clipboard wants --
plain output with `unwrap` and `trim` both true, matching Ghostty's own
`Screen.selectionString()` -- and we had two of the three. A row boundary in the
grid is not a newline in the text: a long line occupies two rows and is still one
line, and where it breaks is a fact about how wide the pane is rather than about
what was copied.

Hard newlines are untouched, which is the half a fix for this could easily have
broken: lib-vt joins only the rows that are continuations, so three separate lines
still come back as three lines. Both directions are now asserted, because "copy
joins everything" would be a worse bug than the one being fixed and would look
identical from the inside.

`emit` is now set explicitly as well, to `GHOSTTY_FORMATTER_FORMAT_PLAIN`. It is
already zero, so this changes nothing today -- but this is the API where a zeroed
field means "whatever the enum happens to start with" (DESIGN.md's note on
`ghostty_terminal_set`), and a silently defaulted field is how the wrap flag came
to be missing. There is one call site, checked.

Verified: three checks in `tests/test_clipboard.py` -- a 50-character line printed
into a 40-column pane, dragged across the row boundary, arrives as the string that
was printed with no newline in it; three short lines keep their two newlines; and a
double-click on a word in a wrapped line brings back no newline either. 46 checks in
that file, 1795 overall, all green.
</content>
</entry>
<entry>
<title>sl0ppty: double-click a word to select it</title>
<updated>2026-08-17T11:53:57Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T11:53:57Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2f9135d2810cfb38ccdd4f07e895c66430d066fe'/>
<id>urn:sha1:2f9135d2810cfb38ccdd4f07e895c66430d066fe</id>
<content type='text'>
`pane_select_word()` was written and never wired to anything: no declaration, no
caller, no way to reach it. This is the rest of it -- the gesture, the setting, and
the tests.

**The word is defined by what it stops at, not by what it is made of.** Anything
that is not blank and not a listed separator belongs to a word, so CJK, accented
letters and emoji need no table and come out whole; whitespace always separates,
listed or not, because a double-click that selected the line would be a different
gesture arrived at by surprise. Wide glyphs continue the run they are in rather than
each being their own word -- otherwise every CJK character is a word.

`word_separators` defaults to `"'`()[]{}&lt;&gt;;,|`, and what it leaves *out* is the
decision: `/ . - _ : = @ # % ? &amp; + ~ $` stay inside words, so a path, a dotted
name, a snake_case identifier, a key=value pair, a URL and `src/app.c:1234` are
each one thing you double-clicked to copy whole. `:` is the difference between
copying an error location and copying half of one, which is why kitty keeps it too.

The gesture reuses what selecting already has: the press starts a selection at the
cell -- which anchors a drag out of the double-click -- then widens it to the word,
and the release path that already copies a drag copies this. So the word *is* the
copy, in the one place that rule lives, and the highlight is the copied span rather
than an approximation of it. Tracked per cell as well as per pane, so clicking two
different words quickly selects the second rather than everything between them.
A program that asked for the mouse still keeps its clicks, with shift as the escape
hatch, exactly as the drag has it.

**One thing this needed on the way: quoted values were dumped unescaped.** The
default separator set opens with a quote, and `cb_add(&amp;b, "key \"%s\"\n", ...)`
wrote it raw -- so `--dump-config` produced a file that no longer parsed, breaking
the property the dump exists for. The escaping loop already existed inside
`cb_chord`, for the same reason and with the reasoning written down; it is now
`cb_qval`/`cb_qstr` and every string setting goes through it: the marks, `shell`,
`editor`, `shader_dir`, `project_roots`, `project_layout`. Latent for all of those,
load-bearing for this one.

**And `--dump-config` does not do what four comments and the docs said it did.** It
calls `config_dump_defaults()`: every setting with its *default*, never the value
your config gives it. The function name was the honest one. Corrected in
`docs/config.md`, `config/config.kdl`, `main.c`, `config.c`, `app.c` and the test's
own docstring, rather than changing what the flag does -- starting a config from it
is the documented use, and `--check` is what reads yours. There is no way to ask for
the effective config; that is a gap, not a lie, and it can be a flag when somebody
wants it.

Verified: 22 checks in `tests/test_clipboard.py`. An error location, an identifier
stopping at a bracket, a URL, a word inside quotes and a run of CJK; the highlight
being exactly as wide as the copy; a separator, a blank cell and a single click each
leaving the clipboard alone; two clicks on different cells not being a double; two
slow clicks not being one; the separator set honoured from a config, including a
quote and a backslash carried through KDL escaping; a word in a scrolled pane coming
from the row on screen; and a mouse-tracking program keeping its double-clicks until
shift takes them back. Three of those failed first because the test asked for the
answer twice -- a second `worded()` call is a second pair of clicks -- and one
because it mapped string indices to columns without counting wide glyphs as two.
</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>
<entry>
<title>sl0ptty: select-to-copy, middle-click paste, and toasts</title>
<updated>2026-08-13T22:04:03Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T22:04:03Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=1532581aa3765512b6629aed94f7f529a53f92b5'/>
<id>urn:sha1:1532581aa3765512b6629aed94f7f529a53f92b5</id>
<content type='text'>
Drag over text, release, and it is on your clipboard — no chord, no menu, the
selection is the copy. The clipboard lives on the client's machine, so the
copy travels as OSC 52 and therefore works over ssh; the session keeps its own
copy as the primary selection, which is what middle click pastes back.

Selected cells are inverted rather than recoloured, so a selection reads the
same over whatever theme the program inside is using. A program that is
tracking the mouse keeps its own clicks (shift is the escape hatch), which is
tested rather than assumed.

Toasts: transient announcements in the bottom-right that expire on their own,
stacked up to three. 'copied 13 chars', 'config reloaded', a pane's OSC 9
notification, or {"cmd":"notify","text":"..."} from an orchestrator — an agent
in a pane can now say 'build finished' and have it appear. OSC 52 from a pane
reaches the same clipboard.

Two real bugs found on the way:

  - mouse events are hit-tested against what was painted, and nothing had been
    painted before the first frame, so a click that arrived first was dropped.
    Both front ends now compose once before accepting input, which is what
    really happens in a session anyway.
  - the plain leaf_new never installed the clipboard and notification
    handlers; only panes built from a layout file had them. Every ordinary
    pane was silently missing both.

The test escaping traps continue to earn their comments: a real newline inside
a driver argument terminates the *command* rather than reaching the shell, \a
is not a driver escape (\x07 is), and an interactive shell's line editor eats
a typed ESC — so a pane driven with escape sequences must not be one.

356 checks green (329 headless + 27 live).
</content>
</entry>
</feed>
