@qxuken/kui 0.0.0-stage → 0.1.0-alpha.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13313 -0
- package/LICENSE +21 -0
- package/README.md +336 -3
- package/docs/adr/0001-accessibility-as-data.md +414 -0
- package/docs/adr/0002-keyboard-focus-as-data.md +452 -0
- package/docs/adr/0003-modal-surfaces.md +248 -0
- package/docs/adr/0004-multi-window.md +626 -0
- package/docs/adr/0005-the-paint-vocabulary.md +601 -0
- package/docs/adr/0006-c-abi-versioning.md +257 -0
- package/docs/adr/0007-composite-keyboard-patterns.md +350 -0
- package/docs/adr/0008-live-regions-and-announcements.md +379 -0
- package/docs/adr/0009-press-drag-release-into-a-popup.md +318 -0
- package/docs/adr/0010-a-segment-primitive.md +410 -0
- package/docs/adr/0011-keys-bubble-to-the-enclosing-sink.md +258 -0
- package/docs/adr/0012-the-exit-budget.md +411 -0
- package/docs/adr/0013-effects-as-data.md +197 -0
- package/docs/adr/0014-slots-an-extension-fills-in-place.md +411 -0
- package/docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md +559 -0
- package/docs/adr/0016-caching-against-the-last-frame.md +351 -0
- package/docs/adr/0017-selection-as-a-scope.md +696 -0
- package/docs/adr/0018-a-menu-bar-the-app-declares.md +264 -0
- package/docs/adr/0019-a-theme-derived-from-appearance-and-accent.md +382 -0
- package/docs/adr/0020-the-surface-the-schema-does-not-cover.md +351 -0
- package/docs/adr/0021-one-subject-per-example.md +729 -0
- package/docs/adr/0022-focus-regions.md +205 -0
- package/docs/adr/0023-layers-stack-in-the-order-they-open.md +376 -0
- package/docs/adr/0024-the-devtools-are-the-cores.md +425 -0
- package/docs/adr/0025-the-image-is-the-canvas.md +507 -0
- package/docs/adr/0026-hit-testing-by-shape.md +197 -0
- package/docs/adr/0027-tokens-beside-the-theme.md +521 -0
- package/docs/adr/0028-derived-tokens.md +572 -0
- package/docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md +543 -0
- package/docs/adr/0030-the-standard-menus-the-runner-keeps.md +292 -0
- package/docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md +409 -0
- package/docs/adr/0032-a-devtools-tab-mounts-a-slot.md +480 -0
- package/docs/adr/0033-a-table-is-a-column-whose-cells-align.md +377 -0
- package/docs/adr/0034-stock-controls-over-the-roles.md +192 -0
- package/docs/adr/0035-a-rounded-background-is-joined-by-meeting.md +134 -0
- package/docs/adr/0036-an-event-handler-gets-its-window.md +142 -0
- package/docs/adr/0037-a-family-is-named.md +134 -0
- package/docs/adr/0038-a-scroll-gesture-latches-its-target.md +188 -0
- package/docs/adr/0039-a-tutorial-is-a-sequence.md +139 -0
- package/encoder.js +1282 -0
- package/howto.md +1774 -0
- package/index.d.ts +5162 -0
- package/index.js +1414 -0
- package/jsx-dev-runtime.js +1 -0
- package/jsx-runtime.d.ts +905 -0
- package/jsx-runtime.js +45 -0
- package/native.cjs +118 -0
- package/package.json +51 -6
- package/prebuilds/darwin-arm64/kui_node.node +0 -0
- package/prebuilds/darwin-x64/kui_node.node +0 -0
- package/prebuilds/linux-arm64/kui_node.node +0 -0
- package/prebuilds/linux-x64/kui_node.node +0 -0
- package/prebuilds/win32-x64/kui_node.node +0 -0
- package/props.md +636 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-09-27
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# A rounded background is joined by meeting
|
|
7
|
+
|
|
8
|
+
> **Accepted and built 2026-09-27**, the day it was proposed (backlog
|
|
9
|
+
> F101). Raised by kawoosh's rounded selection. The editor draws a
|
|
10
|
+
> selection as a translucent `bg` on the spans of each row it covers,
|
|
11
|
+
> and wanted it drawn as one rounded outline, the way an editor's
|
|
12
|
+
> selection usually is. Its first cut was a `fragment` under each row's
|
|
13
|
+
> text, told its own extent and its neighbours' by the app (the reason
|
|
14
|
+
> for a6092cc's square fragment and snapped fragment quad). That cut
|
|
15
|
+
> could not reach a markdown paragraph. A paragraph is one text whose
|
|
16
|
+
> lines wrap where layout breaks them, and a row of the view is not a
|
|
17
|
+
> line of the paragraph, so the app did not know the lines to draw
|
|
18
|
+
> under. And it was a frame behind, since the extents came from the last
|
|
19
|
+
> frame's layout.
|
|
20
|
+
|
|
21
|
+
## Context
|
|
22
|
+
|
|
23
|
+
- **What a span's background is.** One square quad per line a run of
|
|
24
|
+
spans of one background covers (`build_decorations`, one rect per run
|
|
25
|
+
since dfc635b), each edge on a whole pixel from where layout put the
|
|
26
|
+
text (`on_pixels`). Two rows' backgrounds meet on one pixel line with
|
|
27
|
+
nothing drawn twice. Only the corners were left to do.
|
|
28
|
+
- **What a rounded outline needs at a corner.** Where a line reaches
|
|
29
|
+
past the line above, its corner is convex. Where it falls short, the
|
|
30
|
+
corner is concave: a fillet past its end, filled in the line's own
|
|
31
|
+
colour. So a line's corners depend on the lines above and below it.
|
|
32
|
+
Those can be in the same text (a wrap), in another text (an editor's
|
|
33
|
+
next row), or in another node beside it (the cell an editor draws for
|
|
34
|
+
a line's newline).
|
|
35
|
+
- **When the lines are known.** A text's quads are emitted in its
|
|
36
|
+
node's turn of `emit_frame`, in paint order, and at that point the
|
|
37
|
+
node after it has not been laid out into quads. Every text's lines are
|
|
38
|
+
known only once the frame's quads are all emitted, and that is still
|
|
39
|
+
before the frame leaves the core. So a pass there sees every line,
|
|
40
|
+
from the layout of the frame it draws.
|
|
41
|
+
- **What already draws a shape no quad kind has.** The polygon (ADR
|
|
42
|
+
0025, decision 6) is a `fragment` quad painted by a WGSL source the
|
|
43
|
+
core registers itself. Its params are the shape, and every backend
|
|
44
|
+
that draws fragments draws it with nothing new.
|
|
45
|
+
|
|
46
|
+
## Decisions
|
|
47
|
+
|
|
48
|
+
1. **A span's background takes a radius, and nothing else is new on
|
|
49
|
+
the surface.** `Span::bg_radius(r)` in Rust, `bgRadius` on a
|
|
50
|
+
`<span>` in JSX, `bg_radius` in a Lua span table and on `KuiSpan` in
|
|
51
|
+
C, in logical px. At 0, the default, the background is the square
|
|
52
|
+
one it always was, byte for byte.
|
|
53
|
+
2. **Pieces are joined by meeting, not by a name.** Two rounded
|
|
54
|
+
backgrounds belong to one shape when they have the same colour and
|
|
55
|
+
radius and one's edge touches the other's exactly: on the line above
|
|
56
|
+
or below, overlapping it sideways, or end to end on the same line. It
|
|
57
|
+
does not matter which node drew either. Nothing groups them, so
|
|
58
|
+
there is no id to declare, keep stable or clear, and two that touch
|
|
59
|
+
are one. Meeting is exact because the pieces are on whole pixels
|
|
60
|
+
already. Pieces that meet end to end on a line are made one extent
|
|
61
|
+
first. That extent is what the lines above and below are told, and
|
|
62
|
+
no corner is drawn where the pieces meet.
|
|
63
|
+
3. **The join is a pass after emission.** A rounded background is
|
|
64
|
+
emitted square, like every background, and noted (`text::JoinBg`:
|
|
65
|
+
its quad, radius, the clip its text was given, and its text's own
|
|
66
|
+
box when that box clips, as a no-wrap line's does). At the end of
|
|
67
|
+
`emit_frame`, `join::shape` finds each piece's neighbour above and
|
|
68
|
+
below, the one overlapping it most, and rewrites its quad in place.
|
|
69
|
+
Paint order and the quad count are unchanged, and no backend,
|
|
70
|
+
adapter or wire learns a new kind. The shape uses the layout
|
|
71
|
+
of the frame it is drawn in, so it is never a frame behind.
|
|
72
|
+
4. **Each piece is drawn by a stock fragment, `fragment::JOIN`.** The
|
|
73
|
+
core registers it as it registers the polygon's. A piece's quad is as
|
|
74
|
+
tall as its line and as wide as its extent plus the fillets its
|
|
75
|
+
neighbours ask for. Its params are its own extent, the neighbours'
|
|
76
|
+
extents, the radius and which neighbours there are, in physical px
|
|
77
|
+
from the quad's left. A corner is convex where the piece reaches past
|
|
78
|
+
its neighbour, a fillet where the neighbour reaches past it, square
|
|
79
|
+
where the two end together, and round where nothing meets it. At a
|
|
80
|
+
join the radius is at most half the step, and both pieces work it out
|
|
81
|
+
from the same two ends, so the convex half above and the fillet below
|
|
82
|
+
meet. The fill is the quad's colour. Near the ends and arcs it is
|
|
83
|
+
sampled sixteen times a pixel, and elsewhere it is solid.
|
|
84
|
+
5. **A piece is clipped as its text's parent is.** A fillet past the
|
|
85
|
+
end of a short no-wrap line lies outside that text's own box, beside
|
|
86
|
+
it, so the piece takes the clip the text was given, not its own box.
|
|
87
|
+
A piece is still no wider than its text's own box shows. An
|
|
88
|
+
ancestor's clip, such as a scrolled pane, cuts the shape square where
|
|
89
|
+
it cuts, as it cuts everything.
|
|
90
|
+
|
|
91
|
+
## Considered options
|
|
92
|
+
|
|
93
|
+
- **The app draws it (kawoosh's first cut).** A `fragment` a row, the
|
|
94
|
+
extents from the last frame's layout. A frame behind on every edit
|
|
95
|
+
and scroll, and blind to a wrapped paragraph's lines, which only
|
|
96
|
+
layout knows. The square fragment and its snap (a6092cc) stay useful
|
|
97
|
+
for other stacks. For a selection over text, this ADR replaces that
|
|
98
|
+
cut.
|
|
99
|
+
- **A named group** (`bgGroup="sel"`). It would let two selections of
|
|
100
|
+
one colour that touch stay apart. It also needs an id per selection,
|
|
101
|
+
stable across frames, in four bindings. The pass would still have to
|
|
102
|
+
find which pieces of a group are neighbours, and only geometry
|
|
103
|
+
answers that. An editor's selections that touch are one selection.
|
|
104
|
+
Kept in reserve, as an optional key on the same pass, if an app ever
|
|
105
|
+
needs two touching shapes of one colour kept apart.
|
|
106
|
+
- **A new quad kind for rounded runs.** Every backend, the C header's
|
|
107
|
+
`KUI_QUAD_*` and the Node decoder would learn it. The fragment path
|
|
108
|
+
already carries sixteen params and a registered source, and the
|
|
109
|
+
polygon showed that path is enough.
|
|
110
|
+
- **Joining in layout.** Layout sizes boxes and does not know a text's
|
|
111
|
+
line rects in physical px, which is what the join compares. That
|
|
112
|
+
knowledge would move into layout for a paint effect.
|
|
113
|
+
|
|
114
|
+
## Consequences
|
|
115
|
+
|
|
116
|
+
- The first paint that reads across nodes. The pass costs a scan of
|
|
117
|
+
the frame's rounded pieces against each other. That is the pieces on
|
|
118
|
+
screen, a few dozen for a selection, and nothing when no span has a
|
|
119
|
+
radius.
|
|
120
|
+
- A rounded background is a `KUI_QUAD_FRAGMENT` on the wire, and its
|
|
121
|
+
`color` carries the fill with its alpha, not only the group opacity
|
|
122
|
+
as a node's fragment quad does. A host that draws fragments itself
|
|
123
|
+
gets the source from `kui_fragment_source`, as for the polygon. A host
|
|
124
|
+
that draws no fragments draws no rounded background, where it drew a
|
|
125
|
+
square one.
|
|
126
|
+
- Two touching selections in one colour and radius are one shape, by
|
|
127
|
+
design (decision 2).
|
|
128
|
+
- The C `KuiSpan` gains `bg_radius` under the unreleased ABI 20, and the
|
|
129
|
+
Node frame goes to v17, one slot a span.
|
|
130
|
+
- Pinned by `tests/joined_backgrounds.rs` in kui-core (the pieces and
|
|
131
|
+
their params across texts, a wrap, colours and radii that do not
|
|
132
|
+
join, pieces meeting end to end on a line) and in kui-wgpu (the shape
|
|
133
|
+
composited through `JOIN`'s mirror, `tests/wgsl::join_alpha`), and by
|
|
134
|
+
the corpus's `joined-backgrounds` scene in four adapters.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-09-27
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# An event handler gets the window its event came from
|
|
7
|
+
|
|
8
|
+
> **Accepted and built 2026-09-27**, the day it was proposed (backlog
|
|
9
|
+
> DX9, from the DX sweep of kawoosh's views). The user chose the name
|
|
10
|
+
> `on_event_with`. What the building added: a window that closed after
|
|
11
|
+
> its event was made lends the main window's core, not none, so no
|
|
12
|
+
> event is dropped. The clipboard example moved its copy, paste and
|
|
13
|
+
> selection answer into the handler and lost three model fields. Two
|
|
14
|
+
> things were not built as planned. The runner has no two-window test,
|
|
15
|
+
> since a pane needs a real window; `testing::Drive` pins the handler's
|
|
16
|
+
> core, and `window_core` is the one lookup. The debug assertion against
|
|
17
|
+
> `frame()` in a handler was not added, and the trait's doc says it
|
|
18
|
+
> instead.
|
|
19
|
+
|
|
20
|
+
A Rust app hears its events in `App::on_event(&mut self, ev: UiEvent)`
|
|
21
|
+
and has nothing else in hand. Everything it wants to *do* about an event
|
|
22
|
+
beyond changing its model — write the clipboard, ask for a paste, move
|
|
23
|
+
focus, reveal a row, scroll, ask for a frame, open a menu — is a call on
|
|
24
|
+
the window's `Core`, and the only place a Rust app reaches a `Core` is
|
|
25
|
+
`view`, through `Ui`. So kawoosh parks each of those in its model for
|
|
26
|
+
the next `view` to carry out:
|
|
27
|
+
|
|
28
|
+
- `clip_out`, the text to copy, because "`on_event` has no `Ui`"
|
|
29
|
+
(`kawoosh/src/app.rs:191`);
|
|
30
|
+
- `reclaim_focus`, set by ten handlers (`app.rs:224`; DX10's `keepFocus`
|
|
31
|
+
removed most of the need);
|
|
32
|
+
- the devtools tab and alignment requests (`app.rs:137`, `:253`).
|
|
33
|
+
|
|
34
|
+
Each is a field that is not state, a branch in `view` that is not
|
|
35
|
+
drawing, and a frame of delay between the event and its effect. ADR 0008
|
|
36
|
+
(decision 6) and the closed C18 and C33 each met this and wrote around
|
|
37
|
+
it. None of them filed it.
|
|
38
|
+
|
|
39
|
+
## Context
|
|
40
|
+
|
|
41
|
+
- **Node already has it.** `update(model, msg, event, surface)` gets the
|
|
42
|
+
`Ctx` or `KuiWindow` the loop runs on as its fourth argument since D4.
|
|
43
|
+
`surface.setClipboard(…)` from `update` is the Node spelling of what
|
|
44
|
+
kawoosh parks. ADR 0013 kept it that way on purpose: kui's effects
|
|
45
|
+
are calls on the surface, and only the app's own effects are returned
|
|
46
|
+
as data.
|
|
47
|
+
- **C already has it.** A C host drives its core and polls its events
|
|
48
|
+
itself, so its handler holds the `KuiCtx`.
|
|
49
|
+
- **Lua is a guest (ADR 0014).** A script's `on_event(ev)` returns
|
|
50
|
+
replies for its host to act on, and its `env` is a reading of one
|
|
51
|
+
`view`. That is the design, not a gap: what a script wants done
|
|
52
|
+
outside its own tree, it asks its host for (kawoosh's `kawoosh.copy`).
|
|
53
|
+
- **The runner can hand one over.** `Shell::route_events` calls
|
|
54
|
+
`app.on_event(ev)` with `panes` (each window's `Core`) and `app` as
|
|
55
|
+
separate fields of `Shell`, so it can lend the handler the pane's core
|
|
56
|
+
while it runs. `ev.window` names the pane. A pumped runner's
|
|
57
|
+
`route_events(events, to_app)` and `testing::Drive` route the same way.
|
|
58
|
+
- **The core's verbs already work between frames.** `set_clipboard` and
|
|
59
|
+
`request_paste` queue menu actions the runner drains after routing.
|
|
60
|
+
`reveal`, `focus_region` and the DX15 labels resolve when the next
|
|
61
|
+
frame finishes. `set_scroll`, `set_key_focus` and `request_frame` ask
|
|
62
|
+
for a frame. The verb table (`schema::DOORS`) already lists the
|
|
63
|
+
between-frames doors a host has, and it is the same list Node's
|
|
64
|
+
surface is checked against.
|
|
65
|
+
|
|
66
|
+
## Decisions
|
|
67
|
+
|
|
68
|
+
1. **`App` gains a handler that takes the window's core, and the old
|
|
69
|
+
one stays.**
|
|
70
|
+
|
|
71
|
+
```rust
|
|
72
|
+
fn on_event_with(&mut self, ev: UiEvent, core: &mut Core) {
|
|
73
|
+
self.on_event(ev)
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The runner calls `on_event_with`. An app that overrides only
|
|
78
|
+
`on_event` behaves as it does today, so nothing breaks. The name is
|
|
79
|
+
open: `on_event_with` says what changed, and `update` would match
|
|
80
|
+
Node's word, but `update` in Rust usually means the model's step.
|
|
81
|
+
|
|
82
|
+
2. **The surface is the `Core`, not a new context type.** It is the core
|
|
83
|
+
of the window `ev.window` names: the pane's, or the main window's for
|
|
84
|
+
an event a host made itself. It is `&mut Core` because the core's
|
|
85
|
+
between-frames doors already are the curated list. `DOORS` pins them
|
|
86
|
+
against Node's surface and C's header. A `EventCx` with a chosen
|
|
87
|
+
subset would be a fourth list to keep in step, and it would drift.
|
|
88
|
+
|
|
89
|
+
3. **What the handler does lands where the verb says.** A clipboard write
|
|
90
|
+
goes out with the batch the runner drains after routing. A focus move
|
|
91
|
+
or a reveal is seen by the next frame, which the verb asks for. The
|
|
92
|
+
handler adds no ordering of its own.
|
|
93
|
+
|
|
94
|
+
4. **Every driver passes it.** The winit runner, the pumped runner's
|
|
95
|
+
`route_events` (its `to_app` becomes `FnMut(&mut A, UiEvent, &mut
|
|
96
|
+
Core)`, a break for the few hosts that call it), and
|
|
97
|
+
`testing::Drive`. An event raised while a frame is being built, such
|
|
98
|
+
as a slot's reply, is routed after the frame like every other, so the
|
|
99
|
+
core is never lent while `Ui` holds it.
|
|
100
|
+
|
|
101
|
+
5. **Lua and C are unchanged.** C already holds its context. A Lua
|
|
102
|
+
script stays a guest and asks through replies (ADR 0014). This is
|
|
103
|
+
Rust's door catching up with Node's, so no verb is added to the table.
|
|
104
|
+
|
|
105
|
+
## Considered options
|
|
106
|
+
|
|
107
|
+
- **Change `on_event`'s signature.** One method, the obvious spelling,
|
|
108
|
+
and a break for every Rust app on kui (kawoosh has one, the examples
|
|
109
|
+
thirty-odd). The default method in decision 1 gets the same result
|
|
110
|
+
without the break. Kept as the thing to do once it has shipped a
|
|
111
|
+
release, if the old method is to go.
|
|
112
|
+
- **An `EventCx` of chosen verbs** (the backlog's first shape). Easier
|
|
113
|
+
to document and to keep an app from calling `frame()` from a handler.
|
|
114
|
+
But it is a second list of the core's verbs, beside the one `DOORS`
|
|
115
|
+
pins, in Rust only. A verb added to the core would need adding here
|
|
116
|
+
too or be missing only in Rust handlers. What it would buy — a handler
|
|
117
|
+
that cannot call `frame()` and build a frame the runner did not ask
|
|
118
|
+
for — is a rule the core's docs already state for any host holding a
|
|
119
|
+
`Core` between frames, and a debug assertion can hold it here.
|
|
120
|
+
- **Effects as data** (ADR 0013's shape for Rust). Return
|
|
121
|
+
`Vec<Effect>` for the runner to apply. kui's effects are core verbs,
|
|
122
|
+
and ADR 0013 kept those as calls. Only the app's own effects are data.
|
|
123
|
+
A Rust app's own effects are its own code.
|
|
124
|
+
- **Leave it**, since kawoosh's workaround works. The cost is not
|
|
125
|
+
kawoosh's three fields but that every Rust app meets the same wall at
|
|
126
|
+
its first clipboard shortcut, and the Node app next to it does not.
|
|
127
|
+
|
|
128
|
+
## Consequences
|
|
129
|
+
|
|
130
|
+
- A Rust handler copies, pastes, focuses, reveals and scrolls in answer
|
|
131
|
+
to the event, as a Node `update` does. The model fields and `view`
|
|
132
|
+
branches that parked those go. kawoosh's `clip_out`, the devtools
|
|
133
|
+
requests and what `reclaim_focus` has left are the first to go.
|
|
134
|
+
- `route_events`' closure gains the core: a break for pumped hosts,
|
|
135
|
+
listed under What breaks. `testing::Drive` routes the same way, so a
|
|
136
|
+
test sees what the window does.
|
|
137
|
+
- Nothing in the core, the IR, the schema, the C ABI, Lua or Node
|
|
138
|
+
changes.
|
|
139
|
+
- Built with a runner test that a handler's `set_clipboard` reaches the
|
|
140
|
+
drained actions of the same turn and a `reveal` the next frame, and a
|
|
141
|
+
`Drive` test that the handler's core is the window its event came
|
|
142
|
+
from when two windows are open.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-09-27
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# A family is named where the text declares it
|
|
7
|
+
|
|
8
|
+
> **Accepted and built 2026-09-27**, the day it was proposed (backlog
|
|
9
|
+
> DX17, from the DX sweep of kawoosh's views). The user chose frame v18
|
|
10
|
+
> for decision 4. What the building settled: the parser resolves the
|
|
11
|
+
> name. `NameRefs::family`, over a token lookup that now carries the
|
|
12
|
+
> session, registers it through `Session::register_family`, the body
|
|
13
|
+
> `add_system_font` always had, moved where a parse holding the core's
|
|
14
|
+
> lookup can reach it. So Lua and Node resolve it in the one step they
|
|
15
|
+
> already resolve a `$token` in. A miss is remembered beside the token
|
|
16
|
+
> misses and raised by the same code. The schema gained `Kind::Family`
|
|
17
|
+
> and `Parsed::Family`. C keeps the index and the handle door (decision
|
|
18
|
+
> 5), and its parity sample is the stock family at index 1. `font` and
|
|
19
|
+
> `family` write the same field, so the last one declared wins, and the
|
|
20
|
+
> row's doc says so rather than "the handle wins".
|
|
21
|
+
|
|
22
|
+
`family` takes `sans`, `serif` or `mono`. Any other face is drawn with a
|
|
23
|
+
handle: the host registers the family by name (`Core::add_system_font`,
|
|
24
|
+
`addSystemFont`, `kui_font_add_system`) and hands the `FontId` down, and
|
|
25
|
+
the text names it as `font`. A Lua view cannot register anything (ADR
|
|
26
|
+
0014: registering is the host's), so a script that shows an installed
|
|
27
|
+
face needs its host to register it and pass it in. kawoosh built that
|
|
28
|
+
door, `kawoosh.fonts.face(NAME)`. Its first cut registered a family
|
|
29
|
+
when a card first asked for it, at the next frame, so every card the
|
|
30
|
+
fonts pane scrolled to drew one frame in kui's mono and then in its own
|
|
31
|
+
face. The fix it shipped registers all 613 installed families at once,
|
|
32
|
+
10 ms once in a debug build (`kawoosh/src/fonts.rs:7-12`,
|
|
33
|
+
`docs/design/fonts.md:48-60`).
|
|
34
|
+
|
|
35
|
+
## Context
|
|
36
|
+
|
|
37
|
+
- **Registering a family by name loads nothing.** The font database
|
|
38
|
+
scans every installed face's metadata when it starts, which is how
|
|
39
|
+
`system_fonts` (F97) lists them without opening a file. So
|
|
40
|
+
`add_system_font` is a query against that scan and an idempotent
|
|
41
|
+
registry entry. The face's bytes are memory-mapped and shaped only
|
|
42
|
+
when a text first uses it, as for any face. Resolving a name in the
|
|
43
|
+
frame that declares it costs a lookup. The frame of mono in kawoosh
|
|
44
|
+
came from its host deferring the registration a frame, not from kui.
|
|
45
|
+
(Since DX24 the session's first registration of any font also maps
|
|
46
|
+
the installed files once, ~30 ms on a Mac, so the first frame to name
|
|
47
|
+
a family pays that; noted by the alpha.22 regression pass.)
|
|
48
|
+
- **`TextStyle` is `Copy`,** and views rely on that everywhere (a
|
|
49
|
+
closure capturing a style, `style` passed by value). A name cannot
|
|
50
|
+
live in it. A handle can, which is why `FontFamily::Custom(FontId)`
|
|
51
|
+
exists.
|
|
52
|
+
- **Every binding lowers a style where the core is at hand.** Lua's
|
|
53
|
+
parser runs inside `view` with the `Ui`. Node's frame is lowered by
|
|
54
|
+
the addon (`binary.rs`), with the window's `Core`. C builds a
|
|
55
|
+
`KuiTextStyle` itself and has `kui_font_add_system`. Rust has the
|
|
56
|
+
`Ui`.
|
|
57
|
+
- **The weights follow a family, not a handle.** RG59 reweighs text
|
|
58
|
+
already shaped when a face of its family is loaded, and F100
|
|
59
|
+
synthesizes bold for a family with none. Both key on the family. A
|
|
60
|
+
named family resolves to the handle `add_system_font` returns, so
|
|
61
|
+
both keep working.
|
|
62
|
+
|
|
63
|
+
## Decisions
|
|
64
|
+
|
|
65
|
+
1. **`family` takes an installed family's name.** `family = "Berkeley
|
|
66
|
+
Mono"` in Lua, `family="Berkeley Mono"` in JSX. The stock names
|
|
67
|
+
`sans`, `serif` and `mono`, lowercase, stay the stock families. Any
|
|
68
|
+
other string is a family name, matched as `add_system_font` matches
|
|
69
|
+
it: installed, or loaded with `load_fonts_dir` / `load_font_file`.
|
|
70
|
+
2. **The name is resolved where the style is lowered, to the handle.**
|
|
71
|
+
The binding's lowering calls `Core::add_system_font(name)` and sets
|
|
72
|
+
`FontFamily::Custom(id)`, so the text shapes in its face in the frame
|
|
73
|
+
that declares it. `TextStyle` stays `Copy`, and nothing after
|
|
74
|
+
lowering knows a name was involved. The registry's answer is
|
|
75
|
+
idempotent. A name lowered every frame costs a lookup, and a
|
|
76
|
+
name→handle map in the session makes it constant if a profile asks.
|
|
77
|
+
3. **A name nothing matches is a warning, and sans.** `unknown-family`,
|
|
78
|
+
keyed by the name, so a typo in a view drawn every frame is one line.
|
|
79
|
+
The text shapes as sans, as a stale handle does today.
|
|
80
|
+
4. **Node carries the name on the wire.** The frame's `family` slot is
|
|
81
|
+
an index into `schema::FAMILIES`. A name needs the string table the
|
|
82
|
+
frame already has for text. So `family` becomes an index for the
|
|
83
|
+
stock three and a string for anything else, and the frame is v18.
|
|
84
|
+
The encoder and addon ship together, so this breaks only a stale
|
|
85
|
+
prebuilt addon, which refuses the stream by its version. The other
|
|
86
|
+
route is below, with why it is second.
|
|
87
|
+
5. **Rust and C gain nothing new but a pass-through.** Both already hold
|
|
88
|
+
the door. `Ui::system_font(name) -> Option<FontId>` is added so a
|
|
89
|
+
Rust view needs no `core()` to reach it. C keeps
|
|
90
|
+
`kui_font_add_system` and `KuiTextStyle.font`.
|
|
91
|
+
6. **The prop's type says it.** The schema row's kind becomes a family
|
|
92
|
+
kind, the three stock names or a string. Lua's meta types it
|
|
93
|
+
`"sans"|"serif"|"mono"|string`, and TS `'sans' | 'serif' | 'mono' |
|
|
94
|
+
(string & {})`, so an editor still offers the three.
|
|
95
|
+
|
|
96
|
+
## Considered options
|
|
97
|
+
|
|
98
|
+
- **Leave it to the host** (today). It works for Rust, C and Node, which
|
|
99
|
+
can call the door. For Lua it means every host that shows a face
|
|
100
|
+
builds kawoosh's `fonts.face` and learns about its flicker.
|
|
101
|
+
- **A Lua `env.add_system_font` door.** It would let a script register.
|
|
102
|
+
But registering is the host's (ADR 0014), and a handle is a thing to
|
|
103
|
+
keep and pass around. A family by name has nothing to own or free,
|
|
104
|
+
like `family = "mono"`, so it can be a declaration, not a verb.
|
|
105
|
+
- **Node resolves in JS**, with `ctx.addSystemFont(name)` from the
|
|
106
|
+
encoder, cached per name, emitting the `font` handle the wire already
|
|
107
|
+
has. No frame version, but the encoder would need a context. Today
|
|
108
|
+
there is one encoder per module, built from `protocol()` alone
|
|
109
|
+
(`index.js:56`) and shared by every window and every test. A string on
|
|
110
|
+
the wire keeps it that way.
|
|
111
|
+
- **Load asynchronously and swap** (CSS's `font-display: swap`): draw a
|
|
112
|
+
fallback, register off the frame, reshape when ready. It answers a
|
|
113
|
+
cost that is not there (decision 2's context). The swap is the flicker
|
|
114
|
+
kawoosh removed.
|
|
115
|
+
- **A root `fonts = { … }` preload.** A second place to name a family,
|
|
116
|
+
for the same lookup the prop makes.
|
|
117
|
+
|
|
118
|
+
## Consequences
|
|
119
|
+
|
|
120
|
+
- A Lua view draws an installed face by naming it. kawoosh's
|
|
121
|
+
`kawoosh.fonts.face` and its 613-family registration go, and the fonts
|
|
122
|
+
pane's cards say `family = name`.
|
|
123
|
+
- The Node frame is v18: one slot's encoding, under What breaks.
|
|
124
|
+
`schema::FAMILIES` and C's `KUI_FONT_*` are unchanged.
|
|
125
|
+
- One new warning, `unknown-family`, in `diag::WARNINGS`, the TS union
|
|
126
|
+
and the Lua docs.
|
|
127
|
+
- A family's handles and weights come from one registry. A family named
|
|
128
|
+
in a view and one registered by the host get the same handle,
|
|
129
|
+
pinned by a test.
|
|
130
|
+
- Built with a core test that a named family shapes in its face in the
|
|
131
|
+
frame that names it (no frame of sans), the warning for a name
|
|
132
|
+
nothing matches, a Lua and a Node scene naming a family the corpus
|
|
133
|
+
loads from its fixture font, and the reweigh (RG59) still following a
|
|
134
|
+
named family's new face.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-09-28
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# A scroll gesture latches its target
|
|
7
|
+
|
|
8
|
+
> **Accepted and built 2026-09-28**, the day it was proposed (backlog
|
|
9
|
+
> F107). Raised by kawoosh's todo of that day, three items about where
|
|
10
|
+
> the wheel goes. A swipe that moved the pane strip carried a terminal
|
|
11
|
+
> under the pointer, and the terminal took the rest of the swipe, so
|
|
12
|
+
> the strip stopped hard at every terminal it met. A new swipe over a
|
|
13
|
+
> list already at its end moved nothing, where the scroller around it
|
|
14
|
+
> could have moved. And a swipe over a scroller did not always go the
|
|
15
|
+
> scroller's way.
|
|
16
|
+
>
|
|
17
|
+
> **Amended by the alpha.22 regression pass (2026-09-28):** the pick
|
|
18
|
+
> walks the scroller's ancestors in the tree, not the regions painted
|
|
19
|
+
> under the pointer (decision 3), and reads what it needs off the region
|
|
20
|
+
> the last finished frame drew rather than off the tree, so a wheel
|
|
21
|
+
> event that arrives during a build routes by that frame.
|
|
22
|
+
>
|
|
23
|
+
> **Amended 2026-10-02 (backlog F117, from kawoosh's sticky-swipe
|
|
24
|
+
> report):** a finger put down begins a gesture, pause or none
|
|
25
|
+
> (decision 2). A swipe started while the last one still glided was the
|
|
26
|
+
> glide's gesture, its target and its axis, until a pause came.
|
|
27
|
+
>
|
|
28
|
+
> **Amended 2026-10-02 (backlog F118, from the same report):** a wheel
|
|
29
|
+
> handler that is also a scroll container on an axis is answered there
|
|
30
|
+
> by its room (decision 3), so a code view or a table at its sideways
|
|
31
|
+
> edge passes a swipe that way to the strip around it.
|
|
32
|
+
|
|
33
|
+
## Context
|
|
34
|
+
|
|
35
|
+
- **Where a delta went.** `InputEvent::Scroll` walked the scroll
|
|
36
|
+
regions under the pointer, innermost by paint order first. Each
|
|
37
|
+
scroll container took the axes it scrolls, and the rest went on
|
|
38
|
+
(DX13, so a `scrollY` list hands `x` to the `scrollX` strip around
|
|
39
|
+
it). An `on_scroll` node took whatever was left and stopped the walk.
|
|
40
|
+
Every delta walked afresh, from wherever the pointer was at that
|
|
41
|
+
moment.
|
|
42
|
+
- **What moves under a still pointer.** A two-finger swipe does not
|
|
43
|
+
move the pointer. The strip scrolls under it, so a swipe that began
|
|
44
|
+
over a list ends over a terminal. kawoosh's terminal is an
|
|
45
|
+
`on_scroll` node that takes every delta for its history, so the
|
|
46
|
+
moment it arrived under the pointer it took the swipe.
|
|
47
|
+
- **What an `on_scroll` node cannot say.** The core keeps no offset for
|
|
48
|
+
it and cannot ask whether it has anywhere to go. A container it can
|
|
49
|
+
ask, from the last layout's geometry.
|
|
50
|
+
- **Where gestures begin.** The core is clock-free, as it is for
|
|
51
|
+
clicks. The native runner already has a clock and already tells one
|
|
52
|
+
swipe from the next: F104's axis lock ends a swipe after a 200 ms
|
|
53
|
+
pause. winit's `TouchPhase` is not usable for this alone. It names a
|
|
54
|
+
momentum run's start as it names a finger's (F117 tells them apart by
|
|
55
|
+
order, decision 2).
|
|
56
|
+
- **What browsers do.** A wheel or trackpad gesture latches to the
|
|
57
|
+
scroller it started on and keeps it until the gesture ends
|
|
58
|
+
(*latching*). The scroller is picked at the start as the innermost
|
|
59
|
+
one under the pointer that can scroll the way the gesture goes, so a
|
|
60
|
+
scroller at its limit passes the gesture to its ancestor
|
|
61
|
+
(*chaining*). A gesture that reaches a limit partway stops there.
|
|
62
|
+
CSS's `overscroll-behavior: contain` stops the chaining.
|
|
63
|
+
|
|
64
|
+
## Decisions
|
|
65
|
+
|
|
66
|
+
1. **A gesture is delimited by the driver.** `InputEvent::ScrollGesture
|
|
67
|
+
{ delta, begins }` carries a delta and whether it is its gesture's
|
|
68
|
+
first. A bare `InputEvent::Scroll` is a gesture of its own, so a
|
|
69
|
+
driver that knows nothing of gestures (C's `kui_input_scroll`, Node's
|
|
70
|
+
`ctx.scroll`, the corpus) gets per-event targeting, now with
|
|
71
|
+
chaining. C has `kui_input_scroll_gesture(ctx, dx, dy, begins)` for a
|
|
72
|
+
host with its own loop.
|
|
73
|
+
2. **The native runner begins a gesture** on its first event, after a
|
|
74
|
+
pause of 200 ms (F104's `GAP`), on a switch between line and pixel
|
|
75
|
+
deltas, and, for a wheel only, when the pointer moves.
|
|
76
|
+
- A wheel spun without a pause is one gesture, as in a browser, so a
|
|
77
|
+
list reaching its end under a spinning wheel does not hand the
|
|
78
|
+
rest to the page.
|
|
79
|
+
- Moving the mouse is aiming somewhere new, so it ends a wheel's
|
|
80
|
+
gesture.
|
|
81
|
+
- A swipe's momentum outlives the hand, and moving the pointer
|
|
82
|
+
during the glide must not move the glide somewhere else, so it
|
|
83
|
+
does not end a swipe's.
|
|
84
|
+
- An event the axis lock keeps to zero is not dispatched. The
|
|
85
|
+
beginning it carried is owed to the next one that is.
|
|
86
|
+
- *Amended 2026-10-02 (F117):* a finger put down begins a gesture
|
|
87
|
+
too, however soon after the last event, and ends the axis lock's
|
|
88
|
+
swipe. A `Started` within 100 ms of a finger's `Ended` is the
|
|
89
|
+
glide's (macOS starts it on the next event) and keeps the gesture;
|
|
90
|
+
every other `Started` is a finger's — on nothing lifted, or on a
|
|
91
|
+
glide, which macOS ends as the finger lands. Before this, a swipe
|
|
92
|
+
begun mid-glide was the glide's gesture until a 200 ms pause, which
|
|
93
|
+
a glide's events a frame apart never leave.
|
|
94
|
+
3. **The target is picked per axis, when the gesture first moves on
|
|
95
|
+
that axis, and latched.**
|
|
96
|
+
- Picking starts at the topmost region under the pointer and walks
|
|
97
|
+
out through the regions around it in the tree (amended by the
|
|
98
|
+
alpha.22 regression pass: it walked every region under the pointer
|
|
99
|
+
by paint order, so a list at its end in a popover chained to a
|
|
100
|
+
page it merely floated over). The first to take the axis is the
|
|
101
|
+
target:
|
|
102
|
+
- an `on_scroll` node takes it if its `scroll_axes` names it,
|
|
103
|
+
whether or not it has anywhere to go — *amended 2026-10-02
|
|
104
|
+
(F118):* unless it scrolls that axis as a container too, its
|
|
105
|
+
offset the app's to set from what it hears; there the core can
|
|
106
|
+
ask, and it is answered as a container is, below;
|
|
107
|
+
- a container takes it if it scrolls on that axis and can still
|
|
108
|
+
move the way the delta goes (more than half a pixel of room,
|
|
109
|
+
measured from where the delta would be added);
|
|
110
|
+
- a container that says `overscroll: contain` takes it in any
|
|
111
|
+
case.
|
|
112
|
+
- Later events of the gesture go to the latched target, found by key
|
|
113
|
+
among the frame's scroll regions wherever the pointer or the
|
|
114
|
+
content has gone.
|
|
115
|
+
- A target that is gone from the frame, or is behind a modal now, is
|
|
116
|
+
dropped, and the axis is picked again under the pointer.
|
|
117
|
+
- An axis that found nothing stays unlatched, so a gesture that turns
|
|
118
|
+
back can still find a scroller.
|
|
119
|
+
- Per axis is what DX13's hand-off becomes: a list's `y` and the
|
|
120
|
+
strip's `x` are two targets. With F104's lock a swipe has one in
|
|
121
|
+
practice, and a lock that turns picks the new axis's target at the
|
|
122
|
+
turn.
|
|
123
|
+
4. **No chaining partway through.** A latched container at its limit
|
|
124
|
+
keeps the gesture and moves nothing, as a browser's does, and turning
|
|
125
|
+
back within the gesture moves it again.
|
|
126
|
+
5. **`overscroll: "auto" | "contain"`** (`Overscroll`,
|
|
127
|
+
`KUI_OVERSCROLL_*`) opts a container out of being chained past. It
|
|
128
|
+
holds only on the axes the container scrolls. So a contained
|
|
129
|
+
`scrollY` list still passes a sideways swipe to the strip, where CSS
|
|
130
|
+
would stop that too. kui's one-axis scrollers are
|
|
131
|
+
not scroll containers on their other axis.
|
|
132
|
+
6. **`scrollAxes: "both" | "x" | "y"`** (`ScrollAxes`,
|
|
133
|
+
`KUI_SCROLL_AXES_*`) lets an `on_scroll` node decline an axis. A
|
|
134
|
+
gesture on an axis it does not take passes it by. This is how a node
|
|
135
|
+
the core cannot ask about says it does not need a gesture: a
|
|
136
|
+
terminal that scrolls only its history says `y`. It is declared
|
|
137
|
+
rather than answered per event, because the pick happens inside
|
|
138
|
+
`handle_input`, before any app code could answer.
|
|
139
|
+
7. **Programmatic scrolls, scrollbar drags, keyboard scrolling, the
|
|
140
|
+
primary drag's edge scrolling and `reveal` are untouched.** Only the
|
|
141
|
+
wheel routes through the latch.
|
|
142
|
+
|
|
143
|
+
## Considered options
|
|
144
|
+
|
|
145
|
+
- **Re-target on every event, and let `on_scroll` nodes decline by
|
|
146
|
+
axis only.** Declining alone fixes a y-only terminal met by a
|
|
147
|
+
sideways swipe, but a terminal that takes both axes, such as an
|
|
148
|
+
editor's text column, would still grab a strip swipe partway. The
|
|
149
|
+
report's item 3 is about the swipe being cut off, and only latching
|
|
150
|
+
fixes that.
|
|
151
|
+
- **An `on_scroll` node answers whether it can move.** An event whose
|
|
152
|
+
reply says "not mine" would need the app in the middle of dispatch,
|
|
153
|
+
and a frame's latency for a question the next delta has already
|
|
154
|
+
asked. `scrollAxes` covers the case that exists: a node that never
|
|
155
|
+
wants one axis.
|
|
156
|
+
- **Chain partway, when the target hits its limit.** This is what
|
|
157
|
+
browsers moved away from. A list flicked to its end would start
|
|
158
|
+
moving the page behind it mid-flick, which is the surprise item 2
|
|
159
|
+
asks to avoid by deciding at the start.
|
|
160
|
+
- **Keep the gesture clock in the core** (`Core::set_time`). The
|
|
161
|
+
frame clock ticks per frame, not per event, and a clock-free core
|
|
162
|
+
is the rule clicks already follow. The runner has the clock and
|
|
163
|
+
already draws F104's boundaries.
|
|
164
|
+
- **winit's `TouchPhase`.** It marks a momentum run as a new start, so
|
|
165
|
+
the momentum would re-target. The gap is what tells a glide from a
|
|
166
|
+
new swipe. *Amended (F117):* the gap alone kept a swipe begun
|
|
167
|
+
mid-glide in the glide's gesture; the phases' order now tells the
|
|
168
|
+
glide's `Started` from a finger's, and both are used.
|
|
169
|
+
|
|
170
|
+
## Consequences
|
|
171
|
+
|
|
172
|
+
- A notch over a list at its end now moves the scroller around it (a
|
|
173
|
+
`Scroll` is its own gesture). That changes behaviour only where there
|
|
174
|
+
is one to move. kawoosh's lists sit in an `x`-only strip, so nothing
|
|
175
|
+
moves there.
|
|
176
|
+
- An `on_scroll` node in a `both`-axis layout hears a delta with one
|
|
177
|
+
component zero when the other axis is latched elsewhere, where it
|
|
178
|
+
heard the whole delta. F104's lock had already made that the
|
|
179
|
+
trackpad's case.
|
|
180
|
+
- `KuiSpec` gains `overscroll` and `scroll_axes` under the unreleased
|
|
181
|
+
ABI 20 (648 bytes on a 64-bit target). `InputEvent` gains a variant,
|
|
182
|
+
a Rust break for a host that matches it exhaustively.
|
|
183
|
+
- The thresholds are F104's, a first reading to be tuned on a trackpad.
|
|
184
|
+
- Pinned by `tests/scroll_gestures.rs` in kui-core, by
|
|
185
|
+
`scroll_gesture.rs`'s tests in kui-native, by `surface.c`'s check of
|
|
186
|
+
the C door, and by the corpus's `scroll-gestures` scene in four
|
|
187
|
+
adapters. The scene holds a contained list at its limit and a y-only
|
|
188
|
+
handler met by a sideways notch.
|