@qxuken/kui 0.1.0-alpha.48 → 0.1.0-alpha.50

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 CHANGED
@@ -21,6 +21,222 @@ listed under both (backlog F61, from the alpha.12 field reports: the list
21
21
  is what the release knows it broke, and a fix it did not think of as one
22
22
  was the first bare bump to break an app in five releases).
23
23
 
24
+ ## 0.1.0-alpha.50 (2026-10-09)
25
+
26
+ **What breaks.**
27
+
28
+ - `UiEvent::message` reads a core event's `tag` before its payload.
29
+ - A chord a focused editor does not act on reaches the `on_key` sink
30
+ above it, where it went nowhere.
31
+ - An arrow, Backspace or Delete at the edge of an editor's text emits a
32
+ `boundary` event, where it emitted nothing.
33
+ - An editor that lost the keyboard no longer draws its selection.
34
+ - `KUI_ABI_VERSION` is 30: `KuiTextStyle` gains `bold` (29) and
35
+ `KuiMenuItem` `replay` (30). Recompile a C or Odin host.
36
+ - `MenuItem` has a `replay` field: a struct literal of it needs one.
37
+ - A pointer event inside a key sink that draws `role="line"` rows carries
38
+ `inside` beside `line` and `byte` — a click's own map payload too.
39
+ - `MenuRole` has five more variants (`About`, `Hide`, `HideOthers`,
40
+ `ShowAll`, `Quit`): an exhaustive `match` on it needs them.
41
+
42
+ `UiEvent::message` read the payload first and the `tag` second, and a
43
+ core event's payload is `{kind: "key", …}`, `{kind: "open", …}`, `{kind:
44
+ "menu", …}` — so an app whose message enum had a `Key` variant got
45
+ `Msg::Key` back for every key event any of its sinks heard, the sink's
46
+ own tag never consulted (backlog F148, from Noticon, whose root sink
47
+ was tagged `Key` and took the page's keys). A core event — any `kind`
48
+ the events table lists but `click` — now reads as its tag when the tag
49
+ is an `M`, and as the payload otherwise, as before. A click's payload
50
+ is still the app's message as-is, whatever its `kind`. An app that
51
+ matched its own variant against a tagged core event, meaning to or not,
52
+ now gets the tag.
53
+
54
+ A focused editor kept the whole keyboard: ⌘N, Ctrl+K — any
55
+ press — went to the editor and, if it did nothing with it, nowhere, so an
56
+ app's shortcuts were dead while its search box had focus. ADR 0011
57
+ deferred this until an app wanted it (backlog F144, Noticon's title and
58
+ search fields). An editor now claims what it acts on — the editing keys,
59
+ whatever it types, Space, the clipboard and undo chords — and anything
60
+ pressed without Control or Command, which is typing; any other chord
61
+ goes to the nearest `on_key` sink above it, with its release, as a chord
62
+ bubbles from a control. A shell sink that binds a chord now hears it
63
+ from inside a field, which is the point; one that should not act while
64
+ a field has focus has to check `focused` itself.
65
+
66
+ An editing key that meets the edge of the text — ↑ on the first line, ↓
67
+ on the last, ← or Backspace at the start, → or Delete at the end, with
68
+ no selection and no Shift — now emits `{kind: "boundary", key, edge,
69
+ word, doc}` on the editor (backlog F145). A handler that counted an
70
+ editor's events, or matched every event on an editor's key as a change,
71
+ sees one more.
72
+
73
+ An editor that lost the keyboard kept drawing its selection, so a page of
74
+ fields showed a highlight in each and only one of them was what ⌘C
75
+ copied (backlog F147). It now keeps the range and draws none of it until
76
+ focus comes back — and while a context menu or the drawn menu bar opened
77
+ over it is up, since the menu's rows took focus to be chosen and the
78
+ selection is what Copy will copy. A screenshot test of a blurred field
79
+ with a selection changes.
80
+
81
+ `KuiTextStyle` appends `bold` into its tail padding — the 64-bit size stays
82
+ 72 — for F150 below, and `KuiMenuItem` puts `replay` into the padding
83
+ before `submenu`, its stride still 72, for F151; the layouts moved, so
84
+ the ABI did, twice. Zeroed fields are what a text and a row were.
85
+
86
+ ### Added
87
+
88
+ - **Chords out of a focused editor** (backlog F144, ADR 0011 decision
89
+ 10): see *What breaks*.
90
+ - **`boundary`, an editing key at the edge of the text** (backlog
91
+ F145): the event a block editor joins blocks and moves between fields
92
+ by. A field has one line, so its ↑ and ↓ always report.
93
+ - **`keep_tab` on an editor** — `EditOptions::keep_tab`, `keepTab` in
94
+ JSX, `keep_tab` in Lua, `KUI_EDIT_KEEP_TAB` in C, `.Keep_Tab` in Odin
95
+ (backlog F146): a field's Tab and Shift-Tab go to the sink above it
96
+ instead of walking the focus ring, so a list or an outline built from
97
+ fields indents on Tab. A document keeps Tab either way. A new flag
98
+ bit, no ABI change.
99
+ - **`inside` on a line sink's pointer events** (backlog F153): whether the
100
+ point is within the line's own box, false above, below and in the
101
+ margin beside a row — so a margin gesture is told from a press on the
102
+ text without comparing `x` to a rect.
103
+ - **The application menu's rows** — `MenuRole::About`, `Hide`,
104
+ `HideOthers`, `ShowAll`, `Quit`; `"about"`, `"hide"`, `"hideOthers"`,
105
+ `"showAll"`, `"quit"` as a row's `role`; `KUI_MENU_ABOUT` …
106
+ `KUI_MENU_QUIT` in C (appended values, no ABI bump) (backlog F152, ADR
107
+ 0018 amended): on a menu bar macOS draws, AppKit's own rows — the About
108
+ panel, `hide:`, `hideOtherApplications:`, `unhideAllApplications:`,
109
+ `terminate:` — worded with the app's name, with ⌘H, ⌥⌘H and ⌘Q, posting
110
+ nothing. In a menu kui draws, Hide, Hide Others and Show All are not
111
+ offered, and About and Quit post their `menu` event for the app.
112
+ - **A menu row that is its chord** — `MenuItem::replay`, `replay` on a
113
+ plain-data row (JSX, Lua), `KuiMenuItem.replay` and
114
+ `KUI_MENU_ITEM_REPLAY` in C, `replay` on Odin's `Menu_Item` (backlog
115
+ F151, ADR 0018 amended): chosen — by the pointer, or by the key
116
+ equivalent the macOS bar binds — it plays its `accel` where the
117
+ keyboard is, as the standard Edit menu's rows do, and posts no `menu`
118
+ event. A declared Edit menu's Undo is a field's undo while a field has
119
+ focus and the app's otherwise, with no rebuilding of the bar.
120
+ - **`bold` on a whole text or an editor** — `TextStyle::bold`, `bold` in
121
+ JSX and Lua, `KuiTextStyle.bold` in C (ABI 29), `bold` on Odin's
122
+ `Text_Style` (backlog F150): the family's bold — its bold face, or its
123
+ regular drawn bold where it has none, as a span's `bold` is — so a
124
+ heading or a title field is bolder, not only bigger. A span inside a
125
+ bold text is bold too.
126
+ - **A placeholder on an editor** — `EditOptions::placeholder`,
127
+ `placeholder` in JSX and Lua, `kui_text_edit_placeholder` in C (a new
128
+ function, no ABI bump), `placeholder` on Odin's `kui.text_edit`
129
+ (backlog F149): what an empty editor shows where its text would be, in
130
+ the theme's `faint`. Never part of the value, gone with the first
131
+ character typed or composed, and the field's accessible description
132
+ when it declares none.
133
+
134
+ ### Fixed
135
+
136
+ - **A message variant named like a core event no longer catches it**
137
+ (backlog F148): see *What breaks*.
138
+ - **One selection on screen among several editors** (backlog F147): see
139
+ *What breaks*.
140
+
141
+ **What you can delete.**
142
+
143
+ - Renaming a message variant, or a sink's tag, away from a core event's
144
+ kind (`Key`, `Open`, `Menu`, `Changed`) so `message` reads the tag.
145
+ - Routing an app's shortcuts through a declared menu bar, or a field's
146
+ own key handling, so they work while a field has focus.
147
+ - Comparing a drag's `x` against a line's rect to tell a margin press
148
+ from a press on its text.
149
+ - Sending `hide:`, `hideOtherApplications:` or `unhideAllApplications:`
150
+ to `NSApplication` by hand, and quitting from a declared bar by closing
151
+ the window.
152
+ - Rebuilding a declared menu bar as focus moves, unbinding rows a field
153
+ needs its key for, and replaying a chosen row's accelerator by hand.
154
+ - A one-span `rich_text` or a registered bold face standing in for a bold
155
+ heading, and a bold title that could not be an editor.
156
+ - A faint text floated over an empty field and positioned by hand to
157
+ match the editor's insets, standing in for a placeholder.
158
+ - Collapsing an editor's selection when it loses focus, to keep a page
159
+ of fields from showing several highlights.
160
+ - Comparing an editor's text before and after an arrow or Backspace to
161
+ tell whether it reached the edge, or a separate key sink over a field
162
+ to hear ↑ and ↓.
163
+
164
+ ### Native verification
165
+
166
+ The by-hand round on 2026-10-09, over Noticon's macOS polish round —
167
+ F144–F153, W23 and the Odin binding checked against the generator — on
168
+ the Mac: the mechanical round as CI runs it, the Odin binding with an
169
+ Odin built locally against LLVM 21 rather than CI's pinned one in its
170
+ image, and the bench guard. The windowed round, the accessibility audit
171
+ and the other checks that need someone at the screen were not run this
172
+ time.
173
+
174
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `nu
175
+ scripts/test.nu`: **2054 tests over 154 suites**, 0 failed, 4 ignored.
176
+ The C round passes (5 checks), and so do the **58 scenes** through
177
+ Rust, Lua, C, Odin and Node; Node's tests under
178
+ `KUI_CONFORMANCE_REQUIRED=1`, **228 of 228**; `npm run gen` with no
179
+ diff, the examples' typecheck, the headless round (8.0 s), the book's
180
+ examples current. `nu scripts/odin.nu gen --check` says the binding is
181
+ current, `odin.nu check` vets both packages and every example, and
182
+ `odin.nu test` runs its four. The bench guard against the alpha.49 tag:
183
+ **green**, the eight guarded rows within tolerance. Not run: the
184
+ windowed round over every example and the four hosts, and the AX audit.
185
+ No Windows or Linux machine ran this round.
186
+
187
+ ## 0.1.0-alpha.49 (2026-10-09)
188
+
189
+ ### Changed
190
+
191
+ - **A Lua view lowers in about two-thirds of the time** (backlog F143).
192
+ kui-lua reads each table in one pass: `Table::for_each` with a key
193
+ type that copies a string key's bytes off the Lua stack instead of
194
+ making a registry reference for it (a `pairs` step made one for every
195
+ key and every string or table value, and dropped it again), the keys
196
+ matched as bytes, the schema rows looked up in a hash map rather than
197
+ scanned, `size` and `radius` read in the same pass and still applied
198
+ before the rows they underlie, a text's `value` and `spans` taken from
199
+ it, and a span's twelve style keys, a `pad` table's seven edges and
200
+ the unknown-prop check — a second walk over every table while the
201
+ core's diagnostics are on — folded into it. Nothing a view writes
202
+ changes, and a table in an unusual shape (a number for a text's
203
+ `value`, a span's `size` as a string) is read through `get` as before.
204
+ Measured over a settings pane's worth of tables (`cargo bench -p
205
+ kui-lua --bench walk`, medians): **2.55 → 1.64 ms** a frame with the
206
+ diagnostics off, as a release build of the windowed runner has them,
207
+ and **3.42 → 1.75 ms** with them on; in kawoosh's settings, themes,
208
+ theme lab and grammars panes the walk went 3.45 → 2.27, 3.51 → 2.13,
209
+ 2.12 → 1.42 and 1.46 → 1.06 ms on the frames a pane is rebuilt in.
210
+ The rest of the walk is mlua's own reference for each string and
211
+ table value, which its safe API does not let a binding skip.
212
+
213
+ **What you can delete.**
214
+
215
+ - Hand-flattening a Lua view's tree, or building it in fewer, larger
216
+ tables, to keep the lowering off the frame: kawoosh's settings pane
217
+ lowers at about 1.4 µs a table where it took 2.2.
218
+
219
+ ### Native verification
220
+
221
+ The by-hand round on 2026-10-09, over F143 — kui-lua's walk from a
222
+ view's tables to the tree — on the Mac: the mechanical round as CI runs
223
+ it, the Odin binding checked with CI's pinned Odin in its Debian image,
224
+ the windowed round, the accessibility audit and the bench guard.
225
+
226
+ **macOS**, the pre-tag pass. fmt and clippy are clean; `nu
227
+ scripts/test.nu`: **2034 tests over 152 suites**, 0 failed. The C round
228
+ passes (5 checks), and so do the **58 scenes** through Rust, Lua, C and
229
+ Node; Node's tests under `KUI_CONFORMANCE_REQUIRED=1`, **225 of 225**;
230
+ `npm run gen` with no diff, the examples' typecheck, the headless round
231
+ (4.0 s). `nu scripts/odin.nu gen --check` says the binding is current
232
+ and `odin.nu check` vets both packages and every example. The windowed
233
+ round with Node's: **55 examples on both bases**, clean on a first run,
234
+ and `counter`, `host`, `c_panel` and `lua_panel` for 120 frames each.
235
+ The accessibility audit: **106 of 106**. The bench guard against the
236
+ alpha.48 tag: **green**, the eight guarded rows within tolerance; the
237
+ new `walk` rows are in docs/performance.md. No Windows or Linux machine
238
+ ran this round.
239
+
24
240
  ## 0.1.0-alpha.48 (2026-10-09)
25
241
 
26
242
  **What breaks.**
@@ -159,6 +159,34 @@ around its content, which is what both reports already wrote.
159
159
  pointer's rule is untouched: a click past the clip still finds
160
160
  nothing.
161
161
 
162
+ 10. **An editor claims what it acts on, and a chord it does not bubbles**
163
+ (amended 2026-10-09, backlog F144–F146; the deferral under
164
+ *Considered options* met its condition — Noticon wanted ⌘N, ⌘F and
165
+ its sidebar's chord from inside its title and search fields, which is
166
+ "⌘K from inside its search box"). `Core::editor_claims` is decision
167
+ 2's claim for an editor, static like it: every press its editing
168
+ channel acts on (`KeyPress::edit_event` — the editing keys, Space,
169
+ whatever types), the clipboard and undo chords the runner performs
170
+ for it (the primary modifier with C, X, V, A, Z or Y, the table
171
+ `Shell::edit_chord` reads), and every press without Control or
172
+ Command, which is typing — a Mac's Option composes, and a key with no
173
+ text yet may be the first half of a composition. Anything else goes
174
+ to the nearest sink above the editor, exactly as a chord bubbles from
175
+ a control, its release with it. The runner's chord table did not move
176
+ into the core: the core claims those chords by name and the runner
177
+ performs them, and what the ADR feared — ⌘C in a field reaching the
178
+ shell and copying — cannot happen while the claim names them. With no
179
+ sink above, the press goes nowhere, as before. Two things came with
180
+ it. A field declared `keep_tab` does not claim Tab: Tab and Shift-Tab
181
+ go to the sink above instead of walking the ring, so a list built
182
+ from fields indents on Tab (a document keeps Tab either way). And an
183
+ editing key the editor holds but cannot act on — ↑ on its first line,
184
+ ↓ on its last, ← or Backspace at the start, → or Delete at the end,
185
+ from a caret with no selection and without Shift — emits `boundary
186
+ {key, edge}` on the editor, so a block editor joins and moves between
187
+ its fields; the key itself still never bubbles, since the editor did
188
+ claim it.
189
+
162
190
  ## Considered options
163
191
 
164
192
  - **A `keys` allow-list on the sink** (`onKey={handler} keys={['space',
@@ -200,7 +228,9 @@ around its content, which is what both reports already wrote.
200
228
  tells those apart from a shell (splitmux's sink encloses focusable
201
229
  panes and binds chords; a terminal's encloses nothing and binds Tab).
202
230
  Decision 4 gives a shell the same outcome in one line of its own code.
203
- - **Bubble out of a focused editor too.** Deferred, deliberately. An
231
+ - **Bubble out of a focused editor too.** *Built 2026-10-09 as decision
232
+ 10 (backlog F144); what follows is the deferral as it was written.*
233
+ Deferred, deliberately. An
204
234
  editor claims the whole printable keyboard plus the editing keys, so
205
235
  what is left is chords — and the clipboard chords are handled *above*
206
236
  the core, in the runner (`crates/kui-native/src/lib.rs`), which the core cannot
@@ -254,5 +284,5 @@ around its content, which is what both reports already wrote.
254
284
  modal boundary. The corpus's `keys` scene gains the shell over a ring,
255
285
  so all four bindings reproduce it byte for byte.
256
286
  - **What is still one keyboard away:** an editor's leftovers (deferred
257
- above), and a sink hearing a press that landed on a *window* it does not
287
+ above; built 2026-10-09 as decision 10), and a sink hearing a press that landed on a *window* it does not
258
288
  own, which is ADR 0004's routing and not this one's.
@@ -164,6 +164,23 @@ does. An app that has to write the menu twice has not been given a menu.
164
164
  disabled row's in the disabled colour, since AppKit draws an
165
165
  attributed title as given). The rule is unchanged; the Mac keeps it.
166
166
 
167
+ *Amended 2026-10-09 (backlog F151).* The key equivalent made every
168
+ bound row a trap: AppKit takes ⌘Z before a focused field sees it, so an
169
+ app with a declared Edit menu and a field had to unbind its Undo row
170
+ while the field had focus, and rebuild the bar each time focus moved —
171
+ Noticon did. A row can now *be* its chord: `MenuItem::replay` (`replay`
172
+ in the plain-data row, `KuiMenuItem.replay` in C, ABI 30). Chosen — by
173
+ the pointer, or by the key equivalent — it plays its `accel` where the
174
+ keyboard is, exactly as the standard Edit menu's rows do (ADR 0030,
175
+ decision 3): the press to the key sink, then the runner's clipboard
176
+ half or the editing key, then the release (`Shell::replay_key`, which
177
+ the standard rows now go through too). It posts no `menu` event; the
178
+ chord is what it says. In a menu the core draws, or reports for a host,
179
+ `Core::replay_chord` does the same where the menu took the keyboard
180
+ from, with the core doing the clipboard and undo chords itself. The
181
+ core still binds nothing: a replay row on a drawn bar is still only
182
+ chosen by the pointer, and the key is the app's as it always was.
183
+
167
184
  8. **The bar is per application, and the frontmost window's declaration
168
185
  wins.** macOS has one menu bar for the process; kui has a `Core` per
169
186
  window. The runner applies the declaration of the window that holds the
@@ -205,6 +222,22 @@ does. An app that has to write the menu twice has not been given a menu.
205
222
  own answer for what the *drawn* bar does with each. An app spells ⌘Q
206
223
  today with an item and a close in its handler.
207
224
 
225
+ *Built 2026-10-09 (backlog F152).* A declared bar replaces the
226
+ application menu winit made, so an app that declared one lost Hide,
227
+ Hide Others, Show All and Quit, and Noticon sent `hide:` to
228
+ `NSApplication` itself. The answer for the drawn bar is the one Look Up
229
+ already had: `MenuRole::About`, `Hide`, `HideOthers`, `ShowAll` and
230
+ `Quit`, appended to `ALL` (`KUI_MENU_ABOUT` … `KUI_MENU_QUIT`, no ABI
231
+ bump). Where macOS draws the bar each is the platform's own row, sent
232
+ up the responder chain to `NSApplication` (`orderFrontStandardAboutPanel:`,
233
+ `hide:`, `hideOtherApplications:`, `unhideAllApplications:`,
234
+ `terminate:`), worded with the process's name where the Mac words it so
235
+ and given the Mac's ⌘H, ⌥⌘H and ⌘Q, posting nothing. Where kui draws
236
+ the menu, Hide, Hide Others and Show All are not offered (there is no
237
+ application to hide), and About and Quit are drawn and post their
238
+ `menu` event like the app's own rows — the app's about box, the app's
239
+ close. `services` stays declined.
240
+
208
241
  - **A row in `env.system`.** Decision 4.
209
242
 
210
243
  ## Consequences
@@ -127,7 +127,9 @@ date: 2026-09-14
127
127
  under another name would have no way to say so. Naming a menu `Window`
128
128
  costs one word.
129
129
 
130
- - **`MenuRole::Minimize` / `Zoom` / `FullScreen` / `Quit`.** ADR 0018's
130
+ - **`MenuRole::Minimize` / `Zoom` / `FullScreen` / `Quit`.** (*`Quit`
131
+ built 2026-10-09 with the application menu's other rows, backlog F152;
132
+ see ADR 0018.*) ADR 0018's
131
133
  decline, unchanged: each needs a drawn-bar answer, an ABI bump and four
132
134
  bindings, for rows the platform performs on its own responder chain.
133
135
  The condition that would build them is an app that wants to compose a
package/encoder.js CHANGED
@@ -1002,7 +1002,8 @@ export function createEncoder(P) {
1002
1002
  f[fi++] = OP.edit;
1003
1003
  strRef(label);
1004
1004
  strRef(p.initial ?? '');
1005
- f[fi++] = (p.multiline ? 1 : 0) | (p.autofocus ? 2 : 0);
1005
+ f[fi++] = (p.multiline ? 1 : 0) | (p.autofocus ? 2 : 0) | (p.keepTab ? 8 : 0);
1006
+ strRef(p.placeholder ?? null);
1006
1007
  props(p, null, false);
1007
1008
  return;
1008
1009
  }
@@ -1018,6 +1019,7 @@ export function createEncoder(P) {
1018
1019
  strRef(String(label));
1019
1020
  strRef(p.initial ?? '');
1020
1021
  f[fi++] = 4;
1022
+ strRef(null);
1021
1023
  props({}, null, false);
1022
1024
  return;
1023
1025
  }
package/index.d.ts CHANGED
@@ -149,9 +149,11 @@ export type ButtonMsg<T = AppMsg> = {
149
149
  /** On a `cells` grid: the cell under the pointer, clamped to the grid. */
150
150
  cell?: { row: number; col: number };
151
151
  /** Inside an `onKey` sink that draws `role="line"` rows: the line and
152
- * the byte in its text, as a drag carries them. */
152
+ * the byte in its text, as a drag carries them, and whether the point
153
+ * is within that line's own box (false in the margin beside it). */
153
154
  line?: number;
154
155
  byte?: number;
156
+ inside?: boolean;
155
157
  tag?: T;
156
158
  };
157
159
 
@@ -468,6 +470,19 @@ export type ModifiersMsg = {
468
470
  * key is on the event, so `editText(ev.key)` reads it back. */
469
471
  export type EditMsg = { kind: 'changed' } | { kind: 'submit' };
470
472
 
473
+ /** An editing key that met the edge of an editor's text and did nothing
474
+ * (backlog F145): ↑ on the first line, ← or Backspace at the start, ↓ on
475
+ * the last line, → or Delete at the end — from a caret with no selection
476
+ * and without Shift. On the editor's key. `word` and `doc` are the
477
+ * editing modifiers the key carried. */
478
+ export type BoundaryMsg = {
479
+ kind: 'boundary';
480
+ key: 'up' | 'down' | 'left' | 'right' | 'backspace' | 'delete';
481
+ edge: 'start' | 'end';
482
+ word: boolean;
483
+ doc: boolean;
484
+ };
485
+
471
486
  /** A tagged playback (`play(id, { tag })` or `<audio tag>`) finished on its
472
487
  * own — never when something stopped it. On an `<audio>` node's key, or the
473
488
  * root for `play`. `'refused'` is the device declining to start it at all
@@ -506,6 +521,7 @@ export type CoreMsg =
506
521
  | WindowMsg
507
522
  | ModifiersMsg
508
523
  | EditMsg
524
+ | BoundaryMsg
509
525
  | SoundMsg
510
526
  | AccessMsg
511
527
  | ChangeMsg
package/jsx-runtime.d.ts CHANGED
@@ -209,7 +209,8 @@ export interface FloatProp {
209
209
  * the standard rows the core performs itself. The same spelling a
210
210
  * `menu` message reports back. */
211
211
  export type MenuItemRole =
212
- | 'custom' | 'separator' | 'cut' | 'copy' | 'paste' | 'selectAll' | 'lookUp';
212
+ | 'custom' | 'separator' | 'cut' | 'copy' | 'paste' | 'selectAll' | 'lookUp'
213
+ | 'about' | 'hide' | 'hideOthers' | 'showAll' | 'quit';
213
214
  // -- end generated --
214
215
 
215
216
  /** One row to put in a context menu (`Ctx.openMenu`). Everything but
@@ -236,6 +237,13 @@ export interface MenuItemInput {
236
237
  * closes it — and is never chosen itself. A chosen row inside posts its
237
238
  * own `menu` event, on the node the menu is about. */
238
239
  items?: MenuItemInput[];
240
+ /** The row *is* its `accel`: chosen — by the pointer, or by the key
241
+ * equivalent a platform menu bar binds — it plays that chord where the
242
+ * keyboard is and posts no `menu` event, as the standard Edit menu's rows
243
+ * do. A field with focus undoes or copies through its own key, a sink
244
+ * that binds the chord hears it. A row whose `accel` kui cannot parse is
245
+ * an ordinary row. */
246
+ replay?: boolean;
239
247
  }
240
248
 
241
249
  /** One menu of the application menu bar (the `<menuBar menu={…}/>`
@@ -473,6 +481,8 @@ export interface GeneratedSpecProps {
473
481
  }
474
482
 
475
483
  export interface GeneratedStyleProps {
484
+ /** The family's bold, on a whole text or an editor (backlog F150) — a heading, a table's header, a title field: its bold face, or its regular drawn bold where the family has none, as a `<span bold>` is. A span inside a bold text is bold too. */
485
+ bold?: boolean;
476
486
  /** Text color; default foreground when omitted. */
477
487
  color?: ColorProp;
478
488
  /** End the last line with an ellipsis when the text is cut off: a single line unless `maxLines` says otherwise. */
@@ -733,6 +743,14 @@ export interface EditProps extends TextProps, GeneratedSpecProps, CustomSpecProp
733
743
  * while nothing holds focus (ADR 0022, decision 9); a blur afterwards
734
744
  * stands. `focus(key)` moves it at any other time. */
735
745
  autofocus?: boolean;
746
+ /** A field's Tab is the app's: Tab and Shift-Tab do not walk the focus
747
+ * ring from it, and the press goes to the `onKey` sink above it — so a
748
+ * list built from fields indents on Tab. A `multiline` editor keeps Tab
749
+ * either way. */
750
+ keepTab?: boolean;
751
+ /** What the editor shows, faint, while it is empty: never part of its
752
+ * text, and its accessible description when it declares none. */
753
+ placeholder?: string;
736
754
  }
737
755
 
738
756
  type Component<P> = (props: P) => KuiNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qxuken/kui",
3
- "version": "0.1.0-alpha.48",
3
+ "version": "0.1.0-alpha.50",
4
4
  "description": "kui for Node: JSX views lowered into the kui IR, Elm-style messages as data",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
package/props.md CHANGED
@@ -121,6 +121,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
121
121
 
122
122
  | JSX | Lua | C | Odin | type | description |
123
123
  |---|---|---|---|---|---|
124
+ | `bold` | `bold` | `KuiTextStyle.bold`; `KuiSpan.flags` (`KUI_SPAN_BOLD`) | `Text_Style.bold` | boolean | The family's bold, on a whole text or an editor (backlog F150) — a heading, a table's header, a title field: its bold face, or its regular drawn bold where the family has none, as a `<span bold>` is. A span inside a bold text is bold too. |
124
125
  | `color` | `color` | `KuiTextStyle.color` | `Text_Style.color` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | Text color; default foreground when omitted. |
125
126
  | `ellipsis` | `ellipsis` | `KuiTextStyle.ellipsis` | `Text_Style.ellipsis` | boolean | End the last line with an ellipsis when the text is cut off: a single line unless `maxLines` says otherwise. |
126
127
  | `family` | `family` | `KuiTextStyle.family` (`KUI_FONT_*`); `KuiSpan.family` (with `KUI_SPAN_FAMILY`) | `Text_Style.family` | `sans` \\| `serif` \\| `mono` \\| a family name | Font family: `sans`, `serif` or `mono`, kui's own, or the name of an installed family or one loaded with `loadFontsDir` / `loadFontFile` — `"Berkeley Mono"` — drawn in its face in the frame that names it (ADR 0037). A name is matched as `addSystemFont` matches it and registered in the session on first sight, exactly as the font database spells it (`"menlo"` is not `"Menlo"`); the session's first registration of any font maps the installed font files once (~30 ms on a Mac, backlog DX24), which a family named in a view pays in that frame. `systemFonts()` lists the names there are. A name nothing matches shapes as sans and raises `unknown-family`. It and `font` set the same thing, so declare one. |
@@ -164,7 +165,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
164
165
  | `<box dir="table">` | `grid { }` | `kui_open*` with `dir = KUI_TABLE` | `kui.box` with `dir = .Table` | A column whose rows' children line up in columns (`docs/adr/0033-a-table-is-a-column-whose-cells-align.md`): its children are the rows, each row's in-flow children its cells, the nth cell of every row column n, and a column as wide as its widest cell — so a label column sits at its longest label with nothing measured and no width picked by hand, in every binding, since the alignment is the layout's and not a widget's. A cell's `width` says how its column sizes: `fit` (the default) and a fixed number are content the column's fit width is the max of; `grow` makes the whole column grow with the table, `grow` factors splitting the room the fit columns leave; a percent takes its cut of the row; and a column's `minWidth` / `maxWidth` are the strictest its cells declared. Fit columns that overflow the row are compressed toward their floors largest first, as a row's children are, unless the table scrolls x; a fixed column never is. A bare text is a cell too, held at its column's width, so a text straight inside a row is a column; an image straight in a row is a cell the same way, its box the column wide and its own aspect tall, the pixels meeting the box by its `fit` row (wrap an icon in a box to keep its own width). The rows are the table's `row` children, ordinary rows — give them `width="grow"` for the columns to grow into (a `fit` row sits at the columns' width) — with their own `gap` between cells, their own padding, background, click, hover and access rows; a row of a table never wraps (`wrap-ignored`). Anything else straight under the table — a text, a `column` section, another table — is a child with its own width and no cells. The table's own `fit` width is its columns', whatever its rows' sizing, so a table with no width is the aligned list; a `scrollX` table's rows are at least as wide as its columns, and it scrolls to them. Everything else is a column's: `gap` is the space between rows, `scrollY` scrolls them, a float in a row is not a cell. Spelled `grid { }` in Lua, since `table` is Lua's own. |
165
166
  | `<text>` with `<span bold italic underline strikethrough bg bgRadius color family font size>` children | `text("s", {…})`, `text({ "a", { "b", bold = true, underline = true, bg = 0x.., bg_radius = 4 }, { "code", family = "mono", size = 13 } })` | `kui_text`, `kui_rich_text` | `kui.text`, `kui.rich_text` | Plain or rich text; spans shape as one paragraph, so wrapping crosses style boundaries. A text is content plus a style and no box of its own, so the rows it reads are the style rows (`size`, `lineHeight`, `color`, `family`, `font`, `wrap`, `maxLines`, `ellipsis`, `underline`, `strikethrough`, `features`) and nothing else: a container row, an access row (`label`, `role`, `live`), `key` or `onClick` on a text is dropped with an `unknown-prop` warning naming the rows it does take — put them on the box around it. `wrap`, `maxLines` and `ellipsis` control line breaking. A span's `bg` is a background behind its glyphs alone, one rect per line it spans, so it follows the span across a wrap the way a box around a run cannot. With `bgRadius` (`bg_radius` in Lua, `KuiSpan.bg_radius` in C, `Span::bg_radius` in Rust; logical px) the background is rounded and joined into one shape with every rounded background of the same colour and radius it meets: a piece whose edge touches it exactly on the line above or below and overlaps it sideways, or that meets it end to end on its own line, in this text or another. Its corners are then convex where a line reaches past its neighbour, a fillet where it falls short, and round where nothing meets it — a selection over many rows, or over the wrapped lines of a paragraph, is one rounded outline, joined after every text of the frame is laid out and painted, so it is never a frame behind. Nothing names the shape: two that touch are one; `underline` and `strikethrough` on a span or on the whole text are lines where the face puts them. A span takes a face and a size of its own: `family` (a stock name or an installed family's, as the text's) or `font` (a handle, which wins) — inline code in `mono` inside a sans paragraph — and `size` in logical px, its line height scaled at the paragraph's ratio (`Span::family` / `Span::mono` / `Span::size` in Rust, `KuiSpan.family` with `KUI_SPAN_FAMILY`, `.font` and `.size` in C). Its glyphs are shaped in that face, so a caret, a hit, a selection and the measurement read the same glyphs and byte positions stay exact across the change; a line is as tall as its tallest span, and a span's background is its own height around its glyphs. A paragraph with a sized span is shaped whole, never in chunks. A text with no line breaks that is 4096 bytes or longer (and no `maxLines` or `ellipsis`), plain or spans alike, is shaped in ~1 KB chunks as they come on screen, so a minified bundle or a log line with a blob in it costs the screenful it shows and a keystroke into it — or a span moving along it, an editor's caret — costs the chunk it lands in; wrapped, the rows are broken from the chunks' positions, so a 100k-character paragraph costs the rows it shows. Its size is estimated from the first chunk until the rest shape (exact under monospace), and the access tree carries its value without its runs. |
166
167
  | `<button onClick key\|index label description tooltip disabled accent>` | `button { label=, on_click=, key= \| index=, text=, description=, tooltip=, disabled=, accent= }` | `kui_button`, `kui_button_with` | `kui.button` | The stock button: `widgets::button_spec(&theme, &metrics)` — the theme's accent trio as its three backgrounds, declared on the node and resolved by the core — keyed by its text (`key` overrides). It paints from the palette like every stock widget (backlog AR41): the OS's accent where the host reports one, the app's where it set or pinned one, kui's blue otherwise; the label goes black or white by the background's luminance. Its look is its spec, so the layout and paint rows are closed — declared, they are dropped with an `unknown-prop` warning naming the rows it does read — and those are the access rows: `label` when the text is not the name, `description`, `tooltip`, and `disabled` (inert, and dimmed to half). The one paint row it takes is `accent`, which on a button changes nothing (it is the accent already) and is kept for the box's sake. In Lua `label` is the name and the text both unless `text` says otherwise; in C the rows ride a `KuiSpec` whose other fields `kui_button_with` ignores. A button that needs any other row is a box with `role="button"` and the same rows spelled out. |
167
- | `<edit key initial multiline autofocus>`, `<input label initial>` | `edit { key=, initial=, … }`, `input { label=, initial= }` | `kui_text_edit`, `kui_text_input` | `kui.text_edit`, `kui.text_input` | Retained editor state by key; read it back with `editText(key)` after a `changed` event. `initial` seeds a new editor only — a key declared again keeps the draft the user typed, and `setEditText(name, text)` is what resets one (it leaves the caret at the end). Name it by the label its `key` prop declares — `setEditText('note', text)` — or by the hex key an event carried. It reaches an editor that does not exist yet: the text is held for the frame that declares that name and seeds it there, over `initial`, so the `update` that opens a rename field can fill it in the same turn, which is what the label spelling is for — the hex key comes from an event an editor being opened has not fired. A name nothing declares on that frame drops its text with an `edit-text-without-editor` warning. A single-line editor is a field and a `multiline` one a document, which decides how each is laid out as well as how it reads: a field takes one line whatever its box, sizes to the text it holds when its width is `fit`, and scrolls that line under the caret when it is not, while a document wraps to its box. The one exception is a field with `wrap` declared (`wrap="word"` or `"glyph"`): it folds to its width the way a document does and keeps a field's keyboard — Enter still submits, a newline is still never admitted, the caret still opens at the end — so a rename field breaks where the label it renames breaks, and with `width="fit"` plus `maxWidth` it sizes to its wrapped draft on the keystroke frame. A single-line editor opens with the caret after its seeded text, as a native field does; a multiline one is a document and opens at its top — a held `setEditText` is the call, not a seed, so it opens at the end either way. State is kept while the key is declared; an undeclared one is kept until the budget needs the room (256 undeclared editors, longest-undeclared evicted first). `autofocus` asks once: the editor takes focus on the frame the flag starts being declared — a new editor, or one whose flag just turned on — and only while nothing holds focus, so a blur afterwards stands and a focused control is never robbed (`docs/adr/0022-focus-regions.md`, decision 9); `focus(key)` is the call for taking it at any other time. |
168
+ | `<edit key initial multiline autofocus keepTab placeholder>`, `<input label initial>` | `edit { key=, initial=, keep_tab=, placeholder=, … }`, `input { label=, initial= }` | `kui_text_edit`, `kui_text_edit_placeholder`, `kui_text_input` | `kui.text_edit`, `kui.text_input` | Retained editor state by key; read it back with `editText(key)` after a `changed` event. `initial` seeds a new editor only — a key declared again keeps the draft the user typed, and `setEditText(name, text)` is what resets one (it leaves the caret at the end). Name it by the label its `key` prop declares — `setEditText('note', text)` — or by the hex key an event carried. It reaches an editor that does not exist yet: the text is held for the frame that declares that name and seeds it there, over `initial`, so the `update` that opens a rename field can fill it in the same turn, which is what the label spelling is for — the hex key comes from an event an editor being opened has not fired. A name nothing declares on that frame drops its text with an `edit-text-without-editor` warning. A single-line editor is a field and a `multiline` one a document, which decides how each is laid out as well as how it reads: a field takes one line whatever its box, sizes to the text it holds when its width is `fit`, and scrolls that line under the caret when it is not, while a document wraps to its box. The one exception is a field with `wrap` declared (`wrap="word"` or `"glyph"`): it folds to its width the way a document does and keeps a field's keyboard — Enter still submits, a newline is still never admitted, the caret still opens at the end — so a rename field breaks where the label it renames breaks, and with `width="fit"` plus `maxWidth` it sizes to its wrapped draft on the keystroke frame. A single-line editor opens with the caret after its seeded text, as a native field does; a multiline one is a document and opens at its top — a held `setEditText` is the call, not a seed, so it opens at the end either way. State is kept while the key is declared; an undeclared one is kept until the budget needs the room (256 undeclared editors, longest-undeclared evicted first). `autofocus` asks once: the editor takes focus on the frame the flag starts being declared — a new editor, or one whose flag just turned on — and only while nothing holds focus, so a blur afterwards stands and a focused control is never robbed (`docs/adr/0022-focus-regions.md`, decision 9); `focus(key)` is the call for taking it at any other time. A focused editor keeps the keys it acts on — the editing keys, what it types, the clipboard and undo chords — and a chord it does not (⌘N, Ctrl+K) goes to the nearest `onKey` sink above it, as a chord bubbles from a control (`docs/adr/0011-keys-bubble-to-the-enclosing-sink.md`, amended 2026-10-09). `keepTab` makes a field's Tab the app's: Tab and Shift-Tab do not walk the focus ring from it, and the press goes to the sink above it the same way, so a list or an outline built from fields indents on Tab; a `multiline` editor keeps Tab for indentation either way. An arrow, Backspace or Delete that meets the edge of the text emits `boundary` (see the events). `placeholder` is what an empty editor shows where its text would be, in the theme's `faint` — never part of the value, gone with the first character typed or composed, and read as the field's accessible description when it declares none. |
168
169
  | `<select label options={[…]} current>` | `dropdown { label=, options={…}, current= }` | `kui_select` | `kui.select` | The stock select (`widgets::select_items`, backlog F72): a field showing the choice in force that, clicked, opens the core's own menu of the options under it with the current one checked — the menu a right-click opens, drawn in the frame or the platform's where the host shows menus itself, dismissed by Escape or a press outside, its rows walked by the arrows and read as a menu. `label` is the key and the accessible name both; `options` is a list whose entries are strings (an option by its label, posting it) or menu-item objects `{ label, id, enabled }` (posting `id`), and a `{ role: "separator" }` is a separator; `current` is the index in force, counted from 0 in JSX and C and from 1 in Lua, or none — one past the options or on a separator is none, with a `select-current-ignored` warning on the field; an empty `options` is refused, and a key of an option object no row reads (`disabled`, where the key is `enabled`) is an `unknown-prop` warning. The app holds no open state: the choice arrives as the `menu` event a menu row posts, on the field's key — `{kind: "menu", role: "custom", item: <the option>}` — and drawing the field again with the new `current` is the whole loop. A reader hears a button named by the field, described by its choice, expanded while the menu is open. Its look is its spec, so it reads no other row: a layout, paint or access row on it is dropped with an `unknown-prop` warning. Lua spells it `dropdown`, since `select` is Lua's own. |
169
170
  | `<checkbox checked mixed onClick key label description tooltip disabled>text</checkbox>` | `checkbox { label=, checked=, mixed=, on_click=, key=, text=, description=, tooltip=, disabled= }` | `kui_checkbox` | `kui.checkbox` | The stock checkbox (`widgets::toggle_with`, ADR 0034): a box drawn from the state the view declares — `checked`, or `mixed` for the select-all box over a list some of whose rows are selected, drawn as a dash and read as mixed — and its label beside it, keyed by its text (`key` overrides). The state is the app's: a press by the pointer, Space, Enter or assistive technology posts `onClick`, and the view flips its model and draws it again. Its look is its spec, so the layout and paint rows are closed and dropped with an `unknown-prop` warning; the rows it reads are its state and the access rows. In Lua `label` is the name and the text both unless `text` says otherwise. The box is the metrics' control text plus one (16 px comfortable), so `compact` and `scaled` move it with the stock button. |
170
171
  | `<radio checked onClick key label description tooltip disabled>text</radio>` | `radio { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }` | `kui_radio` | `kui.radio` | The stock radio (`widgets::toggle_with`, ADR 0034): a circle drawn from `checked`, and its label, keyed by its text. Put radios in a `radioGroup`, which makes them one Tab stop whose arrows, Home and End move the choice and press the radio they land on (ADR 0007), so radios whose `onClick` each set the choice answer the keyboard with no more code. The state is the app's, as a checkbox's is; the rows are the checkbox's, `mixed` aside. |
@@ -195,8 +196,8 @@ one field. The payload shapes:
195
196
 
196
197
  | kind | payload | when |
197
198
  |---|---|---|
198
- | click | the `onClick` payload as-is | A press and release on the node (suppressed when a drag moved past the slop). A map payload gains fields where the node can say more: `cell: {row, col}` on a `cells` grid, and inside an `onKey` sink that draws `role="line"` rows, `line`, `byte` and `clicks` as a `drag` inside one carries them. |
199
- | drag | `{ kind: "drag", phase: "start" \| "move" \| "end", x, y, dx, dy, parent: { x, y, w, h }, tag }` | A pointer-captured drag on an `onDrag` node. `x`/`y` are where the pointer is; `dx`/`dy` are its displacement **from the press point**, in every phase — `start` carries zero, a `move` how far the pointer is from where it pressed, `end` the whole distance — so a handler sets `value = start + dx` rather than summing deltas, and can commit from `end` alone. Nothing is dropped under the click slop (3 px, measured from the press): the first `move` already carries the whole distance. `parent` is the container rect, so fractions need no geometry query. On a `cells` grid every phase also carries `cell: {row, col}`. Inside an `onKey` sink that draws `role="line"` rows — an editor the app owns — every phase carries `line` (the ordinal among the sink's lines, the numbering its `access` events use), `byte` (where the point falls in that line's text, what `textHit` would answer) and `clicks` (the press's count), so click-to-caret, drag-select and double-click-word are arithmetic on the event with no query and no frame of lag; a point above the first line is the first, below the last the last, and one in a `role="none"` gutter is the line beside it. Nothing is added where the sink draws no lines. |
199
+ | click | the `onClick` payload as-is | A press and release on the node (suppressed when a drag moved past the slop). A map payload gains fields where the node can say more: `cell: {row, col}` on a `cells` grid, and inside an `onKey` sink that draws `role="line"` rows, `line`, `byte`, `inside` and `clicks` as a `drag` inside one carries them. |
200
+ | drag | `{ kind: "drag", phase: "start" \| "move" \| "end", x, y, dx, dy, parent: { x, y, w, h }, tag }` | A pointer-captured drag on an `onDrag` node. `x`/`y` are where the pointer is; `dx`/`dy` are its displacement **from the press point**, in every phase — `start` carries zero, a `move` how far the pointer is from where it pressed, `end` the whole distance — so a handler sets `value = start + dx` rather than summing deltas, and can commit from `end` alone. Nothing is dropped under the click slop (3 px, measured from the press): the first `move` already carries the whole distance. `parent` is the container rect, so fractions need no geometry query. On a `cells` grid every phase also carries `cell: {row, col}`. Inside an `onKey` sink that draws `role="line"` rows — an editor the app owns — every phase carries `line` (the ordinal among the sink's lines, the numbering its `access` events use), `byte` (where the point falls in that line's text, what `textHit` would answer) and `clicks` (the press's count), so click-to-caret, drag-select and double-click-word are arithmetic on the event with no query and no frame of lag; a point above the first line is the first, below the last the last, and one in a `role="none"` gutter is the line beside it, and `inside` says whether the point is within that line's own box at all — false above, below and in the margin beside a row, so a margin gesture (block selection) is told from a press on the text without comparing `x` to a rect. Nothing is added where the sink draws no lines. |
200
201
  | key | `{ kind: "key", phase: "down" \| "up", code, physical, shift, ctrl, alt, super, text, repeat, location, caps_lock, num_lock, tag }` | A key press or release on the focused `onKey` sink; `code` is a character or a name (`"left"`, `"f5"`) and is what a keymap binds against. `physical` is the US-QWERTY key at that *position*, spelled the same way — bind it instead when you want the finger rather than the label (WASD stays a square on every layout). `code` follows the layout while the layout speaks ASCII, so a chord lands on the key the user can see (Dvorak's `⌥v` on the key printed V); on a layout that does not (Cyrillic, Greek, Hebrew, Arabic) the position's US key stands in, as Shift prints it (`J`, `:`; unshifted under Alt, as a chord reads it), so a Latin keymap keeps matching instead of matching nothing. A shifted letter arrives as the upper-case letter — `Z` with `shift` set for ⇧⌘Z, `physical` staying `z` — so a keymap that binds letters folds a one-character `code` to lower case under a chord; a headless press is spelled the same way, since no door re-spells it (`"z"` with `shift` is a chord no keyboard produces). `repeat` marks a press the OS auto-repeated; `text` is what the press would insert — always the layout's own character — and is null on every release. A key only comes up where it went down: a release whose press the sink never got is dropped, and focus leaving while a key is held delivers the `up` first, so a held-key binding (WASD, press-and-hold) cannot be left stuck down. `location` says which of a key's twins it was (backlog F108): `"left"` or `"right"` for a modifier, `"numpad"` for the keypad's digits, operators, Enter and (Num Lock off) arrows — `code` still `"1"`, `"enter"` — else `"standard"`; `caps_lock` and `num_lock` what the lock keys held. The keys F13–F35, `printscreen`, `pause`, `menu`, `clear` and the media keys (`mediaplaypause`, `volumeup`, …) are keys like any other; the modifier and lock keys themselves (`shift`, `ctrl`, `alt`, `super`, `capslock`, `numlock`, `scrolllock`) reach only a sink that says `modifierKeys`. |
201
202
  | text | `{ kind: "text", text, pasted?: true, concealed?: true, transient?: true, tag }` | Text an IME committed at the end of a composition — or the clipboard's text, when the app asked for a paste with `requestPaste` / `request_paste` / `kui_request_paste` — on the focused `onKey` sink (or the nearest one above the focused control, or the root sink with nothing focused) — the one committed text the platform never reports as a key press carrying `text`, so a custom editor inserts it as it would a key's `text`. Plain typing does not arrive this way: the `key` event already carries what the press would insert, and a sink hearing both would type every character twice. A focused `<edit>` takes the commit itself and reports `changed`. A paste's answer carries `pasted: true` (backlog DX14) — an answer being the host's paste reply, or any commit while a paste is outstanding — so a sink that asked tells the clipboard's text from an IME's commit without keeping its own flag; an IME commit has no `pasted`. It also says what the pasteboard marked it (backlog F84): `concealed: true` for a secret — a password manager's copy, which a view should not show, log or keep — and `transient: true` for text not to keep in a history, after the nspasteboard.org convention (on Windows the clipboard's exclusion formats); a marker that is not set is absent, never false. The runner reads them on macOS and Windows; on Linux, and from a host that answers with a bare commit, a paste arrives unmarked. |
202
203
  | preedit | `{ kind: "preedit", text, cursor: [start, end] \| null, tag }` | An in-progress IME composition on the focused `onKey` sink: `text` is the uncommitted string to show inline at the caret, `cursor` the byte range inside it the IME's own caret covers (null when it does not say), and an empty `text` means the composition ended without a commit, so what was shown goes away. The OS candidate window is anchored for you: the `line` carrying `caret` says where. A focused `<edit>` draws the composition itself. |
@@ -204,7 +205,7 @@ one field. The payload shapes:
204
205
  | contextmenu | `{ kind: "contextmenu", x, y, tag }` | A secondary-button press on an `onContextMenu` node, on the press rather than the release; `x`/`y` are logical viewport coordinates — where the menu goes. The core opens nothing: the app declares the menu (a `modal` float) and stops declaring it on `dismiss`. |
205
206
  | menu | `{ kind: "menu", role, item }` | A row of the core's own context menu was chosen (`openMenu` / `open_menu` / `kui_open_menu`), on the node the menu was about. `item` is the row's `id`, or its label when it declared none; `role` is the row's standard role or `custom`. Every chosen row posts, the standard ones included: a `cut` or `paste` role is carried out by the core (its clipboard work queued for the host) *and* reported, so an app can hear its editor being cut from and is free to ignore it (`docs/adr/0017-selection-as-a-scope.md`, decision 5). |
206
207
  | forceclick | `{ kind: "forceclick", x, y, tag }` | A press that deepened past the second stage of a Force Touch trackpad, on an `onForceClick` node, at the logical viewport point it happened at. Routed as a secondary press is — no focus moved, no caret placed, no click — but asked of the topmost node only, and the ordinary click the press is still producing arrives afterwards. Text needs none of this: over an `edit` or a `selectable` scope the core selects the word under it and asks the host for its Look Up panel instead. macOS-only in practice. |
207
- | button | `{ kind: "button", phase: "press" \| "move" \| "release", button: "secondary" \| "middle" \| number, x, y, clicks, cell?: { row, col }, line?, byte?, tag }` | A non-primary button on an `onButton` node that claims it (`buttons`), backlog F105: `press` where it went down — with the driver's click count, `clicks`, which the native runner keeps for the primary button alone and so always reports as 1 here — then `move` for every pointer move while it is held and `release` where it came up, both on the same node wherever the pointer went, since the press captured the button. `button` is the button's name, or for one past the middle button its number (`3 + n`, as `kui_input_mouse_button` takes it; Node's `ctx.mouse` takes the three names only); `x`/`y` are logical viewport coordinates. On a `cells` grid each carries `cell: {row, col}`, clamped to the grid, and inside an `onKey` sink that draws `role="line"` rows `line` and `byte` as a drag does. A claimed secondary press is this event instead of `contextmenu`; the press moves no focus, caret, selection or scrollbar. |
208
+ | button | `{ kind: "button", phase: "press" \| "move" \| "release", button: "secondary" \| "middle" \| number, x, y, clicks, cell?: { row, col }, line?, byte?, inside?, tag }` | A non-primary button on an `onButton` node that claims it (`buttons`), backlog F105: `press` where it went down — with the driver's click count, `clicks`, which the native runner keeps for the primary button alone and so always reports as 1 here — then `move` for every pointer move while it is held and `release` where it came up, both on the same node wherever the pointer went, since the press captured the button. `button` is the button's name, or for one past the middle button its number (`3 + n`, as `kui_input_mouse_button` takes it; Node's `ctx.mouse` takes the three names only); `x`/`y` are logical viewport coordinates. On a `cells` grid each carries `cell: {row, col}`, clamped to the grid, and inside an `onKey` sink that draws `role="line"` rows `line`, `byte` and `inside` as a drag does. A claimed secondary press is this event instead of `contextmenu`; the press moves no focus, caret, selection or scrollbar. |
208
209
  | scroll | `{ kind: "scroll", x, y, dx, dy, lines, mods?: { shift, ctrl, alt, super }, tag }` | The wheel over an `onScroll` node, or a drag-select held past a `cells` grid's top or bottom edge: `dx`/`dy` the delta in logical px as the driver reported it (positive `dy` is the wheel rolling up, toward earlier content), `x`/`y` the pointer in logical viewport coordinates, `lines` the whole lines a `cells` grid's `dy` covers — positive is later history, the sign `originLine` grows in, the fraction carried to the next notch — and null on any other node. The core scrolls nothing for it: the app re-declares the grid's `originLine`, or zooms its canvas. From the edge drag it comes once a frame while the pointer is held past the edge, with the lines that frame's step covers, and the selection's absolute lines survive the scroll the app answers with (`docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md`). |
209
210
  | focus | `{ kind: "focus", phase: "in" \| "out", by: "pointer" \| "keyboard" \| "assistive" \| "program", tag }` | Keyboard focus entered or left an `onFocus` node's subtree (backlog DX18). `by` is what moved it — a press, a key, a screen reader's request, or the view and the app — so a pane that follows a click into its sink tells that apart from a move the app made itself. |
210
211
  | hover | `{ kind: "hover", phase: "enter" \| "leave", by: "pointer" \| "content", tag }` | The pointer entered or left an `onHover` node — also when a new frame moved it under a still cursor. `by` says which (backlog DX20): `pointer` when the pointer moved or left the window, `content` when it stayed and what is under it changed — a list scrolled by the wheel or the keys, a row that grew, a float that opened. A picker whose selection follows the pointer ignores `content`, or the rows sliding under a still pointer as the keys scroll the list drag the selection with them. |
@@ -219,6 +220,7 @@ one field. The payload shapes:
219
220
  | modifiers | `{ kind: "modifiers", shift, ctrl, alt, super }` | The physical modifier state changed (delivered to the host on the root). |
220
221
  | changed | `{ kind: "changed" }`, with the editor's key on the event | An editor's text changed. |
221
222
  | submit | `{ kind: "submit" }`, with the editor's key on the event | Enter in a single-line editor. |
223
+ | boundary | `{ kind: "boundary", key: "up" \| "down" \| "left" \| "right" \| "backspace" \| "delete", edge: "start" \| "end", word, doc }`, with the editor's key on the event | An editing key that met the edge of an editor's text and did nothing: ↑ on the first line, ← or Backspace at the start (`edge: "start"`), ↓ on the last line, → or Delete at the end (`"end"`) — from a caret with no selection and without Shift. What a block editor joins blocks and moves between fields by. A field has one line, so its ↑ and ↓ always report. `word` and `doc` are the editing modifiers the key carried (Option or Ctrl, ⌘ or Ctrl — `docs/adr/0002`). |
222
224
  | sound | `{ kind: "sound", phase: "ended" \| "refused", playback, tag }` | A tagged playback (`play(id, { tag })` or `<audio tag>`) finished on its own — never when something stopped it. `phase: "refused"` instead when the device would not take the play at all (its 128 voices are all held, or the sound did not decode): that playback never starts and so never ends, so this is what arrives in place of the `ended` a view would otherwise wait forever for. |
223
225
  | dismiss | `{ kind: "dismiss", reason: "escape" \| "outside", tag }` on a node; `{ kind: "dismiss", reason, name, id }` on the root for a popup window | The user asked for a surface to go away — Escape, or a press that landed outside it. The core closes nothing: the app stops declaring the surface (or asks first). A `modal` node gets one on the node, carrying its tag, and only the modal in effect does; a `kind: "popup"` window gets one on the root, carrying the window's `name` and `id`, reported by the driver because a press outside a window and a key sent to a non-activating one are both facts only the OS has. The two are the same event, so a dropdown that graduates from a modal float to a popup window changes its declaration and not its handler. |
224
226
  | access | `{ kind: "access", action, tag, text?, value?, anchor?: { line, offset }, focus?: { line, offset } }` | Assistive technology — or the keyboard — asked for what only the app can do: `increment` / `decrement` on a `slider` role that declared no `onChange` (a reader's nudge, or the arrow keys on the focused slider), and `setValue` there with the number asked for as `value` (Windows' UI Automation sets a slider rather than nudging it); `setValue` / `replaceSelectedText` (with `text`) / `setTextSelection` (with `anchor` and `focus` as line ordinals and byte offsets) on a custom editor. `tag` is the node's `onClick` payload (or its `onDrag` / `onKey` tag). Every other request resolves in the core and arrives as the events a pointer would have produced. |