@qxuken/kui 0.1.0-alpha.37 → 0.1.0-alpha.38

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,104 @@ 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.38 (2026-10-05)
25
+
26
+ **What breaks.**
27
+
28
+ - C: `KuiSpec` gains `scroll_mods` at its end (64-bit size 704), so
29
+ `KUI_ABI_VERSION` is 25; recompile. A zeroed spec is what it was.
30
+ - Rust: `event::Scroll` gains a `mods` field and `EventSpec` a
31
+ `scroll_mods` one (under Added), so a struct literal of either needs
32
+ them.
33
+
34
+ The Node wire stays v21: `scrollMods` is one more string row.
35
+
36
+ ### Added
37
+
38
+ - **An `onScroll` node can be for a modified wheel** (backlog F122,
39
+ from kawoosh). `scrollMods` — `"ctrl super"`, any of `shift`, `ctrl`,
40
+ `alt`, `super`; Rust `NodeSpec::scroll_mods(KeyMods)`, C
41
+ `KuiSpec.scroll_mods` in `KUI_KMOD_*` bits, Lua `scroll_mods` — and
42
+ the node hears only a scroll gesture that began with one of them
43
+ held, and hears it first: ahead of every scroll container and every
44
+ `onScroll` that names none, wherever under the pointer the gesture
45
+ began, the innermost such node winning. A Ctrl-wheel zoom declared
46
+ on the window's root is heard over a list, and the list does not
47
+ scroll; a canvas inside can name the same key and take it for
48
+ itself. A wheel with none of them held passes the node by — and
49
+ scrolls it, if it is a scroll container too: a list can name a key
50
+ for its own zoom and scroll for every other wheel (backlog RG119,
51
+ from this release's pre-tag pass; as merged, such a list stood still
52
+ for a plain wheel). Its
53
+ `scroll` events carry `mods` (`Scroll::mods`), the modifiers held
54
+ when the gesture began: a gesture stays what it began as to the end
55
+ of its glide, whatever is let go or pressed meanwhile.
56
+
57
+ **What you can delete.** An `onScroll` handler that read the modifiers
58
+ to tell a zoom from a scroll, and the scroll it then had to do itself
59
+ for the plain wheel — and the knowledge that it only ever worked over
60
+ that handler's own node, every scroll container inside it taking the
61
+ same wheel first.
62
+
63
+ ### Fixed
64
+
65
+ - **`scripts/npm-approve.nu` no longer fails a release it has just
66
+ made.** Approving alpha.37 went through, and the script then waited
67
+ a minute for `npm view` to list the version, gave up with "approved,
68
+ but npmjs does not list it yet; run this again", and on the second
69
+ run — no stage left, the cached packument still without the
70
+ version — said nothing was staged and asked whether the release
71
+ workflow had run. `latest` was set by hand. What is live is now read
72
+ off the dist-tags, which a version takes the moment it is approved,
73
+ and `latest` follows the approval at once. A release script, so
74
+ nothing an app sees.
75
+
76
+ **What you can delete.** Nothing.
77
+
78
+ ### Native verification
79
+
80
+ The by-hand round alpha.6 introduced (backlog R4), on 2026-10-05 — the
81
+ two commits after the alpha.37 tag: `scrollMods` (F122) and the
82
+ approval script — with a regression pass over them first: the diff
83
+ read whole, each claim probed with a test before anything changed.
84
+ RG119 came of it and is in this release. Before any of it the last
85
+ `check` on main was read on both hosts, green at `3d4fff6` — the step
86
+ alpha.37's first tag went without. This round ran on the Mac alone;
87
+ Windows and Linux did not run it for this tag.
88
+
89
+ **macOS 27.0.1 on an M3 Pro MacBook Pro, rustc 1.99.0 (the toolchain
90
+ CI runs), Node 26.10.0, nu 0.116.0**, on the release commit's tree,
91
+ the workspace's own artifacts pruned and rebuilt. `cargo fmt --all
92
+ --check` and `cargo clippy --workspace --all-targets -- -D warnings`
93
+ are clean. `scripts/test.nu`, the workspace's tests with the
94
+ conformance feature: **1808 tests over 136 suites, 0 failed** (4
95
+ ignored). The C round, `cbuild --run`, passes its five checks; the
96
+ corpus passes its **57 scenes** in four adapters, `scroll-gestures`
97
+ now holding a Ctrl-wheel over a contained list at its limit; the ABI
98
+ is **25**. Node's `node --test test.mjs` under
99
+ `KUI_CONFORMANCE_REQUIRED=1`: **210 of 210**. `npm run gen` leaves no
100
+ diff, the examples typecheck and their lockfile installs, the headless
101
+ round passes all **35 drives**, the book builds and
102
+ `scripts/book-examples.nu --check`
103
+ passes.
104
+
105
+ **The windowed round**, `smoke -- --node`, twice over: **51 Rust
106
+ examples and the eleven Node examples, each on both bases, 120 frames
107
+ each, every one exiting 0** — 124 windows, eight at a time, in 34 and
108
+ 35 s — and `counter`, `host`, `c_panel` and `lua_panel` by hand under
109
+ `KUI_SMOKE_FRAMES=120`, each exiting 0 with nothing on stderr: **128
110
+ windows over five hosts.** The AX audit: **106/106**, the audited
111
+ window raised to the front by its pid first, and no warning on the
112
+ fixture's stderr. `scrollMods` itself was not driven in a window in
113
+ this round: it was tried in kawoosh's before the merge, and RG119's
114
+ case is pinned in the core's tests alone.
115
+
116
+ **The bench guard** against the alpha.37 tag, on the release commit's
117
+ tree: **green**, none of the 8 guarded rows more than 10% slower —
118
+ every one between −4.1% and +0.6% (the worst guarded run-to-run spread
119
+ 4.3%), and no row of the run more than 5% slower. `README.md`'s table
120
+ is kept as it was.
121
+
24
122
  ## 0.1.0-alpha.37 (2026-10-05)
25
123
 
26
124
  **What breaks.**
@@ -186,3 +186,11 @@ date: 2026-09-28
186
186
  the C door, and by the corpus's `scroll-gestures` scene in four
187
187
  adapters. The scene holds a contained list at its limit and a y-only
188
188
  handler met by a sideways notch.
189
+ - *Amended (F122, RG119):* a handler that names modifiers
190
+ (`scroll_mods`) is asked before this walk, of the modifiers the
191
+ gesture began with, the innermost such handler taking it whatever
192
+ room or `contain` the regions inside it have. To a gesture it does
193
+ not hear it is what it would be with no `on_scroll`: the container
194
+ it may also be, asked like any other, and otherwise passed. The
195
+ latch keeps the modifiers with the targets. Pinned by
196
+ `tests/scroll_mods.rs`.
package/howto.md CHANGED
@@ -775,6 +775,31 @@ rows.
775
775
  [`transition` row](props.md#container-props) ·
776
776
  [alpha.17](CHANGELOG.md#010-alpha17-2026-09-25)
777
777
 
778
+ ### How do I zoom with Ctrl or ⌘ and the wheel?
779
+
780
+ Put an `onScroll` on the node that zooms and say which keys it is for:
781
+ `<box onScroll={{ kind: 'zoom' }} scrollMods="ctrl super">` (Rust
782
+ `.on_scroll(tag).scroll_mods(KeyMods::NONE.with_ctrl().with_super())`,
783
+ C `.scroll_mods = KUI_KMOD_CTRL | KUI_KMOD_SUPER`, Lua `scroll_mods =
784
+ "ctrl super"`). A wheel turned with one of them held is then that
785
+ node's `scroll` event wherever under the pointer it began — over a
786
+ list inside it, and the list does not move — and a plain wheel passes
787
+ the node by, so the lists go on scrolling — the node itself too, if it
788
+ is the list: a scroll container that names a key for its own zoom
789
+ scrolls for every other wheel. On the window's root it is
790
+ the whole window's zoom; a canvas inside can name the same key and take
791
+ the gesture for itself, the innermost winning. Positive `dy` is the
792
+ wheel rolling up, "bigger". A mouse's notch is 40 px and a trackpad's
793
+ swipe comes in small pixel steps, so add `dy` up and step when the sum
794
+ crosses your notch rather than once an event. The event's `mods` are
795
+ the keys held when the gesture began: a swipe begun with the key held
796
+ stays yours to the end of its glide even if the key was let go, and
797
+ one begun without it never becomes yours, so there is nothing to latch
798
+ yourself. Without the row an `onScroll` cannot do this: every scroll
799
+ container inside it takes the wheel first.
800
+
801
+ [`scrollMods` row](props.md#container-props)
802
+
778
803
  ### Why does a swipe down not move the strip sideways?
779
804
 
780
805
  A trackpad swipe keeps to the axis it started on, so nothing is yours
package/index.d.ts CHANGED
@@ -172,6 +172,9 @@ export type ScrollMsg<T = AppMsg> = {
172
172
  dx: number;
173
173
  dy: number;
174
174
  lines: number | null;
175
+ /** On a node that names `scrollMods`: the modifiers held when the
176
+ * gesture began. */
177
+ mods?: { shift: boolean; ctrl: boolean; alt: boolean; super: boolean };
175
178
  tag?: T;
176
179
  };
177
180
 
package/jsx-runtime.d.ts CHANGED
@@ -393,6 +393,8 @@ export interface GeneratedSpecProps {
393
393
  rules?: ColorProp;
394
394
  /** Which axes `onScroll` takes (backlog F107): `both` (the default), `x` or `y`. A scroll gesture on an axis the node does not take passes it by, to the scroller around it, and hears nothing here: a terminal that scrolls its history says `y`, and a sideways swipe that starts over it moves the strip it sits in. (A swipe that started elsewhere is not the node's either way: a gesture keeps the target it started with.) Meaningless without `onScroll`. */
395
395
  scrollAxes?: 'both' | 'x' | 'y';
396
+ /** The modifiers `onScroll` is for (backlog F122): `"shift"`, `"ctrl"`, `"alt"` and `"super"` (⌘, the Windows key), separated by spaces or commas — `"ctrl super"`. With any named, the node hears only a scroll gesture that began with one of them held, and hears it first: ahead of every scroll container and every `onScroll` that names none, wherever under the pointer the gesture began, the innermost such node winning — so a Ctrl-wheel zoom declared on the window's root is heard over a list, and the list does not scroll. A wheel with none of them held passes the node by, as if it had no `onScroll`: a node that scrolls as well (`overflow`) scrolls for it as any container does. Its `scroll` events carry `mods`, the modifiers held when the gesture began; the gesture stays the node's to the end of its glide, whatever is let go meanwhile, and one begun without them never becomes its. A word that is none of the four is skipped. Unset, a handler like any other. Meaningless without `onScroll`. */
397
+ scrollMods?: string;
396
398
  /** When a scrolling node draws its bars: `visible` (the default — the stock overlay thumb, drawn while the content overflows), `hidden` (no thumb, no track to press; the wheel, the keyboard, `reveal` and the caret still scroll it — for a list that draws its own indicator, or a pane whose bar would sit on a border), or `auto` (shown while the scroll state is changing — the offset or the content's extent moved, the pointer is on the track, a thumb is dragged — and for a second after, then faded out over a quarter of one; a node first seen shows it the same second; what an overlay bar does on macOS). `auto` needs the driver's clock and is `visible` without one. The bars are overlays and take no layout space in any mode. From the last change until it has faded — a second and a quarter — an `auto` bar asks for frames the way a transition of that length would (nothing else could wake the core when the hold ends); while the pointer holds it, it asks for none. */
397
399
  scrollbar?: 'visible' | 'hidden' | 'auto';
398
400
  /** The thumb under the pointer or while dragged; the default is the theme's `scrollbar_active` role. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qxuken/kui",
3
- "version": "0.1.0-alpha.37",
3
+ "version": "0.1.0-alpha.38",
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
Binary file
package/props.md CHANGED
@@ -86,6 +86,7 @@ where they make sense); text props apply to `<text>` and `<edit>`.
86
86
  | `ruleWidth` | `rule_width` | `rule_w` | number, or a `"$length"` token | The width of a table's `rules` in logical px; 1 when unset. |
87
87
  | `rules` | `rules` | `rules` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | On a table (`dir="table"`, ADR 0033): grid lines of this colour between its columns and between its rows (backlog DX21) — down the middle of each gap between the columns of its widest row, from the first row's top to the last row's bottom, and across the middle of each gap between rows, the content box wide. Drawn with the table's box, under its cells and on whole pixels, so give the table and its rows a `gap` at least `ruleWidth` for the lines to show between cells; the outer edge is the table's `border`. Ignored on anything but a table. |
88
88
  | `scrollAxes` | `scroll_axes` | `scroll_axes` (`KUI_SCROLL_AXES_*`; zeroed, both) | `both` \\| `x` \\| `y` | Which axes `onScroll` takes (backlog F107): `both` (the default), `x` or `y`. A scroll gesture on an axis the node does not take passes it by, to the scroller around it, and hears nothing here: a terminal that scrolls its history says `y`, and a sideways swipe that starts over it moves the strip it sits in. (A swipe that started elsewhere is not the node's either way: a gesture keeps the target it started with.) Meaningless without `onScroll`. |
89
+ | `scrollMods` | `scroll_mods` | `scroll_mods` (`KUI_KMOD_*` bits; zeroed, none) | string | The modifiers `onScroll` is for (backlog F122): `"shift"`, `"ctrl"`, `"alt"` and `"super"` (⌘, the Windows key), separated by spaces or commas — `"ctrl super"`. With any named, the node hears only a scroll gesture that began with one of them held, and hears it first: ahead of every scroll container and every `onScroll` that names none, wherever under the pointer the gesture began, the innermost such node winning — so a Ctrl-wheel zoom declared on the window's root is heard over a list, and the list does not scroll. A wheel with none of them held passes the node by, as if it had no `onScroll`: a node that scrolls as well (`overflow`) scrolls for it as any container does. Its `scroll` events carry `mods`, the modifiers held when the gesture began; the gesture stays the node's to the end of its glide, whatever is let go meanwhile, and one begun without them never becomes its. A word that is none of the four is skipped. Unset, a handler like any other. Meaningless without `onScroll`. |
89
90
  | `scrollbar` | `scrollbar` | `scrollbar` | `visible` \\| `hidden` \\| `auto` | When a scrolling node draws its bars: `visible` (the default — the stock overlay thumb, drawn while the content overflows), `hidden` (no thumb, no track to press; the wheel, the keyboard, `reveal` and the caret still scroll it — for a list that draws its own indicator, or a pane whose bar would sit on a border), or `auto` (shown while the scroll state is changing — the offset or the content's extent moved, the pointer is on the track, a thumb is dragged — and for a second after, then faded out over a quarter of one; a node first seen shows it the same second; what an overlay bar does on macOS). `auto` needs the driver's clock and is `visible` without one. The bars are overlays and take no layout space in any mode. From the last change until it has faded — a second and a quarter — an `auto` bar asks for frames the way a transition of that length would (nothing else could wake the core when the hold ends); while the pointer holds it, it asks for none. |
90
91
  | `scrollbarActiveColor` | `scrollbar_active_color` | `scrollbar_active_color` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | The thumb under the pointer or while dragged; the default is the theme's `scrollbar_active` role. |
91
92
  | `scrollbarColor` | `scrollbar_color` | `scrollbar_color` | color (`#hex` or `0xRRGGBBAA`), or a `"$color"` token | The thumb at rest; the default is the theme's `scrollbar` role, a translucent wash over whatever it sits on. |
@@ -196,7 +197,7 @@ one field. The payload shapes:
196
197
  | 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). |
197
198
  | 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. |
198
199
  | 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. |
199
- | scroll | `{ kind: "scroll", x, y, dx, dy, lines, 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`). |
200
+ | 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`). |
200
201
  | 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. |
201
202
  | 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. |
202
203
  | drop | `{ kind: "drop", phase: "enter" \| "move" \| "leave" \| "drop", paths: string[], x, y, tag }` | Files dragged in from the OS over an `onDrop` node (`docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md`): `enter` when they come over the zone, `move` while they move over it (never twice for one point), `leave` when they go to another zone, to no zone or out of the window, `drop` when they land — and no `leave` after a `drop`. `paths` are the OS paths as strings; `x`/`y` the pointer in logical viewport coordinates, absent on `leave`. The zone is the topmost one under the pointer by paint order; a node inside it is its, and a node that is no zone is looked past (an overlay shown on `enter` does not end the hover). Nothing is re-resolved when a frame lands: only the driver's next report moves the files, so a zone the view stops declaring hears its `leave` then. |