@cssints/apg-specs 0.0.0-stage → 0.5.2

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.
Files changed (260) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -2
  3. package/dist/apg/accordion.bridge.d.mts +35 -0
  4. package/dist/apg/accordion.bridge.mjs +326 -0
  5. package/dist/apg/accordion.model.json +8397 -0
  6. package/dist/apg/alert.bridge.d.mts +22 -0
  7. package/dist/apg/alert.bridge.mjs +86 -0
  8. package/dist/apg/alert.model.json +871 -0
  9. package/dist/apg/alertdialog.bridge.d.mts +21 -0
  10. package/dist/apg/alertdialog.bridge.mjs +221 -0
  11. package/dist/apg/alertdialog.model.json +5232 -0
  12. package/dist/apg/breadcrumb.bridge.d.mts +26 -0
  13. package/dist/apg/breadcrumb.bridge.mjs +120 -0
  14. package/dist/apg/breadcrumb.model.json +574 -0
  15. package/dist/apg/button.bridge.d.mts +35 -0
  16. package/dist/apg/button.bridge.mjs +208 -0
  17. package/dist/apg/button.model.json +5814 -0
  18. package/dist/apg/carousel.bridge.d.mts +44 -0
  19. package/dist/apg/carousel.bridge.mjs +262 -0
  20. package/dist/apg/carousel.model.json +8963 -0
  21. package/dist/apg/checkbox-group.bridge.d.mts +25 -0
  22. package/dist/apg/checkbox-group.bridge.mjs +276 -0
  23. package/dist/apg/checkbox-group.model.json +3863 -0
  24. package/dist/apg/checkbox.bridge.d.mts +34 -0
  25. package/dist/apg/checkbox.bridge.mjs +253 -0
  26. package/dist/apg/checkbox.model.json +3158 -0
  27. package/dist/apg/combobox-autocomplete.bridge.d.mts +14 -0
  28. package/dist/apg/combobox-autocomplete.bridge.mjs +326 -0
  29. package/dist/apg/combobox-autocomplete.model.json +11586 -0
  30. package/dist/apg/combobox-dialog.bridge.d.mts +17 -0
  31. package/dist/apg/combobox-dialog.bridge.mjs +250 -0
  32. package/dist/apg/combobox-dialog.model.json +8981 -0
  33. package/dist/apg/combobox-grid.bridge.d.mts +17 -0
  34. package/dist/apg/combobox-grid.bridge.mjs +227 -0
  35. package/dist/apg/combobox-grid.model.json +8554 -0
  36. package/dist/apg/combobox-select-only.bridge.d.mts +24 -0
  37. package/dist/apg/combobox-select-only.bridge.mjs +304 -0
  38. package/dist/apg/combobox-select-only.model.json +7846 -0
  39. package/dist/apg/combobox-tree.bridge.d.mts +17 -0
  40. package/dist/apg/combobox-tree.bridge.mjs +211 -0
  41. package/dist/apg/combobox-tree.model.json +9345 -0
  42. package/dist/apg/dialog-modal.bridge.d.mts +35 -0
  43. package/dist/apg/dialog-modal.bridge.mjs +186 -0
  44. package/dist/apg/dialog-modal.model.json +7580 -0
  45. package/dist/apg/disclosure.bridge.d.mts +25 -0
  46. package/dist/apg/disclosure.bridge.mjs +202 -0
  47. package/dist/apg/disclosure.model.json +4703 -0
  48. package/dist/apg/feed.bridge.d.mts +31 -0
  49. package/dist/apg/feed.bridge.mjs +246 -0
  50. package/dist/apg/feed.model.json +4523 -0
  51. package/dist/apg/grid-selection.bridge.d.mts +13 -0
  52. package/dist/apg/grid-selection.bridge.mjs +160 -0
  53. package/dist/apg/grid-selection.model.json +6974 -0
  54. package/dist/apg/grid-sort.bridge.d.mts +12 -0
  55. package/dist/apg/grid-sort.bridge.mjs +138 -0
  56. package/dist/apg/grid-sort.model.json +3799 -0
  57. package/dist/apg/grid.bridge.d.mts +54 -0
  58. package/dist/apg/grid.bridge.mjs +441 -0
  59. package/dist/apg/grid.model.json +3802 -0
  60. package/dist/apg/link.bridge.d.mts +24 -0
  61. package/dist/apg/link.bridge.mjs +126 -0
  62. package/dist/apg/link.model.json +694 -0
  63. package/dist/apg/listbox-multi.bridge.d.mts +10 -0
  64. package/dist/apg/listbox-multi.bridge.mjs +261 -0
  65. package/dist/apg/listbox-multi.model.json +10893 -0
  66. package/dist/apg/listbox-rearrangeable.bridge.d.mts +20 -0
  67. package/dist/apg/listbox-rearrangeable.bridge.mjs +237 -0
  68. package/dist/apg/listbox-rearrangeable.model.json +17592 -0
  69. package/dist/apg/listbox-shared.d.mts +58 -0
  70. package/dist/apg/listbox-shared.mjs +171 -0
  71. package/dist/apg/listbox-single.bridge.d.mts +10 -0
  72. package/dist/apg/listbox-single.bridge.mjs +196 -0
  73. package/dist/apg/listbox-single.model.json +8024 -0
  74. package/dist/apg/listbox-typeahead.bridge.d.mts +10 -0
  75. package/dist/apg/listbox-typeahead.bridge.mjs +153 -0
  76. package/dist/apg/listbox-typeahead.model.json +5615 -0
  77. package/dist/apg/menu-button.bridge.d.mts +29 -0
  78. package/dist/apg/menu-button.bridge.mjs +259 -0
  79. package/dist/apg/menu-button.model.json +5706 -0
  80. package/dist/apg/menu-checkable.bridge.d.mts +25 -0
  81. package/dist/apg/menu-checkable.bridge.mjs +226 -0
  82. package/dist/apg/menu-checkable.model.json +4277 -0
  83. package/dist/apg/menu-menubar.bridge.d.mts +27 -0
  84. package/dist/apg/menu-menubar.bridge.mjs +273 -0
  85. package/dist/apg/menu-menubar.model.json +9540 -0
  86. package/dist/apg/meter.bridge.d.mts +28 -0
  87. package/dist/apg/meter.bridge.mjs +159 -0
  88. package/dist/apg/meter.model.json +1185 -0
  89. package/dist/apg/player.bridge.d.mts +24 -0
  90. package/dist/apg/player.bridge.mjs +342 -0
  91. package/dist/apg/player.model.json +11013 -0
  92. package/dist/apg/radio.bridge.d.mts +35 -0
  93. package/dist/apg/radio.bridge.mjs +371 -0
  94. package/dist/apg/radio.model.json +5011 -0
  95. package/dist/apg/slider-multithumb.bridge.d.mts +34 -0
  96. package/dist/apg/slider-multithumb.bridge.mjs +365 -0
  97. package/dist/apg/slider-multithumb.model.json +5774 -0
  98. package/dist/apg/slider.bridge.d.mts +54 -0
  99. package/dist/apg/slider.bridge.mjs +359 -0
  100. package/dist/apg/slider.model.json +4881 -0
  101. package/dist/apg/spinbutton.bridge.d.mts +41 -0
  102. package/dist/apg/spinbutton.bridge.mjs +327 -0
  103. package/dist/apg/spinbutton.model.json +7571 -0
  104. package/dist/apg/switch.bridge.d.mts +30 -0
  105. package/dist/apg/switch.bridge.mjs +243 -0
  106. package/dist/apg/switch.model.json +3159 -0
  107. package/dist/apg/tabs.bridge.d.mts +41 -0
  108. package/dist/apg/tabs.bridge.mjs +294 -0
  109. package/dist/apg/tabs.model.json +7659 -0
  110. package/dist/apg/timeline.bridge.d.mts +36 -0
  111. package/dist/apg/timeline.bridge.mjs +265 -0
  112. package/dist/apg/timeline.model.json +6189 -0
  113. package/dist/apg/toolbar.bridge.d.mts +25 -0
  114. package/dist/apg/toolbar.bridge.mjs +307 -0
  115. package/dist/apg/toolbar.model.json +6616 -0
  116. package/dist/apg/tooltip.bridge.d.mts +36 -0
  117. package/dist/apg/tooltip.bridge.mjs +208 -0
  118. package/dist/apg/tooltip.model.json +8295 -0
  119. package/dist/apg/treegrid.bridge.d.mts +28 -0
  120. package/dist/apg/treegrid.bridge.mjs +311 -0
  121. package/dist/apg/treegrid.model.json +17358 -0
  122. package/dist/apg/treeview.bridge.d.mts +26 -0
  123. package/dist/apg/treeview.bridge.mjs +396 -0
  124. package/dist/apg/treeview.model.json +13745 -0
  125. package/dist/apg/windowsplitter.bridge.d.mts +36 -0
  126. package/dist/apg/windowsplitter.bridge.mjs +242 -0
  127. package/dist/apg/windowsplitter.model.json +6253 -0
  128. package/dist/canvas/camera.model.json +6568 -0
  129. package/dist/canvas/canvas.bridge.d.mts +190 -0
  130. package/dist/canvas/canvas.bridge.mjs +729 -0
  131. package/dist/canvas/pointer.model.json +17095 -0
  132. package/dist/scenario.d.mts +233 -0
  133. package/dist/scenario.mjs +313 -0
  134. package/package.json +57 -4
  135. package/scenarios/apg/README.md +221 -0
  136. package/scenarios/apg/accordion.base-ui.json +1005 -0
  137. package/scenarios/apg/accordion.react-aria.json +929 -0
  138. package/scenarios/apg/alert.base-ui.json +81 -0
  139. package/scenarios/apg/alert.react-aria.json +98 -0
  140. package/scenarios/apg/alertdialog.base-ui.json +331 -0
  141. package/scenarios/apg/alertdialog.react-aria.json +225 -0
  142. package/scenarios/apg/breadcrumb.react-aria.json +312 -0
  143. package/scenarios/apg/button.ariakit.json +136 -0
  144. package/scenarios/apg/button.base-ui.json +747 -0
  145. package/scenarios/apg/button.react-aria.json +443 -0
  146. package/scenarios/apg/carousel.apg.json +909 -0
  147. package/scenarios/apg/checkbox.ariakit.json +299 -0
  148. package/scenarios/apg/checkbox.base-ui.json +1126 -0
  149. package/scenarios/apg/checkbox.react-aria.json +518 -0
  150. package/scenarios/apg/combobox.ariakit.json +945 -0
  151. package/scenarios/apg/combobox.base-ui.json +2444 -0
  152. package/scenarios/apg/combobox.react-aria.json +845 -0
  153. package/scenarios/apg/dialog-modal.ariakit.json +1102 -0
  154. package/scenarios/apg/dialog-modal.base-ui.json +775 -0
  155. package/scenarios/apg/dialog-modal.react-aria.json +638 -0
  156. package/scenarios/apg/disclosure.apg.json +349 -0
  157. package/scenarios/apg/disclosure.ariakit.json +163 -0
  158. package/scenarios/apg/disclosure.base-ui.json +451 -0
  159. package/scenarios/apg/disclosure.react-aria.json +643 -0
  160. package/scenarios/apg/feed.apg.json +442 -0
  161. package/scenarios/apg/grid.ariakit.json +197 -0
  162. package/scenarios/apg/grid.base-ui.json +720 -0
  163. package/scenarios/apg/grid.react-aria.json +969 -0
  164. package/scenarios/apg/landmarks.ariakit.json +93 -0
  165. package/scenarios/apg/landmarks.react-aria.json +330 -0
  166. package/scenarios/apg/link.ariakit.json +83 -0
  167. package/scenarios/apg/link.base-ui.json +88 -0
  168. package/scenarios/apg/link.react-aria.json +186 -0
  169. package/scenarios/apg/listbox.ariakit.json +78 -0
  170. package/scenarios/apg/listbox.base-ui.json +163 -0
  171. package/scenarios/apg/listbox.react-aria.json +1118 -0
  172. package/scenarios/apg/menu-button.ariakit.json +924 -0
  173. package/scenarios/apg/menu-button.base-ui.json +1155 -0
  174. package/scenarios/apg/menu-button.react-aria.json +1080 -0
  175. package/scenarios/apg/menubar.ariakit.json +758 -0
  176. package/scenarios/apg/menubar.base-ui.json +673 -0
  177. package/scenarios/apg/meter.base-ui.json +243 -0
  178. package/scenarios/apg/meter.react-aria.json +201 -0
  179. package/scenarios/apg/radio.ariakit.json +459 -0
  180. package/scenarios/apg/radio.base-ui.json +581 -0
  181. package/scenarios/apg/radio.react-aria.json +576 -0
  182. package/scenarios/apg/slider-multithumb.base-ui.json +316 -0
  183. package/scenarios/apg/slider-multithumb.react-aria.json +363 -0
  184. package/scenarios/apg/slider.base-ui.json +544 -0
  185. package/scenarios/apg/slider.react-aria.json +456 -0
  186. package/scenarios/apg/spinbutton.base-ui.json +1688 -0
  187. package/scenarios/apg/spinbutton.react-aria.json +426 -0
  188. package/scenarios/apg/switch.base-ui.json +456 -0
  189. package/scenarios/apg/switch.react-aria.json +303 -0
  190. package/scenarios/apg/table.ariakit.json +65 -0
  191. package/scenarios/apg/table.react-aria.json +221 -0
  192. package/scenarios/apg/tabs.ariakit.json +329 -0
  193. package/scenarios/apg/tabs.base-ui.json +875 -0
  194. package/scenarios/apg/tabs.react-aria.json +644 -0
  195. package/scenarios/apg/toolbar.ariakit.json +595 -0
  196. package/scenarios/apg/toolbar.base-ui.json +1189 -0
  197. package/scenarios/apg/toolbar.react-aria.json +383 -0
  198. package/scenarios/apg/tooltip.ariakit.json +441 -0
  199. package/scenarios/apg/tooltip.base-ui.json +411 -0
  200. package/scenarios/apg/tooltip.react-aria.json +454 -0
  201. package/scenarios/apg/treegrid.apg.json +2558 -0
  202. package/scenarios/apg/treegrid.react-aria.json +5094 -0
  203. package/scenarios/apg/treeview.apg.json +928 -0
  204. package/scenarios/apg/windowsplitter.apg.json +557 -0
  205. package/scenarios/canvas/README.md +69 -0
  206. package/scenarios/canvas/camera.contract.json +237 -0
  207. package/scenarios/canvas/camera.excalidraw.json +57 -0
  208. package/scenarios/canvas/marquee.excalidraw.json +122 -0
  209. package/scenarios/canvas/pan.excalidraw.json +46 -0
  210. package/scenarios/canvas/pointer.contract.json +528 -0
  211. package/scenarios/parts.json +291 -0
  212. package/shared/aria.qnt +186 -0
  213. package/shared/details-group.qnt +50 -0
  214. package/shared/focusgroup.qnt +152 -0
  215. package/shared/invokers.qnt +81 -0
  216. package/shared/popover.qnt +71 -0
  217. package/specs/apg/STATIC.md +103 -0
  218. package/specs/apg/accordion.qnt +578 -0
  219. package/specs/apg/alert.qnt +131 -0
  220. package/specs/apg/alertdialog.qnt +455 -0
  221. package/specs/apg/breadcrumb.qnt +88 -0
  222. package/specs/apg/button.qnt +445 -0
  223. package/specs/apg/carousel.qnt +732 -0
  224. package/specs/apg/checkbox-group.qnt +388 -0
  225. package/specs/apg/checkbox.qnt +394 -0
  226. package/specs/apg/combobox-autocomplete.qnt +764 -0
  227. package/specs/apg/combobox-dialog.qnt +627 -0
  228. package/specs/apg/combobox-grid.qnt +632 -0
  229. package/specs/apg/combobox-select-only.qnt +626 -0
  230. package/specs/apg/combobox-tree.qnt +633 -0
  231. package/specs/apg/dialog-modal.qnt +499 -0
  232. package/specs/apg/disclosure.qnt +309 -0
  233. package/specs/apg/feed.qnt +485 -0
  234. package/specs/apg/grid-selection.qnt +512 -0
  235. package/specs/apg/grid-sort.qnt +330 -0
  236. package/specs/apg/grid.qnt +360 -0
  237. package/specs/apg/link.qnt +119 -0
  238. package/specs/apg/listbox-multi.qnt +799 -0
  239. package/specs/apg/listbox-rearrangeable.qnt +801 -0
  240. package/specs/apg/listbox-single.qnt +511 -0
  241. package/specs/apg/listbox-typeahead.qnt +396 -0
  242. package/specs/apg/menu-button.qnt +363 -0
  243. package/specs/apg/menu-checkable.qnt +304 -0
  244. package/specs/apg/menu-menubar.qnt +531 -0
  245. package/specs/apg/meter.qnt +129 -0
  246. package/specs/apg/player.qnt +1081 -0
  247. package/specs/apg/radio.qnt +394 -0
  248. package/specs/apg/slider-multithumb.qnt +441 -0
  249. package/specs/apg/slider.qnt +396 -0
  250. package/specs/apg/spinbutton.qnt +494 -0
  251. package/specs/apg/switch.qnt +389 -0
  252. package/specs/apg/tabs.qnt +412 -0
  253. package/specs/apg/timeline.qnt +612 -0
  254. package/specs/apg/toolbar.qnt +364 -0
  255. package/specs/apg/tooltip.qnt +553 -0
  256. package/specs/apg/treegrid.qnt +983 -0
  257. package/specs/apg/treeview.qnt +1004 -0
  258. package/specs/apg/windowsplitter.qnt +487 -0
  259. package/specs/canvas/camera.qnt +407 -0
  260. package/specs/canvas/pointer.qnt +841 -0
@@ -0,0 +1,841 @@
1
+ // Infinite canvas: the pointer / interaction machine over a fixed node set.
2
+ // Implements docs/research/canvas-prior-art.md (Decision 2, items 4-7). Bean e9jm.
3
+ //
4
+ // STATES: idle | pointing (down, below the drag threshold) | panning | marquee | dragging.
5
+ // idle -> pointing | panning; pointing -> marquee | dragging | idle; panning | marquee |
6
+ // dragging -> idle. `prev` records the previous mode so OnlyLegalTransitions can check the edge.
7
+ //
8
+ // WORLD / SCREEN SPLIT (the contract). Pointer positions and the drag threshold are SCREEN
9
+ // quantities; the grab point, node geometry, marquee rectangle and snap candidates are WORLD
10
+ // quantities. The camera is the camera.qnt convention (world-offset, z = zoom * 4096, t in
11
+ // 1/256 world units, round-half-up division); node coordinates are whole world units, pointer-
12
+ // derived world points are in 1/256 units ("fp"). The ~10 camera defs below are COPIES of
13
+ // camera.qnt's (a spec file cannot import a machine, and shared/ is closed to this ticket):
14
+ // test/canvas/pointer.test.ts asserts they are structurally identical.
15
+ //
16
+ // WHAT IS ENCODED
17
+ // - ONE CAPTURE OWNER: `owner` is the pointer id holding the capture (0 iff idle). While a
18
+ // gesture runs a pointerdown from the other pointer is ignored, as are its moves and ups.
19
+ // - DRAG THRESHOLD in screen px (DRAG_PX = 4, squared distance > 16 leaves `pointing`). The move
20
+ // pool contains positions at 5, 16 (boundary: still pointing) and 25 squared px from the down
21
+ // position, at every zoom.
22
+ // - A DRAG = POINTERDOWN SNAPSHOT + WORLD DELTA: committed node positions do not change during a
23
+ // drag; the live delta (ddx, ddy) is f(world pointer, world grab point) and is recomputed on
24
+ // every move AND on every camera change (zoom_to), never accumulated. The grabbed point stays
25
+ // under the pointer within half a unit (DragPreservesGrabOffset), plus the snap distance when
26
+ // snapped. pointercancel aborts to the snapshot (nothing was committed).
27
+ // - SNAPPING, host policy reduced to a grid (candidates every GRID = 50 units on the dragged node's
28
+ // origin): SNAP_PX = 8 SCREEN px, converted by the zoom (a world distance d snaps iff d * z <=
29
+ // 8 * ZONE). Guides are an OUTPUT cleared on every non-drag state.
30
+ // - MARQUEE, mode a parameter (contain default, as Excalidraw; tldraw touch / xyflow full differ),
31
+ // selection live while the rectangle changes, shift = additive over the pointerdown snapshot.
32
+ // contain is checked to be a subset of overlap (ContainSubsetOfOverlap).
33
+ // - CULLING as a derived predicate: a node is culled iff outside the viewport AND not exempt;
34
+ // selected, cursor (focus candidate) and editing nodes are exempt. Geometry, not the DOM,
35
+ // decides hits and marquee, so a culled node can still be selected.
36
+ // - TOPMOST HIT WINS: higher node index = higher z-order.
37
+ // - HOST-CONTROLLED POSITIONS (host_move): a host (auto-layout, a controlled scene) sets one node's
38
+ // committed position in any mode, clamped into the world bound (NodesInWorldBound). A running
39
+ // drag or marquee is re-solved against the new geometry, like zoom_to for the camera. The one
40
+ // thing it can break is "the target was the topmost hit at pointerdown", so `hostMoved`
41
+ // (cleared when the gesture ends) stands TargetIsTopmostHit down for that gesture. Node SIZES
42
+ // (constants here) and the Z-ORDER (index = z) are host-controlled in the primitive too, but not
43
+ // modelled: tested directly in packages/headless/test/canvas.controlled.browser.test.tsx.
44
+ //
45
+ // ACCESSIBILITY CONTRACT (no focus model is chosen here; h9vk decides). The cursor, the selection
46
+ // and the node geometry live in WORLD space with no DOM reference. `annCount` / `annSel` are
47
+ // OUTPUTS: annCount increases (and annSel carries the new selection) whenever a gesture ends with a
48
+ // changed selection; never mid-gesture. Real DOM focus and virtual focus + live region can both
49
+ // consume them; `cursor` is the node a focus model would move to.
50
+ //
51
+ // INVARIANTS ACHIEVED (the ticket's list): selection is a subset of the nodes (SelectionSubsetOfNodes,
52
+ // IdsInRange); exactly one capture owner (OneCaptureOwner); a gesture is reachable only from idle
53
+ // (OnlyLegalTransitions, via `prev`); drag preserves the grab offset (DragPreservesGrabOffset,
54
+ // DragIsSnapshotPlusDelta); contain selects a subset of overlap (ContainSubsetOfOverlap) and a marquee
55
+ // selects exactly its mode's hits (MarqueeSelectionExact). Also IdleIsClean, GestureTargetConsistent,
56
+ // PointingWithinThreshold, TargetIsTopmostHit, SnappedPositionOnGrid, NoMissedSnap,
57
+ // GuidesOnlyWhileSnapping, PanKeepsGrabUnderPointer, CameraInBound, NodesInWorldBound,
58
+ // MarqueeAdditiveKeepsBase, ExemptNodesNeverCulled, CulledOnlyWhenOffscreen,
59
+ // EditingIsTheSingleSelection, AnnouncementsTrackSelection. The two culling invariants restate the
60
+ // `culled` def and cannot fail on their own; the test mutates the def to show they catch a forgetful
61
+ // implementation. Not achieved: "a node stays within bounds while resizing" (no resize here).
62
+ //
63
+ // NOT MODELLED: host-set sizes and z-order (see HOST-CONTROLLED POSITIONS), resize / rotate
64
+ // handles, edge auto-pan, pinch (two-pointer) zoom, locked / hidden nodes, groups and frames,
65
+ // hollow-shape hit rules, undo, keyboard, wheel normalisation. A touch
66
+ // (coarse) threshold is host policy. The grid is this model's instance of the host's snap
67
+ // candidates: the primitive takes them as an injected per-axis function (whole world units) and
68
+ // defaults to this grid.
69
+ //
70
+ // GENERATOR GAPS HIT: no const / collections (3 nodes = 6 position vars + pure defs; selection =
71
+ // bitmask with arithmetic bit tests, no bitwise ops); one nondet per action (touch = pointer * 12 +
72
+ // palette); no let in defs; every action assigns every var (frame lines emitted by a throwaway
73
+ // script); `size`-like builtin names are rejected (QNT101); a spec cannot import another machine
74
+ // (camera math is duplicated, see above).
75
+ module canvas_pointer {
76
+ var mode: str // "idle" | "pointing" | "panning" | "marquee" | "dragging"
77
+ var owner: int // capture owner: pointer id (1 or 2), 0 = nobody (iff idle)
78
+ var prev: str // the mode before the last event (checks the legal-edge graph)
79
+ var tgt: int // node under the pointer at pointerdown (-1 = empty canvas / pan)
80
+ var shift: bool // additive-select modifier, sampled at pointerdown (frozen while a gesture runs)
81
+ var space: bool // pan modifier (hand tool), sampled at pointerdown
82
+ var mqmode: str // marquee mode parameter: "contain" | "overlap"
83
+ var snapOn: bool // snapping enabled (sampled at pointerdown)
84
+ var tx: int // camera x, world units * TONE (same convention as camera.qnt)
85
+ var ty: int // camera y
86
+ var z: int // camera zoom * ZONE
87
+ var px: int // pointer, screen px
88
+ var py: int
89
+ var downx: int // pointerdown position, screen px (drag threshold origin)
90
+ var downy: int
91
+ var gx: int // WORLD point under the pointer at pointerdown (fp); the grab point of drag / pan / marquee
92
+ var gy: int
93
+ var sel: int // selection: bit i = node i is selected (world-space ids, no DOM)
94
+ var selSnap: int // selection at pointerdown (abort-to-snapshot, additive base); == sel when idle
95
+ var cursor: int // last interacted node, -1 = none (consumed by whichever focus model h9vk picks)
96
+ var editing: int // node being edited, -1 = none
97
+ var n0x: int // COMMITTED node positions, whole world units (sizes are constants)
98
+ var n0y: int
99
+ var n1x: int
100
+ var n1y: int
101
+ var n2x: int
102
+ var n2y: int
103
+ var ddx: int // live drag delta, whole units: f(snapshot, pointer) - recomputed, never accumulated
104
+ var ddy: int
105
+ var guide: int // snap guides, OUTPUT: bit 0 = x snapped, bit 1 = y snapped; 0 unless dragging
106
+ var annCount: int // announcements emitted (OUTPUT: a live region / focus model reads this)
107
+ var annSel: int // selection carried by the last announcement
108
+ var hostMoved: bool // a host_move landed during the running gesture (history: relaxes TargetIsTopmostHit); false when idle
109
+
110
+ // ---- constants (camera constants are copies of camera.qnt) --------------
111
+ def ZONE: int = 4096
112
+ def TONE: int = 256
113
+ def TZ: int = ZONE * TONE
114
+ def ZMIN: int = 256
115
+ def ZMAX: int = 32768
116
+ def WB: int = 1000000
117
+ def WT: int = WB * TONE
118
+ def DRAG_PX: int = 4
119
+ def SNAP_PX: int = 8
120
+ def GRID: int = 50
121
+ def VW: int = 1024
122
+ def VH: int = 768
123
+
124
+ // ---- integer helpers (absI, clampI, fdiv, rdiv are copies of camera.qnt) -
125
+ def absI(a: int): int = if (a < 0) -a else a
126
+ def minI(a: int, b: int): int = if (a < b) a else b
127
+ def maxI(a: int, b: int): int = if (a > b) a else b
128
+ def clampI(v: int, lo: int, hi: int): int = if (v < lo) lo else if (v > hi) hi else v
129
+ def fdiv(a: int, b: int): int = if (a >= 0) a / b else -((-a + b - 1) / b)
130
+ def rdiv(a: int, b: int): int = fdiv(2 * a + b, 2 * b)
131
+ def one(b: bool): int = if (b) 1 else 0
132
+
133
+ // ---- camera (copies of camera.qnt) ---------------------------------------
134
+ def screenToWorld(s: int, t: int, zf: int): int = rdiv(s * TZ, zf) - t
135
+ def clampZ(zf: int): int = clampI(zf, ZMIN, ZMAX)
136
+ def clampT(t: int): int = clampI(t, -WT, WT)
137
+ def anchoredT(t: int, zf: int, zf2: int, s: int): int =
138
+ clampT(t + rdiv(s * TZ, zf2) - rdiv(s * TZ, zf))
139
+ // panning: keep the grab world point g under screen point s
140
+ def panGrab(s: int, zf: int, g: int): int = clampT(rdiv(s * TZ, zf) - g)
141
+ def zoomPreset(k: int): int =
142
+ if (k == 0) ZMIN else if (k == 1) 2048 else if (k == 2) 4096 else if (k == 3) 8192 else ZMAX
143
+
144
+ // ---- the fixed node set: 0 (bottom) .. 2 (top); sizes are constants, positions are vars
145
+ def NW(i: int): int = if (i == 0) 200 else if (i == 1) 200 else 100
146
+ def NH(i: int): int = if (i == 0) 100 else if (i == 1) 150 else 100
147
+ def pick(i: int, a: int, b: int, c: int): int = if (i == 0) a else if (i == 1) b else c
148
+ // node ids as bitmasks (3 bits)
149
+ def pow2(k: int): int = if (k == 0) 1 else if (k == 1) 2 else 4
150
+ def bit(m: int, k: int): bool = (m / pow2(k)) % 2 == 1
151
+ def maskOr(a: int, b: int): int =
152
+ one(or(bit(a, 0), bit(b, 0))) + 2 * one(or(bit(a, 1), bit(b, 1))) + 4 * one(or(bit(a, 2), bit(b, 2)))
153
+ def toggleBit(m: int, k: int): int = if (bit(m, k)) m - pow2(k) else m + pow2(k)
154
+ def single(m: int): int = if (m == 1) 0 else if (m == 2) 1 else if (m == 4) 2 else -1
155
+
156
+ // ---- screen palette (px): 0-3 on node 0, at 0 / 5 / 16 / 25 squared px from position 0;
157
+ // 4 in nodes 0 and 1 (node 1 is on top); 5 node 1; 6 node 2; 7 empty; 8 node 1; 9-11 empty
158
+ def posX(k: int): int =
159
+ if (k == 0) 150 else if (k == 1) 152 else if (k == 2) 154 else if (k == 3) 155
160
+ else if (k == 4) 280 else if (k == 5) 400 else if (k == 6) 650 else if (k == 7) 60
161
+ else if (k == 8) 320 else if (k == 9) 500 else if (k == 10) 800 else 600
162
+ def posY(k: int): int =
163
+ if (k == 0) 130 else if (k == 1) 131 else if (k == 2) 130 else if (k == 3) 130
164
+ else if (k == 4) 170 else if (k == 5) 250 else if (k == 6) 450 else if (k == 7) 60
165
+ else if (k == 8) 220 else if (k == 9) 350 else if (k == 10) 600 else 60
166
+ def ptrOf(c: int): int = c / 12 + 1
167
+ def posOf(c: int): int = c % 12
168
+
169
+ // ---- hit testing, topmost first (half-open boxes, fp) --------------------
170
+ def inBox(wx: int, wy: int, bx: int, by: int, w: int, h: int): bool =
171
+ and(wx >= bx * TONE, wx < (bx + w) * TONE, wy >= by * TONE, wy < (by + h) * TONE)
172
+ def hitAt(wx: int, wy: int, x0: int, y0: int, x1: int, y1: int, x2: int, y2: int): int =
173
+ if (inBox(wx, wy, x2, y2, NW(2), NH(2))) 2
174
+ else if (inBox(wx, wy, x1, y1, NW(1), NH(1))) 1
175
+ else if (inBox(wx, wy, x0, y0, NW(0), NH(0))) 0
176
+ else -1
177
+
178
+ // ---- marquee: contain (rect encloses the box) vs overlap (positive-area intersection)
179
+ def contained(rx0: int, ry0: int, rx1: int, ry1: int, bx: int, by: int, w: int, h: int): bool =
180
+ and(rx0 <= bx * TONE, (bx + w) * TONE <= rx1, ry0 <= by * TONE, (by + h) * TONE <= ry1)
181
+ def overlapped(rx0: int, ry0: int, rx1: int, ry1: int, bx: int, by: int, w: int, h: int): bool =
182
+ and(rx0 < (bx + w) * TONE, bx * TONE < rx1, ry0 < (by + h) * TONE, by * TONE < ry1)
183
+ def hitBy(m: str, rx0: int, ry0: int, rx1: int, ry1: int, bx: int, by: int, w: int, h: int): bool =
184
+ if (m == "contain") contained(rx0, ry0, rx1, ry1, bx, by, w, h)
185
+ else overlapped(rx0, ry0, rx1, ry1, bx, by, w, h)
186
+ def rectMask(m: str, rx0: int, ry0: int, rx1: int, ry1: int, x0: int, y0: int, x1: int, y1: int, x2: int, y2: int): int =
187
+ one(hitBy(m, rx0, ry0, rx1, ry1, x0, y0, NW(0), NH(0)))
188
+ + 2 * one(hitBy(m, rx0, ry0, rx1, ry1, x1, y1, NW(1), NH(1)))
189
+ + 4 * one(hitBy(m, rx0, ry0, rx1, ry1, x2, y2, NW(2), NH(2)))
190
+ // marquee between the world grab point (gxM, gyM) and the world pointer (wx, wy)
191
+ def maskBy(m: str, gxM: int, gyM: int, wx: int, wy: int, x0: int, y0: int, x1: int, y1: int, x2: int, y2: int): int =
192
+ rectMask(m, minI(gxM, wx), minI(gyM, wy), maxI(gxM, wx), maxI(gyM, wy), x0, y0, x1, y1, x2, y2)
193
+ // live selection while marquee-ing: (shift ? snapshot : nothing) + the hits
194
+ def liveSel(m: str, shiftB: bool, snapM: int, gxM: int, gyM: int, wx: int, wy: int, x0: int, y0: int, x1: int, y1: int, x2: int, y2: int): int =
195
+ maskOr(if (shiftB) snapM else 0, maskBy(m, gxM, gyM, wx, wy, x0, y0, x1, y1, x2, y2))
196
+
197
+ // ---- selection at pointerdown / pointerup --------------------------------
198
+ def selOnDown(hit: int, spaceB: bool, shiftB: bool, selM: int): int =
199
+ if (spaceB) selM
200
+ else if (hit >= 0) (if (bit(selM, hit)) selM else if (shiftB) maskOr(selM, pow2(hit)) else pow2(hit))
201
+ else (if (shiftB) selM else 0)
202
+ def selAfterUp(m: str, tg: int, shiftB: bool, selM: int, snapM: int): int =
203
+ if (m == "pointing")
204
+ (if (tg >= 0) (if (shiftB) toggleBit(snapM, tg) else pow2(tg)) else (if (shiftB) snapM else 0))
205
+ else selM
206
+
207
+ // ---- drag, snapping (threshold in SCREEN px, candidates and results in WORLD units)
208
+ def moved(xM: int, yM: int, dxM: int, dyM: int): bool =
209
+ (xM - dxM) * (xM - dxM) + (yM - dyM) * (yM - dyM) > DRAG_PX * DRAG_PX
210
+ def modeAfter(m: str, tg: int, mv: bool): str =
211
+ if (and(m == "pointing", mv)) (if (tg >= 0) "dragging" else "marquee") else m
212
+ def rawD(w: int, g: int): int = rdiv(w - g, TONE)
213
+ def snapNearest(v: int): int = rdiv(v, GRID) * GRID
214
+ def snapHit(v: int, on: bool, zf: int): bool =
215
+ and(on, absI(v - snapNearest(v)) * zf <= SNAP_PX * ZONE)
216
+ def snapNudge(v: int, on: bool, zf: int): int = if (snapHit(v, on, zf)) snapNearest(v) - v else 0
217
+ // base = the dragged node's committed position on this axis
218
+ def liveDD(w: int, g: int, base: int, on: bool, zf: int): int =
219
+ rawD(w, g) + snapNudge(base + rawD(w, g), on, zf)
220
+ def liveGuide(wx: int, gxM: int, bx: int, wy: int, gyM: int, by: int, on: bool, zf: int): int =
221
+ one(snapHit(bx + rawD(wx, gxM), on, zf)) + 2 * one(snapHit(by + rawD(wy, gyM), on, zf))
222
+ def clampPos(v: int, extent: int): int = clampI(v, -WB, WB - extent)
223
+ def committed(i: int, v: int, d: int, selM: int, m: str, extent: int): int =
224
+ if (and(m == "dragging", bit(selM, i))) clampPos(v + d, extent) else v
225
+
226
+ // ---- the legal mode graph --------------------------------------------------
227
+ def legalEdge(a: str, b: str): bool =
228
+ or(
229
+ a == b,
230
+ and(a == "idle", or(b == "pointing", b == "panning")),
231
+ and(a == "pointing", or(b == "marquee", b == "dragging", b == "idle")),
232
+ and(or(a == "panning", a == "marquee", a == "dragging"), b == "idle")
233
+ )
234
+
235
+ // ---- derived views of the state (used by the invariants) -------------------
236
+ def curWx: int = screenToWorld(px, tx, z)
237
+ def curWy: int = screenToWorld(py, ty, z)
238
+ def primX: int = pick(tgt, n0x, n1x, n2x)
239
+ def primY: int = pick(tgt, n0y, n1y, n2y)
240
+ // live (on screen) node origin: a dragged node is its committed position + the live delta
241
+ def liveX(i: int): int = pick(i, n0x, n1x, n2x) + (if (and(mode == "dragging", bit(sel, i))) ddx else 0)
242
+ def liveY(i: int): int = pick(i, n0y, n1y, n2y) + (if (and(mode == "dragging", bit(sel, i))) ddy else 0)
243
+ def inView(i: int): bool =
244
+ and(
245
+ liveX(i) * TONE < screenToWorld(VW, tx, z), (liveX(i) + NW(i)) * TONE > screenToWorld(0, tx, z),
246
+ liveY(i) * TONE < screenToWorld(VH, ty, z), (liveY(i) + NH(i)) * TONE > screenToWorld(0, ty, z)
247
+ )
248
+ def exempt(i: int): bool = or(bit(sel, i), cursor == i, editing == i)
249
+ def culled(i: int): bool = and(not(inView(i)), not(exempt(i)))
250
+
251
+ // A press whose TOPMOST hit is the node being edited is NOT a gesture: it must leave the machine
252
+ // idle and keep `editing`, so a mouse press inside a text editor selects text instead of starting
253
+ // a drag. `pointerdown` in the primitive mirrors this by returning early. A press anywhere else
254
+ // still ends editing (the `editing' = -1` case below).
255
+ def inEditor(t: int): bool =
256
+ and(editing >= 0, hitAt(screenToWorld(posX(posOf(t)), tx, z), screenToWorld(posY(posOf(t)), ty, z), n0x, n0y, n1x, n1y, n2x, n2y) == editing)
257
+ def downGesture(t: int): bool = and(mode == "idle", not(inEditor(t)))
258
+ def nodeOk(x: int, y: int, i: int): bool =
259
+ and(x >= -WB, x + NW(i) <= WB, y >= -WB, y + NH(i) <= WB)
260
+ // deviation of the grabbed point from the pointer on one axis, in fp, against its allowance
261
+ def grabDevOk(w: int, g: int, d: int, snapped: bool, zf: int): bool =
262
+ if (snapped) 2 * absI(w - g - d * TONE) * zf <= TONE * zf + 2 * SNAP_PX * ZONE * TONE
263
+ else 2 * absI(w - g - d * TONE) <= TONE
264
+ def axisGrab(s: int, g: int, t: int, zf: int): bool =
265
+ or(absI(t) == WT, absI(screenToWorld(s, t, zf) - g) < TONE)
266
+
267
+ // host_move pool: place = 3 * k + node. Node `place % 3` takes position k = place / 3: the origin, a spot
268
+ // that covers palette 5, off screen (negative), and one far past the world bound (clamped).
269
+ def extX(k: int): int = if (k == 0) 0 else if (k == 1) 320 else if (k == 2) -300 else 5000000
270
+ def extY(k: int): int = if (k == 0) 0 else if (k == 1) 200 else if (k == 2) -300 else -5000000
271
+ def hostX(place: int, i: int, v: int): int = if (place % 3 == i) clampPos(extX(place / 3), NW(i)) else v
272
+ def hostY(place: int, i: int, v: int): int = if (place % 3 == i) clampPos(extY(place / 3), NH(i)) else v
273
+
274
+ action init = all {
275
+ mode' = "idle",
276
+ owner' = 0,
277
+ prev' = "idle",
278
+ tgt' = -1,
279
+ shift' = false,
280
+ space' = false,
281
+ mqmode' = "contain",
282
+ snapOn' = false,
283
+ tx' = 0,
284
+ ty' = 0,
285
+ z' = 4096,
286
+ px' = 0,
287
+ py' = 0,
288
+ downx' = 0,
289
+ downy' = 0,
290
+ gx' = 0,
291
+ gy' = 0,
292
+ sel' = 0,
293
+ selSnap' = 0,
294
+ cursor' = -1,
295
+ editing' = -1,
296
+ n0x' = 100,
297
+ n0y' = 100,
298
+ n1x' = 250,
299
+ n1y' = 150,
300
+ n2x' = 600,
301
+ n2y' = 400,
302
+ ddx' = 0,
303
+ ddy' = 0,
304
+ guide' = 0,
305
+ hostMoved' = false,
306
+ annCount' = 0,
307
+ annSel' = 0
308
+ }
309
+
310
+ // pointerdown: touch = (pointer id - 1) * 12 + palette position. Ignored unless idle, so a
311
+ // second pointer can never start a gesture while one holds the capture. The hit is the TOPMOST
312
+ // node under the pointer in WORLD space; the grab point g is stored in world space.
313
+ action down = {
314
+ nondet touch = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23).oneOf()
315
+ all {
316
+ mode' = if (downGesture(touch)) (if (space) "panning" else "pointing") else mode,
317
+ owner' = if (downGesture(touch)) ptrOf(touch) else owner,
318
+ prev' = mode,
319
+ tgt' = if (and(downGesture(touch), not(space))) hitAt(screenToWorld(posX(posOf(touch)), tx, z), screenToWorld(posY(posOf(touch)), ty, z), n0x, n0y, n1x, n1y, n2x, n2y) else tgt,
320
+ shift' = shift,
321
+ space' = space,
322
+ mqmode' = mqmode,
323
+ snapOn' = snapOn,
324
+ tx' = tx,
325
+ ty' = ty,
326
+ z' = z,
327
+ px' = if (downGesture(touch)) posX(posOf(touch)) else px,
328
+ py' = if (downGesture(touch)) posY(posOf(touch)) else py,
329
+ downx' = if (downGesture(touch)) posX(posOf(touch)) else downx,
330
+ downy' = if (downGesture(touch)) posY(posOf(touch)) else downy,
331
+ gx' = if (downGesture(touch)) screenToWorld(posX(posOf(touch)), tx, z) else gx,
332
+ gy' = if (downGesture(touch)) screenToWorld(posY(posOf(touch)), ty, z) else gy,
333
+ sel' = if (downGesture(touch)) selOnDown(hitAt(screenToWorld(posX(posOf(touch)), tx, z), screenToWorld(posY(posOf(touch)), ty, z), n0x, n0y, n1x, n1y, n2x, n2y), space, shift, sel) else sel,
334
+ selSnap' = if (downGesture(touch)) sel else selSnap,
335
+ cursor' = if (and(downGesture(touch), not(space), hitAt(screenToWorld(posX(posOf(touch)), tx, z), screenToWorld(posY(posOf(touch)), ty, z), n0x, n0y, n1x, n1y, n2x, n2y) >= 0)) hitAt(screenToWorld(posX(posOf(touch)), tx, z), screenToWorld(posY(posOf(touch)), ty, z), n0x, n0y, n1x, n1y, n2x, n2y) else cursor,
336
+ editing' = if (downGesture(touch)) -1 else editing,
337
+ n0x' = n0x,
338
+ n0y' = n0y,
339
+ n1x' = n1x,
340
+ n1y' = n1y,
341
+ n2x' = n2x,
342
+ n2y' = n2y,
343
+ ddx' = ddx,
344
+ ddy' = ddy,
345
+ guide' = guide,
346
+ hostMoved' = hostMoved,
347
+ annCount' = annCount,
348
+ annSel' = annSel
349
+ }
350
+ }
351
+
352
+ // pointermove (same touch encoding). Only the capture owner drives a gesture; when idle it just
353
+ // tracks the hover position. The drag threshold is in SCREEN px; the delta is recomputed from the
354
+ // pointerdown snapshot every time, in WORLD space (snapshot + delta, not an accumulation).
355
+ action move = {
356
+ nondet touch = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23).oneOf()
357
+ all {
358
+ mode' = if (and(mode != "idle", ptrOf(touch) == owner)) modeAfter(mode, tgt, moved(posX(posOf(touch)), posY(posOf(touch)), downx, downy)) else mode,
359
+ owner' = owner,
360
+ prev' = mode,
361
+ tgt' = tgt,
362
+ shift' = shift,
363
+ space' = space,
364
+ mqmode' = mqmode,
365
+ snapOn' = snapOn,
366
+ tx' = if (and(and(mode != "idle", ptrOf(touch) == owner), mode == "panning")) panGrab(posX(posOf(touch)), z, gx) else tx,
367
+ ty' = if (and(and(mode != "idle", ptrOf(touch) == owner), mode == "panning")) panGrab(posY(posOf(touch)), z, gy) else ty,
368
+ z' = z,
369
+ px' = if (or(mode == "idle", and(mode != "idle", ptrOf(touch) == owner))) posX(posOf(touch)) else px,
370
+ py' = if (or(mode == "idle", and(mode != "idle", ptrOf(touch) == owner))) posY(posOf(touch)) else py,
371
+ downx' = downx,
372
+ downy' = downy,
373
+ gx' = gx,
374
+ gy' = gy,
375
+ sel' = if (and(and(mode != "idle", ptrOf(touch) == owner), modeAfter(mode, tgt, moved(posX(posOf(touch)), posY(posOf(touch)), downx, downy)) == "marquee")) liveSel(mqmode, shift, selSnap, gx, gy, screenToWorld(posX(posOf(touch)), tx, z), screenToWorld(posY(posOf(touch)), ty, z), n0x, n0y, n1x, n1y, n2x, n2y) else sel,
376
+ selSnap' = selSnap,
377
+ cursor' = cursor,
378
+ editing' = editing,
379
+ n0x' = n0x,
380
+ n0y' = n0y,
381
+ n1x' = n1x,
382
+ n1y' = n1y,
383
+ n2x' = n2x,
384
+ n2y' = n2y,
385
+ ddx' = if (and(and(mode != "idle", ptrOf(touch) == owner), modeAfter(mode, tgt, moved(posX(posOf(touch)), posY(posOf(touch)), downx, downy)) == "dragging")) liveDD(screenToWorld(posX(posOf(touch)), tx, z), gx, pick(tgt, n0x, n1x, n2x), snapOn, z) else ddx,
386
+ ddy' = if (and(and(mode != "idle", ptrOf(touch) == owner), modeAfter(mode, tgt, moved(posX(posOf(touch)), posY(posOf(touch)), downx, downy)) == "dragging")) liveDD(screenToWorld(posY(posOf(touch)), ty, z), gy, pick(tgt, n0y, n1y, n2y), snapOn, z) else ddy,
387
+ guide' = if (and(and(mode != "idle", ptrOf(touch) == owner), modeAfter(mode, tgt, moved(posX(posOf(touch)), posY(posOf(touch)), downx, downy)) == "dragging")) liveGuide(screenToWorld(posX(posOf(touch)), tx, z), gx, pick(tgt, n0x, n1x, n2x), screenToWorld(posY(posOf(touch)), ty, z), gy, pick(tgt, n0y, n1y, n2y), snapOn, z) else guide,
388
+ hostMoved' = hostMoved,
389
+ annCount' = annCount,
390
+ annSel' = annSel
391
+ }
392
+ }
393
+
394
+ // pointerup from pointer `lift`: only the owner ends the gesture. A click selects / toggles, a drag
395
+ // commits snapshot + delta (clamped into the world bound), a marquee keeps its live selection. The
396
+ // selection is ANNOUNCED here (never mid-gesture): annCount++ when it changed.
397
+ action up = {
398
+ nondet lift = Set(1, 2).oneOf()
399
+ all {
400
+ mode' = if (and(mode != "idle", lift == owner)) "idle" else mode,
401
+ owner' = if (and(mode != "idle", lift == owner)) 0 else owner,
402
+ prev' = mode,
403
+ tgt' = if (and(mode != "idle", lift == owner)) -1 else tgt,
404
+ shift' = shift,
405
+ space' = space,
406
+ mqmode' = mqmode,
407
+ snapOn' = snapOn,
408
+ tx' = tx,
409
+ ty' = ty,
410
+ z' = z,
411
+ px' = px,
412
+ py' = py,
413
+ downx' = downx,
414
+ downy' = downy,
415
+ gx' = gx,
416
+ gy' = gy,
417
+ sel' = if (and(mode != "idle", lift == owner)) selAfterUp(mode, tgt, shift, sel, selSnap) else sel,
418
+ selSnap' = if (and(mode != "idle", lift == owner)) selAfterUp(mode, tgt, shift, sel, selSnap) else selSnap,
419
+ cursor' = cursor,
420
+ editing' = editing,
421
+ n0x' = if (and(mode != "idle", lift == owner)) committed(0, n0x, ddx, sel, mode, NW(0)) else n0x,
422
+ n0y' = if (and(mode != "idle", lift == owner)) committed(0, n0y, ddy, sel, mode, NH(0)) else n0y,
423
+ n1x' = if (and(mode != "idle", lift == owner)) committed(1, n1x, ddx, sel, mode, NW(1)) else n1x,
424
+ n1y' = if (and(mode != "idle", lift == owner)) committed(1, n1y, ddy, sel, mode, NH(1)) else n1y,
425
+ n2x' = if (and(mode != "idle", lift == owner)) committed(2, n2x, ddx, sel, mode, NW(2)) else n2x,
426
+ n2y' = if (and(mode != "idle", lift == owner)) committed(2, n2y, ddy, sel, mode, NH(2)) else n2y,
427
+ ddx' = if (and(mode != "idle", lift == owner)) 0 else ddx,
428
+ ddy' = if (and(mode != "idle", lift == owner)) 0 else ddy,
429
+ guide' = if (and(mode != "idle", lift == owner)) 0 else guide,
430
+ hostMoved' = if (and(mode != "idle", lift == owner)) false else hostMoved,
431
+ annCount' = if (and(and(mode != "idle", lift == owner), selAfterUp(mode, tgt, shift, sel, selSnap) != annSel)) annCount + 1 else annCount,
432
+ annSel' = if (and(mode != "idle", lift == owner)) selAfterUp(mode, tgt, shift, sel, selSnap) else annSel
433
+ }
434
+ }
435
+
436
+ // pointercancel / lost capture (palm rejection, the browser took the pointer): abort to the
437
+ // pointerdown snapshot. Nothing was committed, so the selection snapshot is all that is restored.
438
+ action cancel_pointer = {
439
+ nondet lost = Set(1, 2).oneOf()
440
+ all {
441
+ mode' = if (and(mode != "idle", lost == owner)) "idle" else mode,
442
+ owner' = if (and(mode != "idle", lost == owner)) 0 else owner,
443
+ prev' = mode,
444
+ tgt' = if (and(mode != "idle", lost == owner)) -1 else tgt,
445
+ shift' = shift,
446
+ space' = space,
447
+ mqmode' = mqmode,
448
+ snapOn' = snapOn,
449
+ tx' = tx,
450
+ ty' = ty,
451
+ z' = z,
452
+ px' = px,
453
+ py' = py,
454
+ downx' = downx,
455
+ downy' = downy,
456
+ gx' = gx,
457
+ gy' = gy,
458
+ sel' = if (and(mode != "idle", lost == owner)) selSnap else sel,
459
+ selSnap' = selSnap,
460
+ cursor' = cursor,
461
+ editing' = editing,
462
+ n0x' = n0x,
463
+ n0y' = n0y,
464
+ n1x' = n1x,
465
+ n1y' = n1y,
466
+ n2x' = n2x,
467
+ n2y' = n2y,
468
+ ddx' = if (and(mode != "idle", lost == owner)) 0 else ddx,
469
+ ddy' = if (and(mode != "idle", lost == owner)) 0 else ddy,
470
+ guide' = if (and(mode != "idle", lost == owner)) 0 else guide,
471
+ hostMoved' = if (and(mode != "idle", lost == owner)) false else hostMoved,
472
+ annCount' = if (and(and(mode != "idle", lost == owner), selSnap != annSel)) annCount + 1 else annCount,
473
+ annSel' = if (and(mode != "idle", lost == owner)) selSnap else annSel
474
+ }
475
+ }
476
+
477
+ // modifiers and parameters are sampled at pointerdown: changes mid-gesture are ignored
478
+ action set_shift = {
479
+ nondet held = Set(true, false).oneOf()
480
+ all {
481
+ mode' = mode,
482
+ owner' = owner,
483
+ prev' = mode,
484
+ tgt' = tgt,
485
+ shift' = if (mode == "idle") held else shift,
486
+ space' = space,
487
+ mqmode' = mqmode,
488
+ snapOn' = snapOn,
489
+ tx' = tx,
490
+ ty' = ty,
491
+ z' = z,
492
+ px' = px,
493
+ py' = py,
494
+ downx' = downx,
495
+ downy' = downy,
496
+ gx' = gx,
497
+ gy' = gy,
498
+ sel' = sel,
499
+ selSnap' = selSnap,
500
+ cursor' = cursor,
501
+ editing' = editing,
502
+ n0x' = n0x,
503
+ n0y' = n0y,
504
+ n1x' = n1x,
505
+ n1y' = n1y,
506
+ n2x' = n2x,
507
+ n2y' = n2y,
508
+ ddx' = ddx,
509
+ ddy' = ddy,
510
+ guide' = guide,
511
+ hostMoved' = hostMoved,
512
+ annCount' = annCount,
513
+ annSel' = annSel
514
+ }
515
+ }
516
+
517
+ action set_space = {
518
+ nondet held = Set(true, false).oneOf()
519
+ all {
520
+ mode' = mode,
521
+ owner' = owner,
522
+ prev' = mode,
523
+ tgt' = tgt,
524
+ shift' = shift,
525
+ space' = if (mode == "idle") held else space,
526
+ mqmode' = mqmode,
527
+ snapOn' = snapOn,
528
+ tx' = tx,
529
+ ty' = ty,
530
+ z' = z,
531
+ px' = px,
532
+ py' = py,
533
+ downx' = downx,
534
+ downy' = downy,
535
+ gx' = gx,
536
+ gy' = gy,
537
+ sel' = sel,
538
+ selSnap' = selSnap,
539
+ cursor' = cursor,
540
+ editing' = editing,
541
+ n0x' = n0x,
542
+ n0y' = n0y,
543
+ n1x' = n1x,
544
+ n1y' = n1y,
545
+ n2x' = n2x,
546
+ n2y' = n2y,
547
+ ddx' = ddx,
548
+ ddy' = ddy,
549
+ guide' = guide,
550
+ hostMoved' = hostMoved,
551
+ annCount' = annCount,
552
+ annSel' = annSel
553
+ }
554
+ }
555
+
556
+ action set_marquee_mode = {
557
+ nondet choice = Set("contain", "overlap").oneOf()
558
+ all {
559
+ mode' = mode,
560
+ owner' = owner,
561
+ prev' = mode,
562
+ tgt' = tgt,
563
+ shift' = shift,
564
+ space' = space,
565
+ mqmode' = if (mode == "idle") choice else mqmode,
566
+ snapOn' = snapOn,
567
+ tx' = tx,
568
+ ty' = ty,
569
+ z' = z,
570
+ px' = px,
571
+ py' = py,
572
+ downx' = downx,
573
+ downy' = downy,
574
+ gx' = gx,
575
+ gy' = gy,
576
+ sel' = sel,
577
+ selSnap' = selSnap,
578
+ cursor' = cursor,
579
+ editing' = editing,
580
+ n0x' = n0x,
581
+ n0y' = n0y,
582
+ n1x' = n1x,
583
+ n1y' = n1y,
584
+ n2x' = n2x,
585
+ n2y' = n2y,
586
+ ddx' = ddx,
587
+ ddy' = ddy,
588
+ guide' = guide,
589
+ hostMoved' = hostMoved,
590
+ annCount' = annCount,
591
+ annSel' = annSel
592
+ }
593
+ }
594
+
595
+ action set_snap = {
596
+ nondet snapping = Set(true, false).oneOf()
597
+ all {
598
+ mode' = mode,
599
+ owner' = owner,
600
+ prev' = mode,
601
+ tgt' = tgt,
602
+ shift' = shift,
603
+ space' = space,
604
+ mqmode' = mqmode,
605
+ snapOn' = if (mode == "idle") snapping else snapOn,
606
+ tx' = tx,
607
+ ty' = ty,
608
+ z' = z,
609
+ px' = px,
610
+ py' = py,
611
+ downx' = downx,
612
+ downy' = downy,
613
+ gx' = gx,
614
+ gy' = gy,
615
+ sel' = sel,
616
+ selSnap' = selSnap,
617
+ cursor' = cursor,
618
+ editing' = editing,
619
+ n0x' = n0x,
620
+ n0y' = n0y,
621
+ n1x' = n1x,
622
+ n1y' = n1y,
623
+ n2x' = n2x,
624
+ n2y' = n2y,
625
+ ddx' = ddx,
626
+ ddy' = ddy,
627
+ guide' = guide,
628
+ hostMoved' = hostMoved,
629
+ annCount' = annCount,
630
+ annSel' = annSel
631
+ }
632
+ }
633
+
634
+ // wheel / pinch under a running gesture: zoom to a preset level about the POINTER. The pointer's
635
+ // world point is fixed, so a drag / marquee / pan composes with it; every derived gesture value
636
+ // is re-solved against the new camera (a panning camera re-grabs so it never keeps a stale offset).
637
+ action zoom_to = {
638
+ nondet level = Set(0, 1, 2, 3, 4).oneOf()
639
+ all {
640
+ mode' = mode,
641
+ owner' = owner,
642
+ prev' = mode,
643
+ tgt' = tgt,
644
+ shift' = shift,
645
+ space' = space,
646
+ mqmode' = mqmode,
647
+ snapOn' = snapOn,
648
+ tx' = (if (mode == "panning") panGrab(px, clampZ(zoomPreset(level)), gx) else anchoredT(tx, z, clampZ(zoomPreset(level)), px)),
649
+ ty' = (if (mode == "panning") panGrab(py, clampZ(zoomPreset(level)), gy) else anchoredT(ty, z, clampZ(zoomPreset(level)), py)),
650
+ z' = clampZ(zoomPreset(level)),
651
+ px' = px,
652
+ py' = py,
653
+ downx' = downx,
654
+ downy' = downy,
655
+ gx' = gx,
656
+ gy' = gy,
657
+ sel' = if (mode == "marquee") liveSel(mqmode, shift, selSnap, gx, gy, screenToWorld(px, if (mode == "panning") panGrab(px, clampZ(zoomPreset(level)), gx) else anchoredT(tx, z, clampZ(zoomPreset(level)), px), clampZ(zoomPreset(level))), screenToWorld(py, if (mode == "panning") panGrab(py, clampZ(zoomPreset(level)), gy) else anchoredT(ty, z, clampZ(zoomPreset(level)), py), clampZ(zoomPreset(level))), n0x, n0y, n1x, n1y, n2x, n2y) else sel,
658
+ selSnap' = selSnap,
659
+ cursor' = cursor,
660
+ editing' = editing,
661
+ n0x' = n0x,
662
+ n0y' = n0y,
663
+ n1x' = n1x,
664
+ n1y' = n1y,
665
+ n2x' = n2x,
666
+ n2y' = n2y,
667
+ ddx' = if (mode == "dragging") liveDD(screenToWorld(px, if (mode == "panning") panGrab(px, clampZ(zoomPreset(level)), gx) else anchoredT(tx, z, clampZ(zoomPreset(level)), px), clampZ(zoomPreset(level))), gx, pick(tgt, n0x, n1x, n2x), snapOn, clampZ(zoomPreset(level))) else ddx,
668
+ ddy' = if (mode == "dragging") liveDD(screenToWorld(py, if (mode == "panning") panGrab(py, clampZ(zoomPreset(level)), gy) else anchoredT(ty, z, clampZ(zoomPreset(level)), py), clampZ(zoomPreset(level))), gy, pick(tgt, n0y, n1y, n2y), snapOn, clampZ(zoomPreset(level))) else ddy,
669
+ guide' = if (mode == "dragging") liveGuide(screenToWorld(px, if (mode == "panning") panGrab(px, clampZ(zoomPreset(level)), gx) else anchoredT(tx, z, clampZ(zoomPreset(level)), px), clampZ(zoomPreset(level))), gx, pick(tgt, n0x, n1x, n2x), screenToWorld(py, if (mode == "panning") panGrab(py, clampZ(zoomPreset(level)), gy) else anchoredT(ty, z, clampZ(zoomPreset(level)), py), clampZ(zoomPreset(level))), gy, pick(tgt, n0y, n1y, n2y), snapOn, clampZ(zoomPreset(level))) else guide,
670
+ hostMoved' = hostMoved,
671
+ annCount' = annCount,
672
+ annSel' = annSel
673
+ }
674
+ }
675
+
676
+ // enter / leave editing of the single selected node (only while idle)
677
+ action toggle_edit = all {
678
+ mode' = mode,
679
+ owner' = owner,
680
+ prev' = mode,
681
+ tgt' = tgt,
682
+ shift' = shift,
683
+ space' = space,
684
+ mqmode' = mqmode,
685
+ snapOn' = snapOn,
686
+ tx' = tx,
687
+ ty' = ty,
688
+ z' = z,
689
+ px' = px,
690
+ py' = py,
691
+ downx' = downx,
692
+ downy' = downy,
693
+ gx' = gx,
694
+ gy' = gy,
695
+ sel' = sel,
696
+ selSnap' = selSnap,
697
+ cursor' = if (and(mode == "idle", editing < 0, single(sel) >= 0)) single(sel) else cursor,
698
+ editing' = if (mode != "idle") editing else if (editing >= 0) -1 else single(sel),
699
+ n0x' = n0x,
700
+ n0y' = n0y,
701
+ n1x' = n1x,
702
+ n1y' = n1y,
703
+ n2x' = n2x,
704
+ n2y' = n2y,
705
+ ddx' = ddx,
706
+ ddy' = ddy,
707
+ guide' = guide,
708
+ hostMoved' = hostMoved,
709
+ annCount' = annCount,
710
+ annSel' = annSel
711
+ }
712
+
713
+ // host_move: the HOST sets one node's COMMITTED position (an auto-layout, a controlled scene), in
714
+ // ANY mode, clamped into the world bound. A running drag or marquee is re-solved against the new
715
+ // geometry, like zoom_to does for the camera, so the derived values stay f(pointer, geometry).
716
+ // `hostMoved` records that it happened in this gesture: the target was the topmost hit at
717
+ // pointerdown, which a host move may since have changed. Sizes and the z-order are not modelled.
718
+ action host_move = {
719
+ nondet place = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11).oneOf()
720
+ all {
721
+ mode' = mode,
722
+ owner' = owner,
723
+ prev' = mode,
724
+ tgt' = tgt,
725
+ shift' = shift,
726
+ space' = space,
727
+ mqmode' = mqmode,
728
+ snapOn' = snapOn,
729
+ tx' = tx,
730
+ ty' = ty,
731
+ z' = z,
732
+ px' = px,
733
+ py' = py,
734
+ downx' = downx,
735
+ downy' = downy,
736
+ gx' = gx,
737
+ gy' = gy,
738
+ sel' = if (mode == "marquee") liveSel(mqmode, shift, selSnap, gx, gy, screenToWorld(px, tx, z), screenToWorld(py, ty, z), hostX(place, 0, n0x), hostY(place, 0, n0y), hostX(place, 1, n1x), hostY(place, 1, n1y), hostX(place, 2, n2x), hostY(place, 2, n2y)) else sel,
739
+ selSnap' = selSnap,
740
+ cursor' = cursor,
741
+ editing' = editing,
742
+ n0x' = hostX(place, 0, n0x),
743
+ n0y' = hostY(place, 0, n0y),
744
+ n1x' = hostX(place, 1, n1x),
745
+ n1y' = hostY(place, 1, n1y),
746
+ n2x' = hostX(place, 2, n2x),
747
+ n2y' = hostY(place, 2, n2y),
748
+ ddx' = if (mode == "dragging") liveDD(screenToWorld(px, tx, z), gx, pick(tgt, hostX(place, 0, n0x), hostX(place, 1, n1x), hostX(place, 2, n2x)), snapOn, z) else ddx,
749
+ ddy' = if (mode == "dragging") liveDD(screenToWorld(py, ty, z), gy, pick(tgt, hostY(place, 0, n0y), hostY(place, 1, n1y), hostY(place, 2, n2y)), snapOn, z) else ddy,
750
+ guide' = if (mode == "dragging") liveGuide(screenToWorld(px, tx, z), gx, pick(tgt, hostX(place, 0, n0x), hostX(place, 1, n1x), hostX(place, 2, n2x)), screenToWorld(py, ty, z), gy, pick(tgt, hostY(place, 0, n0y), hostY(place, 1, n1y), hostY(place, 2, n2y)), snapOn, z) else guide,
751
+ hostMoved' = or(hostMoved, mode != "idle"),
752
+ annCount' = annCount,
753
+ annSel' = annSel
754
+ }
755
+ }
756
+
757
+ action step = any { init, down, move, up, cancel_pointer, set_shift, set_space, set_marquee_mode, set_snap, zoom_to, toggle_edit, host_move }
758
+
759
+ val ModeValid: bool =
760
+ or(mode == "idle", mode == "pointing", mode == "panning", mode == "marquee", mode == "dragging")
761
+ // exactly one capture owner while a gesture runs, nobody otherwise
762
+ val OneCaptureOwner: bool = and((mode == "idle") == (owner == 0), owner >= 0, owner <= 2)
763
+ // a gesture starts only from idle; the others only follow the legal edges
764
+ val OnlyLegalTransitions: bool = legalEdge(prev, mode)
765
+ // selection (and its snapshot / announcement) is a subset of the 3 nodes
766
+ val SelectionSubsetOfNodes: bool =
767
+ and(sel >= 0, sel <= 7, selSnap >= 0, selSnap <= 7, annSel >= 0, annSel <= 7)
768
+ val IdsInRange: bool =
769
+ and(tgt >= -1, tgt <= 2, cursor >= -1, cursor <= 2, editing >= -1, editing <= 2)
770
+ // idle carries no gesture residue and the selection snapshot is the selection
771
+ val IdleIsClean: bool =
772
+ or(mode != "idle", and(tgt == -1, ddx == 0, ddy == 0, guide == 0, selSnap == sel, not(hostMoved)))
773
+ val GestureTargetConsistent: bool = and(
774
+ or(mode != "pointing", and(ddx == 0, ddy == 0, guide == 0)),
775
+ or(mode != "marquee", and(tgt == -1, ddx == 0, ddy == 0, guide == 0)),
776
+ or(mode != "panning", and(tgt == -1, ddx == 0, ddy == 0, guide == 0)),
777
+ or(mode != "dragging", and(tgt >= 0, tgt <= 2, bit(sel, tgt)))
778
+ )
779
+ // below the threshold (screen px, squared) a press has not become a gesture yet
780
+ val PointingWithinThreshold: bool = or(mode != "pointing", not(moved(px, py, downx, downy)))
781
+ // the gesture target is the topmost node under the pointer at pointerdown (world hit test); a
782
+ // host_move during the gesture (hostMoved) may have moved it since, so the check stands down
783
+ val TargetIsTopmostHit: bool =
784
+ or(mode == "idle", mode == "panning", hostMoved, tgt == hitAt(gx, gy, n0x, n0y, n1x, n1y, n2x, n2y))
785
+ // drag = snapshot + world delta: the stored delta equals f(world pointer, world grab point)
786
+ val DragIsSnapshotPlusDelta: bool = or(mode != "dragging", and(
787
+ ddx == liveDD(curWx, gx, primX, snapOn, z),
788
+ ddy == liveDD(curWy, gy, primY, snapOn, z),
789
+ guide == liveGuide(curWx, gx, primX, curWy, gy, primY, snapOn, z)
790
+ ))
791
+ // the grabbed point stays under the pointer (half a unit; plus the snap distance when snapped)
792
+ val DragPreservesGrabOffset: bool = or(mode != "dragging", and(
793
+ grabDevOk(curWx, gx, ddx, bit(guide, 0), z),
794
+ grabDevOk(curWy, gy, ddy, bit(guide, 1), z)
795
+ ))
796
+ // a snapped axis sits on the grid; an unsnapped axis was not within the screen threshold
797
+ val SnappedPositionOnGrid: bool = or(mode != "dragging", and(
798
+ or(not(bit(guide, 0)), (primX + ddx) % GRID == 0),
799
+ or(not(bit(guide, 1)), (primY + ddy) % GRID == 0)
800
+ ))
801
+ val NoMissedSnap: bool = or(mode != "dragging", not(snapOn), and(
802
+ bit(guide, 0) == snapHit(primX + ddx, true, z),
803
+ bit(guide, 1) == snapHit(primY + ddy, true, z)
804
+ ))
805
+ // guides are output state: only while dragging with snapping on
806
+ val GuidesOnlyWhileSnapping: bool = or(and(mode == "dragging", snapOn), guide == 0)
807
+ // panning keeps the grab world point under the pointer (unless the world bound pins the camera)
808
+ val PanKeepsGrabUnderPointer: bool =
809
+ or(mode != "panning", and(axisGrab(px, gx, tx, z), axisGrab(py, gy, ty, z)))
810
+ val CameraInBound: bool =
811
+ and(ZMIN > 0, z >= ZMIN, z <= ZMAX, absI(tx) <= WT, absI(ty) <= WT)
812
+ val NodesInWorldBound: bool =
813
+ and(nodeOk(n0x, n0y, 0), nodeOk(n1x, n1y, 1), nodeOk(n2x, n2y, 2))
814
+ // marquee monotonicity: whatever the rectangle, contain selects a subset of overlap
815
+ val ContainSubsetOfOverlap: bool =
816
+ maskOr(
817
+ maskBy("contain", gx, gy, curWx, curWy, n0x, n0y, n1x, n1y, n2x, n2y),
818
+ maskBy("overlap", gx, gy, curWx, curWy, n0x, n0y, n1x, n1y, n2x, n2y)
819
+ ) == maskBy("overlap", gx, gy, curWx, curWy, n0x, n0y, n1x, n1y, n2x, n2y)
820
+ // while marquee-ing the selection is exactly the mode's hits, on top of the base when additive
821
+ val MarqueeSelectionExact: bool = or(mode != "marquee",
822
+ sel == liveSel(mqmode, shift, selSnap, gx, gy, curWx, curWy, n0x, n0y, n1x, n1y, n2x, n2y))
823
+ val MarqueeAdditiveKeepsBase: bool =
824
+ or(mode != "marquee", not(shift), maskOr(sel, selSnap) == sel)
825
+ // selected / cursor / editing nodes are never culled, and a culled node is off screen
826
+ val ExemptNodesNeverCulled: bool = and(
827
+ or(not(exempt(0)), not(culled(0))),
828
+ or(not(exempt(1)), not(culled(1))),
829
+ or(not(exempt(2)), not(culled(2)))
830
+ )
831
+ val CulledOnlyWhenOffscreen: bool = and(
832
+ or(not(culled(0)), not(inView(0))),
833
+ or(not(culled(1)), not(inView(1))),
834
+ or(not(culled(2)), not(inView(2)))
835
+ )
836
+ val EditingIsTheSingleSelection: bool =
837
+ or(editing == -1, and(mode == "idle", sel == pow2(editing), cursor == editing))
838
+ // the live region only ever carries the settled selection
839
+ val AnnouncementsTrackSelection: bool =
840
+ or(mode != "idle", and(annSel == sel, or(sel == 0, annCount >= 1)))
841
+ }