| Commit message (Collapse) | Author | Age |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It was a bare coloured rectangle painted over whatever pane it landed
on: no border, no title, no scrim, no way out you could see. It read as
output from the program underneath rather than as a window in front of
it, which is the one thing an overlay has to get right. It is now a
modal like the cheatsheet -- same frame, same scrim, same close button
in the place a pane keeps one -- differing in the way that matters: the
cheatsheet is read, so any key dismisses it, while this one is used, so
it keeps the keyboard until it is answered.
The list was the other half of the problem. It showed `1:` for a tab
with no name, which looks like something failed to load; a row for a
pane with no purpose was three columns of nothing; there was no mark for
the pane you are already in, no count, and nothing to say a list had
more below the tenth row -- so a list that scrolled looked like a list
that ended. An empty result was an empty box, which reads as a broken
finder rather than a wrong query.
Now: a query field that looks like one (so it shows where the typing
goes before you type), the current pane marked, tab/title/purpose told
apart by weight, the row under the pointer lit, "N of M" in the footer,
arrows for what is off each end, and "no pane matches that" when there
is nothing.
Keys, because a picker in this shape is reached for by hands that
already know one: C-n/C-p, tab/shift-tab, home/end, page up/down, C-u to
clear the query, and wrapping at both ends -- one press of Up for the
last entry is worth more in a short list than overshoot protection.
Backspace deleted a *byte*, so one press through a multi-byte character
left the rest of it in the query; it deletes a character now, the way
the rename editor already did.
The finder owns the pointer as well as the keyboard: a press that is not
on one of its rows dismisses it and stops there, rather than also
landing on the layout behind and focusing the pane you were clicking
away from.
Found on the way: CSI Z (shift+tab) was not parsed at all, so it reached
neither a binding nor the program in the pane -- every terminal sends
back-tab that way, and it is the one chord whose shift lives in the
final byte rather than in a modifier param.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Good catch: a two-column bell mark shifted the pane title row. Everything in
app.c that positions chrome -- title budgets, button placement, the hit rects
that have to agree with them -- came from a `cells()` that counted codepoints,
and a glyph drawn in two columns was booked as one, so every glyph after it
landed a column early.
Widths now come from lib-vt's own table, via ghostty_unicode_grapheme_width.
That is deliberately the same table the terminal uses for pane content, so
chrome and content cannot disagree about how wide something is, and it is
cluster-aware: an emoji with a variation selector is two codepoints and one
glyph. A wide cell claims its right half as a tail (width 0), which the
renderer already knew how to skip.
HANDOVER said this was a known trap and to dump the actual cells before
touching arithmetic, which is exactly how this went: the first assertion I
wrote passed against a deliberately broken width function, because it measured
the frame's right corner -- drawn at an absolute position, so it does not move
-- rather than the columns the mark claims. The test now reads the mark's own
colour runs and demands 1 column for a narrow mark and 2 for a wide one, and it
fails when the width function is stubbed back to 1.
Two things fell out on the way:
ghostty_unicode_grapheme_width measures only the *first* cluster and returns
the codepoints it consumed. My first loop ignored that and asked "did the width
change when I added the next codepoint?", which concludes that an entire ASCII
string is a single cluster -- the cheatsheet modal collapsed to ten columns
wide with the text clipped. Use the return value.
A cluster can also be longer than a cell holds: a ZWJ family emoji is 18 bytes
against 16. Both the cell writer and the config's mark fields (now 32 bytes)
clamp on a codepoint boundary, because snprintf cuts at a byte and half a
codepoint is invalid UTF-8 -- which showed up as a decoder error in a test far
from the cause, not as a short mark.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
You were right. The watch is on the *directory* -- editors write a new file
and rename it over the old one, so watching the file stops working the first
time you save -- but a directory that does not exist cannot be watched at
all. inotify_add_watch failed, the session gave up on watching for good, and
writing a config later did nothing until a restart. Which is exactly the
state a fresh machine is in, and exactly the state C-a e leaves you in.
sl0ppty creates its own config directory at startup now, so there is always
something to watch and a file appearing later is picked up. Tested from a home
directory with no .config at all.
Answering the other half of the question -- which settings actually reload --
turned up one that never worked at all: `shell`. main.c read $SHELL directly
and nobody read the config's value, so the setting parsed, rendered in
--dump-config, and did nothing. It is resolved per pane now, which fixes it and
makes it live: edit it, save, and the next pane you open uses it.
Everything else measured rather than assumed, and it is nearly everything:
colours, geometry, the status bar and line, frame marks, shader chains and
their states, and the keys including the leader itself. The two exceptions are
about *when* a value is read, not reluctance -- `shell` at pane creation, and
shader plugins which load on reload but never unload. Both are now written
down where someone would look.
One test needed rescuing from itself: it asserted the new leader worked by
splitting, in panes that were by then too narrow to split. A test that fails on
geometry while claiming to be about the prefix is worse than no test; it opens
a tab instead.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`C-a e` on a machine with no config opened an empty buffer, which tells you
nothing about what you can set. The obvious fix -- embed config/config.kdl and
write it out -- trades one problem for a worse one: a checked-in copy of the
defaults is a second source of truth, and it drifts. Ours already had. Four
theme colours existed in the code and not in that file, and nothing said so.
So the file is generated. `sl0ppty --dump-config` renders every setting with
the value it currently holds -- geometry, behaviour, all 48 theme colours, the
state shader chains, and every binding including the leader-less ones -- and
`C-a e` seeds a missing config with exactly that, creating the directories on
the way (a fresh container has no ~/.config either).
config/config.kdl stays, and is now explicitly the *prose*: what a setting is
for, what it cost to get right, which ones are opinions. Values are generated,
essays are written, and the file says so at the top.
Three things keep the two honest:
- the theme colours became one file-scope table that the parser and the
renderer both walk. 48 names that used to exist in one place and be typed
again in another now exist once.
- the dump must parse back to itself byte-for-byte. That is what caught
`bind \ split-cols` rendering unquoted -- valid-looking output that would
not have survived being read back.
- a test scans config.c for `kdl_child(root, "...")`, which is the parser's
own vocabulary, and fails if a key is missing from the dump. Add a knob,
forget to render it, and the suite tells you rather than a user.
It never overwrites a config that exists: fopen(..., "wx") loses the race
rather than an edit, and there is a test for that too, because it is the one
thing here that must never go wrong.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The config watcher's whole point is the loop -- edit, save, watch the session
change under you, edit again -- and the loop had a hole in the middle of it:
you had to leave the session, or remember where the file lives, to start.
So: one keystroke opens `$EDITOR <config>` in a new pane, down rather than
across because a config file is lines and half the height beats half the
width for reading them. `editor` in the config beats $EDITOR, and vi is the
fallback, being the one editor a POSIX system must have -- a wrong guess you
can see and change beats a pane that opens empty.
The interesting part is what kind of pane it is. Panes given a command keep
their corpse (D14), which is right for a dev server and wrong for this: quit
the editor and you would be left dismissing a dead pane you opened thirty
seconds ago. Rather than gaming that policy by giving the pane no command --
which would be a lie in the one field dump-layout and re-run read -- panes can
now be *ephemeral*: a task rather than part of the session's shape. An
ephemeral pane keeps no corpse and is not written into a dumped layout, so a
restart does not reopen somebody's editor on a file they had finished with.
That concept is the reusable half of this commit; the keybinding is the part
you notice.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Keeping every dead pane was too much. Split off a shell, do something in it,
type `exit` -- and you are left with a corpse to dismiss, which is fussiness
rather than information. You know what happened. You did it.
So the policy has a line now, and the line is the *command*, not who made the
pane. A pane told to run `npm run dev` is part of the session's shape: when it
dies you want the error it left and the button that runs it again. A pane that
is a shell is finished when you say so.
Not provenance, because a shell is a shell whether a layout file made it or
C-a \ did -- and that also means a session restored from dump-layout behaves
like the one it was dumped from, since the dump writes `command=` only for
panes that had one. The distinguishing mark turned out to need no new state:
pane_label() already holds the command a layout gave it and is empty for a
plain shell, so this is a policy over something already recorded.
`keep_dead` is now `commands` (default), `all` or `none`, and still reads
`true`/`false` as `all`/`none` -- that was the shipped spelling and a config
saying it should keep working rather than quietly changing behaviour.
Most of the diff is tests. The dead-pane suite had been written against the
session's own pane, which is a shell under the new rule, so it was testing the
half of the policy that now says "reap it". They run commanded panes from
layouts instead, and the pair of cases -- a shell you split off goes, a
commanded pane stays -- sits together in one place so the rule is legible in
the test file too.
One of those layouts wanted an escape sequence, which has to be written `\\033`
so KDL's unescaping hands `\033` to the shell. Written singly the shell prints
`033[38;2;...` and the test reads as "the colour stopped working".
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It was centred in whatever space the two ends had left over, which meant it
moved whenever a pane title, a session name or a state indicator changed
length. The one thing on that line you might want to glance at was never
twice in the same place -- which is most of the value of having a fixed place
to put it.
It is centred on the row now. And when the row is too full for it *there*, it
is not drawn: not squeezed, not slid along, and not written over the ends.
The first attempt at this did overwrite them, which read fine on a wide
terminal and, on a narrow one, ate the words "exited: status 3" -- caught by
a test for the dead-pane indicator rather than by anything looking at the
status line, which is the good kind of accident.
The rule that came out of it is the honest one: the ends say which session
and pane you are in and are not written anywhere else on screen, while the
middle is a hint you can get back by hovering or a version you can read a
moment later. So the middle is the part that disappears. It keeps a blank
column on each side while it is there, because touching reads as overlapping.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The middle of the status line is empty unless the pointer is on something,
which is most of the time. It says which sl0ppty this is now.
That reads as decoration and is not. A session keeps the binary it started
with, so after a rebuild there are two versions in play: the one on disk and
the one you are attached to. This session has been dogfooding sl0ppty for a
while, and "which build is this?" has already been the answer to two bugs
that looked like something else -- once when a config reload appeared to do
nothing, and once when kitty graphics were fixed and still broken on screen.
The banner answers it without asking, and `sl0ppty --version` answers for the
binary, from the same string.
The version is baked in by the Makefile from `git describe --always --dirty`,
into a generated header that is only rewritten when the value changes -- so a
commit rebuilds and a second `make` does nothing.
It takes the hint's slot and yields to it: a hint is about what you are doing
right now, the banner is about always, and it comes back the moment the
pointer moves off. Both give way to the ends of the line, which was already
the rule for hints and is why a narrow terminal loses the middle rather than
its session name. `version_banner false` for anyone who finds it noisy; it is
a separate knob from `hints`, and the tests hold both directions of that.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Dogfooding this while building it has one sharp edge: a running session keeps
the binary it started with. `reload` re-reads the config, it cannot re-read
the code, so every build you want to try means losing your screen.
The only thing worth carrying across a restart is the shape, and sl0ppty
already knows how to read one -- it just could not write one. `dump-layout`
is the inverse of `--layout`: tabs with their names and purposes, how the
panes are split, in what proportion, in which directory, running what, which
tab was active and which pane had focus.
Two details make it worth trusting. The directory is where the shell *is*,
read from /proc, not where the pane was started -- after any amount of `cd`
those differ and only one of them is useful. And a pane running the session's
default shell is dumped with no `command`, so it comes back as a shell rather
than as a shell wrapped in a shell, one layer deeper every restart.
contrib/sl0ppty-dev is the loop: attach, and when the session ends because you
asked for a restart, come straight back with the layout. It is two halves
because it has to be -- `restart` runs inside the session, and the moment the
session ends so does the shell it was typed in, so it leaves the layout and a
note and the loop outside picks them up.
What does not come back is what was running *inside* a program. A shell
returns as a shell, in the right directory, with an empty history. That is the
limit of restoring a layout rather than a session, and the script says so
rather than implying otherwise.
Three bugs found by the round-trip test, all the same kind -- a dump that
loads without complaint and rebuilds something subtly different:
- `split=` belongs on the node that *has* the children, not on the children.
Written the other way it parses fine and gives the wrong tree.
- a split's weight was never restored, because the edit meant to read it
silently matched nothing and was never applied.
- `active=true` landed on the wrong tab: the index is recorded while the new
tabs sit after the old ones, and `replace` then moves them to the front.
Which is why the test asserts that a dump of a reload is byte-identical to
the first dump. Anything the dumper loses shows up there at once, where
reading it and nodding would not.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A blank line above every section heading, so the sheet reads as five short
lists instead of one wall of thirty rows. Not above the first: the "C-a then:"
caption already separates that one, and two blank lines in a row is the same
mistake in the other direction.
The blank is a row like any other, which keeps the two-column arithmetic
honest -- it takes up space, it just draws nothing. Two consequences that had
to be handled rather than discovered later:
- the column fold looks for a heading, and would happily land on the blank
*above* one, starting the second column with an empty line that reads as a
bug. It lands on the heading itself now.
- that leaves the stranded blank at the bottom of the first column, which made
the box a line taller than its contents. Trailing blanks are not counted
when sizing, so the footer sits one line under the last row of keys, whether
the sheet is one column or two.
Both are asserted, because both are the kind of thing that looks fine in the
size you happened to test at.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Every binding needed the prefix first, which is the safe design and the
reason a leader exists: a multiplexer that takes chords away from the
programs inside it is a multiplexer you fight. But it is the user's keyboard.
Somebody who wants ctrl+alt+arrow to move between panes should be allowed to
have it, including the part where that chord stops reaching vim.
keys {
direct {
bind "ctrl+alt+left" "focus-left"
bind "ctrl+alt+right" "focus-right"
}
}
A block rather than a flag on each line, because these take a chord away from
every program in every pane, and that is worth seeing at a glance -- and
worth having somewhere to write the warning down, which the config now does
in full: prefer chords no program wants, a bare letter certainly is not one,
`none` gives it back.
Three ordering decisions hold it together, none of them obvious from reading
the code, all three tested:
- the direct lookup happens *last* in the key path -- after the rename editor,
the finder, the cheatsheet, and the prefix itself. An overlay still owns the
keyboard, and a direct binding cannot shadow the leader, which would make
the leader unusable with no way back. There is a test that tries exactly
that and finds the prefix still working.
- `direct` is part of a binding's identity rather than a property of it. `x`
after the leader and `x` on its own are two bindings; binding one must not
silently redefine the other.
- the after-the-leader lookup falls through to direct bindings, so pressing
the prefix first never makes a binding stop working. A rule you have to
keep track of is a rule that will catch you out.
The cheatsheet lists them under "without the leader", apart from the rest,
because the caption at the top of it says "<prefix> then:" and these are
precisely the ones that is not true of.
The tests state the bargain rather than just the mechanism: the chord fires
with no leader, and what it fires on, the program never sees -- asserted with
`cat -v`, so a keystroke arriving in the pane is visible on screen rather
than an invisible control byte that would prove nothing either way.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The leader has been configurable since M6 -- `keys { prefix "ctrl+b" }` --
and one place had not been told. The badge in the status bar, the indicator
whose entire job is to say "you are in prefix mode", was the literal string
"C-a" whatever you had configured.
That is worse than not having the setting. A prefix you can change and a UI
that keeps naming the old one teaches you to distrust the thing telling you
which mode you are in, and it is precisely the person who rebound it -- the
one who wanted C-a back for start-of-line -- who gets lied to.
It renders the binding now, through the same function the cheatsheet uses, so
the two cannot disagree. Measured rather than assumed to be three columns
wide, because `C-space` is seven and `M-\`` is three of a different width.
tests/test_prefix.py holds the whole story in one place: four prefixes, each
shown correctly in the badge; the rebound key running commands; the *old* key
going through to the program untouched (checked with `cat -v`, so a control
byte arriving is visible as ^A rather than an invisible character that would
prove nothing); the double-press still sending the key itself; and the
cheatsheet agreeing with the badge.
Also said it out loud in the README and the config, since the way this came
up was somebody reasonably assuming it was not possible.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two things wrong with the first one, and both are about it being a modal
rather than a pane that happens to float.
The close button was invisible. It was drawn in the pane button colour on the
finder background, and in the default theme those are the same value --
#45454a on #45454a -- so it only appeared when the pointer landed on it and
the hover colour took over. That is the hazard of borrowing names from
another surface: each one is right, and together they are nothing. A modal is
its own surface now, with its own names (modal_bg, modal_fg, modal_border,
modal_title, modal_button, modal_button_hover), a dark panel a shade lighter
than the dimmed screen, a border in the accent, and a button chosen to be
visible *without* the pointer on it. All six themes set them.
And nothing behind it moved, so it read as another pane rather than as
something in front. There is a scrim now, `modal_scrim` (0..255, 120 by
default), and the pleasing part is that it is not a new mechanism: it is the
shader pass aimed at the screen instead of at a pane. Same dim, same
materialisation of default colours, no special case. It goes down after
everything else is painted and before the modal, so the modal is the only
thing at full brightness -- which is the whole idea, and is asserted rather
than assumed.
The shape is now reusable: modal_frame() draws the box, title, close button
and hit, and returns the rect inside it; draw_scrim() is one line above it.
The next modal is a function call and looks the same as this one.
The finder is deliberately left alone. It is a picker rather than a modal --
it navigates, where a modal here dismisses on any key -- so unifying them
would be making two things one because they are both rectangles.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A prefix-key multiplexer is unusable until you know its keys, and the only
place they were written down was a config file and a README. Now they are one
keystroke away, in a pane-shaped modal floating over the layout.
It is built from CFG.binds, not from a list of what the defaults are. Rebind
something and the sheet says what you bound; unbind it (`bind "-" "none"`)
and it stops advertising it; change the prefix and the "C-a then:" line at the
top changes with it. A cheatsheet that can disagree with the keyboard is worse
than no cheatsheet, and it would disagree the first time anybody edited a
config -- so it cannot be a list.
Bindings that share an action are merged onto one row: `h ←` is one thing to
learn, not two, and the nine tab digits are `1…9` rather than nine rows.
Keys are named the way you press them -- `\` and `?` and `H`, not "backslash"
and "S-slash" and "shift+h" -- and both spellings still parse as config, so
the sheet stays copyable.
The frame is a pane's frame, corners and title and close button, because a
dialog should wear the chrome of the program it belongs to. It fills its own
background: a modal you can read a shell prompt through is one nobody trusts.
Two columns when there is room, one when there is not, split at a group
boundary so a heading never ends a column alone, and every string clipped at
the frame -- a small terminal loses words rather than its border.
Any key dismisses it, and that key does nothing else: dismissing a modal must
not also run the thing you happened to press. A click anywhere does the same
and does not reach what it landed on.
`?` is bound twice, to shift+slash and to slash, because whether it arrives
with a modifier depends on the outer terminal: as a plain byte there is no
shift to be had, and under the kitty keyboard protocol -- which our own client
asks for -- there is. Binding one of them is a binding that works on the
machine it was written on.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The shader machinery has been able to do this since it existed, and shipped
an opinion about exactly one state. Now it ships three, and the line between
those and the rest is the point:
a pane that is *not live* gets a default -- dead, suspended, scrolled. In
all three the cells are showing something other than a running program's
present: output from something that has exited, a pane that never started,
or the past. None of that is discoverable by looking unless something says
so, which is the whole argument for the feature.
a pane that is merely *not the one you are in* does not -- unfocused, and
one being dragged. That is ambient contrast, which is a taste, and a taste
shipped as a default is how a tool gets a reputation for fighting you. It
is one line of config away for anyone who disagrees, and there is now a
test asserting we have not quietly changed our minds.
So: suspended is greyed and dimmed (inert, still legible -- its contents are
the command it is waiting to run), and scrollback takes a 9% wash of the
scrollback indicator's own colour, so a theme moves the indicator and the
wash together. Checked against phosphor, amber and paper: each gets its own
hue rather than a hardcoded one.
The other half is the gap this leaves. A collapsed header never reaches the
shader pass -- a header is chrome and shaders colour contents (D13) -- so
collapsing a tab used to hide precisely the facts the colour exists to show.
The header now carries them in words, in the same order the status line ranks
them: `exited: status 3`, `not started`, `▲ 12`.
tests/test_states.py asserts the line in both directions, including that an
unfocused pane is left exactly as it was, and that a flattened tab still says
which of its panes never started.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Images rendered after the last commit, and were still wrong twice. Both
mistakes are invisible in a still picture, which is what every test was
looking at, and obvious the moment anything moves.
**The sub-cell offset was dropped.** A placement says where the image starts
*inside* its first cell (`X=`/`Y=`); that is what makes motion smooth rather
than a slideshow. We tracked the placement and re-emitted it without those,
so a bouncing logo moved in steps of a whole cell -- eight pixels across,
sixteen down.
**`c=`/`r=` were passed on unconditionally.** They mean *scale the image into
this many cells*, and the count a natural-size image happens to cover goes up
by one whenever it straddles one more boundary -- so the picture changed size
as it slid along. They are now sent only when the program asked for them,
which lib-vt reports as 0 when it did not.
Cropping splits the same way, and getting it wrong took a test failure to
notice: at natural size a lost cell costs the screen pixels the pane has left
(measured from the offset, not the cell edge, or an offset image hangs up to
a cell over the border); when the program asked to scale, a lost cell is
worth source/cells pixels and the crop is proportional. Cropping a 4-pixel
image scaled across 40 cells by screen pixels removes all of it.
gfx_place() took twenty positional arguments, half of them uint32_t pixel
quantities where a transposed pair compiles perfectly and lands the picture
slightly wrong -- the exact bug class this file exists for. It takes a struct
now.
`{"cmd":"graphics","format":"bytes"}` answers with the escape sequences the
client is actually sent. The model being right is not the same as the bytes
being right, and this whole episode was a lesson in asserting on the first
while the second was wrong. The new tests assert on the bytes.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"Kitty graphics pass through" was true of the tests and not of anything else.
Every test transmitted and placed in one command with explicit c=/r=; real
programs upload once with `a=t` and place per frame with `a=p`, saying
nothing about cells, and that path was broken from end to end.
A placement that does not say how many cells it covers is sized from the
image's pixels and the cell size. We never had a cell size:
- pty_spawn and pty_resize left ws_xpixel/ws_ypixel at zero, so a program
asking its own tty how big a cell is got nothing and fell back to guessing;
- ghostty_terminal_resize was called with 0, 0 for the cell size, so lib-vt
computed zero cells for such a placement -- and a placement covering zero
cells is skipped, which on screen is an image that simply is not there.
So the size now runs the whole way through. The client reads it from
TIOCGWINSZ (re-read on every resize, because a window can move to a monitor
with different metrics), MSG_HELLO and MSG_RESIZE carry it -- a 4-byte
payload still means "I did not say", so an older client still attaches -- the
app hands it to every pane in every tab, and each pane gives it to both its
pty and its terminal.
Where nobody knows, the default is 8x16 rather than 0. That is the whole
lesson of this bug: a plausible cell size means an image appears at roughly
the right size, and a zero means it does not appear at all.
`{"cmd":"resize"}` takes optional cell_w/cell_h, so a script can be a client
that knows its own metrics, and so the tests can be one.
The tests now use the protocol the way programs use it, including a check
that a program can read real pixel dimensions out of its own pty -- the thing
that was silently zero. The remaining half of the fix is one layer down, in
vendor/patches/0001.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Tabs had names -- a layout could set one, the control API could set one --
and no way to type one. A tab you made yourself was stuck being a number.
And the strip's order was the order tabs happened to be created in, forever.
Both are the same gesture as the pane equivalent, on the other thing that
has a name:
double-click a tab rename it in place, in its own cell in the strip
drag a tab move it along the strip
They land together because they share the machinery they are built on, and
splitting them would have meant writing the shared half twice.
The rename editor is the pane's, generalised from "renaming a pane" to
"renaming a pane or a tab" -- one editor with a subject rather than two that
would drift. Enter keeps, Escape abandons, clicking away commits, empty
gives the tab back its number. It is seeded with the name only: a pane seeds
from its title because a title is always something, but a tab shows its
*number* until it is named, and offering "3" to backspace over would be
offering to edit the wrong thing.
The drag is the existing drag machine's third verb, and it reorders **as you
go**. The strip is the only thing that could show an insertion point, and a
strip already showing the result needs no such invention -- no ghost tab, no
caret, nothing remembered between frames. Dragging off the strip is not a
cancel: it is simply not a place to put a tab.
Two things that were not obvious until they were written down:
- pane ids and tab ids are separate sequences, so pane 2 and tab 2 both
exist. The double-click detector remembered only the id, so a click on one
followed by a click on the other read as a double-click on whichever came
second. It remembers the kind too now, and the test asserts the ids really
do collide before asserting they are told apart -- otherwise it would
quietly stop testing anything the day the sequences diverge.
- `cur` is a *position*, not an identity, so a move has to recompute it.
Carried across, you would be looking at whichever tab slid into the index
you used to be at.
`{"cmd":"move-tab","id":N,"index":M}` does the same thing from a script, and
is what tests the arithmetic from a tab that is not the one being moved --
the case a drag can never produce, because a drag selects what it grabs.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It read "+tab" because of this, in the code, right above it:
Not a bare "+": the frame already has one, for splitting a pane. Two
verbs that look identical is a UI bug the fork shipped and noticed.
That was true when it was written and has not been since f2f3d67, which
removed the frame's "+" when the border became the button. The collision it
was avoiding no longer exists, and the word was left explaining itself to
nobody -- while every other affordance in this UI is a bare mark: _ # x on a
frame, and a hint under the pointer for anyone who wants the word.
So: "+", padded to three cells, because their own comment applies -- a
one-cell target is a thing you miss with a mouse.
Also configurable, as `newtab_mark`, which it should have been already: it
was the only mark here that was not (zoom_mark, zoom_on_mark, close_mark,
min_mark, bell_mark all are). Try "[+]" or "new" if you disagree; a session
picks it up on save.
Four tests were asserting the literal "+tab". Three of them only needed the
new string, but two were using it to *locate* the button -- bar.index("+tab")
-- which is exactly the mistake of asserting something incidental: what the
button says is now config, and a test that spells it out fails the day
somebody changes their mind. They look it up through the hit list now, which
is what they meant.
test_no_more_plus keeps the invariant that makes the bare mark safe, and now
says so: there is exactly one "+" in the UI, so it means one thing. If one
ever returns to a frame, that button needs its word back.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`cwd="~/dev/api"` is in the README, in config/layout.example.kdl, and did
nothing. `~` is a shell thing, and no shell has touched a path that arrives
from a layout file -- so it went to chdir() literally, failed, and left the
pane in whatever directory the session happened to be started from. The pane
still ran, so nothing looked wrong until you typed `ls`.
path_expand() handles `~` and `~/...` against $HOME and returns everything
else untouched, including `~user`, which needs the password database and is
not worth linking it in for -- returned unchanged rather than half-expanded
into something wrong.
Applied where a layout's cwd is read, which is one place because a pane
inherits its tab's. Found while documenting a shader directory that was
about to have exactly the same bug.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|