@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,452 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-09-03
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Keyboard focus as data: one focus, every control reachable
|
|
7
|
+
|
|
8
|
+
Keyboard focus in kui reached editors and key sinks only: Tab cycled the
|
|
9
|
+
editors, a click or a per-frame declaration put focus on a sink, and a
|
|
10
|
+
button could not be reached or pressed from the keyboard at all. We
|
|
11
|
+
decided that the core keeps **one** focus, that every control the access
|
|
12
|
+
tree knows about is focusable, that Tab walks them in tree order and
|
|
13
|
+
Enter / Space activate them, that keyboard-driven focus draws a ring the
|
|
14
|
+
view did not have to ask for, and that all of it is data: a `focusable`
|
|
15
|
+
row, a `disabled` row and a `focusBg` row in the schema, `focused` and
|
|
16
|
+
`disabled` on the access node, and a focus request from assistive
|
|
17
|
+
technology landing on the same focus a Tab press moves.
|
|
18
|
+
|
|
19
|
+
## Context
|
|
20
|
+
|
|
21
|
+
- ADR 0001 made semantics data and deferred keyboard reach as a focus-model
|
|
22
|
+
decision. The gap it left is concrete: `Core::focus_adjacent_edit` walked
|
|
23
|
+
`NodeContent::Edit` nodes only, buttons advertised `Click` but not
|
|
24
|
+
`Focus`, and Enter and Space never activated anything, because Space
|
|
25
|
+
became `InputEvent::Text` and Enter an `EditKey` that only an editor
|
|
26
|
+
consumed.
|
|
27
|
+
- The same gap is why a screen reader seemed to see editors only. A
|
|
28
|
+
VoiceOver cursor is pulled to whatever the application reports as its
|
|
29
|
+
focused element; kui reported an editor or a key sink or nothing, and
|
|
30
|
+
the remaining controls sat inside the AccessKit view as an unnamed
|
|
31
|
+
group the user had to interact with by hand. AccessKit also treats a
|
|
32
|
+
node as focusable only when it supports the `Focus` action, so a
|
|
33
|
+
reader's own "move keyboard focus here" was a no-op on every control.
|
|
34
|
+
- Focus lived in two places: `EditStore::focused` for editors and
|
|
35
|
+
`Core::key_focus` for sinks, with every path (click, Tab, access
|
|
36
|
+
requests, autofocus) keeping the pair consistent by hand.
|
|
37
|
+
`Core::is_focused` answered for editors only, so a binding could not
|
|
38
|
+
draw a focused button even if it wanted to.
|
|
39
|
+
- `set_key_focus` was a per-frame declaration that always won ("the next
|
|
40
|
+
declaration wins it back"), and the examples that own their keyboard
|
|
41
|
+
call it every frame. Any focus the core moved by itself would be
|
|
42
|
+
clobbered on the next frame.
|
|
43
|
+
- There was no `disabled`: nothing to skip in a Tab ring, nothing to tell
|
|
44
|
+
a screen reader, and a disabled-looking button still clicked.
|
|
45
|
+
|
|
46
|
+
## Decision
|
|
47
|
+
|
|
48
|
+
1. **One focus, in the core.** `Core` keeps `focus: Option<Key>`; the
|
|
49
|
+
edit store's focus mirrors it for editor keys (the caret, IME and
|
|
50
|
+
clipboard paths read that mirror as before). `Core::set_focus` is the
|
|
51
|
+
only writer. `is_focused(key)` answers for any node, `focus()` names
|
|
52
|
+
it, and a `focus_visible` flag says whether the keyboard put it there.
|
|
53
|
+
2. **What is focusable is what the access tree calls a control.** In tree
|
|
54
|
+
order of the last frame: built-in editors, custom editors, key sinks
|
|
55
|
+
(`on_key`), every declarable control role (`button`, `checkbox`,
|
|
56
|
+
`radio`, `switch`, `slider`, `tab`, `link`), a derived button (a box
|
|
57
|
+
with `on_click`), and any node declaring the new `focusable` flag (a
|
|
58
|
+
list row, a card). Never: `disabled` nodes, `role="none"` subtrees,
|
|
59
|
+
window chrome (the platform's own controls are not Tab stops), plain
|
|
60
|
+
structure.
|
|
61
|
+
3. **Tab walks the ring; Enter and Space activate.** Tab and Shift-Tab
|
|
62
|
+
move to the next / previous focusable node and wrap; with nothing
|
|
63
|
+
focused they enter from either end. A multiline editor (built-in or a
|
|
64
|
+
custom editor role) keeps Tab as indentation, and a key sink keeps
|
|
65
|
+
every key, Tab included, because a sink is an app that owns its
|
|
66
|
+
keyboard; it hands focus on with `Ui::focus_next` when it wants to.
|
|
67
|
+
(What the sink hears *after* it has handed focus on is the amendment at
|
|
68
|
+
the end of this file, and
|
|
69
|
+
`docs/adr/0011-keys-bubble-to-the-enclosing-sink.md`.) On
|
|
70
|
+
a focused control that is neither an editor nor a sink, Enter and Space
|
|
71
|
+
emit its click payload through the path `AccessAction::Click` already
|
|
72
|
+
uses, and on a focused slider the arrow keys emit the `increment` /
|
|
73
|
+
`decrement` access events the app already handles. Keyboard-driven
|
|
74
|
+
focus scrolls the node into view through the shared helper.
|
|
75
|
+
4. **Focus visible, as data and as pixels.** Focus moved by Tab or by an
|
|
76
|
+
assistive-technology request sets `focus_visible`; a mouse press that
|
|
77
|
+
moves focus clears it, like the web's `:focus-visible`. When it is set,
|
|
78
|
+
the core draws a ring around the focused node on top of the frame, in
|
|
79
|
+
the frame's own display list, so every binding gets it without drawing
|
|
80
|
+
anything. A node that declares `focusBg` swaps its background instead
|
|
81
|
+
(the same resolution as `hoverBg`; pressed wins over focus wins over
|
|
82
|
+
hover) and gets no ring. Editors and sinks get no ring either: an
|
|
83
|
+
editor shows its caret and a sink is an app surface that styles
|
|
84
|
+
itself, through `is_focused` and `focus_visible` like any other state.
|
|
85
|
+
5. **Declaration is edge-triggered; there is an imperative call.**
|
|
86
|
+
`take_key_focus(key)` (`keyFocus` in JSX, `key_focus` in Lua,
|
|
87
|
+
`kui_set_key_focus` in C) now means "focus this node when it starts
|
|
88
|
+
being declared": a node declared every frame takes focus once, on the
|
|
89
|
+
first frame, and a Tab press afterwards is not clobbered. To move focus
|
|
90
|
+
at any time a view calls `Ui::focus(key)` (`ctx.focus(key)`,
|
|
91
|
+
`kui_focus`), `blur()`, `focus_next()` / `focus_prev()`. An
|
|
92
|
+
`autofocus` editor takes focus only while nothing else holds it, as
|
|
93
|
+
before, but through the same single writer.
|
|
94
|
+
6. **`disabled` is a row.** A disabled node keeps its hit region (a
|
|
95
|
+
tooltip may explain why) and loses everything else: no click, no drag,
|
|
96
|
+
no key sink, no hover or pressed background, no place in the Tab ring,
|
|
97
|
+
and the access tree says `disabled` and drops the `Click` action.
|
|
98
|
+
7. **The access tree carries the same focus.** Every focusable node
|
|
99
|
+
advertises `Focus` and `Blur`; `focused` and the tree's `focus` follow
|
|
100
|
+
`Core::focus` for any node, so AccessKit's focus events point a
|
|
101
|
+
screen reader's cursor at the button Tab landed on, and a reader's
|
|
102
|
+
`Focus` request lands on the core's focus the way Tab does. Windows,
|
|
103
|
+
macOS and AT-SPI adapters read `disabled` from the node.
|
|
104
|
+
8. **Three schema rows, every binding.** `focusable` (id 61, flag),
|
|
105
|
+
`disabled` (id 62, flag) and `focusBg` (id 63, colour) join `PROPS`;
|
|
106
|
+
JSX, Lua and C get them the way every simple row lands (one `npm run
|
|
107
|
+
gen`, a `KuiSpec` field each, pinned by the parity test). Nothing else
|
|
108
|
+
in the IR changes.
|
|
109
|
+
|
|
110
|
+
## Considered options
|
|
111
|
+
|
|
112
|
+
- **Tab through every `on_click` node and leave the rest.** Rejected: a
|
|
113
|
+
checkbox or slider drawn by the app has no click payload, and a row
|
|
114
|
+
that opens on Enter needs to be reachable without pretending to be a
|
|
115
|
+
button. Tying focusability to the role vocabulary reuses the decision
|
|
116
|
+
ADR 0001 already made about what a control is.
|
|
117
|
+
- **A `tabIndex` row.** Rejected: an ordering integer is how the web
|
|
118
|
+
patched a document model that had no notion of tree order. kui's tree
|
|
119
|
+
order is reading order, and a view that wants a different order draws
|
|
120
|
+
in a different order.
|
|
121
|
+
- **Keep two focus stores and add a third for controls.** Rejected: the
|
|
122
|
+
two existing ones already cost every path a pair of updates; a third
|
|
123
|
+
would triple it, and the access tree needs one answer.
|
|
124
|
+
- **Let a key sink give up Tab to the ring.** Rejected: a modal editor,
|
|
125
|
+
a terminal and a game bind Tab, and a sink is by definition an app
|
|
126
|
+
that owns its keyboard. It gets `focus_next` instead.
|
|
127
|
+
- **Arrow keys within radio groups, tab lists and lists.** Deferred: the
|
|
128
|
+
ARIA patterns (arrows move within a composite, Tab leaves it) need
|
|
129
|
+
the composite roles' children enumerated, which the access tree can
|
|
130
|
+
do, but the activation rules differ per pattern and no example needs
|
|
131
|
+
them yet. A slider's arrows are in because the app already handles
|
|
132
|
+
the events.
|
|
133
|
+
- **A focus ring the view draws.** Rejected as the only option: every
|
|
134
|
+
binding would draw its own ring, or forget to. The core's ring is a
|
|
135
|
+
default; `focusBg` and `is_focused` remain for views that want their
|
|
136
|
+
own look.
|
|
137
|
+
|
|
138
|
+
## Consequences
|
|
139
|
+
|
|
140
|
+
- A keyboard alone reaches and operates every control in every example
|
|
141
|
+
and in every binding, and a screen reader's cursor follows. The
|
|
142
|
+
accessibility example needs no change beyond its comments: its
|
|
143
|
+
per-frame `take_key_focus` is now one-shot by definition.
|
|
144
|
+
- `take_key_focus` changes meaning for apps that relied on it winning
|
|
145
|
+
back focus every frame after a click elsewhere. None of the examples
|
|
146
|
+
did; an app that wants that calls `focus(key)` when it decides to.
|
|
147
|
+
- `Core::key_focus()` keeps working and answers the unified focus.
|
|
148
|
+
`Core::is_focused` widens from editors to every node, which is what
|
|
149
|
+
`widgets::text_input` and the bindings' `isFocused` already assumed.
|
|
150
|
+
- `EditStore::declare` no longer touches focus; autofocus is the core's
|
|
151
|
+
decision, so an autofocus editor cannot steal focus from a focused
|
|
152
|
+
button.
|
|
153
|
+
- Headless tests pin the ring order, activation, slider arrows, the ring
|
|
154
|
+
quad, `focusBg`, `disabled`, the access tree's focus and actions, and
|
|
155
|
+
the edge-triggered declaration. What only a screen reader session can
|
|
156
|
+
show is announcement and cursor following; the macOS audit script
|
|
157
|
+
gains a Tab check.
|
|
158
|
+
- Arrow-key composites (radio groups, tab lists, lists) and a
|
|
159
|
+
configurable ring colour are the next steps, both small.
|
|
160
|
+
|
|
161
|
+
## Amendment: a press inside a sink, built (2026-09-05)
|
|
162
|
+
|
|
163
|
+
The consequence above — "`take_key_focus` changes meaning for apps that
|
|
164
|
+
relied on it winning back focus every frame after a click elsewhere. None
|
|
165
|
+
of the examples did" — was true when it was written and stopped being true
|
|
166
|
+
the moment `examples/rust/splitmux.rs` landed. Splitmux owns its whole
|
|
167
|
+
keyboard through one `on_key` sink and draws its panes *inside* that sink,
|
|
168
|
+
each with an `on_click` so a click can focus one. Decision 2 makes a box
|
|
169
|
+
with `on_click` a derived button, and so focusable; decision 5 makes the
|
|
170
|
+
sink's per-frame declaration one-shot. Together: the first click on a pane
|
|
171
|
+
moved focus to the pane, the sink's next declaration was not an edge, and
|
|
172
|
+
every ⌥ chord in the app was dead for the life of the process — with no
|
|
173
|
+
way for the app to notice, because focus moving is not an event it sees.
|
|
174
|
+
|
|
175
|
+
That is not a splitmux problem. It is the shape of every app that owns its
|
|
176
|
+
keyboard and draws surfaces to click: a multiplexer, a canvas with
|
|
177
|
+
handles, a game with a HUD, a diagram editor. Decision 3 already says why:
|
|
178
|
+
*a key sink is an app that owns its keyboard*, which is why it keeps Tab.
|
|
179
|
+
The pointer had simply not been held to the same rule.
|
|
180
|
+
|
|
181
|
+
So, refining decisions 2 and 3 rather than reversing either:
|
|
182
|
+
|
|
183
|
+
9. **A primary press inside a key sink leaves the keyboard on the sink.**
|
|
184
|
+
The press resolves to the nearest enclosing sink, not to the node it
|
|
185
|
+
landed on — a pane, a derived button, a `focusable` row, or dead space,
|
|
186
|
+
which used to blur. Those nodes stay focusable and stay in the Tab
|
|
187
|
+
ring; being focusable makes a node a Tab stop, not a claim on keys the
|
|
188
|
+
app drawing it already owns. A node that owns a keyboard in its own
|
|
189
|
+
right still takes focus from inside a sink: an editor (with its caret
|
|
190
|
+
placed where the click was, as ever) and a nested sink. A `disabled`
|
|
191
|
+
node is not a sink at all (decision 6), so it neither answers nor hides
|
|
192
|
+
a live sink above it.
|
|
193
|
+
10. **A press on window chrome leaves focus exactly where it is.** A
|
|
194
|
+
`window_drag` strip or a window button is the platform's control, and
|
|
195
|
+
decision 2 already keeps it out of the Tab ring for that reason;
|
|
196
|
+
dragging a window to another monitor is not the app being asked to
|
|
197
|
+
give up its keyboard. Previously it blurred, because chrome is not
|
|
198
|
+
focusable and a press on nothing focusable blurred.
|
|
199
|
+
|
|
200
|
+
Nothing changes outside a sink: a click on a button in ordinary content
|
|
201
|
+
focuses that button, and a click on the background still blurs.
|
|
202
|
+
|
|
203
|
+
An app whose keyboard leaves for something genuinely outside its sink —
|
|
204
|
+
splitmux's tab bar, which sits above the sink and whose tabs are real
|
|
205
|
+
controls that should focus — still asks for it back with `focus(key)`, the
|
|
206
|
+
imperative call decision 5 exists to provide. That is the case the original
|
|
207
|
+
consequence describes, and it is the right one to make an app spell out:
|
|
208
|
+
it is the app choosing to overrule a focus the user moved.
|
|
209
|
+
|
|
210
|
+
`cargo test -p kui-native --example splitmux` drives the example headlessly
|
|
211
|
+
through the `App` trait and presses ⌥v after a pane click, a tab click and
|
|
212
|
+
a titlebar grab; `crates/kui-core/tests/keys.rs` pins the three rules on
|
|
213
|
+
the shapes themselves, including the editor that must still win.
|
|
214
|
+
|
|
215
|
+
## Amendment: a key has two codes, built (2026-09-05)
|
|
216
|
+
|
|
217
|
+
Chasing the sink bug above turned up a worse one underneath it. `code` was
|
|
218
|
+
the character the active *layout* produced, and every keymap in the repo is
|
|
219
|
+
written in Latin — `match code { "v" => split, "t" => new_tab }`. On a
|
|
220
|
+
Cyrillic, Greek, Hebrew or Arabic layout the key US-QWERTY prints V on
|
|
221
|
+
produces `м`, `ω`, `ה`, `ر`. Not a different arm: *no* arm. Splitmux was
|
|
222
|
+
silently keyboard-dead for a large fraction of the world, and nothing in
|
|
223
|
+
the app could detect it — the events arrived, they just matched nothing.
|
|
224
|
+
|
|
225
|
+
Pure scan codes are the obvious fix and the wrong one. Bind position and a
|
|
226
|
+
Dvorak user pressing the key **printed V** gets whatever chord QWERTY keeps
|
|
227
|
+
at that slot; the docs say ⌥v, the keycap says V, and the app disagrees
|
|
228
|
+
with both. Neither view is right on its own: position is right about the
|
|
229
|
+
Cyrillic case and wrong about the Dvorak one, and the layout is exactly the
|
|
230
|
+
reverse.
|
|
231
|
+
|
|
232
|
+
So both, with a stated default:
|
|
233
|
+
|
|
234
|
+
11. **`code` prefers the label, and falls back to the position.** A press
|
|
235
|
+
carries what the layout produced and which key produced it, and
|
|
236
|
+
`KeyPress::from_layout` resolves the two: while the layout's key is
|
|
237
|
+
ASCII it wins, so a chord lands on the key the user can see (Dvorak's
|
|
238
|
+
⌥v on the key printed V, AZERTY's ⌘a on the one printed A, QWERTZ's ⌘z
|
|
239
|
+
on the one printed Z). When it is not — or names nothing this
|
|
240
|
+
vocabulary knows, as a dead key does — the US-QWERTY key at that
|
|
241
|
+
position stands in, so a Latin keymap keeps matching; as Shift prints
|
|
242
|
+
it, since the position is reported unshifted (`J`, `:` and `~` on a
|
|
243
|
+
Russian layout, not `j`, `;` and `` ` ``; F76, 2026-09-21) — except under
|
|
244
|
+
Alt, where the unshifted position stands in, since ⌥ composes a
|
|
245
|
+
character (⌥⇧J is `Ô` on a US Mac) and the chord a keymap names is
|
|
246
|
+
⌥⇧j, as the winit runner always reported it (RG27, 2026-09-25). This is the rule
|
|
247
|
+
browsers use to keep ⌘C copying on a Russian layout, and it is the
|
|
248
|
+
reason an app can stay ignorant that layouts exist. `text` is
|
|
249
|
+
untouched: the typing view is always the layout's own character.
|
|
250
|
+
Judged key by key, though, the label rule took a non-Latin layout's
|
|
251
|
+
ASCII for a label: macOS's Russian puts `]` on the key printed
|
|
252
|
+
`` ` `` and `:` on ⇧5, Windows' `.` on `/`, and a keymap's `` ` ``,
|
|
253
|
+
`%` and `/` went to `]`, `:` and `.` (F115, 2026-10-01). Whether a
|
|
254
|
+
layout's ASCII is its label is the layout's to say, not the key's —
|
|
255
|
+
AZERTY's `&` on the key printed 1 is, Russian's `]` is not — so the
|
|
256
|
+
driver says which alphabet the layout writes (`LayoutScript`,
|
|
257
|
+
through `KeyPress::from_layout_in`), and on one that writes no Latin
|
|
258
|
+
every key falls back to the position, its ASCII too; a key that is
|
|
259
|
+
its position's own character, or sits where US-QWERTY has no key,
|
|
260
|
+
keeps the layout's. It is macOS's rule for a ⌘ shortcut, resolved
|
|
261
|
+
through the ASCII-capable layout when the current one is not; the
|
|
262
|
+
winit runner asks macOS that question and Windows what its letter
|
|
263
|
+
keys type, and elsewhere goes by what the letter keys last typed.
|
|
264
|
+
A driver that cannot ask says `Latin`, and gets the key-by-key rule.
|
|
265
|
+
12. **`physical` is a payload field of its own.** The US-QWERTY key at that
|
|
266
|
+
position, in the *same* vocabulary as `code` — `"v"`, `"1"`, `"left"`,
|
|
267
|
+
`"f5"` — so an app switching a keymap from one to the other keeps its
|
|
268
|
+
match arms byte-identical. A keymap that wants the finger rather than
|
|
269
|
+
the label reads it: WASD stays a square on AZERTY, where `code` would
|
|
270
|
+
make it ZQSD. It rides on every key event in every binding.
|
|
271
|
+
|
|
272
|
+
The derivation lives in `kui_core::KeyPress::from_layout`, not in the winit
|
|
273
|
+
runner, so a host with its own windowing gets it by handing over both codes
|
|
274
|
+
— C through the new `physical` argument on `kui_input_key_down` / `_up`,
|
|
275
|
+
Node through the trailing `physical` argument on `keyDown` / `keyUp`. NULL
|
|
276
|
+
or omitted means "the key I named", which is what a host that does not
|
|
277
|
+
track positions says, and what every existing call already meant.
|
|
278
|
+
|
|
279
|
+
`crates/kui-core/tests/keys.rs` pins the fold for Russian, Greek, Hebrew and
|
|
280
|
+
Arabic and the pass-through for Dvorak, AZERTY and QWERTZ; splitmux presses
|
|
281
|
+
⌥v on a Russian layout and on a Dvorak one and checks it splits both times,
|
|
282
|
+
for opposite reasons.
|
|
283
|
+
|
|
284
|
+
### Rejected
|
|
285
|
+
|
|
286
|
+
- **Replace `code` with the position outright.** The clean model, and it
|
|
287
|
+
makes every Latin non-QWERTY user's chords disagree with their own
|
|
288
|
+
keycaps. Position is the fallback because it is right less often than
|
|
289
|
+
the label, not more.
|
|
290
|
+
- **Leave `code` alone and add `physical` beside it.** Purely additive and
|
|
291
|
+
fixes nothing: the naive binding stays the broken one, and every app has
|
|
292
|
+
to be rewritten one at a time to get what it already assumed it had.
|
|
293
|
+
- **W3C `code` names for `physical`** (`"KeyV"`, `"Digit1"`, `"ArrowLeft"`).
|
|
294
|
+
Unambiguous about being a position, at the price of a second vocabulary
|
|
295
|
+
in the docs, a second name table in C and Lua, and match arms that no
|
|
296
|
+
longer line up with `code`'s. Reusing the one vocabulary makes switching
|
|
297
|
+
a keymap between the two a one-word edit.
|
|
298
|
+
|
|
299
|
+
## Amendment: what a sink hears once it is not the focus, built (2026-09-06)
|
|
300
|
+
|
|
301
|
+
Decision 3 answers the Tab half of an app that owns some of its keyboard:
|
|
302
|
+
a sink keeps Tab and hands the ring on with `focus_next`. It has no answer
|
|
303
|
+
to the half that comes next. Once focus is on a control, `route_key`
|
|
304
|
+
resolves nothing — a sink hears a press only while it holds focus — so an
|
|
305
|
+
app shell that hands the ring on goes deaf, and the pomodoro in backlog F7
|
|
306
|
+
loses the Space that starts its timer the moment its slider becomes
|
|
307
|
+
reachable. Both field reports on alpha.6 found it, from opposite ends: one
|
|
308
|
+
had a ring and no shortcuts, the other shortcuts and no ring.
|
|
309
|
+
|
|
310
|
+
The rejected option above, "let a key sink give up Tab to the ring", is
|
|
311
|
+
still rejected and still for the same reason. What was missing is not a
|
|
312
|
+
way to take keys *from* a sink but a way to give it the ones nothing else
|
|
313
|
+
wanted:
|
|
314
|
+
|
|
315
|
+
13. **A press the focused node does not claim goes to the nearest
|
|
316
|
+
enclosing sink.** A focused control keeps the keys the core presses it
|
|
317
|
+
with — Enter and Space where there is a click payload, a slider's
|
|
318
|
+
arrows, a composite's arrows, Home, End and type-ahead — and Tab stays
|
|
319
|
+
the ring's wherever focus is. Every other press, chords included,
|
|
320
|
+
walks up to the first non-disabled `on_key` ancestor, which hears it
|
|
321
|
+
as it hears everything today. A sink that holds focus keeps every key,
|
|
322
|
+
exactly as decision 3 says.
|
|
323
|
+
|
|
324
|
+
The rules, the alternatives (a `keys` allow-list on the sink, claiming by
|
|
325
|
+
role, bubbling dynamically), the phase question and the modal boundary are
|
|
326
|
+
in `docs/adr/0011-keys-bubble-to-the-enclosing-sink.md`. Decisions 2, 3, 5
|
|
327
|
+
and 9 are unchanged; decision 3's `focus_next` is now half of a pattern
|
|
328
|
+
rather than a whole answer.
|
|
329
|
+
|
|
330
|
+
## Amendment: a key that acts on the focus shows it, built (2026-09-09)
|
|
331
|
+
|
|
332
|
+
Decision 4 names two ways focus starts showing — a Tab step and an
|
|
333
|
+
assistive-technology request — and one way it stops: a mouse press. That
|
|
334
|
+
is the whole of `:focus-visible` as a *starting* rule and only half of it
|
|
335
|
+
as a live one. A browser also turns the indicator on when the keyboard is
|
|
336
|
+
used on focus a click placed, and kui did not:
|
|
337
|
+
|
|
338
|
+
Click the `+1` button in `examples/rust/counter`, then press Space. The
|
|
339
|
+
count goes up — Space presses the focused control, decision 3 — and
|
|
340
|
+
nothing on screen says which of the three buttons answered. Press Tab
|
|
341
|
+
first and the ring appears, and from then on Space looks like it works.
|
|
342
|
+
So the honest reading of the report this came from is not "Space does not
|
|
343
|
+
press the button" but "I cannot tell that it did".
|
|
344
|
+
|
|
345
|
+
4a. **A key the core acts on the focused control with shows that focus.**
|
|
346
|
+
Space and Enter pressing it, a slider's arrows, a composite's arrows,
|
|
347
|
+
Home, End and type-ahead — the same set decision 13 says a control
|
|
348
|
+
keeps. Escape is the exception, because it acts by letting go and a
|
|
349
|
+
ring around nothing is not a ring; and a key that bubbles to an
|
|
350
|
+
enclosing sink (ADR 0011) never reaches this arm, so a shortcut
|
|
351
|
+
handled above the control does not pop a ring around it.
|
|
352
|
+
|
|
353
|
+
Composite motion already did this (`docs/adr/0007`, decision 5, through
|
|
354
|
+
`move_within_composite`), which is the same rule reached one pattern at a
|
|
355
|
+
time. The clearing rule is untouched: the next mouse press hides it again.
|
|
356
|
+
|
|
357
|
+
### Rejected
|
|
358
|
+
|
|
359
|
+
- **Show on any key, as the browsers do.** They set the flag on *every*
|
|
360
|
+
keydown that is not a modifier, wherever it goes. In kui that would
|
|
361
|
+
light a ring on a control while a shortcut layer above it handled the
|
|
362
|
+
key — the app's own surface answering, and the ring naming a node that
|
|
363
|
+
did nothing — because ADR 0011 sends unclaimed keys past the control.
|
|
364
|
+
"The core acted on this node" is the fact the ring is drawn from.
|
|
365
|
+
- **Show it on the click.** A pointer user gets a ring around whatever
|
|
366
|
+
they last touched, which is the noise `:focus-visible` exists to
|
|
367
|
+
remove, and the app that reads `focus_visible` for its own styling
|
|
368
|
+
loses the distinction entirely.
|
|
369
|
+
|
|
370
|
+
## Amendment: rings, not a ring — and the two rules beside them (2026-09-10)
|
|
371
|
+
|
|
372
|
+
Decision 2 says every control is in *the* Tab ring, and decision 5 says an
|
|
373
|
+
`autofocus` editor "takes focus only while nothing else holds it". The
|
|
374
|
+
first is now "in the ring of the region it is in", and the second is an
|
|
375
|
+
edge rather than a standing claim; a third rule, that a key sink on the
|
|
376
|
+
root hears keys when nothing is focused, closes the gap that made shells
|
|
377
|
+
take focus on the root. All three are
|
|
378
|
+
`docs/adr/0022-focus-regions.md`; decisions 2, 3, 5 and 8 here are
|
|
379
|
+
otherwise unchanged.
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
## Amendment: where a key is, the modifier keys, the locks, built (2026-09-28)
|
|
383
|
+
|
|
384
|
+
A terminal speaking kitty's keyboard protocol asked for the keyboard
|
|
385
|
+
whole (backlog F108, from kawoosh): the protocol reports the keypad's
|
|
386
|
+
digits apart from the main block's, the left Shift apart from the
|
|
387
|
+
right, the modifier keys pressed and released on their own, Caps Lock
|
|
388
|
+
and Num Lock, F13 to F35, and the media keys. A press carried none of
|
|
389
|
+
it — the keypad's `1` was the main block's `1` in both codes, the
|
|
390
|
+
modifiers were only ever held, nothing said what was locked, and the
|
|
391
|
+
runner dropped every key it had no name for.
|
|
392
|
+
|
|
393
|
+
14. **A key says which of its twins it is.** `location` —
|
|
394
|
+
`"standard"`, `"left"`, `"right"`, `"numpad"` (`KeyLocation`) — rides
|
|
395
|
+
on every key payload, as winit and the DOM report it. `code` and
|
|
396
|
+
`physical` stay what the key is: the keypad's `1` is still `"1"` and
|
|
397
|
+
its Enter `"enter"`, so every keymap written against decision 11 is
|
|
398
|
+
unchanged, and one that cares reads the place. A press and its
|
|
399
|
+
release are matched by place as well as position, so the two Shifts
|
|
400
|
+
and the two `1`s are two keys held.
|
|
401
|
+
15. **The modifier keys are keys only to a sink that asks.** `shift`,
|
|
402
|
+
`ctrl`, `alt`, `super`, `capslock`, `numlock`, `scrolllock` are codes
|
|
403
|
+
of their own, delivered to a sink that says `modifierKeys`
|
|
404
|
+
(`NodeSpec::modifier_keys`, `KuiSpec.modifier_keys`) with the side in
|
|
405
|
+
`location`, and to no other. A keymap is the common case, and a
|
|
406
|
+
keymap reading `<leader>F` would see Space, Shift, F — the Shift a
|
|
407
|
+
key between two others, the sequence broken — so the default is the
|
|
408
|
+
old reading: a modifier is held, in the next key's `shift` / `ctrl` /
|
|
409
|
+
`alt` / `super` and the `modifiers` event, and never pressed. A
|
|
410
|
+
modifier key's own event carries the state *after* it — Shift's
|
|
411
|
+
press has `shift`, its release none unless the other Shift is still
|
|
412
|
+
down — as a terminal speaking kitty's protocol reports it; winit
|
|
413
|
+
tells the modifiers after the key, so the runner sets that bit
|
|
414
|
+
itself.
|
|
415
|
+
16. **The lock state is the press's, not a modifier.** `caps_lock` and
|
|
416
|
+
`num_lock` (`KeyLocks`) ride on every key payload beside `location`.
|
|
417
|
+
`KeyMods` stays what is held down: an accelerator and the devtools
|
|
418
|
+
chord compare it exactly, and a Caps Lock left on must not make
|
|
419
|
+
⇧⌘I miss. The runner asks the OS where it answers cheaply — macOS's
|
|
420
|
+
`NSEvent.modifierFlags` (a Mac has no Num Lock, so it reads off, as a
|
|
421
|
+
Mac terminal reports it),
|
|
422
|
+
Windows' `GetKeyState` and the X server's XKB state (amended by
|
|
423
|
+
backlog RG104) — and elsewhere (Wayland) tracks the lock keys' own
|
|
424
|
+
presses, which knows nothing of a lock set before the app's first
|
|
425
|
+
window opened or turned while another app had the keyboard. One
|
|
426
|
+
record for the app, since there is one keyboard,
|
|
427
|
+
and a lock key's own press reports the state it made on all three —
|
|
428
|
+
what the two OS answers are read after the toggle (amended by
|
|
429
|
+
backlog RG96, where Linux said what it found and each window kept
|
|
430
|
+
its own).
|
|
431
|
+
|
|
432
|
+
The keys past the editing block — F13–F35, `printscreen`, `pause`,
|
|
433
|
+
`menu`, `clear`, and the media keys (`mediaplaypause`, `volumeup`, …) —
|
|
434
|
+
are named and delivered like any other; their names join the one table
|
|
435
|
+
`KeyCode::name` and `KeyCode::from_name` share. The doors take the place
|
|
436
|
+
and the locks with the key: C in bits of the `kmods` word that were
|
|
437
|
+
zero (`KUI_KLOC_*`, `KUI_KLOCK_*`), so a host passing only `KUI_KMOD_*`
|
|
438
|
+
sends what it sent; Node in the mods object; Rust as `with_location` and
|
|
439
|
+
`with_locks`.
|
|
440
|
+
|
|
441
|
+
### Rejected
|
|
442
|
+
|
|
443
|
+
- **Keypad codes of their own** (`"kp1"`, `"kpenter"`). Every keymap that
|
|
444
|
+
binds `1` or Enter would miss the keypad's until it learned a second
|
|
445
|
+
name, which is the breakage decision 11 exists to avoid. The place is
|
|
446
|
+
the new fact; the key is the same key.
|
|
447
|
+
- **The modifier keys to every sink.** The browser model, where every
|
|
448
|
+
app filters Shift out of its keydown handler. Here it breaks every
|
|
449
|
+
sequence keymap on upgrade, silently, for a fact few sinks want.
|
|
450
|
+
- **Lock bits in `KeyMods`.** One word for the C door, and exact
|
|
451
|
+
comparisons of held modifiers — accelerators, chords — start failing
|
|
452
|
+
whenever Caps Lock is on.
|