aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_tabs.py (follow)
Commit message (Collapse)AuthorAge
* slosh: defaults most people can live in, said by shadersuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The out-of-the-box look, rethought as one piece: - the compiled-in palette calms down: muted blue #7aa2f7 on the same neutral darks, with the colours that mean something kept semantic -- amber bell, red-orange dead. sl0p stays one include away as the house style, and contrib/themes/default.kdl is the starting point written down (theme-tour shows it). - derived state defaults now actually follow the theme: the scrolled wash is rebuilt from scroll_bg after the theme block (it used to bake the compiled accent), and declaring a state -- in any file of an include chain -- still wins. The dump writes derived states as comments so C-a e cannot pin what is meant to keep following. - a bell you can see: the frame blinks three times in theme's bell colour then breathes until answered, and a soft diagonal shimmer -- the bell colour halved towards default_fg, width breathing on a sine -- crosses the body for the same 900ms. bell_indicator false silences all of it; states { bell { } } replaces it like any state. since carries the clock dependency, so an idle session pays nothing. - scrollback says where it continues: while scrolled, the viewport's top and bottom rows melt towards an edge with content past it and turn solid at the buffer's end. New expression variables above/below (lines hidden past each edge, own dependency bit, cached per scroll position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4. - expressions survive the dump: a program keeps its source and cb_chain writes it back, so amount="(y % 2) * 40" no longer dumps as a number. - headers move to include/, -Iinclude everywhere; the plugin Makefile's SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every pane it runs, and building from inside a session read that as a path. - comment drift culled where features outran the prose, and the derivation boilerplate folded into derived_pass()/ctx_scroll().
* sl0ppty: ruff format the Pythonuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* sl0ppty: workspaces, and layouts that write themselvesuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A **project** is a directory on disk. A **workspace** is the tab it occupies in a session. Two words because they are two things, and conflating them is how a session grows a second source of truth about what it is looking at. `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `sl0ppty.layout.kdl` -- or a `.git`, which is the project that has not said what it needs yet. Both markers, because a `~/dev` of forty checkouts and three layout files is a picker with three rows, and `C-a W` is how a project stops being the second kind. A directory that *is* a project is never descended into, so the walk stays off `node_modules` without anyone writing a rule about `node_modules`. `C-a w` is the third subject of the one picker (D19): the finder searches what this session has, this lists what is on disk, including the projects with no workspace open yet, which is most of them and the whole point. **Identity is the tab's `purpose`** -- `project:<name>.<hash8>` of the resolved path, the shape D3 already published -- so there is no second field to disagree with it, membership is a string comparison, and a dumped session restores it for free because a dump already writes tab purposes and applying one already locks them (D8). Hashed on the *path* rather than the name, so two worktrees of one repo are two workspaces, which is what they are. A tab whose layout gave it a purpose of its own keeps it and is simply not a member: overwriting a declared purpose is the one thing D8 forbids, and the reply counts the ones it left alone. **Nothing watches the filesystem.** The list is derived when it is asked for -- one `readdir` per root and two `stat`s per entry -- and an answer nobody remembered cannot be stale, which is a promise no watcher can make on a bind mount. The picker scans once as it opens, not per frame, because `draw_picker` asks for its rows every frame and a `readdir` per repaint is a filesystem walk at 120Hz. Opening is idempotent (`sl0ppi up`'s property): the same request twice is one workspace, focused, and the answer says which branch it took. Four things had to be true before `save-workspace` could exist, and none of them were. **A relative `cwd=` in a layout file now means that file's own directory** -- the rule `include` already followed for configs, applied to the other half of the same syntax. A project's layout could previously only name absolute directories, which are right on exactly one machine and wrong in a worktree of the same repo. **A dump can be asked for one tab, against a base.** `dump-layout` takes `tab`, `relative_to` and `suspend`. Writing it also turned up a bug: `dump_node` asked `cur(a)->focus` whichever tab it was writing, so a dump of three tabs restored the focus of one. **A dump records what a pane is *running*.** `label` only ever knew what a layout said, so a pane you split and typed `npm run dev` into had nothing to say for itself and came back as a bare shell -- which made arranging a project and writing it down two jobs instead of one. The kernel knows what the label does not: a pty has a foreground process group, and that group is by definition the job that owns the terminal, so the answer is read the way `live_cwd` already reads `/proc/PID/cwd`. Three things are deliberately not commands: a shell at a prompt (its group is the pane's own, and `command="zsh"` on every idle pane restores a session where each shell runs inside a shell), a background job (it does not own the terminal), and an ephemeral pane. A declared label still outranks a live process, because it survives the program exiting (D14) and re-saving must not degrade `npm run dev` into whatever the process tree looks like this minute. argv is joined with shell quoting, since `command=` goes through `/bin/sh -c`. **A pane from a split starts where the pane it came out of stands**, not where the server was launched. Wrong on its own, and it had been quietly writing an absolute path to somebody else's directory into every saved project. A saved project defaults to `suspend="commands"`: the pane running this morning's dev server is written asleep, because a *project's* layout is a statement of shape and a *session's* dump is a statement of fact. Over the API, replacing a layout that is already there is refused without `force`; from the keyboard `C-a W` replaces and says `replaced`, because a key cannot pass a flag and the file is committed text whose diff is the review. `--check` now holds a layout to the layout schema instead of answering with the name of the flag that reads one. `unknown pane property: cmd`, `split is cols or rows`, `purpose is ignored on a pane with panes in it` -- the mistakes a layout can make that still parses. The lists live beside `build_pane` and a test greps for every prop the loader reads, the arrangement that keeps `KNOWN_TOP` honest. **`C-a P` sets a pane's purpose**, a third subject on the editor the pane title and the tab label already share. A purpose is what tooling finds a pane by, and it could only be declared by a layout or set over the socket -- so every pane anybody arranged by hand had none, and a layout dumped from one was a shape with no tags in it. Typed by an operator it counts as declared (D8: "a layout or an operator"). Clearing one now *unlocks* it: a lock held over an empty string was the one state nothing could escape. `set-purpose` also reads `id: 0` as the focused pane now, which every sibling verb already did. Two gaps found on the way, both reachable only over the socket before: **`C-a X` closes a tab.** It used to be a side effect -- press `x` once per pane and the tab evaporates when the last one goes -- so four panes meant four presses and counting. It says how many panes went, since most of them were not the one you were looking at, and on the last tab it refuses and points at `q`: a key that closes a tab four times and ends your session the fifth is one you cannot press without counting first. No `✕` on the tabs, for the arithmetic that took the per-pane `+` away. **`scrollback` is a number we choose.** D4's "free from lib-vt" was true of the machinery and quietly false of the size: handed no limit, lib-vt picks its own -- 10,000 *bytes*, which measured at 622 lines of an 80-column pane, less than one `make` run. `scrollback` is lines, defaulting to the 10,000 every other multiplexer settled on, and `scrollback_bytes` is the ceiling it runs into, because either limit can be reached first and neither can see what the other depends on. Measured at 10,000 lines of styled output: ~6 MB at 80 columns (the ceiling never bites), ~15 MB at 200 (2% of the lines), ~32 MB at 400 (half of them). No `unlimited`, deliberately: the ceiling is per pane, and a negative is refused rather than wrapped into eighteen quintillion lines. Read when a pane is created, so it applies to the next pane you open -- same rule as `shell`, because shrinking a pane's history retroactively throws away output somebody is reading. `--project-root` was considered and dropped: the config is the one place projects are declared, and tests pin a config like every other config-driven feature. Verified: 1752 checks, all green. 90 in `tests/test_workspaces.py` (the two markers, depth without descending into a project, two worktrees as two workspaces, idempotent open, the honoured-purpose count, the save/close/open round trip byte-for-byte, the set-up-by-hand-and-write-it-down flow, the picker, and every verb's bare form); 53 in `tests/test_portable_layouts.py` (relative and absolute `cwd=`, text having no base, per-tab focus, the four `suspend` words, captured commands, an idle shell and a background job captured as nothing, spaces surviving the round trip, label precedence, and the checker's lists not going stale); 21 in `tests/test_purpose_editor.py`; 14 in `tests/test_scrollback.py`; 5 more in `tests/test_tabs.py`. `tests/test_osc5577.py` had a vacuous check: `\033` through the driver's unescaper is NUL + "33", so "a pane cannot relabel a declared purpose" passed without the sequence ever arriving. It uses `emit_cmd` now and has a positive control that the same sequence lands once the purpose is cleared. Three cheatsheet tests were pinned to terminal sizes that exactly fitted the old sheet, which four new bindings pushed past. They now ask for room; truncation has its own test.
* sl0ppty: shaders on the chrome, and two keys for the arrangementuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Five things, in one commit because they land in the same few files -- the action table, the shader pass, config.kdl -- and splitting them by hunk would buy a tidier log at the price of commits that do not build. Each is verified end to end; the suite is 46 files green. Chrome shaders. `where="chrome"` on any entry in `shaders` or in a `states` block runs that pass over a pane's frame -- border, padding, title, buttons -- instead of its contents. Nothing else changes: same registry, same `amount`, same expressions, same chain order, one new word, because a frame is another rect and so it is another pass. It runs over the frame's whole rect with the contents punched out as a hole, rather than four passes over four sides: an effect has to be able to travel round a border, and four passes would each count x/y from zero and restart at every corner. The hole keeps the two honest -- a skipped cell is never visited, so a full-strength chrome tint cannot reach your text. It is also the only kind of pass that can colour a pane a small window has collapsed to a single row (D6), which is the state where a colour is worth the most. `channel="fg"|"bg"|"both"` came out of looking at the first border flash: `tint` mixes both of a cell's colours, and a frame's background is the terminal's own default, so the pass materialised it to our guess at that default and mixed towards the colour -- a dark rectangle behind the glyphs, left there until the pane changed state. The mask is enforced by the pass, not the shader: the other colour is snapshotted before materialising and put back after, so an unset colour goes back to unset, it works for a built-in and a plugin alike, and no shader grows an fg-only twin of itself. A `bell` pane state and a `since` variable, because "flash the border when a pane rings" needed both and neither existed. `since` is milliseconds since the pane entered its current state, stamped in the one function that decides the state -- a transition is the one thing a frame cannot tell you, so it is remembered where it cannot disagree with the state it dates. `bell` ranks above the ambient states and below `dead`: a pane that rang while you were elsewhere is news, and a pane that rang and then exited is a pane that exited. It ends when you look at the pane, because that is what answering a bell is. Animation needed a clock and did not have one: a shader reading `t` only advanced when something else caused a frame, so a pulse froze the moment you stopped typing. `app_next_deadline_ms` now reports `anim_ms` while a chain that reads the clock ran over the last frame -- derived from what was applied, so a pulse hung off `dead` costs a clock while a pane is dead and nothing for the rest of the session. A `deadline` verb makes that observable; the server's poll loop already lived on the number. contrib/chrome has seven presets and the perimeter trick they are built on: on a frame the coordinate you want is distance along the ring, which the language can work out, and `min(d, P - d)` is what makes an effect cross the corner it started from. Hand-written, unlike contrib/shaders, because the toy previews contents and a frame has no preview to give -- so tests/test_chrome_presets.py checks each one the only way left: load it into a real session, assert the frame moves and the contents do not. `equalize` (C-a =) puts every share back to even: each split's children weighted by how many visible panes are behind them, so one pane beside a stack of three is a quarter of the width and "even" means the same thing at every depth. Exactly equal cell counts are not something a tree can always express, so the action promises the even division it can. `rotate-layout` (C-a Space) turns the whole tab a quarter turn clockwise. Every split changes axis and a row split's children reverse, because the pane on top of a stack is the pane on the right once you turn it; get that asymmetry wrong and you have a mirror image, which looks almost right until four turns fail to come back. They do come back, exactly, which is why the inverse is three more turns rather than a second mirrored routine with its own chance of being wrong -- and why a key as easy as space is the right one rather than a hazard. A turn is refused when it would not fit: a share of the height is not always a share the width can afford, and a node that cannot give its children their floor takes the whole tab down to a list. So the turn is tried, the layout is asked what it made of it, and one that flattened the tab is put back with a word. Same rule `split_fits` applies to splitting. Keys: the palette moves from `shift+p` to `p`, and cycling tabs from `n`/`p` to tab/shift+tab. `p` is pressed far more often than "the tab before this one" and was the only shifted letter in the defaults; tab is what every other tabbed thing cycles with, and `\e[Z` decodes as shift+tab on a plain terminal, so this costs nothing on a client without the kitty protocol. `n` is free again. The actions are untouched: two lines of config put the old binds back. The shader ABI goes to 3: `shade_ctx_t` gained `state_ms` and struct shader gained `channels`, both the pass's business rather than a shader's. Plugins need a rebuild, which is what the version is for.
* sl0ppty: a tab is something you can name and moveuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Tabs had names -- a layout could set one, the control API could set one -- and no way to type one. A tab you made yourself was stuck being a number. And the strip's order was the order tabs happened to be created in, forever. Both are the same gesture as the pane equivalent, on the other thing that has a name: double-click a tab rename it in place, in its own cell in the strip drag a tab move it along the strip They land together because they share the machinery they are built on, and splitting them would have meant writing the shared half twice. The rename editor is the pane's, generalised from "renaming a pane" to "renaming a pane or a tab" -- one editor with a subject rather than two that would drift. Enter keeps, Escape abandons, clicking away commits, empty gives the tab back its number. It is seeded with the name only: a pane seeds from its title because a title is always something, but a tab shows its *number* until it is named, and offering "3" to backspace over would be offering to edit the wrong thing. The drag is the existing drag machine's third verb, and it reorders **as you go**. The strip is the only thing that could show an insertion point, and a strip already showing the result needs no such invention -- no ghost tab, no caret, nothing remembered between frames. Dragging off the strip is not a cancel: it is simply not a place to put a tab. Two things that were not obvious until they were written down: - pane ids and tab ids are separate sequences, so pane 2 and tab 2 both exist. The double-click detector remembered only the id, so a click on one followed by a click on the other read as a double-click on whichever came second. It remembers the kind too now, and the test asserts the ids really do collide before asserting they are told apart -- otherwise it would quietly stop testing anything the day the sequences diverge. - `cur` is a *position*, not an identity, so a move has to recompute it. Carried across, you would be looking at whichever tab slid into the index you used to be at. `{"cmd":"move-tab","id":N,"index":M}` does the same thing from a script, and is what tests the arithmetic from a tab that is not the one being moved -- the case a drag can never produce, because a drag selects what it grabs.
* sl0ppty: the new-tab button is a bare +, and yours to chooseuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* sl0ppty: one theme name per decision, and a tab you can seeuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The count, since it was asked: no hardcoded colours at all in the drawing code. Every colour already came from the config. The problem was the other way round — six names were serving about thirty decisions, so the names were the thing that was hardcoded. frame_focus was the focused frame *and* the split guide *and* the resize handle *and* the tab hover *and* a collapsed row under the pointer. Recolouring one recoloured five. There are thirty-five entries now, one per surface, each documented in config.kdl by being its own default. Several still share a value: that is a statement that they look right together, not that they are the same thing, and the difference only matters the first time someone wants to change one of them. The tab you are in is filled rather than merely bold, which was the ask. Bold alone is easy to miss on a strip of short labels, and it was the only thing distinguishing the tab you were in from the four you were not. That immediately raised a question the old scheme could not ask: what does hovering the active tab look like, when it already has the strongest treatment on the strip? Keeping its fill and brightening its text, because it is still the tab you are in and clicking it does nothing. My first version gave it no hover at all, which reads as the hover being broken rather than as the click being pointless. Three surfaces were still borrowing a name that did not describe them: the in-place rename editor was wearing the button colours, a pane's OSC status text and a suspended pane's hint were wearing the frame's. They have their own now.
* sl0ppty: the tab strip answers the pointeruser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | Tabs and +tab were clickable and looked identical whether the mouse was on them or on the other side of the screen. They light up now, in the same language the collapsed stack already uses: foreground only, immediately, no dwell — this is feedback about where the pointer is, not an action being armed. Colour and weight are kept independent. Weight says which tab you are in, colour says where the pointer is, so hovering the tab you are already in does not make it look like you left it. That is the case worth having a test for and it has one. Each label is drawn, its width read back, and redrawn in the hover colour only if that width turns out to be under the pointer. Two paints of a few cells buys the guarantee that the lit cells are exactly the registered ones — the alternative was measuring the label separately from drawing it, which is the same "two implementations of one rect" that D-one exists to prevent. The pointer test is now shared with the stack header rather than written twice, since that is three places asking the same question of a rect they are about to register.
* sl0ppty: a floor under splitting, and an arrow saying which wayuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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".
* sl0ptty: M3 — tabs, purposes, and the JSON control APIsl0p2026-08-13
Tabs are layout trees, one per tab, and panes in every tab keep running: fds, pumping and reaping span all of them, only the current one is composed. A one-row strip shows them (M5 makes it the real status bar) and each label registers a hit, so clicking a tab switches to it through the same mechanism as every other click. C-a c new tab C-a n / C-a p cycle C-a 1..9 select Purposes on panes and tabs with D8's trust model implemented as a lock: declared (from a layout or an operator) outranks in-band, cannot be overridden, and everything is sanitised to [A-Za-z0-9_.:/-] on ingest — 'project:foo bar/../$(rm -rf)' becomes 'project:foobar/../rm-rf'. The in-band path arrives with the OSC scanner in M4 and always passes declared:false; the refusal is already tested. The control API is now JSON in, JSON out: {"cmd":"new-tab","name":"agent","purpose":"project:x.deadbeef"} {"ok":true,"id":2} with panes/tabs/snapshot/send/raw/resize/split/focus/close/select-tab/ set-name/set-purpose/alive/quit. Panes are addressed by id, not by 'the focused one', so a background tab can be scripted. The bare-verb form stays as a harness alias and runs the same code, so the tests cannot drift from the API. Adds a small JSON reader (jsonval.c) — we could only emit until now. 129 checks green.