| Commit message (Collapse) | Author | Age |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 server set `signal(SIGCHLD, SIG_IGN)`, which is not the same as having
no handler. SIG_IGN asks the kernel to reap children itself and throw their
statuses away, so `waitpid` can never answer: every pane's program exited
with "no idea".
Nothing read a status yet, so nothing was visibly wrong -- and the headless
driver, which is what the whole test suite drives, never set it at all. A
suite that is green either way is a suite that would never have caught this.
SIG_DFL is what was actually wanted: SIGCHLD's default action is to be
discarded, so nothing is interrupted and no handler is needed, but the child
waits as a zombie until somebody asks how it went.
That makes statuses readable and zombies possible, so pty_close now collects
the child it just hung up rather than glancing once with WNOHANG -- bounded
to a few hundred microseconds, because a process that outlives a SIGHUP is a
larger problem than the process-table entry it leaves behind.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Half the crossings were invisible, and it was the half that depends on how you
built the layout rather than on how it looks.
Rows of columns gives a full-width row boundary with column boundaries running
into it from above and below. Columns of rows gives a full-height column
boundary with row boundaries running into it from the sides. On screen these
are the same picture; in the tree they are opposite, and the adjacency test
only looked for the first — two gaps touching side to side never matched, so a
layout built by splitting the other way round had no crossings at all.
The test is now that the two touch, in either axis. The crossing itself was
never in doubt: it is the column boundary's columns by the row boundary's rows,
which is the same cell either way.
Corners now hold up to two boundaries of each kind rather than two column ones
and a single row one. That asymmetry was an artefact of only ever having seen
the first nesting — in the second it is two row boundaries that can line up and
need to move together, for exactly the reason two column boundaries did.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Resizing one row of a two-by-two puts its column boundary out of line with the
one below, which should leave two crossings on the row boundary, one for each.
Only one was found.
find_corners took the first column boundary it saw and then accepted others
only if they occupied exactly the same columns, which is the aligned case and
nothing else: anything out of line was discarded rather than becoming a
crossing of its own. It groups by the columns a boundary occupies now, so
aligned pairs are one crossing that moves both — still the case worth having,
since moving one of a matched pair would put a step in what reads as one line —
and everything else is its own crossing moving its own boundary.
The test starts aligned, moves one row's boundary, and checks the second
crossing appears and that dragging it leaves the other row alone.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three faults in the corner drag, reported as glitches and picking panes up
along the way.
The drag held an index into the corner list, and that list is rebuilt from the
layout every frame — by a drag whose entire job is changing the layout. Move
the crossing and the corners are recomputed: the entry you grabbed can shift
position, or stop being a corner, and the index then names somebody else's
boundaries. That is exactly "it picks up additional panes and loses them
again", and it is the rule this codebase already states about the rect and the
collapsed flag, applied to something I stored anyway. The drag now carries the
boundaries themselves, so it moves what you grabbed until you let go, whatever
the layout does underneath. The test drags a deliberately misaligned grid in
circles and checks the boundary that was never part of the crossing is
untouched throughout.
The redraw was the dwell list again. Anything that arms after the pointer rests
has to be named in app_next_deadline_ms or it only appears when something else
happens to repaint — so it looked intermittent, which is the worst way for it
to be wrong. "corner:" is in the list now, next to the three that were already
there for the same reason.
And a crossing is two cells wide with nothing about it to suggest it is
anything, so it marks itself the moment the pointer arrives, before the dwell
that arms both boundaries. Passing over one costs a character; resting on one
still explains itself.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported twice now, with two different marks, so the glyph was never the
variable: the gap between two marks is blank cells plus whatever blank each
glyph carries inside its own cell, and the second part differs by glyph and by
font. An underscore keeps its ink on the baseline and a square fills its box,
so two blank cells between them read as more than two between the square and
the x.
One blank cell instead of two. The difference is still there — it is a property
of the font, not of the layout — but at half the size it stops being the thing
you notice. Chasing it with per-mark padding would have been compensating for a
font I cannot see, and would come apart for anyone who changed a mark.
It also gives three columns of every frame back. The three buttons are seven
columns now rather than ten, which matters on a frame that only has twenty-four
to begin with.
The click targets are two cells each rather than three. That is still a
reasonable mouse target, but it is a real trade against a row whose rightmost
button closes the pane, and worth saying out loud rather than discovering.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
□ 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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`--headless` is documented as "run once, print the screen", and it printed
nothing for any command that finished — which is most of them. `make smoke`
had been showing six blank rows.
settle() calls app_reap() every iteration, and app_reap closes a pane whose
child has exited; closing the last one takes the tab with it. A command like
`sh -c printf ...` exits immediately, so the pane and its content were gone
before app_compose ever ran, and the compose found an app with no tabs.
So settle() now takes an optional screen and composes into it before the reap
rather than after. This is exact rather than a narrowed race: pane_pump() only
marks a pane dead on read() returning 0, and it reaches EOF only after it has
drained everything the pty had, so a pane cannot be marked dead while its
output is still pending and the last snapshot always holds the finished
screen. --script passes NULL and is byte-for-byte unchanged.
smoke also asked for 6 rows, which after the outer margin, the tab strip and
two borders leaves about one row for the pane — under min_pane rows, so it
collapsed to an empty frame and would have shown nothing even with the fix.
It asks for 12 now.
Found while renaming; it predates the rename, reproduced on the commit before.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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).
|