| Commit message (Collapse) | Author | Age |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`parse_shader_entry` is the whole of "what does `dim amount=90
where="chrome" channel="fg"` mean", lifted out of the loop that reads a
`shaders { }` block. The block parser calls it and files complaints; a new
`config_parse_chain` calls it for a chain that came as text rather than as
a file, and hands the compiled expressions back to whoever asked.
Nothing behaves differently. It is a refactor for the next commit, which
lets a program in a pane set that pane's chrome chain: the reason to do it
this way is that a chain typed at a running pane and a chain in a config
file must mean the same thing, or prototyping teaches you something that
then doesn't work when you paste it.
Two details the split had to settle. Expression ownership is now the
caller's -- the config keeps owning the programs a file produced, freed on
reload, and a chain parsed for someone else comes back with its programs
listed so that someone can free them on their own schedule. And a bad
colour no longer drops the pass: it complains and runs at the default,
because a shader at the wrong colour is visible and can be corrected,
while a shader that isn't there looks like a shader that didn't work.
One chain is one rect. The caller says which, and that is the *default* an
entry's `where=` starts from, so a line lifted out of a config still means
what it meant there -- while a line naming the other rect is refused
rather than quietly moved to the one you asked about.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
padding 1 // every side
padding 0 2 // vertical, horizontal
padding 2 0 0 1 // top, right, bottom, left
One value, two, or four in CSS order, because that is the order anybody
who has written a stylesheet already knows. `pad` becomes four fields and
the content rect is deflated per side rather than symmetrically, which is
the only real change: everything downstream already worked off
`n->content`.
The units do not change with how many numbers you write. They are ROWS,
like `gap`, and a horizontal one is multiplied by `gap_aspect` on the way
out -- so `padding 1`, `padding 1 1` and `padding 1 1 1 1` are one padding
rather than three, and a ring that looks square is still one number rather
than an arithmetic problem. Anyone who wants plain cells sets
`gap_aspect 1` and gets them everywhere, which is what that knob is for.
Three values is refused with a line saying what it wanted. CSS reads it as
top/horizontal/bottom, and a line whose meaning you have to look up is a
line nobody can read -- and `sl0ppty --check` now has something to say
about it, which is the difference between refusing and ignoring.
The dump writes the shortest form that means what is in force, so `padding
0` stays `padding 0` and a dump of a dump is still the same dump.
Checked by measuring both rects: a pane's insets from its own frame, four
numbers, for every form -- including that `gap_aspect 1` turns the
horizontal ones into cells, and that a refused padding leaves the rest of
the file applied.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported as "C-a is not a valid prefix in my config.kdl", which it was
not, and neither were ten other lines of the same file -- a file written
by `sl0ppty --dump-config`.
The loader now collects every complaint instead of keeping the first.
Each one carries the file and the line it happened at, so
"line 14: bad binding" became "config.kdl:116: bad key: ←", and a
complaint from an included file names that file. `err` still means the
first message, so a session's one status line behaves as before; the list
is what a linter needs, and `--check` (next commit) is that linter. Both
changes live in the same regions of config.c, which is why they are one
commit.
Then what the list found.
`--dump-config` is documented as "a file you could have written", and
`--dump-config > ~/.config/sl0ppty/config.kdl` as a supported way to
start one. It wrote every chord in the *cheatsheet's* notation:
prefix "C-a"
bind "?" "help"
bind "←" "focus-left"
bind "S-tab" "prev-tab"
and the parser only spoke the config's, so eleven of the file's own lines
came back as complaints and the session ran on the defaults those lines
were describing. Two sessions looked identical for the worst possible
reason, which is why the test asserting exactly that never noticed.
Both halves were wrong, so both are fixed.
The parser accepts what the sheet prints: `C-` `M-` `S-` as well as
`ctrl+ alt+ shift+`, the arrow glyphs as well as `left`/`right`, and a
character that carries its own shift -- `?` is shift+slash, `H` is
shift+h, `|` is shift+backslash. config.kdl has claimed for months that
`?` and `S-slash` were both writable; only one of them was.
A punctuation binding registers *both* encodings, because whether a
terminal reports `?` as shift+slash or as a bare byte is up to the
terminal and not to what the config meant. The shipped default has bound
`?` twice by hand for exactly this reason since it was written; this is
that, done once, in the one place that knows the character implied the
shift. A capital letter needs no such thing -- the decoder reports `H` as
shift+h everywhere.
And the dumper writes the canonical config spelling: `ctrl+a`,
`shift+slash`, `left`, `shift+tab`. `shift+` spelled out rather than
folded back into `?` or `H`, because both are accepted on the way in and
one spelling on the way out keeps a dump of a dump the same dump. A key
with no spelling the parser would accept is written as an empty chord
rather than as its display form: writing the display form is what caused
this, and a dump that does not load is worse than a dump missing a line.
The test that should have caught it now does: a session started from the
dump is asked to reload and must have nothing to say. Looking at the
screen was never going to find this, because the screen was right.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
▬ □ ✕ away, bigger, gone
▬ ■ ✕ ...and while it is already filling the tab
The boxed operators of the commit before this (⊞ ⊟ ⊡ ⊠) were one tidy
family and unreadable with it. They are *math* symbols, sized to sit
beside x and y in an equation, so at a terminal's cell size the interior
that carried the whole meaning -- the minus, the plus, the dot -- was two
or three pixels of it. Four identical smudges.
Geometric shapes and dingbats are drawn at full cell size, which is what
makes them readable at all, so legibility wins over uniformity here: four
different shapes, one verb each. Picking glyphs that fill their cell is
also the answer to the evenness that put plain ASCII here originally --
a glyph that does not fill its cell hands the slack to the gap beside it,
by an amount the font decides.
A bar for minimise rather than the obvious ▼, because the split guide
already owns all four triangles (◄ ► ▲ ▼) and the scroll indicator owns
▲ again. A glyph that means "split downward" on the border of a frame
cannot also mean "put this pane away" in its corner. Found by the guide's
own test failing on the first attempt, which is exactly what that test is
for -- and the reason it caught a UI collision rather than a typo is that
it asserts only *one* arrow is on screen at a time.
Widths checked in a real frame again: the rule still closes on its
corner, each button is still measured rather than booked, and the hits
land on the glyphs.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
⊟ ⊞ ⊠ away, bigger, gone
⊟ ⊡ ⊠ ...and while it is already filling the tab
`_ # x` said what they did only if you already knew, and `*` for a zoomed
pane was the weakest of the four: nothing about an asterisk says "put it
back". So there are four marks now instead of three and a half, with the
unzoom state getting one of its own.
ASCII was there for a reason, and the reason survives: the cells are
even, but a glyph that does not fill its own cell hands the slack to the
gap beside it, and how much it hands over depends on the font -- which is
why box-drawing and block characters were tried here and looked unevenly
spaced. Four copies of *the same box* answer that better than four
unrelated glyphs did: whatever a font does with the box, it does four
times, and only the interior changes. Minus for away, plus for bigger,
dot for back to normal, times for gone.
Single width, checked in a real frame rather than assumed -- the row
still closes on its corner and each button is still measured rather than
booked, so the hits land where the glyphs are. Not emoji, which many
terminals draw two columns wide while chrome here is booked as one.
Two tests were pinning the old glyphs by hand (`line.rindex("x")` for the
cheatsheet's close button, `"*" in line` for the zoom state). They read
them out of `sl0ppty --dump-config` now, which is generated from the
code: picking a nicer mark later cannot leave a test asserting a glyph
nothing draws.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A config that half-applied said nothing. An unknown shader, a binding
that does not parse, an include that is not there: the file loaded, that
line did not, and the session announced "config reloaded" as though
nothing had happened. Every one of those messages was already being
produced -- config_load has filled `err` on a successful load since it
was written -- and then dropped on the floor by every caller, because a
non-zero return meant "fine".
So the complaint from the last load is kept, and the front end says it
where it would have said "config reloaded". Once at startup too: a config
that loaded with a complaint still started the session, and the stderr
line goes where nobody attached can read it. The `reload` control verb
answers `{"ok":true,"warning":"..."}` -- ok, because the file did apply,
with the reason attached for a script that cares.
Two things fell out of writing the test for it.
An include's complaint was reported by nobody when the file in the middle
was fine. apply_includes only looked at the return value, so a theme that
loaded while *its* include was missing swallowed the message: the child
returned true, and true meant there was nothing to say. The complaint now
travels up whether or not the include itself loaded.
And a complaint now carries the name of the file it happened in, exactly
once -- added by the file that produced it, never by the files that
included it, because prefixing at every level builds a chain that
truncates before the part that matters. So:
t.kdl: unknown shader: bloom
keys.kdl: line 2: unknown action fly
b.kdl: includes nested too deep (a cycle?)
cannot open /home/you/.config/sl0ppty/themes/nope.kdl
which is enough to know which file to open, in a message that fits in a
toast.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An included theme that does not reload when you save it is half a
feature, and the half that is missing is the one the file headers in
contrib/themes have been promising all along ("a running session re-reads
it the moment you save"). That was true while a theme was something you
pasted; the moment it became something you include, the watch was on the
wrong file.
So a config now remembers every file it was read from -- itself and
everything it included -- and the watcher watches all of them. Recorded
before each parse rather than after, because a file that is not there yet
is exactly the file worth watching: writing it is the next thing that
will happen, and that is the same reason the watch is on directories
rather than on files. Deduplicated, so a cycle does not fill the list
with two names twice.
The set is rebuilt after every successful load, because it is a property
of the config that just loaded: an include can be added, pointed
somewhere else, or taken out, and the watch has to follow. One inotify
watch per directory, deduplicated by the descriptor inotify hands back
for a directory it is already watching; names are matched against the
whole set rather than against the directory whose watch fired, which
means two directories holding a file of the same name reload for each
other's save. That costs a debounced re-read of a config that has not
changed, and is not worth a second data structure to avoid.
Only the main config's directory is created if missing. An include's is
not: a mistyped path should not leave a directory behind.
tests/test_reload.py drives a real session for this one -- the watcher is
the thing under test and the headless driver has none. It writes a config
that includes a theme, checks the theme applied at startup, then edits
*only the included file* and waits for the session to change colour.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
include "themes/amber.kdl"
include "keys/vim.kdl" "shaders/crt.kdl"
which is how you switch a theme by editing one line rather than pasting
one in. Nothing about it is theme-specific: an include is another config
file applied here, so the same line composes keys, shader chains, states
or geometry, and contrib/themes stops being a directory you copy out of.
A relative path is relative to *the file doing the including*, never to
the directory the session was started from -- a config is a thing on disk
that refers to its neighbours, and where you were standing is not part of
what it means. A leading `~` is a home directory, an absolute path is
itself, and an included file may include others.
What you include is the base: the file with the `include` line in it wins
wherever they disagree, and a later include wins over an earlier one.
That holds wherever the line sits, because the loader reads a document by
asking it for the keys it knows rather than walking it top to bottom --
so "here" is not a position it could honour, and "the file you are
editing wins" is the rule worth having anyway. It is what makes a theme
something you can put two lines on top of.
A depth limit rather than a set of files already seen, because the two
failures it has to stop are the same failure: a config that includes
itself and a config eleven files deep are both a mistake, and the message
says which one you have. A file that will not load is skipped and the
rest of the config still applies (D9) -- losing your keybindings over a
mistyped theme name would be the worse answer.
The loader was already shaped for this: `config_load` applied one parsed
root onto a config_t, so it becomes a recursive `load_into` that applies
its includes first and then itself. Errors from an include get their own
buffer, so a broken theme cannot overwrite a message the file that
included it has already produced.
tests/test_include.py covers the two rules that carry the weight -- the
outer file winning either way round, and a nested include resolving
against the file that wrote it -- plus multiple files on one line, `~`, a
binding from an included file, a missing file, a cycle, switching theme
through a reload, and every file in contrib/themes applying as an
include, checked against each file's own frame_focus so it cannot pass by
looking default.
Not yet: an included file is not watched, so editing the theme itself
does not reload the session. Editing the include line does.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Every action by name, without having to know its key. The cheatsheet
answers "what is the key for this?" and then needs you to press it; this
answers "just do the thing", which is the question you have when you
cannot remember there was a key at all. `C-a shift+p` by default, and
`bind "<key>" "palette"` for anything else.
It lists actions that are bound to nothing, which the cheatsheet by
design cannot -- that sheet is built from the binds, so an action with no
key does not appear on it, and that is exactly the action you have no
other way to reach. Two of ours are in that state today (scroll by a
line, either direction). Where a binding does exist the palette shows it
beside the row, so using the palette teaches the key that would have
skipped it. Where several exist it shows them all: a config's binds add
to the defaults rather than replacing them, so naming only the first
would hide the one the user actually chose.
Rows match on the phrase, the group, and the name a config file uses, so
"split into columns", "panes" and "split-cols" all find the same thing
depending on whether you have been editing the config lately.
The finder and the palette are now one picker with a subject rather than
two machines that would drift -- the same modal, query, filtered list,
selection, keys and mouse rules, differing only in what fills the rows
and what Enter does with one. Choosing goes through the row's own action
string, so the keyboard and the mouse cannot take different paths to the
same row. The finder's 55 checks pass unchanged across the refactor.
Left out on purpose: `literal-prefix`, which means "type this key"
rather than "do this thing" and picked from a list would send the prefix
to a pane hidden behind that list; and `select-tab-N`, which is
positional, so a row reading "go to that tab" cannot say which.
Running is by action rather than by synthesising the keystroke that
would have caused it -- a palette that had to invent input to do its job
would be lying to the rest of the program about what happened. That
meant splitting prefix_command in two, which it wanted anyway: the
lookup and the doing were only ever together by accident.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Good catch: a two-column bell mark shifted the pane title row. Everything in
app.c that positions chrome -- title budgets, button placement, the hit rects
that have to agree with them -- came from a `cells()` that counted codepoints,
and a glyph drawn in two columns was booked as one, so every glyph after it
landed a column early.
Widths now come from lib-vt's own table, via ghostty_unicode_grapheme_width.
That is deliberately the same table the terminal uses for pane content, so
chrome and content cannot disagree about how wide something is, and it is
cluster-aware: an emoji with a variation selector is two codepoints and one
glyph. A wide cell claims its right half as a tail (width 0), which the
renderer already knew how to skip.
HANDOVER said this was a known trap and to dump the actual cells before
touching arithmetic, which is exactly how this went: the first assertion I
wrote passed against a deliberately broken width function, because it measured
the frame's right corner -- drawn at an absolute position, so it does not move
-- rather than the columns the mark claims. The test now reads the mark's own
colour runs and demands 1 column for a narrow mark and 2 for a wide one, and it
fails when the width function is stubbed back to 1.
Two things fell out on the way:
ghostty_unicode_grapheme_width measures only the *first* cluster and returns
the codepoints it consumed. My first loop ignored that and asked "did the width
change when I added the next codepoint?", which concludes that an entire ASCII
string is a single cluster -- the cheatsheet modal collapsed to ten columns
wide with the text clipped. Use the return value.
A cluster can also be longer than a cell holds: a ZWJ family emoji is 18 bytes
against 16. Both the cell writer and the config's mark fields (now 32 bytes)
clamp on a codepoint boundary, because snprintf cuts at a byte and half a
codepoint is invalid UTF-8 -- which showed up as a decoder error in a test far
from the cause, not as a short mark.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`C-a e` on a machine with no config opened an empty buffer, which tells you
nothing about what you can set. The obvious fix -- embed config/config.kdl and
write it out -- trades one problem for a worse one: a checked-in copy of the
defaults is a second source of truth, and it drifts. Ours already had. Four
theme colours existed in the code and not in that file, and nothing said so.
So the file is generated. `sl0ppty --dump-config` renders every setting with
the value it currently holds -- geometry, behaviour, all 48 theme colours, the
state shader chains, and every binding including the leader-less ones -- and
`C-a e` seeds a missing config with exactly that, creating the directories on
the way (a fresh container has no ~/.config either).
config/config.kdl stays, and is now explicitly the *prose*: what a setting is
for, what it cost to get right, which ones are opinions. Values are generated,
essays are written, and the file says so at the top.
Three things keep the two honest:
- the theme colours became one file-scope table that the parser and the
renderer both walk. 48 names that used to exist in one place and be typed
again in another now exist once.
- the dump must parse back to itself byte-for-byte. That is what caught
`bind \ split-cols` rendering unquoted -- valid-looking output that would
not have survived being read back.
- a test scans config.c for `kdl_child(root, "...")`, which is the parser's
own vocabulary, and fails if a key is missing from the dump. Add a knob,
forget to render it, and the suite tells you rather than a user.
It never overwrites a config that exists: fopen(..., "wx") loses the race
rather than an edit, and there is a test for that too, because it is the one
thing here that must never go wrong.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The config watcher's whole point is the loop -- edit, save, watch the session
change under you, edit again -- and the loop had a hole in the middle of it:
you had to leave the session, or remember where the file lives, to start.
So: one keystroke opens `$EDITOR <config>` in a new pane, down rather than
across because a config file is lines and half the height beats half the
width for reading them. `editor` in the config beats $EDITOR, and vi is the
fallback, being the one editor a POSIX system must have -- a wrong guess you
can see and change beats a pane that opens empty.
The interesting part is what kind of pane it is. Panes given a command keep
their corpse (D14), which is right for a dev server and wrong for this: quit
the editor and you would be left dismissing a dead pane you opened thirty
seconds ago. Rather than gaming that policy by giving the pane no command --
which would be a lie in the one field dump-layout and re-run read -- panes can
now be *ephemeral*: a task rather than part of the session's shape. An
ephemeral pane keeps no corpse and is not written into a dumped layout, so a
restart does not reopen somebody's editor on a file they had finished with.
That concept is the reusable half of this commit; the keybinding is the part
you notice.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Keeping every dead pane was too much. Split off a shell, do something in it,
type `exit` -- and you are left with a corpse to dismiss, which is fussiness
rather than information. You know what happened. You did it.
So the policy has a line now, and the line is the *command*, not who made the
pane. A pane told to run `npm run dev` is part of the session's shape: when it
dies you want the error it left and the button that runs it again. A pane that
is a shell is finished when you say so.
Not provenance, because a shell is a shell whether a layout file made it or
C-a \ did -- and that also means a session restored from dump-layout behaves
like the one it was dumped from, since the dump writes `command=` only for
panes that had one. The distinguishing mark turned out to need no new state:
pane_label() already holds the command a layout gave it and is empty for a
plain shell, so this is a policy over something already recorded.
`keep_dead` is now `commands` (default), `all` or `none`, and still reads
`true`/`false` as `all`/`none` -- that was the shipped spelling and a config
saying it should keep working rather than quietly changing behaviour.
Most of the diff is tests. The dead-pane suite had been written against the
session's own pane, which is a shell under the new rule, so it was testing the
half of the policy that now says "reap it". They run commanded panes from
layouts instead, and the pair of cases -- a shell you split off goes, a
commanded pane stays -- sits together in one place so the rule is legible in
the test file too.
One of those layouts wanted an escape sequence, which has to be written `\\033`
so KDL's unescaping hands `\033` to the shell. Written singly the shell prints
`033[38;2;...` and the test reads as "the colour stopped working".
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`dim_unfocused 60`, on by default, 0 to turn it off.
The dimming itself was already possible -- `states { unfocused { dim } }` --
and deliberately not the default. The argument then was that ambient contrast
is a taste, and shipping a taste as a default is how a tool earns a
reputation for fighting you. That argument was mostly about the escape hatch:
"turn it off" meant knowing the states block exists and what a shader chain
is. A single number does not have that problem, so the objection goes away
and the default can be the one most people want.
The knob and the table are one mechanism -- the knob fills the unfocused
chain in when the config has not -- so writing the chain by hand still wins,
including writing an empty one. `states { unfocused { } }` means nothing at
all, and is not quietly refilled, which needed the parser to distinguish
"said nothing" from "said none".
60 by choice: white text lands on #c3c3c3, which reads as "not this one"
without reading as "not available", because half the time you are reading the
other pane. Picked by looking at 40, 55, 70, 90 and 120 rather than by taste
alone.
The tests that had to change are the interesting part. Six in test_shaders.py
asserted "back to green after the drag", which is no longer what normal looks
like -- and rewriting them to compare against a snapshot taken before the
drag failed too, because a drag *moves focus*: press a title and that pane is
yours, so the two panes legitimately swap appearances across one. They assert
the rule now (the pane you are in keeps its colour, the others are pushed
back), which is both what we mean and immune to the next change of defaults.
test_states.py's "an unfocused pane is left alone" is inverted, with the
reason written where the assertion used to be, because a test that flips
should say why it flipped.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The middle of the status line is empty unless the pointer is on something,
which is most of the time. It says which sl0ppty this is now.
That reads as decoration and is not. A session keeps the binary it started
with, so after a rebuild there are two versions in play: the one on disk and
the one you are attached to. This session has been dogfooding sl0ppty for a
while, and "which build is this?" has already been the answer to two bugs
that looked like something else -- once when a config reload appeared to do
nothing, and once when kitty graphics were fixed and still broken on screen.
The banner answers it without asking, and `sl0ppty --version` answers for the
binary, from the same string.
The version is baked in by the Makefile from `git describe --always --dirty`,
into a generated header that is only rewritten when the value changes -- so a
commit rebuilds and a second `make` does nothing.
It takes the hint's slot and yields to it: a hint is about what you are doing
right now, the banner is about always, and it comes back the moment the
pointer moves off. Both give way to the ends of the line, which was already
the rule for hints and is why a narrow terminal loses the middle rather than
its session name. `version_banner false` for anyone who finds it noisy; it is
a separate knob from `hints`, and the tests hold both directions of that.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Every binding needed the prefix first, which is the safe design and the
reason a leader exists: a multiplexer that takes chords away from the
programs inside it is a multiplexer you fight. But it is the user's keyboard.
Somebody who wants ctrl+alt+arrow to move between panes should be allowed to
have it, including the part where that chord stops reaching vim.
keys {
direct {
bind "ctrl+alt+left" "focus-left"
bind "ctrl+alt+right" "focus-right"
}
}
A block rather than a flag on each line, because these take a chord away from
every program in every pane, and that is worth seeing at a glance -- and
worth having somewhere to write the warning down, which the config now does
in full: prefer chords no program wants, a bare letter certainly is not one,
`none` gives it back.
Three ordering decisions hold it together, none of them obvious from reading
the code, all three tested:
- the direct lookup happens *last* in the key path -- after the rename editor,
the finder, the cheatsheet, and the prefix itself. An overlay still owns the
keyboard, and a direct binding cannot shadow the leader, which would make
the leader unusable with no way back. There is a test that tries exactly
that and finds the prefix still working.
- `direct` is part of a binding's identity rather than a property of it. `x`
after the leader and `x` on its own are two bindings; binding one must not
silently redefine the other.
- the after-the-leader lookup falls through to direct bindings, so pressing
the prefix first never makes a binding stop working. A rule you have to
keep track of is a rule that will catch you out.
The cheatsheet lists them under "without the leader", apart from the rest,
because the caption at the top of it says "<prefix> then:" and these are
precisely the ones that is not true of.
The tests state the bargain rather than just the mechanism: the chord fires
with no leader, and what it fires on, the program never sees -- asserted with
`cat -v`, so a keystroke arriving in the pane is visible on screen rather
than an invisible control byte that would prove nothing either way.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two things wrong with the first one, and both are about it being a modal
rather than a pane that happens to float.
The close button was invisible. It was drawn in the pane button colour on the
finder background, and in the default theme those are the same value --
#45454a on #45454a -- so it only appeared when the pointer landed on it and
the hover colour took over. That is the hazard of borrowing names from
another surface: each one is right, and together they are nothing. A modal is
its own surface now, with its own names (modal_bg, modal_fg, modal_border,
modal_title, modal_button, modal_button_hover), a dark panel a shade lighter
than the dimmed screen, a border in the accent, and a button chosen to be
visible *without* the pointer on it. All six themes set them.
And nothing behind it moved, so it read as another pane rather than as
something in front. There is a scrim now, `modal_scrim` (0..255, 120 by
default), and the pleasing part is that it is not a new mechanism: it is the
shader pass aimed at the screen instead of at a pane. Same dim, same
materialisation of default colours, no special case. It goes down after
everything else is painted and before the modal, so the modal is the only
thing at full brightness -- which is the whole idea, and is asserted rather
than assumed.
The shape is now reusable: modal_frame() draws the box, title, close button
and hit, and returns the rect inside it; draw_scrim() is one line above it.
The next modal is a function call and looks the same as this one.
The finder is deliberately left alone. It is a picker rather than a modal --
it navigates, where a modal here dismisses on any key -- so unifying them
would be making two things one because they are both rectangles.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A prefix-key multiplexer is unusable until you know its keys, and the only
place they were written down was a config file and a README. Now they are one
keystroke away, in a pane-shaped modal floating over the layout.
It is built from CFG.binds, not from a list of what the defaults are. Rebind
something and the sheet says what you bound; unbind it (`bind "-" "none"`)
and it stops advertising it; change the prefix and the "C-a then:" line at the
top changes with it. A cheatsheet that can disagree with the keyboard is worse
than no cheatsheet, and it would disagree the first time anybody edited a
config -- so it cannot be a list.
Bindings that share an action are merged onto one row: `h ←` is one thing to
learn, not two, and the nine tab digits are `1…9` rather than nine rows.
Keys are named the way you press them -- `\` and `?` and `H`, not "backslash"
and "S-slash" and "shift+h" -- and both spellings still parse as config, so
the sheet stays copyable.
The frame is a pane's frame, corners and title and close button, because a
dialog should wear the chrome of the program it belongs to. It fills its own
background: a modal you can read a shell prompt through is one nobody trusts.
Two columns when there is room, one when there is not, split at a group
boundary so a heading never ends a column alone, and every string clipped at
the frame -- a small terminal loses words rather than its border.
Any key dismisses it, and that key does nothing else: dismissing a modal must
not also run the thing you happened to press. A click anywhere does the same
and does not reach what it landed on.
`?` is bound twice, to shift+slash and to slash, because whether it arrives
with a modifier depends on the outer terminal: as a plain byte there is no
shift to be had, and under the kitty keyboard protocol -- which our own client
asks for -- there is. Binding one of them is a binding that works on the
machine it was written on.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The shader machinery has been able to do this since it existed, and shipped
an opinion about exactly one state. Now it ships three, and the line between
those and the rest is the point:
a pane that is *not live* gets a default -- dead, suspended, scrolled. In
all three the cells are showing something other than a running program's
present: output from something that has exited, a pane that never started,
or the past. None of that is discoverable by looking unless something says
so, which is the whole argument for the feature.
a pane that is merely *not the one you are in* does not -- unfocused, and
one being dragged. That is ambient contrast, which is a taste, and a taste
shipped as a default is how a tool gets a reputation for fighting you. It
is one line of config away for anyone who disagrees, and there is now a
test asserting we have not quietly changed our minds.
So: suspended is greyed and dimmed (inert, still legible -- its contents are
the command it is waiting to run), and scrollback takes a 9% wash of the
scrollback indicator's own colour, so a theme moves the indicator and the
wash together. Checked against phosphor, amber and paper: each gets its own
hue rather than a hardcoded one.
The other half is the gap this leaves. A collapsed header never reaches the
shader pass -- a header is chrome and shaders colour contents (D13) -- so
collapsing a tab used to hide precisely the facts the colour exists to show.
The header now carries them in words, in the same order the status line ranks
them: `exited: status 3`, `not started`, `▲ 12`.
tests/test_states.py asserts the line in both directions, including that an
unfocused pane is left exactly as it was, and that a flattened tab still says
which of its panes never started.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Adding an effect meant writing C -- in the binary, or since D15 in a shared
library. Both are a lot of ceremony for "dim every other row".
So: `amount` may be an expression, compiled to bytecode when the config is
read and evaluated per cell.
shaders {
dim amount="(y % 2) * 40" // scanlines
dim amount="(x > cols - 10) * 120" // a right margin
tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7"
}
The scope is the argument, and that is the whole design. Every built-in here
is already colour_op(amount) with the amount a pure function of position --
vignette, zebra, ruler, margin and spotlight differ only in that function --
so the part worth handing to a config is the *number*, not the colour maths.
Three things follow at once: the mixing stays in C where it is fast and
correct; an expression cannot produce an invalid cell, because it produces
one byte; and that byte is cacheable.
The cache is what makes an interpreter affordable, and the numbers are why
this exists at all. Measured on a 200x50 pane, one pass:
native vignette 6.1 ns/cell
constant expression, folded 5.6 ns/cell
positional expression, mapped 5.2 ns/cell
animated (reads the clock) 35.0 ns/cell
A shader you wrote in your config costs what a compiled one does. A program
that does not read the clock is evaluated once per cell into a byte map and
reused until something it reads changes; one that does read the clock is
interpreted per cell, per frame, which is the honest price of animation and
is opt-in. (The earlier measurement of 56ns for interpretation was per
evaluation -- correct, and the reason the map exists rather than an argument
against the language.)
What a program reads is derived by the compiler rather than declared, and
that dependency set *is* the cache key. A stale map is therefore
unrepresentable rather than merely unlikely -- the same rule the layout
follows, arrived at from the other direction. A unit test caught the first
violation of it before anything else did: `cols` and `rows` carried no
dependency flag, on the theory that the key always contains the rect, and so
a program reading only the size looked constant and was folded at compile
time, where every size is zero.
The language is deliberately total. No loops, no recursion, no jumps: even
`?:` compiles to evaluating both sides and selecting, because nothing in it
has an effect. Evaluation is O(program length), so a config cannot spin, and
there is nothing to sandbox. Division by zero is 0 rather than a trap: an
expression that is wrong should look wrong, not take the session with it. One
that does not compile drops that one shader with a warning -- not the config,
and not the half-strength version of what you asked for, which is what the
number path did with garbage and is now also fixed.
shader_t gained the compiled program, so the plugin ABI goes to 2. A plugin
built against 1 is refused with "shader ABI 1, we speak 2" and needs nothing
but a rebuild -- the check earning its keep the first time it was asked to.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Adding an effect meant editing shader.c and rebuilding, which is fine for us
and useless to anyone else. Now any `*.so` in the shader directory that
exports a plugin table is loaded at startup, and what it declares is named in
the config exactly like a built-in -- same syntax, same three parameters,
same pass. Default location is a `shaders` directory beside the config file,
so dropping the .so next to config.kdl is the whole installation.
The interesting part is what a plugin can see, which is one header.
shader_abi.h holds cell_t, color_t, shade_ctx_t, shader_t and nothing else:
no libghostty-vt, no pty, no layout, no session. A shader is therefore
unable to reach anything but the cell it was handed, not by rule but because
nothing else is declared where it can find it. cell_t and color_t moved
there from sl0ppty.h for the same reason -- that is the one place they are a
contract with code compiled outside this tree rather than an internal type.
Loading is deliberately narrow:
- refused unless the library reports our ABI version *and* matching struct
sizes. The version says which contract; the sizes catch someone editing
their copy of the header, which a version number alone calls fine and a
running session calls garbled colours;
- refused if it redefines an existing name, because a config naming `dim`
means the `dim` it was written against;
- all-or-nothing per file, and per file: a bad library is a line on stderr
and everything else still loads (D9 fail-open);
- nothing is ever unloaded. A shade_fn may be sitting in a live config or on
a pane, so dlclose() is a jump into an unmapped page. A reload picks up
newly *added* plugins; replacing one needs a new session.
This is the single exception to D9's "a plugin is a subprocess", and it is
argued in D15 rather than drifted into: a shader is called per cell, per
pass, per frame, which no subprocess can be in the path of, and the measured
alternative -- interpreting bytecode per cell -- is 56ns against 8ns for the
compiled function. On trust, `shell` and a layout's `command=` already let a
file in the config directory run anything as you, so a .so beside them is
not a new boundary. The docs say so plainly rather than implying a sandbox.
contrib/shader-plugin is a working skeleton: Makefile, two commented effects
(one positional, one animated), and the three rules a shader has to keep. It
is built and loaded by the test suite, so a broken example is a failing test
rather than someone else's afternoon.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pane whose program exited was reaped before the next paint. The pane went,
and with it everything the program had printed -- including, always, the one
line that would have explained why it stopped. Close the last one and the
session went too, so a mistyped command in a fresh session closed the
terminal you were typing in.
Now the pane stays. It keeps its contents, writes one line into its own
backlog saying how it ended -- `[process exited: status 3]`, `signal 9` --
says the same thing in its frame and in the status line, and offers the two
verbs that still mean anything:
╰ exited: status 3 ──────────────────────────────[re-run]─[close]─╯
`[re-run]` runs the same argv in the same cwd in the *same pane*: same id,
same node in the tree, same terminal. Nothing that referred to that pane has
to be told anything, and the run that ended stays above the new one in the
scrollback, which is what makes comparing two runs possible at all. C-a r
and {"cmd":"rerun"} do the same thing; a live pane is refused rather than
restarted, because "run it again" is not a request to kill something.
Those buttons are the OSC 5577 row, not a second mechanism. A dead pane's
own buttons are inert -- a click would write a report into a closed pty --
so the row is given over rather than added to. `close` is listed last, so it
is drawn rightmost and is the one that survives a narrow frame: being unable
to dismiss a dead pane is a trap, being unable to re-run one is an
inconvenience.
`dead` is a shader state now, which D13 said it could never be: it named a
frame that could not be drawn, and now there is one. Dead panes drain to
grey by default, so a wall of them reads at a glance. A collapsed header
says "exited" in words as well, because the shader pass does not reach one
and a flattened tab draws nothing else of a pane.
Two things about this are load-bearing rather than incidental:
- the pty fd is closed the moment EOF is seen. A pane that outlives its
program stays in the poll set, and an fd at EOF is readable forever: the
session would spin a core behind a pane that looks idle.
- the exit status is collected with a bounded wait, never a blocking one.
EOF means the program already closed its fds, so it is on its way out and
a few hundred microseconds wins that race. A blocking waitpid would hang
the whole session on one program that closes its terminal and keeps
running; losing the race costs the words "status 0" and nothing else.
`keep_dead false` restores the old behaviour for a one-shot layout that
wants it. `panes` reports alive/exit_code/exit_signal, so a script can find
the pane whose command failed and rerun it.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The frame's affordances are nearly all one character wide, and the widest one
is invisible: a border that splits when you click it looks exactly like a
border. Hovering anything now puts a word in the middle of the status line.
Read off the hit list rather than tracked. Anything that registers a hit gets a
hint by being named in one table, and nothing has to remember to raise or clear
one — which also means the hint cannot disagree about what is under the pointer
with the thing that would actually be clicked, since both are the same lookup.
The status line is drawn after everything else has registered, so the list is
complete by the time it asks.
It is centred in what is left between the two ends rather than in the row.
Centring it in the row would put it under the session name on a narrow screen,
and a hint that overwrites the thing it is explaining is worse than no hint; it
is simply not drawn when it does not fit, which has a test with a deliberately
long pane title.
Two of that test's checks were wrong before they were right, in the ordinary
way: one searched for a substring that also occurs in the text on the right,
and one assumed forty-four columns was too narrow to fit a hint when it was
not. Both now assert against measured positions rather than my guess at them.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
_ for minimise, # for zoom, * for the way back, x for close.
Box-drawing and block characters were each tried and each looked unevenly
spaced, which is the answer: the cells are even every time, and what differs is
how much of its own cell a glyph fills. That is a property of the font, not
something to be fixed from here, and single-width ASCII of a similar weight is
the way to stop asking the question.
The measurement stays, so a mark is still yours to change and may be more than
one character. The config note now says what the three attempts established
rather than what I expected each time.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
□ and ▣ read as a pane and a filled pane, which is what zoom does to one; [] and
][ were only ever a hedge against a spacing problem that turned out to be
elsewhere. Minimise and close stay as the underscore and the x, so the row is
_ □ x.
The measured button width stays, which is what makes the choice free: a mark of
any width can go in there now, including the two-character ones, so the config
note is about how a glyph *looks* beside its neighbours rather than about what
will fit.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The strip was a row per minimised pane, which let putting things away cost more
room than leaving them out. It is one row now, shaped like the tab strip and
for the same reason: a list of names you click to go somewhere. Three panes put
away leave the survivor exactly as tall as one did, and there is a test that
says so, because that is the whole change.
The panes on it are no longer laid out at all — the bar draws them, so they
have no rect. The row the bar occupies is worked out by the layout and stored,
rather than by the drawing working it out again from the same inputs; two
copies of that sum is how a click lands somewhere the eye did not.
The frame's marks are plain ASCII now. Spacing has been reported twice and the
cells were even both times: what is uneven is the glyphs. A light character
floating in a mostly empty cell hands that space to the gap beside it, and the
fix of picking heavier glyphs only holds for fonts I can see. ASCII has neither
that problem nor the double-width one, and "[]" and "][" say maximise and
restore about as clearly as anything.
That needed the button width measured rather than assumed. It was hardcoded to
three cells, which was fine while every mark was one character and silently
wrong the moment one was two: the mark would have been drawn over its
neighbour, and the neighbour's hit would have covered a cell it did not draw.
Also fixes a case that could not happen with a row each: when there is no room
for the bar at all, the tab flattens rather than leaving minimised panes with
no rect and nobody drawing them.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The buttons looked unevenly spaced. They are not: the cells are mark, space,
space, mark, space, space, mark, exactly even, which is worth checking before
touching any arithmetic.
The gap was in the glyph. An en dash is a short stroke floating in a mostly
empty cell, so it donated its own whitespace to the space beside it and sat
further from the zoom button than the zoom button sat from close, which fill
theirs. A lower one-eighth block has weight comparable to the square and the
cross, and is also the conventional minimise icon and a fair description of
where the pane actually goes.
Noted in the config beside the width warning, since it is the same class of
problem: the mark is yours to choose and the two things to know about the
choice are that emoji are drawn double-width and that a light glyph will look
badly spaced next to heavy ones.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pane can be put away: it leaves the layout, becomes one row at the bottom of
the tab, and keeps running. The panes it leaves behind take the space back.
Clicking its row brings it back.
Minimised is intent, so it sits beside the weight on the node rather than with
the rect and the collapsed flag — and layout_node has to be careful not to
clear it the way it clears those, or every frame would undo it. That is the
only sharp edge in the whole thing and it has a comment.
Everything else fell out of rules that already existed. A split divides among
the children that still want space, so a split with one live child is that
child, exactly as a split with one child already was. A minimised leaf returns
from layout_node before its rect is touched, which also skips the floor check —
a pane that is one row by request is not a pane that failed to fit. And the row
itself is drawn by the same code as a collapsed header, because it is the same
thing: a pane you can see the name of and click.
Restoring is not a verb. A focused pane is never minimised, checked once a
frame, so every route that ends with the pane focused restores it — clicking
the row, the control API, and anything added later that moves focus without
knowing minimising exists.
In a tab that has flattened into a list, minimising has no effect at all, which
is what was asked for and also what falls out: every pane down there is already
a row, so there is no second thing for it to be. The strip is not reserved when
it would leave too little above it to be worth looking at; the tab flattens
instead, and arrives at the same place.
Minimising the last visible pane is refused with a toast. A tab showing nothing
has no way back that can be discovered from the tab, and it is not a state
worth being able to reach by accident.
The frame's third button costs another three columns, which is why they are
drawn right to left and stop when they run out: a narrow pane keeps close and
zoom, and loses minimise first.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
libghostty-vt already had a BEL effect; nothing was listening. A pane now marks
its titlebar when one arrives, and stays marked until the pane is looked at.
It marks the *tab* as well, which is the half that makes it useful. A bell
exists for the pane you are not watching, and a mark that only appeared on
screen would be missing precisely when the pane is in a background tab — which
is where an agent finishing a job, or a build wanting input, actually happens.
Looking at a pane is the acknowledgement, and it is done once per frame before
anything is drawn rather than while the pane itself is painted. The tab strip is
drawn first, so clearing during the pane's own paint left the strip reporting a
bell that had already been answered for one frame — visible as a stale dot that
went away on the next unrelated repaint. Doing it up front also means every
route to "this pane is focused" clears it, not only the routes that thought to.
The mark is a configurable string rather than a fixed glyph. The obvious choice
is an emoji bell, emoji are drawn two columns wide by many terminals, and
screen_text books every chrome cell as one — so the default is a narrow bullet
and anyone who knows their own terminal can say otherwise. config.kdl has no \\u
escape, so it holds the character itself; that cost a round trip to notice.
bell_indicator false makes a bell silent and invisible, which is a real choice
and not an oversight, so it is spelled out in the config.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
Wider than the panes' own margin (gap * gap_aspect, 2 at the defaults) rather
than equal to it, so the strip and the line read as chrome sitting outside the
layout instead of as another row of it. Picked by looking at 2, 3, 4 and 6 on a
real screen; the difference between adjacent values is one column and is not
something to choose from a config file.
One test had an opinion about where the tab strip starts. It sampled a style at
column 3 to prove the shader pass leaves the strip alone, which was true only
because the strip happened to begin there; it now finds the strip's first
painted column and asks about that. The assertion was never about column 3 and
should not have said so.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A bare count answered "how big is this tab" and left "where am I in it" to be
worked out by counting frames — which is exactly the question you have once a
tab has collapsed into a list and only one pane is open, and exactly when
counting frames stops working. It reads `pane 2/5` now.
It also stops looking like the session count in the opposite corner. Two
numbers in two corners, both rendered "N panes" and disagreeing, was a puzzle
the reader had to solve once and then remember.
Both bars also take a `status_pad` for how far they are held off the left and
right edges. They were using `gap * gap_aspect`, which is the space around the
*panes*: the two match at the defaults and there was never a reason they had to
be the same number.
The count is kept as a fallback for when nothing is focused, which should not
happen but says something true if it does.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three things about the stack, and the third is the one that matters.
The hover feedback loses its filled bar and highlights the foreground instead:
the rule takes the focus colour, the title goes white and bold. A solid block
was louder than the thing it was telling you, and these rows have to sit in a
list of their own kind without shouting.
Titles get air. A header read `charlie─────` with the rule welded to the name,
which parses as one longer word; it is now `╭── charlie ───────╮`, with a space
guaranteed on each side and a configurable `title_inset` between the corner and
the title. The inset applies to the open pane's frame too, for whichever edge
its title is anchored to, so both agree about what a title looks like.
And the corners. A collapsed row is not a label that happens to sit above a
pane — it *is* a pane, closed — so it now draws the top edge of one, corners
included, in the same style the open pane below it wears. A stack of them
reads as a stack of panes rather than as a table of contents, which is what
the thing actually is. It costs two characters and a comment.
Headers stay left-anchored at the inset while the open pane keeps title_align
(centred by default). That looks inconsistent written down and right on screen:
a list is scanned down its left edge, and a frame is looked at whole.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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".
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
dim_unfocused, drag_grayscale and drag_dim were three knobs describing two
situations, and there was no way to say anything about the third, fourth or
fifth. They are replaced by a table: a state names a chain, and the chain is
exactly as expressive as the global `shaders` block because it is the same
parser.
states {
dragging { }
drop_target { grayscale amount=200; dim amount=140 }
suspended { grayscale amount=180; dim amount=90 }
scrolled { tint amount=30 color="#ffcc88" }
unfocused { dim amount=90 }
}
A pane is usually in several states at once, so exactly one wins: the first to
match, in a fixed order running from the most deliberate to the most ambient.
Fixed rather than read from the block, because it is a ranking by urgency
rather than a preference — a pane you are holding should not be recoloured by
anything, and a mode the whole screen is in outranks an ambient hint about one
pane. They do not stack, which is the same call the drag policy already had to
make by hand: two reasons to be grey compound into a muddy grey that reads as
neither. Defaults are the old drag behaviour and nothing else, so out of the
box this changes nothing.
drop_hover is now separable from drop_target. The pane under the pointer was
already distinguished by its border; it can now differ in colour too, and
defaults to the same chain so it does not have to.
Two states asked for did not survive contact with the code, and it is worth
writing down why:
`dead` cannot exist. app_reap() runs every iteration of the server loop, before
the paint, and closing the last pane closes the tab — so a pane whose program
exits is gone before a frame containing it is ever composed. A `dead` shader
would name something that could never draw. Making it real means keeping dead
panes until they are dismissed, which is a feature about lifetimes and not
about colour.
`suspended` could not be seen either, for a much smaller reason: draw_cb drew
its "press a key to run" label and returned before reaching the shader pass. It
falls through now. A pane that has not started is a state worth colouring, and
its label is the only contents it has.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Six passes that are pure functions of where a cell is: vignette, gradient,
zebra, ruler, margin, spotlight. The position was already in the context and
nothing had used it; these are what it was for.
The one worth having on its own merits is `ruler`. A column guide that costs
no columns, needs no support from the program in the pane, and works in
anything — an editor that has never heard of one, a pager, a shell. It marks
the background only: a guide that recoloured the text would be making the code
it marks harder to read, which is the opposite of the job. `margin` is the
same idea from the other side, everything past a column receding so an
over-long line visibly runs out of the pane you meant to use.
`spotlight` is the first thing to need something new in the context. The
cursor belongs to whichever pane is focused, so it is handed only to that
pane — otherwise every other pane's light would chase a cursor somewhere else.
It leaves everything inside its radius completely alone, because dimming the
text you are actually working on to make it easier to find would be silly, and
ramps rather than stepping at the edge.
`vignette` and `spotlight` both measure distance with a row counting double,
because a cell is about twice as tall as it is wide and an effect that claims
to be round has to say so in cells. The layout's own centre-distance already
does this. The test for it is that on a 41x21 pane — square on screen, not in
cells — the top edge and the left edge come out equally dark.
Scanlines are not a seventh shader. A CRT scanline is `zebra` with a band of
one row, and shipping the same arithmetic under two names would be pretending
they are different things.
These are not policy, so they needed a way to be asked for, and a KDL block
whose children are the passes gets it without inventing a syntax: order in the
block is order in the chain, because a chain is a sequence, and listing a
shader twice is how you get two rulers. Unknown names are refused with a
warning rather than guessed at. Empty by default — these are yours to want,
not opinions we hold.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two changes to the same moment, on opposite sides of the chrome boundary.
Every pane that is not being dragged now draws a dashed border, because every
one of them is somewhere the pane could be dropped and a dashed frame says so
without needing a legend. The pane in your hand keeps a solid one, so the two
states are distinguishable by shape and not only by colour — which matters on
a terminal whose palette makes the greying subtle, and to anyone who would not
see the greying at all. The pane actually under the pointer still takes the
existing drop-target highlight on top of the dashes: these are all targets,
that one is the one you are on.
Borders are chrome, so this is not a shader and could not have been. It lives
in draw_frame, keyed off the same drag.moved that gates the greying, so a press
that turns out to be a click neither greys nor dashes anything.
The greying itself gets a second pass: desaturated *and* darkened, rather than
only desaturated. Grey alone still competes for attention at the same
brightness, which is what made the dragged pane fail to pop. Two shaders
instead of one that does both, because "colourless" and "recessed" are
different things to want and want separate knobs — drag_grayscale 200 and
drag_dim 140. This is the first thing to actually use the chain, and it is
what the chain was for.
The knob that used to mean "off" no longer does, and the test that asserted it
caught that immediately: drag_grayscale 0 now leaves drag_dim still dimming.
Rather than couple them, the test became three — both off, each alone — which
is better coverage of a two-knob effect than the single case was of a one-knob
one.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The first two consumers of the shader pass, and between them they settle how a
pane gets one.
Dragging a pane by its title now greys every *other* pane, so the one that is
moving lifts off the page — which is the thing that made a colour pass worth
building. It engages only once the pointer has actually moved, because a press
on a title that turns out to be a click would otherwise flash the whole
session grey on its way to doing nothing. dim_unfocused fades everything but
the focused pane and is off by default: an always-on effect is the kind you
stop seeing and start fighting, so it should be asked for.
Both are derived every frame rather than attached and cleared. Focus and drags
move through far too many paths — hover, click, the finder, a close, a layout
apply, any keystroke ending a drag — to keep an attachment in sync with, and a
pane left grey by the one path that forgot is a bug that only turns up in
front of someone. The rect and the collapsed flag are recomputed every pass
for exactly this reason; this is the same rule applied to colour. It also
makes the awkward cases free: dropping into the margin, a keystroke ending the
drag, or the dragged pane being closed under it all un-grey without anywhere
having to say so, and there are tests for each because "it turns off again" is
the whole risk.
The two policies replace rather than stack. Dragging an unfocused pane while
dim_unfocused is on would otherwise give it two reasons to be grey, and they
compound into one muddy grey that reads as neither; the drag wins, so the
dragged pane is at full colour whatever the focus says.
shade_apply no longer caps at SHADE_MAX. That cap was right when the only
shaders were the ones a pane had attached, and wrong the moment the session
derives one on top of them: a full stack would have silently dropped it. The
count is the caller's to bound.
The chrome check that mattered was already there: test_drop_target_is_visible
samples a frame corner mid-drag, and it still passes, because the pass runs
between the contents and the chrome drawn over them.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A shader is a pure function from (cell, position, parameters) to that cell's
colours, applied to a pane's contents. Tinting, dimming and greying are the
first three; the point is the mechanism, because it is going to carry a lot of
UX later — drag a pane and grey the others, dim what is not focused, mark a
pane that wants attention — and all of those want to be a colour pass rather
than another special case in the drawing code.
Where it runs is the whole design. draw_cb already paints the frame, then the
contents, then the split guide, so the pass goes between the last two and
"contents, not chrome" stops being a rule anyone has to remember: the frame
was painted before and lies outside the content rect, and an affordance
painted after stays legible on top of a pane that has been dimmed underneath
it. node_t already carries the content rect, so the pass has its bounds.
Cells only, and colours and attributes only. Rewriting a cell's text would
desync selection and copy, which read the terminal rather than the screen, and
could break the wide-cell invariant; the tail half of a wide cell is skipped
for the same reason, since shading it computes a colour nothing displays.
Each cell is shaded independently — no neighbours — so anything positional
works and convolutions do not. That is deliberate: blur would need a second
buffer, and everything actually wanted here is a function of position and
colour. The context carries the content size and rect-relative coordinates so
gradients and vignettes are expressible, plus a timestamp for whatever ends up
animated.
The one real problem is that "terminal default" has no RGB. We never learn
what the client's default foreground is, and most terminal text is
default-coloured, so a shader that skipped those cells would leave nearly
everything at full brightness and read as broken. The pass materialises them
to a configured default_fg/default_bg (white on black) before running, which
means a shaded pane stops tracking the terminal's own theme for those cells —
so the config exists to make that right, and asking the terminal directly with
OSC 10/11 is the eventual fix. A pane with no shaders skips the pass entirely
and still defers to the terminal exactly as before.
Attachment is internal policy: app_shade_add/clear, callable from inside the
app and from nowhere else. Not over the control API and not in-band, so a
program in a pane cannot restyle itself. Nothing attaches one yet.
Kitty graphics are re-emitted as images rather than cells, so a shader cannot
touch them: a greyed pane keeps a full-colour image in it. Known and accepted.
Tested as a C unit test, since the pass is pure: the built-ins and their
strength scale, luma weighting, chaining order mattering, defaults being
materialised, wide-cell tails and text surviving, degenerate input, what the
context tells a shader, and — the one that protects the chrome — that the pass
touches nothing outside its rect.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The top row was both the frame's drag handle and its top edge: resting on it
armed the split guide, and a click that never moved split upward. That is
right for a border and wrong for the cells the title sits on, because it left
no gesture free for naming a pane — a double-click there split twice.
So the title text carves its own hit region out of the top row. It is
registered after the row's handle and after the title is painted, which is
what makes it correct: hit_test scans backwards, so the region that wins is
the one whose glyphs the user can actually see, and its width comes from
screen_text's return so a title of wide characters claims the cells it drew.
Resting there arms nothing (draw_split_guide matches "title:" exactly), and a
press takes no side, so the click-splits path — now guarded on a side being
set — does nothing. Dragging still moves the pane: that reads as grabbing it
by its name. Everything else on the top row still splits upward.
Renaming happens in the title cell itself. There is no dialog to place,
nothing to dismiss, and the pane it belongs to cannot be in doubt. It is the
finder's shape: state on app_t, a key handler that owns the keyboard while it
is open, drawn by the pass that draws the frame. Enter keeps the name, Escape
abandons it, clicking away keeps it the way leaving a field does.
A typed name shadows the program's title rather than overwriting it. The
program's OSC 2 keeps arriving underneath, so a shell that rewrites its title
cannot undo a rename, and clearing the name gives the pane back to what the
program calls itself *now* rather than to whatever it happened to say when the
rename began. That is the whole reason pane_t grew a second string instead of
a flag.
Backspace deletes a character rather than a byte — a title is whatever a
program could set, and half a UTF-8 sequence is not a name — and a name too
long for the frame scrolls its head off, because the caret is the one thing
that has to stay visible. double_click_ms (400) joins hover_delay_ms in the
config.
The tests keep a control beside each behaviour, since most of these could pass
by breaking something else: two slow clicks must open no editor, the top edge
must still split, keystrokes must not reach the shell, and a pane closing
mid-rename must give the keyboard back.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pty is a pseudo-terminal, and that is what this multiplexes: the extra t
was never standing for anything. Renaming now, while the only users are the
people who typed the old name on purpose, is cheaper than renaming later.
Mechanical, but not only cosmetic. The name is also the runtime contract, so
this moves $SL0PTTY and $SL0PTTY_CONFIG, ~/.config/sl0ptty/config.kdl, the
socket directory ($XDG_RUNTIME_DIR/sl0ptty, /tmp/sl0ptty-<uid>), and the
identity string a program gets back from an OSC 5577 hello. A session left
running under the old binary keeps its old socket directory and is invisible
to the new one; there is no migration and it does not seem worth writing one.
The sl0ppi fork keeps its name: it is a different thing, and D1 still points
at it.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pointer crossing a border on its way somewhere else made the screen flash.
The guide now waits for the pointer to rest (hover_delay_ms, 250ms by default,
0 to disable).
The timer is on pointer stillness rather than on the hovered target, which is
what makes a sweep across three borders arm none of them: every motion event
that changes the position restarts the clock, so only stopping counts.
Holding the button on a border skips the wait, because that is intent rather
than passage.
This needs the loop to wake when nothing has happened — a pointer that is
deliberately not moving generates no events to repaint on — which is the same
self-scheduled repaint toasts already needed, so app_next_deadline_ms grew a
second reason rather than the loop growing a second mechanism.
Four older guide tests waited 60ms and now wait for the dwell; they were
asserting on contact because contact was all there was.
398 checks green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
libghostty-vt already keeps the scrollback, so the work was deciding whose
wheel it is:
- a program tracking the mouse gets the wheel events themselves;
- a full-screen program without mouse tracking (vim, less) has no scrollback
of ours to show, so the wheel becomes arrow keys, which is what it means
to that program;
- anything else scrolls our viewport.
All three are tested against real programs rather than asserted in principle.
C-a PageUp/PageDown/Home/End, and scroll_lines in the config for the wheel.
Typing snaps back to the live view, because reading scrollback and then typing
into a screen you are not looking at is a trap. New output does not yank the
viewport back — it just grows the gap, which the indicator shows.
The indicator is compact, right-aligned, budgeted after the split button and
before the title (a title is the thing you can most afford to lose), and
clicking it returns to the bottom.
Three test failures on the way, all mine and all instructive: the top of the
scrollback is the shell's first prompt rather than the first line of output;
typing without a newline types rather than runs; and two side-by-side panes
share a screen row, so asserting on line(y) reads both frames at once.
341 checks green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Hovering a pane focuses it. The feature is three lines; the reason it is not
obnoxious is the guards, and each one is a test:
- not while the prefix is held, so a chord cannot be hijacked by a nudge of
the mouse;
- not through the finder, whose rows are on top of the hit list anyway;
- not during a drag, which owns the mouse until the button comes up;
- not onto a collapsed pane. Only 'pane:' and 'title:' targets count, never
the frame rect's 'focus:', so the pointer crossing a stack of headers does
not expand three panes on its way past.
Off with 'focus_follows_mouse false'; clicking still focuses either way.
This needed the client to ask for ?1003 (any motion) rather than ?1002 (motion
while a button is held), which fixes something that was quietly broken: a pane
requesting any-event tracking could never receive hover, because the outer
terminal was never asked to report it. It can now, and a pane that asked only
for clicks still gets none — both tested.
One real bug behind that: our decoder represents 'no button held' as
MBTN_UNKNOWN, and pane_send_mouse was passing it to the encoder as a concrete
button. lib-vt has ghostty_mouse_event_clear_button() for exactly this, and
without it bare motion encoded to nothing at all.
320 checks green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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).
|