Skip to content

Mouse and selection - #7

Merged
softov merged 16 commits into
mainfrom
mouse-and-selection
Aug 25, 2026
Merged

Mouse and selection#7
softov merged 16 commits into
mainfrom
mouse-and-selection

Conversation

@softov

@softov softov commented Aug 25, 2026

Copy link
Copy Markdown
Owner

No description provided.

softov added 16 commits August 25, 2026 01:48
Mouse dispatch is a hit test, so a drag only ever reached the node the
pointer happened to be over - which is not the node the drag is about the
moment the pointer leaves it. An `onMouse` returning true on a `down` now
claims the drags and the up that follow, wherever they land.

Hover was worse: `onHover` was declared on every node and called from
nowhere, and the `hover` style overlay compared a focus id against
`props.id`, so only a focusable node could ever be hovered. It is a hit
test over laid-out boxes now, inheriting down the chain the way it does in
a browser - a row is hovered while the pointer is over the label inside it,
because the label is what the hit test finds.

`MouseEvent.at` is a timestamp, stamped by whatever produced the event. The
wire reports presses and releases and has no notion of a double click, so
telling one gesture from two is arithmetic on when they arrived and where.

Also here, all of it about the terminal rather than the tree:

- OSC 52 was switched off in the one place it exists for. The clipboard
  capability required a recognised terminal, and none of the variables that
  name one survive an ssh hop - TERM_PROGRAM, KITTY_WINDOW_ID and
  WT_SESSION are set by the terminal you are sitting at, not the machine
  the program runs on. A remote session saw a bare xterm-256color and
  dropped every copy. On now for everything but screen, which shows the
  payload as text.
- A theme can say what shape the caret is: `cursor` is block, underline or
  bar, applied through `TerminalAdapter.setCursorShape` (DECSCUSR) and
  reset at teardown only if the session set it.
- A divider is not a border. `divider` and `dividerChars` are their own
  theme setting with their own six sets, resolved down the extends chain,
  so a borderless theme can still separate with a line.

The harness gains `drag(from, ...to)` and `clickRepeat(x, y, times)`;
`click` steps its clock past the repeat window, so two clicks in a test are
two clicks.
…mselves

A click puts the caret where it landed, a drag selects, and the release
puts the selection on the system clipboard over OSC 52 and into the store.
Dragging past the edge scrolls the field. Double click takes the run under
it - letters with letters, spaces with spaces - and a third takes the
logical line rather than the row it was drawn on. shift with the movement
keys extends and copies; ctrl+left and ctrl+right move a word at a time.

That is a debt being paid rather than a feature: reporting mouse events
takes the terminal's own select-and-copy away, so an application that reads
the mouse has to hand one back.

The palette and the menu:

- The category names the group, once, above it. In every row's right-hand
  column four screens read "Screens, Screens, Screens, Screens" - in the
  width the rows needed for saying what they do, and still without marking
  where a group started.
- `descriptions: 'below'` gives a row's description a line of its own.
  Four approval modes named in two words each are told apart entirely by
  the sentence under them, and inline that sentence shows the same
  truncated half of every answer.
- The panel fits what it holds, up to `maxWidth`. A constant is too wide
  for one-word answers and too narrow for sentences, and it is the same
  constant either way.
- It opens on the answer in force, which `ArgSpec.default` is where a
  command says. Opening at the top says the first option is the current
  one, which is wrong on every list where it is not.

And two things that were wrong about width and space:

- A narrow `List` row gives up its description, not its status. All three
  columns shrank together, so a catalogue at 58 columns cut the label
  *and* the status to keep a workspace path nobody was scanning for.
- The workbench shell had one pad on the left of the main area and none on
  the right, in every theme.
…t was done

The slash menu listed the client's own commands and then sent whatever was
typed down the session channel, so `/go.sessions` went to the agent as a
message - the one place it could not possibly mean anything. It also could
not be navigated: the list was `focusable={false}` with no highlight and no
click, so it was decoration over a field.

Arrows walk it, a click chooses, and enter runs the command. A slash the
menu does not match is left alone and sent, because that is how a command
the *agent* offers reaches it. One that still has a question to ask opens
its picker rather than `execute` refusing a missing argument.

A tool call is something the agent did, not something it said. It was drawn
inside the speech gutter, one indent in, as though it were a paragraph of
the answer - and its input went straight onto the row, newlines and all, so
a three-line JSON object made the row three lines tall with the tool's name
floating beside the middle brace. It sits at the turn's own left edge now
with its status glyph where the header's bullet is, a chevron trailing when
there is something under it, a one-line summary on the row, and the whole
input on its own lines once opened. Clicking it opens it, and it lights up
under the pointer.

A chip's panel is also that chip's toggle, and the settings it offers now
declare what they are currently set to.
The client subscribed to one session's channel and to nothing else, so a
session appearing, finishing or starting to wait was invisible until
somebody navigated away and came back - a reader doing by hand what the
host had already said. `HostConnection.onSessions` is the catalogue moving
as opposed to one session's channel, and the live client raises it from the
root channel it was already draining for the agents it advertises. Reads
are coalesced: a turn finishing is several actions and one `listSessions`.

The header had the other half. `openSession` is a plain store read, so a
title or a status arriving changed the store and left the row showing what
it said when the session was opened - which is why it only caught up when
navigating away and back remounted it.

Also:

- Only the title gives way in the header. Everything else on that row is
  fixed-width and says what the application is; truncating "Assistant" to
  "Assist…" to fit more of a session title gives up the one part that is
  the same on every screen.
- A caption above the transcript, outside the scrolling: harness, model,
  workspace, when it started, and the chat uri that gets pasted into a
  shell. The things the header cannot fit and a reader scrolls up looking
  for. Not the title - that is on the line directly above.
- `pageKeys="always"` on the transcript, so page up from the composer means
  the conversation rather than a key nothing wanted.

The snapshot test asserted the tool call from the top of the transcript,
which made a test about *parsing a snapshot* a hostage to the row budget:
anything the screen grew pushed it out of view. It moves the cursor onto
the call now, which is where the call is rather than where it fitted.
`{caption}{items.map(...)}` reaches a component as `[caption, [a, b, c]]` -
two children, one of which is an array. A feed of twenty entries with
anything written beside the map therefore counted two: the cursor clamped
to the second, every key after the first did nothing, and the scroll never
moved.

It reads as a dead list and is an arity bug. Flattened at the door, where
JSX's grouping stops being the caller's problem.
…ing it

The test hardcoded `'▎'`, so changing `activeRule` in the icons file
broke a test that was not about which glyph it is - which is exactly the
drift the icons file exists to stop, reappearing one layer up.

It now imports `Icon.activeRule` and asserts the pane is marked, leaving
the choice of mark where it belongs.
The built-in row - icon, title, description, meta, on one line - is what
most catalogues are, and it stays exactly what you get by passing nothing.
What was missing was the way out. Every caller that wanted a different
shape was going to ask for another field on `ListItem` and another flag
saying where to put it, and that road ends with a component whose props
are a small layout language which still cannot draw the row after next.

So `renderItem` draws the contents and the list keeps everything a row
cannot do for itself: the selection, the keys that move it, the window
that scrolls, the highlight, the marker column and the click. `state`
tells a row what it cannot know - whether it is the selected one, and
whether that selection is live.

`itemHeight` comes with it because the window is arithmetic, not
measurement: the list decides how many rows fit before anything is drawn,
which is the only way a thousand rows cost what ten do. A row taller than
a line has to say so rather than be discovered.

There is no `keyExtractor`. `ListItem.id` is already both the
reconciliation key and the selection's name, deliberately the same string:
a row that reconciled under one identity while the selection pointed at
another is a highlight on the wrong row, with nothing in either place to
show why.

The prop extractor needed the same change made to it. A generic component
cannot state its props through `defineComponent`, so it is written as a
plain function and cast on the way out - and the extractor only read bare
calls, so `List` lost its entire table to a "_No props of its own._". A
table that disagrees with the source is the one thing that script exists
to prevent.
A new row kind, because the invariant everything downstream depends on is
that every row is exactly one row tall. A cell wider than its column is
cut, never wrapped: a viewer windows these by index, so a table that grew
a line when it was measured would put every row after it in the wrong
place. The edges are rows for the same reason a fence's rules are - they
take a line on the screen, so anything counting rows has to count them.

Columns are measured once for the whole table and repeated on every row of
it, because a column measured per row is not a column. When they will not
fit they give the width back in proportion, the widest losing most, with
three cells as the floor. `:---:` and `---:` are honoured, `\|` stays
inside its cell, and the outer pipes are optional because that is what
people write.

A line of pipes with no divider under it is left alone. `cat f | grep x`
is a pipeline far more often than it is a one-row table, and treating
every bar as a column is how prose ends up in a grid.

The box is drawn even under a theme with `border: 'none'`. That is a theme
saying "do not box things" - panels, fences, dialogs - not "do not tell
these columns apart": a table's rules are its structure, and without them
it is text that happens to line up with nothing to say where it ended. The
glyphs are asked for by name, which routes them through the terminal's own
limits, so a console that cannot draw box characters gets `+`, `-` and `|`
rather than a row of tofu.

`tableRules: 'all'` adds a rule between every pair of rows. It is the
theme's call and it opts in, because those lines are right for a table of
few long rows and noise on a table of twenty short ones. It is answered in
the layout rather than by the painter for the same reason as the edges: a
rule between two rows is a row.
`arrowUp` and `arrowDown` were there and the other two were not, so a hint
row naming a left or right key had nothing to name it with. Both sets get
them, including the ASCII one - which is not a fallback nobody sees but
what a Windows console actually prints, and a hint that named a key with a
glyph the console draws as a box is a hint that has told you nothing.
…rkdown has an off switch

The catalogue first. A title, a harness, a workspace and a status sharing
a pane that is also sharing the terminal with the detail panel leaves all
four truncated - a row reading `Draft replies for desk-produ…` beside
`1b444e78-d050-4fb5-a5…` has answered neither of the two questions it was
asked. So the title gets the width and what qualifies it goes underneath,
through the list's own `renderItem` rather than through another field on
`ListItem`.

The harness stands where a model would. `listSessions` answers with a
`SessionSummary`, and the model AHP knows is the one on the last message,
which lives on the session channel: a model per row would be a
subscription per row.

Below `splitAt` the detail pane is a drawer. Forty cells of it take the
list down to a column that cuts every title, and the pane they were taken
for is still too narrow to hold the URIs it exists to show - two truncated
halves rather than one whole one. Right opens it and left puts it away,
the key pointing at the pane. Opening it is going to it, because the width
follows the focus and a wide pane nobody asked for is the list being
narrow for nothing. The width is settable at boot.

The pane has to claim the keyboard itself: a focusable registers in its
own effect, so on the render that opens the drawer the id does not exist
yet and the screen's `focus()` quietly returns false.

Then the conversation. The session header lives inside the scroll rather
than above it, which is what was asked for and where it has room to say
everything instead of what fits in a title bar. Page keys reach the
transcript from the composer, a tool call sits a level out under a dot
with its arguments on their own lines, and rows light up under the mouse.

And markdown gets a switch. Drawing it is the default - reading `**this**`
is reading the punctuation instead of the sentence - but off is for when
the punctuation is the point: copying a fence out with its fence, or
reading a link's target rather than its label. Raw is a plain `text`, not
a `MarkdownView` told not to parse, because anything that lays the
document out has already decided some of those characters were structure.

Bound to `m` in the chat scope, and to `ctrl+m` for the terminals that can
send it. Ctrl+M and Return are the same byte: in raw mode Return sends CR,
`0x0d`, and this stack names that `enter` - only the kitty protocol or
xterm's `modifyOtherKeys` can tell the two apart, and no binding changes
that.

A refresh no longer forgets what the host refused. `refresh()` clears the
error on success and the new per-event refresh runs constantly, so the
reason a request was turned down was being wiped a frame after it arrived.
…ttice

A repeated tile reads as a lattice. The eye finds the rhythm in about a
second, and a sky of evenly spaced marks is graph paper - which is what
it was, drawn on a screenshot with a ruler.

`spacing` is the air between copies, always. `jitter` is how much more of
it is left to chance: a limit rather than a factor, so `jitter.x = 6` is
"somewhere between flush and six cells further on", dealt again for every
step. The two stack. Neither set is the grid it always drew, exactly, and
that is what the untouched tests are for.

Breaking the lattice took three things, and only the first was obvious.

Jittering the step alone leaves the copies in columns, because every row
of them shares one set of positions - so the walk across is re-dealt per
row. That breaks the columns and not the rows, because every copy in a
row still shares a `top` - so a copy also wobbles vertically. And a tile
as wide as its box is drawn once at zero with no second copy for a step
to differ from, so every seed dealt the same picture - so the walk also
starts up to a step earlier, backwards, which shows as a different slice
rather than as a blank margin.

The drawing had to be rebuilt for it. Rows used to be composed by
repeating the tile's own string, which only works while every copy is
exactly a tile from the last; the grid is now stamped and read off
afterwards, through the same `runs` split that keeps a transparent cell
transparent.

`seed` because a pattern re-renders whenever its box changes, and one
that reached for `Math.random()` while painting is a texture that crawls.
The chat example's `bood` does the opposite on purpose - one creature per
file, drawn by hand at each size, with a comment saying that "a creature
that fits a template is a template wearing a hat". That is right for what
it is: a still figure whose whole job is to look drawn.

This is the other trade. A persona is a puppet, and the point is that its
parts recombine, so hand-drawing six parts times three facings times four
moods is not art - it is arithmetic done by a person.

Five rows of seven. Two rows of hat, a head, a middle, a pair of feet.

The head is written with its eye socket empty and the eyes are stamped
into it, which is what makes a mood four short strings rather than four
whole heads. Eyes carry both axes: a mood chooses which eyes, and a
facing chooses what they do when the figure turns - sometimes only moving
within the socket, which `eyeAt` does for nothing, and sometimes a
different drawing, which is the only option left to a bug whose eyes fill
its whole head.

A hat belongs to nobody. It is its own list and `drawPersona` takes one,
because a hat that were a field on a figure could only ever be worn by
that figure - and the question was how to put this one on somebody else.
Its second row exists even when a hat only uses the first: one that
borrowed a row from the head would move the face down for anyone wearing
a tall one.

Every part is a path. Frame zero is the still - what shows with animation
off, in a snapshot and in a test - and the rest is the walk. Indexed per
part and wrapped, so an owl blinks at its own rate without its feet being
told about it.

Every glyph is CP437, the set a console font is guaranteed to have. The
`⅃` and `´` of the sketch are not in it and a Windows console draws them
as empty boxes; `J` and `'` are the same shapes and survive.
Sky over earth at sixty-forty, both of them patterns, with clouds and
flowers nobody placed by hand. An even split reads as a diagram; a low
horizon reads as a landscape and leaves the clouds the room they need.

Everything random is seeded, and the seed is rolled once at mount: a new
scene per run and the same scene within one. A component re-renders
whenever its box changes, so a scatter rolled while painting deals a new
sky on every keystroke and differs in a test from on the screen.

The clouds are built rather than chosen. Three sprites in a list is three
clouds in the sky and you can see it - so a cloud is a stack of runs,
widest at the bottom, each starting and ending a cell or two off its
neighbour, with a lighter glyph at each end so the rim fades into the sky
instead of stopping at an edge.

A persona walks about in it, and goes where it is pointed. The walk is a
clock rather than a transition: one cell per tick towards the target, and
the same tick advances the stride, so the feet move at the speed the
figure travels. When it arrives the ticker stops, so a scene with a
persona in it costs what one without it costs.

The figure is drawn by the *bands*, not in a layer over them. A cell's
background belongs to the box that painted it - `Buffer.put` writes one
unconditionally, and the only transparency this library has is declining
to emit a cell at all. Drawn in a layer of its own, the figure arrives as
a seven-by-five rectangle of the application's canvas cut out of the sky.
So its position is published in screen cells and whichever band covers a
row paints it, clipped to itself; one straddling the horizon is painted
by both. Only the marks are drawn, so the gap between its feet shows
whatever is behind it.

The sky tile, the sun and the bot's turning head are Softov's.
…that works

Two bugs in one row, and the second was worse than it looked.

`width` is a starting size, not a floor - shrink is 1 unless it is said -
so a value with nowhere to break was taking cells off the column beside
it, and `Chat` started its value four columns left of `Harness`. A column
that moves per row is not a column.

And a wrapped value makes the row two lines tall while its label is one.
A row centres its children unasked, so the label drifted down to sit
beside the *second* line of a URI with nothing at all beside the first.

`values` is the option for the rest of it: `selected` keeps the pane a
list and wraps only the row under the cursor, `all` wraps every row. The
catalogue's pane uses `all`, because the values there are the answer and
showing the first half of the one you are looking for makes somebody walk
to it to find out it was not the one.

`alt+m` for the markdown switch, because `ctrl+m` cannot work and no
binding changes that: in raw mode the Return key sends CR, `0x0d`, and
this stack names that `enter`. They are one byte. Only the kitty protocol
or xterm's `modifyOtherKeys` sends them apart, and over ssh there is
neither. `alt+m` arrives as ESC then the letter, which survives ssh, tmux
and a console that has heard of neither.
…lace

Two pages, because they are two things.

The clipboard is the terminal's. There is no key an application can bind
to mean "copy" - the emulator takes its own shortcut before any byte
reaches the program, and `Ctrl+C` and `Ctrl+V` were never available to be
that shortcut, being SIGINT and readline's literal-next. So the table of
what each emulator actually uses is written down, along with tmux and
screen, and the two things that matter while writing an application:
reporting the mouse takes the terminal's own select-and-copy away, and an
application can write the clipboard but not read it.

The keys are ours. Moving, selecting, the mouse gestures, enter, and the
collision family that keeps catching people out - `ctrl+m`, `ctrl+i` and
`ctrl+j` are the bytes for Return, Tab and ctrl+Return, so binding one is
binding that key, and `alt+<letter>` is the modifier to reach for. That
last one cost a round trip today, so it is written down rather than
learnt again.

Every control in `components/input/` now leaves to it, and `TextArea`
cites the clipboard table where it explains what it owes a reader whose
first drag selected nothing.
A `text` takes one colour for the whole run, which is right for nearly
everything and no answer at all where the colour *is* the content - a
banner, a ramp across a title, a palette walked down a block of ascii
art. `ColorText` is that case: it paints on a canvas and asks an `ink`
for a colour per cell.

An ink is spelled three ways. A ramp between stops, across the columns,
down the lines or corner to corner. A palette walked in runs, so
`every: [4, 3]` is four cells of one colour and three of the next. Or a
function handed the cell, which is the escape hatch - and the one that
cannot be written in a JSON screen, the same trade canvas makes with
draw.

Two things the building of it decided:

  * the cell carries a `col` and an `index`, because they stop being the
    same number the moment the text is not plain ascii, and the walk
    advances by grapheme width - colour by index and paint at col, and a
    gradient shears through the first wide character it meets;

  * `alignBlock`, because textAlign centres every line over its own
    middle. That is right for prose and it shears a picture: five rows
    of block letters do not have equal widths once the trailing spaces
    are gone, so each row lands somewhere slightly different and the
    letters lean.

A block that wraps asks for no width and takes what it is given; one
that does not is as wide as its widest line and says so. Reporting the
unwrapped width in both cases pushed every sibling off the row.

The examples/ink example is the demonstration and the test. A field, a
list of inks, a font, and a panel that scrolls - and a switch that
swaps the block letters for three paragraphs of prose, because the
component has no idea which it is looking at. Its fonts are one
hand-drawn table with both cases on one baseline, plus three transforms
of it: wide, slant and shadow. A bitmap font is a grid of characters,
and a grid of characters can be sheared, doubled or duplicated by ten
lines of code.

ScrollView takes a `focusId`. It was the only focusable control without
one, so the example had no way to name the panel it scrolls - and the
test that scrolls it was passing for the wrong reason until it did.
@softov
softov merged commit 60baecc into main Aug 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant