| Commit message (Collapse) | Author | Age |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Where a boundary between rows crosses one between columns, that crossing is now
a handle: drag it and both move, following the pointer in x and y at once.
The two never overlap in the tree, which is the whole difficulty. A column
boundary belongs to a split inside one row and stops where the row boundary
begins; there is no cell that is part of both. So a corner is found by
adjacency instead — the cells of a row gap that have a column gap running into
them from above or below — and that has to be computed across splits rather
than within one, which is why it is a pass over the tree rather than something
draw_node could notice on its way past.
It moves every boundary that meets there, not one of them. In a two-by-two the
column boundary above the row and the one below are separate splits that happen
to line up, and moving only the one you were nearest would leave a step in what
reads as a single line. Both get the same delta.
The corner's hit is registered after the two gap hits it sits on, so those
cells resolve to the corner rather than to whichever gap drew last: it is one
target, not an overlap. Resting on it arms *both* boundaries rather than
lighting the cell under the pointer, because the hint is supposed to show what
is about to move.
Finding the corners needed the gap rectangle, which draw_node had been working
out inline. It is one function now, shared by the drawing, the hit and the
corner search — three things that have to agree about where a gap is down to
the cell, and would not have for long.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A minimised pane is not on screen at all, which is precisely the case a bell
exists for, and it had nowhere to say so. Its entry on the bar carries the mark
now.
The mark keeps its own colour there rather than taking the row's, and the same
in the tab strip, where it had been drawn in whatever colour the tab was — an
indicator in the same dim grey as the label beside it is one you have to
already be looking for. It stays in the bell colour whether the entry is
hovered or not, because it is not reporting the pointer.
Testing it needed the pane to ring itself on a delay: `raw` writes to the
focused pane, and focusing a minimised one is what restores it, so there is no
way to ring one from outside without also answering it. The test waits for the
mark rather than for a duration.
Three copies of "count the columns in this short string" became one helper on
the way past. The third was about to be a fourth.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A double "config reloaded" was reported, and the immediate cause was me saving
the file twice in a row — but looking properly turned up a real one underneath.
One save is not always one inotify event. Watching a directory for
CLOSE_WRITE, MOVED_TO and CREATE, an editor that *creates* the file rather than
rewriting it produces CREATE and then CLOSE_WRITE, measured, and each one was
reloading the config and announcing it. Rewriting in place gives one event, and
so does the rename-over that sed and vim do, which is why it took a specific
kind of editor to see it.
Events now arm a deadline rather than reloading, and the reload happens once
the file has been quiet for eighty milliseconds. That collapses the double, and
also means an editor that writes in chunks is read after it has finished rather
than halfway through — which should make the mid-save parse error, the thing
the fail-open behaviour exists for, rarer than it was.
The test counts the toasts, because toasts stack: a config that reloaded twice
says so twice, which is exactly what was seen on screen. I checked it against
the un-debounced build first and it reported two, so it is testing the thing it
claims to.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The reload existed but you had to ask for it, which is the wrong shape for the
thing it is mostly used for: changing a colour and looking at the result. The
server now watches the config and re-reads it when it changes.
It watches the config's *directory* and filters by name, rather than watching
the file. Editors overwhelmingly save by writing a temporary file and renaming
it over the target, which swaps the inode out from under a watch on the file
itself — it fires once and then never again, which is worse than not watching
at all because it looks like it works. The test writes in place *and* renames
over, because only the second one would have caught that.
Reloading goes through the same call the command does, so it keeps the
fail-open behaviour: a file that does not parse leaves the running config alone
and says so in a toast. That matters more here than it did for the command,
because a file caught halfway through a save is a normal thing for a watcher to
see rather than an operator mistake — and there is a test for it, and for the
recovery once the file is valid again.
Turning it off is a command-line flag rather than a setting, because a setting
would be circular: you would have to reload the config to stop it reloading the
config. --no-reload only stops the *watching*; asking explicitly still works,
which is the distinction the flag name is trying to make.
The test is a live one because inotify lives in the server's poll loop and the
headless driver has no server. It polls for the colour it expects rather than
sleeping a guess, so the passing cases cost milliseconds; the two cases that
assert nothing happened are the only ones that wait, because proving a negative
has to.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"▲12" ran the arrow into the first digit. U+25B2 is drawn wide enough in plenty
of fonts to touch whatever follows it, and the pair then reads as one smudged
token instead of an arrow and a number. Both places that draw it — the pane
frame and the status line — now put a space between.
Both were also measuring the indicator in bytes. The arrow is three bytes and
one column, so strlen reserved two columns that were never drawn, holding the
whole thing further from the edge than it needed to be. The frame now counts
cells arithmetically and hit-tests the width screen_text actually drew; the
status line counts codepoints, which is exact for a string whose only non-ASCII
is that arrow.
Fixing the measurement immediately made the bottom line worse, which is a
pleasing kind of bug: the two-column overcount had been supplying the gap
between "scrolled" and "pane 1/1" by accident, and correcting it left them
adjacent and reading as one phrase. The separation between right-hand clusters
is now deliberate and two columns wide, rather than however much the arithmetic
happened to be wrong by.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two panes stacked on top of each other could be squeezed to four columns wide
and nothing collapsed, at any width.
The fit check lived only on split nodes, and a split node only ever asks about
the dimension it divides: a row split asks about height and never about width.
A leaf was asked nothing at all. So for a stacked layout there was no node in
the tree responsible for noticing that its panes had become unusably narrow —
the check existed, it was just looking the other way.
A leaf is the only node that can see both of its own sides, so it now checks
both. The floor is per pane and the same in every arrangement, which is what
made this a hole rather than a missing feature.
That immediately made resizing dangerous, which is the more interesting half.
Dragging a divider one cell too far could push a pane under the floor and
collapse the entire tab into a list — a drastic answer to a nudge, and it
looked like the session falling over rather than a limit being reached. So the
transfer stops at the floor instead, the way every other resizable thing stops.
The clamp went into transfer_weight rather than into the caller, because the
mouse reaches it through drag_edge and the keyboard through resize_focus, and
my first attempt clamped only the mouse. A limit that one of two paths respects
is worse than none: it looks fixed until you use the other hand.
It also has to round the weight *up*. The weight that buys exactly `floor_`
cells is a fraction, and rounding it down buys one cell fewer — which is
precisely the cell that puts the pane under the floor. The first version
rounded down and collapsed anyway, one drag later than before.
Two tests changed with real content rather than arithmetic: one used a
seventy-six by eight screen whose panes were four rows tall, which is under the
floor and should always have collapsed, and one asserted a squeezed pane stayed
"at least 3 columns" when it now stops at 24 exactly.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Tabs and +tab were clickable and looked identical whether the mouse was on
them or on the other side of the screen. They light up now, in the same
language the collapsed stack already uses: foreground only, immediately, no
dwell — this is feedback about where the pointer is, not an action being armed.
Colour and weight are kept independent. Weight says which tab you are in,
colour says where the pointer is, so hovering the tab you are already in does
not make it look like you left it. That is the case worth having a test for and
it has one.
Each label is drawn, its width read back, and redrawn in the hover colour only
if that width turns out to be under the pointer. Two paints of a few cells buys
the guarantee that the lit cells are exactly the registered ones — the
alternative was measuring the label separately from drawing it, which is the
same "two implementations of one rect" that D-one exists to prevent.
The pointer test is now shared with the stack header rather than written twice,
since that is three places asking the same question of a rect they are about to
register.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 counts every pane in the session, which is the right
number for a strip that lists every tab. It is the wrong number for "how big is
the thing I am working in", and nothing answered that.
So the line along the bottom carries its own count, of this tab only. The two
disagree as soon as there is more than one tab, which is the point of having
both — and the test says so directly rather than checking each in isolation.
It sits hard against the right edge with the pane's state inboard of it, which
is the order the strip above already uses for its own pair: the number is
always there, the mode only sometimes, and a thing that comes and goes should
not shove a thing that does not.
Both counts now say "1 pane" rather than "1 panes". The top has been ungrammatical
since it was written and a test had faithfully pinned it that way; adding a second
place to say it made that worth fixing rather than copying.
One of my own tests had to go: it asserted the bottom line contained no pane
count at all, on the reasoning that the top already had one. That was right
when it was written and is exactly what this change reverses, so it now checks
the thing that is still true — the bottom line is not a second copy of the tab
strip.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Once a tab is a list, its rows are what you are picking from, and until now
they gave no sign of being pickable at all — a flat rule that looked the same
whether the mouse was on it or on the other side of the screen.
Lit immediately rather than on dwell. The dwell that keeps a split guide from
flashing as the pointer crosses a border is exactly wrong here: this is
feedback about where the pointer *is*, not an action being armed, and a list
you are scanning has to track the mouse continuously or it feels broken.
The finder's language for "this is the row", a solid bar, rather than a
brighter rule. FRAME_FOCUS would have read as "this pane is focused", which is
a different claim and one a collapsed row cannot make.
Hovering deliberately does not open the row. focus: already ignored everything
but a press, so this needed no guard — but it is the property worth keeping and
now has a test: a list that opened panes as the mouse crossed it would shuffle
under you while you read it. Clicking is still what opens one.
The lit row is decided from the same rect that is registered as the row's hit,
in the same function, so what lights up and what would be clicked cannot drift
apart.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Collapsing was a local decision: the subtree that could not fit flattened and
everything else carried on. In practice that produces a screen with a
full-height pane on the left and a stack of headers on the right, which
explains neither what just happened nor what to do about it. Seeing it is
enough to know it is wrong.
The question is now asked of the whole tab and answered once. If any node
cannot give its children their floor, every pane in the tab becomes a row and
the focused one opens below them — including the panes that would have fitted
perfectly well on their own.
That costs something real and it is worth being honest about it: a wide screen
with one cramped column now flattens entirely, where before it would have kept
the rest of the layout. The trade is that there is one collapsed state instead
of a spectrum of partial ones, and a rule you can hold in your head is worth
more here than squeezing the last working layout out of a bad size.
Mechanically this made layout simpler rather than more complex. layout_node no
longer decides anything about presentation; a node that cannot fit sets a flag
and returns. Laying out happens twice — once to ask, once to do — and the probe
is the real layout with resizing switched off rather than a second copy of the
fitting arithmetic, because two copies of "does this fit" is exactly how the
split guide and the layout would begin to disagree. The probe not resizing is
the load-bearing part: a pane it lays out normally may be a header by the time
the real pass runs, and a pane about to be collapsed must not be told a size it
will never show.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Collapse was a local decision that stayed local all the way down, so a tab
whose root did not fit could show one pane as a header and then, inside the
child that did fit, three more panes side by side. That is a degraded mode
still spending rows on an arrangement it has no room to express, next to a
pane it has reduced to a single line.
Now a subtree that does not fit flattens: every pane under it becomes one row,
in one list, with the focused one opened below them. Nesting is precisely what
there is no space for, and a reader in that state is asking which pane, not
which arrangement.
The better reason is that the old shape made panes unreachable. mark_collapsed
gave every leaf under a collapsed subtree the *same* rect, and draw_collapsed
registered one hit for first_leaf_of(). Three stacked panes therefore became
one header whose only action was focus on the first of them: the other two
could not be clicked at all, and directional focus could not separate them
either, because their rects were identical. One row per pane fixes that by
construction rather than by adding a case.
Headers sit together with the body below rather than the open pane keeping its
position in the list. Switching then moves only the content while the list you
are picking from holds still, and the tree order that an accordion would have
preserved is the thing being flattened away.
The stack's guard counts panes rather than children now, since that is what it
spends a row on each of, and layout_solo flattens the same way so there is one
mental model rather than two. subtree_has() went with them.
One regression, caught by the test that exists for it: collapse_leaf set
`content` to the header rect, and a hidden pane's content is the size the
program inside still believes it has. It is not resized on the way in and must
not appear to have been. Nothing draws a collapsed pane's content, so writing a
header rect there would only have made the lie more convincing.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Measured before changing anything, because the obvious suspect was wrong: the
build is not slow. zig caches objects, so even a clean rebuild is ~0.15s and an
incremental one is 0.08s. The C unit tests are 0.00s. All eighty seconds were
in the python suite, and almost all of that was waiting on durations that were
guesses.
Four things, in order of what they bought.
Run the python tests in parallel. They are separate processes that share
nothing — script mode opens no socket — so each is now its own make target with
a stamp, and `make -j` runs them at once. 78s -> 21s for free. The stamps are
worth as much as the parallelism day to day: an untouched test whose binary has
not changed does not run again, so `make test` after editing one test file is
1.1s and after editing nothing is 0.02s. The binary is a prerequisite of every
stamp, so changing the code still re-runs everything.
Then stop guessing at durations. `settle N` is the only thing that reads a
pane's pty, so it is how you wait for a program's output — but `send` and
`snapshot` are already synchronous with the app, which means a settle after a
mouse event, waiting to assert on our own chrome, is waiting for nothing at
all. Those are removed. Where a program's output really is the thing being
waited for, harness.until()/until_text() polls in 10ms steps and returns the
instant it lands: measured, that is one poll, against the 200-300ms those calls
used to donate. The default settle drops 120ms -> 20ms on the same reasoning.
Three effects are gated on a real clock rather than on anything observable —
the hover dwell, and a toast's lifetime. Those genuinely have to wait, so the
tests that are not *about* the timing now configure the clock down instead
(hover_delay_ms 20, toast_ms 150) and the two tests that are about it keep the
real defaults, because a dwell test that cannot observe not-yet-elapsed proves
nothing.
Two tests turned out to be passing by accident, which is the useful kind of
thing a speedup shakes loose: both asserted the resize hint was gone after a
drop while the pointer was still sitting on the handle. It was only gone
because the dwell had not re-elapsed — resting on a handle *should* re-arm the
hint. They now move off the handle first and assert the thing they meant.
Unrelated but found on the way: the Makefile tracked no header dependencies, so
editing a .h rebuilt nothing and the only safe move was `make clean`. -MMD -MP
fixes it; touching shader.h now rebuilds exactly the six objects that include
it. That one was a correctness bug wearing a performance costume.
.fastfeedback/SPEED.md records the measurements and how to write a fast test
here, so the next person does not rediscover that settle-after-a-click is free
to delete.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The dotted line said "this is a handle" and left what pulling it would do to
be worked out from the fact that it sits between two panes. An arrow in the
middle of the line says it outright.
It points along the axis the boundary *moves*, not the one it is drawn on: a
divider between two columns is a vertical line that travels sideways, and the
travelling is the half worth showing. So a column boundary gets a sideways
arrow and a row boundary an up-down one, which is the opposite of the line
each is drawn with and the right way round for the gesture.
Always bold, in both states, because it is the one cell of the hint that is
meant to be read; the line around it carries whether you are hovering or
holding.
Double arrows rather than U+2194 and U+2195. Those carry emoji presentation
and are widely rendered double-width, and screen_text books every chrome glyph
as exactly one cell, so one that drew as two would shift the rest of the row —
the same trap the split-guide arrows had to avoid. U+21D4 and U+21D5 have no
emoji form, and happen to rhyme with the doubled line the active state already
uses.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The gap between two panes has always been draggable and has always looked
exactly like nothing. Resting on one now marks it dotted, and the mark doubles
and goes bold once you actually have hold of it — because "you could move
this" and "you are moving this" are different claims, and the second should
not have to be inferred from the panes changing size underneath you.
Dotted for a possibility, weight for a commitment: the same grammar the split
guide already uses, so there is one visual language to learn rather than two.
Vertical boundaries get vertical marks and horizontal ones horizontal, along
the axis the boundary actually runs.
It arms on dwell like the split guide, because crossing a gap on the way to
the pane on the other side is the most ordinary mouse movement there is, and a
pointer already carrying a pane is not shopping for a boundary — a title drag
passing over a gap arms nothing.
The hint is tested against the very rect registered as that gap's hit, in the
same loop iteration that registers it, so it cannot appear anywhere the click
would not land without having to trust a lookup to agree.
One thing that would have made this silently useless: app_next_timeout only
scheduled a repaint for hovers over `border:` and `title:`, so a hint that
arms on dwell over `edge:` would only have appeared when some unrelated event
happened to repaint the frame — visible when the session was busy and not when
it was idle, which is the worst way for it to be wrong. Everything that arms
on dwell has to be listed there, and now is.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 program in a pane transmits an image and it appears. libghostty-vt parses
the protocol and tracks images and placements; what a multiplexer has to add
is re-emitting them to the client, which is exactly what tmux and zellij do
not do — and the reason tools fall back to sixel under them.
Per frame: walk the visible tab's panes, transmit each image once (a=t),
place every visible placement (a=p,C=1) after the cell diff so a repainted
cell cannot land on top of one, and delete placements that vanished. Image ids
are remapped into a high range, so two panes both using image 7 stay two
images and neither collides with what the client's terminal already had. A
reattaching client is told everything again, because it has seen nothing.
The cropping rule is the part worth remembering: a placement is N pixels drawn
across M cells, so the placement itself tells you the pixels per cell — the
client's font metrics never come into it. Clipping at a pane edge, or at the
top of the viewport when a tall image is partly scrolled off, moves the source
rectangle; asking for fewer columns or rows alone scales the image into them
instead of cropping it. The first version did exactly that, and dropped
partially-visible placements entirely rather than cropping them.
Tests cover the multiplexer-specific case first: two panes, both using image
id 7, must get different client ids. Then placement position in screen cells,
following the layout, cropping at edges and when taller than the pane, nothing
from collapsed panes, and removal when a pane closes or the image scrolls
away. A live check confirms the wire: transmitted once, re-placed per frame,
not re-transmitted.
416 checks green (389 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Which border the pointer is over was remembered from the last motion event, so
after a click split the guide stayed attached to the old pane and the old edge
until the mouse moved and corrected it.
It is derived during the paint now: draw_split_guide asks the hit list where
the pointer is, using the entries that same pass just registered for that pane.
Drawing and hit-testing already could not disagree; now drawing and the layout
cannot either. The pointer position is the only thing kept.
Same lesson as the hit list, one level up: do not remember what you can derive
from the frame you are drawing.
Regression test splits by clicking a border and then deliberately does not move
the mouse, asserting exactly one pane shows a guide, that it is the pane the
pointer is actually inside, and that it is on the edge the pointer is on.
391 checks green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The per-pane '+' is gone. It was one glyph for a verb with four directions, so
it always split into columns; it taxed every frame three columns of title
forever; and there were N of them on screen for one verb.
Clicking a pane's border now splits toward that border: the side you click is
the side the new pane appears on. Hovering arms the edge (it goes heavy) and
draws a dashed line exactly where the new boundary would land, so the gesture
explains itself and an idle frame stays quiet.
╭──────────────────── shell ─────────────────────╮
┃ ╎ │
┃ ╎ │ hovering the left
╰────────────────────────────────────────────────╯ border
│╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌│ hovering the bottom
╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯ border
The top border keeps its other job: a click splits upward, a drag moves the
pane. A press that never moves is a click, which is a distinction the drag
machine already had to make.
The guide has to be painted after the pane's content, not with the frame: the
dashed line crosses the terminal's own cells, and the first version was drawn
underneath them and was invisible.
Two old tests changed rather than the code, both because the gesture set is
better now: 'beside the split button' is a drag handle, and 'dropping a pane
on itself does nothing' is a click, which splits. The replacement asserts what
actually needs asserting — a drag that wanders and returns changes nothing.
387 checks green (360 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A session can be a file:
layout {
tab name="api" purpose="project:api.a1b2c3" cwd="~/dev/api" {
pane purpose="agent:main" command="pi"
pane split="rows" {
pane purpose="service:web" command="npm run dev" suspended=true
pane purpose="shell:scratch"
}
}
}
built by --layout at startup or applied to a running session with
{"cmd":"apply-layout", path|kdl, replace?}. Idempotency stays with the caller,
which is where sl0ppi's CLI design already puts it: diff list-tabs against the
projects on disk, then apply what is missing. close-tab completes that loop.
Purposes a layout declares are locked, so identity comes from the file rather
than from whatever the program inside prints — tested by having a pane try to
take one in-band and be refused.
Suspended panes: a pane that exists, is laid out, and has run nothing, showing
what it would run and starting on its first keystroke (which is consumed, not
forwarded). This is sl0ppi's D6 — a root with twelve projects must not become
twelve running servers — and it is the reason can be cheap.
Also fixes a test-isolation bug latent since M2: the live tests attached to the
session called "main", which is whatever happens to exist on the box rather
than one they created. They now use a unique session per run, which is how a
run that had somebody else's htop in it became reproducible.
284 checks green (257 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
D2, delivered: a hand-rolled KDL subset (kdl.c, 300 lines, no dependency) with
arguments, key=value properties, children, // and /* */ comments, and ;
terminators for single-line nodes. config/config.kdl documents every setting
by being the defaults.
Geometry, theme, the prefix and every binding now come from that file instead
of being compiled in. Bindings are a table of (key, mods) -> action, so an
action can be rebound, unbound with "none", or given a different prefix
entirely; caps and num lock are masked out so they cannot stop a binding
working.
The property that matters is fail-open, applied to the mux itself rather than
only to the CLI wrapper: a missing config is silent, a broken one writes the
line number to stderr and keeps the compiled-in defaults, and a bad binding
does not discard the rest of the file. A live session can reload, and a reload
that fails to parse keeps the configuration that works instead of leaving you
with half a theme.
min_pane is configuration too, so the point at which a layout collapses is a
preference rather than my taste — tested by making the same split collapse or
not on the same terminal size.
14 parser cases as a C test, 24 config checks through the harness, and a README
that shows what the thing actually looks like.
250 checks green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
D6 as promised: layout(tree, rect) recomputed every frame, and a split that
cannot give every child a floor (24 cols / 6 rows) collapses *itself* into a
stack — one expanded child, every sibling a one-row header. No swap layouts,
no remembered position, no synthesized anything.
Three properties fall out of the design rather than being coded:
- the expanded child is the one holding focus, so focusing a collapsed pane
expands it: from the finder, by clicking its header, or by walking into it;
- a hidden pane is never resized, so its program does not reflow off-screen;
- a narrow/wide cycle returns the exact rects it started with, and a pane
added afterwards is not stacked.
That last one is the fork's 'panes stacked after a responsive collapse/expand
cycle' bug, asserted directly. It cannot regress here without the layout
function growing memory it does not have.
A collapsed header shows the pane's title and its OSC 5577 status, so a
stacked agent is still legible — the monitored strip, without a 'rail' mode.
Below the size where headers fit, a node shows only the focused subtree: at
12x4 you get one pane instead of rects that do not fit on the screen. Every
size from 200x60 down to 12x4 is asserted for overlap and containment.
Status bar: tabs, a +tab button, live pane count, and the held prefix shown as
a mode. The button is '+tab', not '+', because the frame already has a '+' for
splitting — two verbs sharing a glyph is a UI bug the fork shipped, noticed,
and wrote down; the collision showed up here as two hit-list tests failing.
Pane finder (C-a f): substring filter over title, purpose and tab name across
every tab, keyboard and mouse, painted last so its rows win the hit list. It
is how you reach a pane the screen has collapsed away, which is tested.
Deferred to M6: drag-to-reorder, which wants the same drag-state machine as
pane resizing and should be built with it.
213 checks green (186 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The payoff milestone: sl0ppi's pi extensions work here unmodified.
printf '\033]5577;1;status;building 3/7\033\\'
printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\'
╰ building 3/7 ──────────────────────[Approve]─[Cancel]─╯
libghostty-vt's UNKNOWN_SEQUENCE reports APC only, so the sequences are picked
up by a scanner (osc5577.c) reading the same bytes the terminal does — lib-vt
discards an OSC it does not know, so nothing is drawn and no pane output has
to be buffered. The scanner is a byte-at-a-time state machine, so a sequence
split across reads still lands.
Buttons are budgeted from the right before the status text gets any columns,
and each registers its hit as it is painted — the split button's rule, for the
split button's reason. A press sends the click report to that pane's stdin.
In-band purposes arrive here too, and by construction can never be declared: a
pane printing one is asking, not telling. A pane cannot relabel a purpose a
layout declared, which is now tested end to end rather than only at the API.
One real bug, found by the hostile-input tests: an over-long button id was
truncated into a *valid* one, so a pane could mint a 40-character id that
collides with the 32-character id its program already trusts. Ids are now
unescaped into a large buffer and validated at full length.
Acceptance test replays answer-picker's exact byte patterns, including
`buttons` with no payload (drop buttons, keep status) and a click report that
matches the extension's own CLICK_RE.
Four test bugs on the way, each worth remembering: printf eats %3B as a format
specifier; the driver's unescaper reads \0 as NUL so \033 became NUL+'33'; a
trailing ST inside a double-quoted shell string escapes the quote (BEL avoids
it); and readline eats a typed ESC as a meta prefix, so tests that drive a
pane must not use an interactive shell.
187 checks green (160 headless + 27 live).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Tabs are layout trees, one per tab, and panes in every tab keep running: fds,
pumping and reaping span all of them, only the current one is composed. A
one-row strip shows them (M5 makes it the real status bar) and each label
registers a hit, so clicking a tab switches to it through the same mechanism
as every other click.
C-a c new tab C-a n / C-a p cycle C-a 1..9 select
Purposes on panes and tabs with D8's trust model implemented as a lock:
declared (from a layout or an operator) outranks in-band, cannot be overridden,
and everything is sanitised to [A-Za-z0-9_.:/-] on ingest — 'project:foo
bar/../$(rm -rf)' becomes 'project:foobar/../rm-rf'. The in-band path arrives
with the OSC scanner in M4 and always passes declared:false; the refusal is
already tested.
The control API is now JSON in, JSON out:
{"cmd":"new-tab","name":"agent","purpose":"project:x.deadbeef"}
{"ok":true,"id":2}
with panes/tabs/snapshot/send/raw/resize/split/focus/close/select-tab/
set-name/set-purpose/alive/quit. Panes are addressed by id, not by 'the
focused one', so a background tab can be scripted. The bare-verb form stays as
a harness alias and runs the same code, so the tests cannot drift from the API.
Adds a small JSON reader (jsonval.c) — we could only emit until now.
129 checks green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The session now outlives its client, which is the point: agents keep running
while you are gone. The server owns the panes, the layout and the renderer;
the client is a terminal in raw mode and a socket, forwarding bytes and
painting what it is sent. It does not even decode input, so the decoder can
change without redeploying clients.
sl0ptty attach to 'main', creating it if nobody is home
sl0ptty -s NAME a named session
sl0ptty ls live sessions
sl0ptty cmd LINE one control command
C-a d detach
Connections are not clients. A connection becomes the display client only by
sending HELLO; control commands and liveness probes are just connections and
never displace anybody. This was found the hard way, one hour after the server
first ran: 'sl0ptty ls' kicked the attached client off, because ls connects to
see whether a socket answers. That is precisely the fault that gave the zellij
fork a 20-25% CLI failure rate, rebuilt and then fixed structurally. The
regression runs 20 x (ls + control) against a live client and demands 0
failures and a client that can still type.
cmd.c now holds the command vocabulary once, shared by the headless driver and
the control socket, so a script written against one works against the other —
'sl0ptty cmd "snapshot text"' against a detached session prints its screen.
tests/test_session.py: 18 checks over create, split, probe-resistance, detach,
survival, drive-while-detached, reattach, displacement and cleanup.
123 checks green overall.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
app_t owns the tree, focus, the prefix and what a key means; the interactive
client and the headless driver are now both thin front ends over it, so there
is exactly one implementation of every command.
Layout is the pure function D6 promised: layout(tree, rect) recomputed every
frame, remainder spread across children so nothing drifts, and splitting in a
direction that already exists grows that split rather than nesting — three
splits give three equal columns, not 1/2 + 1/4 + 1/4.
Chrome: rounded frames, aspect-corrected gap, centred titles. The split button
is budgeted BEFORE the title and hit-tested from the rect it was drawn in,
which is the fix the fork needed four attempts to land.
Every painted element registers (rect, action) as it is painted; a click is a
lookup. Mouse into a pane is translated to pane-local coordinates.
Three bugs the harness caught, none of which unit tests would have:
- one click on the split button split twice: press *and* release both fired.
Chrome now acts on press; forwarding to a pane still gets every event.
- callbacks were registered as &fn, but the option is pointer-typed, so the
stored 'callback' was a stack address. Any OSC 2 or DSR jumped into a dead
frame. Now a hard segfault turned into two regression tests.
- a keystroke that changed nothing still emitted a cursor hide/show pair.
The cursor is hidden lazily now, so an idle frame costs zero bytes.
96 checks green in 6s (40 unit, 23 screen, 33 layout), plus 9 live.
|
| | |
|