@weasel-js/gestures 1.10.0 → 1.11.0

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/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # @weasel-js/gestures
2
2
 
3
- Pure gesture taxonomy, route grammars, and matcher primitives. No React, no DOM, no scene.
3
+ The vocabulary weasel uses to say which user input fires what, and the pure
4
+ functions that decide whether an input matches. It has no React, no scene, and
5
+ no dependencies, so it can run in a test, a worker, or a host that is not
6
+ weasel at all.
4
7
 
5
8
  Part of [weasel](https://github.com/orochi235/weasel), a domain-agnostic 2D
6
9
  scene-graph canvas kit for React. See the
7
- [API reference](https://orochi235.github.io/weasel/api/).
10
+ [API reference](https://orochi235.github.io/weasel/#pkg/gestures).
8
11
 
9
12
  ## Install
10
13
 
@@ -12,12 +15,111 @@ scene-graph canvas kit for React. See the
12
15
  npm install @weasel-js/gestures
13
16
  ```
14
17
 
15
- ## Usage
18
+ ## Terms
19
+
20
+ These words have fixed meanings across weasel:
21
+
22
+ - A **gesture** is a form of input: a click, a drag, a wheel turn, a keystroke,
23
+ a pinch, a file dropped on the canvas.
24
+ - An **action** is a user-intent operation: move, delete, zoom in.
25
+ - An **interaction** is a gesture bound to an action: "shift-click on empty
26
+ canvas adds to the selection".
27
+
28
+ This package covers the gesture side only. It describes gestures, recognizes
29
+ them from raw pointer input, and matches them against descriptions. Actions,
30
+ and the dispatcher that runs the action a matched gesture is bound to, live in
31
+ other weasel packages.
32
+
33
+ ## What is in it
34
+
35
+ ### Describing a gesture
36
+
37
+ A `GestureSpec` is an object describing one gesture a binding listens for:
38
+ its `kind` (`'click'`, `'drag'`, `'key'`, `'wheel'`, and so on), and optionally
39
+ the modifier keys that must be held, what the pointer must be over, and the
40
+ phase the owning tool must be in.
41
+
42
+ A **route** is the same description as a string, short enough to write inline
43
+ and to show in a shortcut list: `[initial] click => empty +shift`.
44
+
45
+ `parseRoute` turns a route into a `ParsedRoute`, `formatRoute` writes the
46
+ canonical string back, `routeToSpec` converts a parsed route into the
47
+ `GestureSpec` the matcher takes, and `describeRoute` renders it as an English
48
+ sentence. `GESTURE_DESCRIPTORS` is the table of gesture names the grammar
49
+ accepts, with whether each takes a target and what argument it carries.
50
+
51
+ ### Matching input against a description
52
+
53
+ An `InputEvent` is one normalized input: a union keyed by `kind`, each arm
54
+ carrying the modifier state and whatever that kind of input reports.
55
+
56
+ `matchSpec(event, spec, isMac)` returns whether an event satisfies a spec.
57
+ Its parts are exported too: `matchModifiers`, `matchKey`, `matchTarget`, and
58
+ `matchPhase`. When several specs match one event, `specificity(spec)` gives a
59
+ tuple to rank them by, so the narrower description wins.
60
+
61
+ ### Recognizing gestures from pointer input
62
+
63
+ `createPointerRecognizer` turns a stream of pointer presses, moves, and
64
+ releases into `InputEvent`s. It decides when a press has moved far enough to be
65
+ a drag, when a release in place is a click, when two clicks make a double
66
+ click, when a held touch is a long-press, and when several pointers form a
67
+ multitouch gesture. It touches no DOM: the host feeds it samples and supplies
68
+ the clock, and it hands each recognized event to a sink the host provides.
69
+
70
+ The thresholds it uses are exported as constants: `DRAG_THRESHOLD_PX`,
71
+ `LONG_PRESS_MS`, `DOUBLE_CLICK_MAX_MS`, `DOUBLE_CLICK_MAX_PX`, and
72
+ `TAP_THRESHOLD_PX`.
73
+
74
+ ### How they fit together
75
+
76
+ | From | Through | To |
77
+ |---|---|---|
78
+ | Pointer samples | `createPointerRecognizer` | `InputEvent` |
79
+ | Route string | `parseRoute`, then `routeToSpec` | `GestureSpec` |
80
+ | `InputEvent` and `GestureSpec` | `matchSpec` | `true` or `false` |
81
+
82
+ A host listens to the DOM, feeds pointer samples to a recognizer, and builds
83
+ key, wheel, and drop events itself. Each binding carries a `GestureSpec`,
84
+ written directly or parsed from a route. For every event, the host asks
85
+ `matchSpec` which bindings apply, ranks them with `specificity`, and runs the
86
+ winner's action.
87
+
88
+ The package also exports a few helpers a host needs while doing this:
89
+ `isApplePlatform` to decide what the `mod` modifier means, and
90
+ `isEditableTarget`, `focusedControlOwnsKey`, and `isInControlWithin` to leave
91
+ keys and presses alone when they belong to a text field or a button.
92
+
93
+ ## Example
94
+
95
+ Parse a route, turn it into a spec, and test an event against it:
16
96
 
17
97
  ```ts
18
- import { /* … */ } from '@weasel-js/gestures';
98
+ import {
99
+ describeRoute, matchSpec, parseRoute, routeToSpec, type InputEvent,
100
+ } from '@weasel-js/gestures';
101
+
102
+ const route = parseRoute('[*:*] click => empty +shift');
103
+ const spec = routeToSpec(route);
104
+ // { kind: 'click', target: 'empty', mods: { shift: true } }
105
+
106
+ const shiftClick: InputEvent = {
107
+ kind: 'click',
108
+ bodyTarget: 'empty',
109
+ altKey: false, ctrlKey: false, metaKey: false, shiftKey: true,
110
+ };
111
+
112
+ matchSpec(shiftClick, spec, false); // true
113
+ matchSpec({ ...shiftClick, shiftKey: false }, spec, false); // false: shift is required
114
+
115
+ describeRoute(parseRoute('[initial] drag +mod ?shift'));
116
+ // 'Fires when the user Mod-drags anywhere, while the tool is idle (Shift optional).'
19
117
  ```
20
118
 
119
+ Modifier matching is strict. A modifier the spec does not mention must not be
120
+ held, so a spec for plain `click` does not match a shift-click. Mark a modifier
121
+ `?shift` in a route, or `shift: 'optional'` in a spec, to accept it either way.
122
+
21
123
  ## License
22
124
 
23
125
  MIT
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { MenuShortcut } from '@weasel-js/menu';
2
+
1
3
  /**
2
4
  * Declarative gesture taxonomy. Single source of truth for:
3
5
  * - which gestures hit-test (have a `.target` slot in the route string)
@@ -7,7 +9,7 @@
7
9
  * Reflection, matcher, and inspector UI all read this table. Adding a new
8
10
  * gesture name in one place updates every consumer.
9
11
  */
10
- type GestureName = 'click' | 'pointerDown' | 'hover' | 'dblTap' | 'drag' | 'wheel' | 'pinch' | 'keyDown' | 'keyUp' | 'keyHeld' | 'contextMenu' | 'longPress' | 'multiTouch' | 'multiTouchTap' | 'drop' | 'paste';
12
+ type GestureName = 'click' | 'pointerDown' | 'hover' | 'dblTap' | 'drag' | 'wheel' | 'pinch' | 'keyDown' | 'keyUp' | 'keyHeld' | 'contextMenu' | 'longPress' | 'multiTouch' | 'multiTouchTap' | 'drop' | 'carry' | 'paste';
11
13
  /**
12
14
  * Gesture kinds an affordance claim can bar, in the spec vocabulary bindings
13
15
  * are written in. `'pointer'` is one token because `pointerDown` / `click` /
@@ -338,6 +340,11 @@ interface PointerDownSpec {
338
340
  * The pointer moving over the surface with no button pressed. Fires on every
339
341
  * such move; a held pointer's moves belong to its drag and fire nothing here.
340
342
  *
343
+ * With `motion: 'leave'` it fires instead when the pointer passes out of what
344
+ * the binding targets: onto something its `target` does not match, or off the
345
+ * surface. With no `target` that is the whole surface. Its params are the
346
+ * move's that took the pointer out, or the last move's before it left the surface.
347
+ *
341
348
  * A hover binding claims nothing. Every live binding that matches the move
342
349
  * runs, not only the best one, and the move still reaches the hover-cursor
343
350
  * pump. There is no press to own, so bind an immediate action, not an ongoing
@@ -346,6 +353,7 @@ interface PointerDownSpec {
346
353
  */
347
354
  interface HoverSpec {
348
355
  kind: 'hover';
356
+ motion?: 'move' | 'leave';
349
357
  target?: TargetSpec;
350
358
  mods?: ModSpec;
351
359
  phase?: PhaseSpec;
@@ -391,6 +399,24 @@ interface DropSpec {
391
399
  mods?: ModSpec;
392
400
  phase?: PhaseSpec;
393
401
  }
402
+ /**
403
+ * A press that began elsewhere on the page, carried over the canvas: a palette
404
+ * item dragged in, a row dragged out of a list. The in-app sibling of
405
+ * {@link DropSpec}, which is the OS's drag. A carry is begun with `startCarry`.
406
+ * `types` filters by the payload's `type`, as globs; omitted or empty matches
407
+ * any carry.
408
+ *
409
+ * An ongoing action bound here starts when the carry comes over the surface:
410
+ * its moves update it, the release commits it, and leaving the surface or
411
+ * canceling the press cancels it. An immediate action fires once, on the
412
+ * release.
413
+ */
414
+ interface CarrySpec {
415
+ kind: 'carry';
416
+ types?: string[];
417
+ mods?: ModSpec;
418
+ phase?: PhaseSpec;
419
+ }
394
420
  /** System-clipboard paste of external content. Same `types` semantics as
395
421
  * {@link DropSpec} — omitted or empty = matches any paste. */
396
422
  interface PasteSpec {
@@ -400,7 +426,7 @@ interface PasteSpec {
400
426
  phase?: PhaseSpec;
401
427
  }
402
428
  /** The full union of supported gesture spec kinds. */
403
- type GestureSpec = KeySpec | KeyHeldSpec | WheelSpec | PinchSpec | ClickSpec | DoubleClickSpec | ContextMenuSpec | DragSpec | PointerDownSpec | HoverSpec | LongPressSpec | MultiTouchSpec | MultiTouchTapSpec | DropSpec | PasteSpec;
429
+ type GestureSpec = KeySpec | KeyHeldSpec | WheelSpec | PinchSpec | ClickSpec | DoubleClickSpec | ContextMenuSpec | DragSpec | PointerDownSpec | HoverSpec | LongPressSpec | MultiTouchSpec | MultiTouchTapSpec | DropSpec | CarrySpec | PasteSpec;
404
430
 
405
431
  /**
406
432
  * Mini-grammar for the `arg` slot of `keyDown` / `keyUp` routes.
@@ -441,7 +467,7 @@ declare function keyRouteToSpec(r: ParsedKeyRoute): KeySpec;
441
467
  * Wildcards become omitted fields: a `*` target, direction or MIME list, and a
442
468
  * `[*:*]` phase slot. `[*]` is `[&:*]`, which an ambient binding never
443
469
  * matches, so it stays a phase atom. A `keyDown` / `keyHeld` arg is read as a
444
- * key route, so `keyDown(z?shift)` yields `mods.shift: 'optional'`; a drop or
470
+ * key route, so `keyDown(z?shift)` yields `mods.shift: 'optional'`; a drop, carry, or
445
471
  * paste arg is a `|`-separated MIME list.
446
472
  *
447
473
  * Throws on a route no spec can express: `keyUp`, a key or finger-count
@@ -513,13 +539,6 @@ declare function modifierComboToParsed(key: ModifierCombo): ParsedModifiers;
513
539
  * `name=req` pairs joined by `&`. */
514
540
  declare function canonicalModifiers(mods: ParsedModifiers): string;
515
541
 
516
- /** Keyboard/pointer modifier flags carried by every {@link InputEvent} arm. */
517
- interface EventModifiers {
518
- altKey: boolean;
519
- ctrlKey: boolean;
520
- metaKey: boolean;
521
- shiftKey: boolean;
522
- }
523
542
  /**
524
543
  * Body-target classification from the scene hit-test. Populated by
525
544
  * `useGestureDispatcher` when a `classifyTarget` thunk is supplied to its
@@ -546,6 +565,28 @@ interface BodyClassification {
546
565
  /** Semantic kind of the hit body. Undefined on empty canvas, and undefined
547
566
  * on a body whose kind the scene can't name — never guessed. */
548
567
  kind?: BodyKind;
568
+ /** The node classified. Matching ignores it. */
569
+ id?: string;
570
+ /** What a double-click at this point names, where that is not the body hit: the group a hit
571
+ * member belongs to. A double click's targets match against it. Absent, the hit is named. */
572
+ doubleClick?: {
573
+ body: BodyTarget;
574
+ kind?: BodyKind;
575
+ id?: string;
576
+ };
577
+ }
578
+ /** The body a double-click names, as a press carries it to the double click it composes. */
579
+ interface DoubleClickBody {
580
+ bodyTarget: BodyTarget;
581
+ bodyKind?: BodyKind;
582
+ }
583
+
584
+ /** Keyboard/pointer modifier flags carried by every {@link InputEvent} arm. */
585
+ interface EventModifiers {
586
+ altKey: boolean;
587
+ ctrlKey: boolean;
588
+ metaKey: boolean;
589
+ shiftKey: boolean;
549
590
  }
550
591
  /** A keystroke. */
551
592
  interface KeyEvent extends EventModifiers {
@@ -741,9 +782,19 @@ interface ContextMenuEvent extends EventModifiers {
741
782
  bodyTarget?: BodyTarget;
742
783
  bodyKind?: BodyKind;
743
784
  }
744
- /** A move of a pointer with no button pressed. Matched by `hover` specs. */
785
+ /** What a hover found under the pointer. */
786
+ interface HoverWhere {
787
+ affordance?: unknown;
788
+ bodyTarget?: BodyTarget;
789
+ bodyKind?: BodyKind;
790
+ }
791
+ /** A move of a pointer with no button pressed, or its leaving the surface. Matched by `hover` specs. */
745
792
  interface HoverEvent extends EventModifiers {
746
793
  kind: 'hover';
794
+ /** What the pointer was over at its last move, when it has made one since coming onto the surface. */
795
+ from?: HoverWhere;
796
+ /** The pointer has left the surface. Where it is and what it is over are its last move's. */
797
+ off?: boolean;
747
798
  /** World-space position of the pointer. */
748
799
  x: number;
749
800
  y: number;
@@ -834,6 +885,21 @@ interface DropEvent extends EventModifiers {
834
885
  clientX?: number;
835
886
  clientY?: number;
836
887
  }
888
+ /** A carry over the canvas (`startCarry`). `stage` is what it did here: came
889
+ * over, moved, was released, or left — off the surface, or canceled on it. */
890
+ interface CarryEvent extends EventModifiers {
891
+ kind: 'carry';
892
+ stage: 'enter' | 'move' | 'drop' | 'leave';
893
+ pointerId?: number;
894
+ /** The payload's type, which a spec's `types` globs filter, and the payload. */
895
+ type: string;
896
+ value: unknown;
897
+ /** World-space position (post view transform). */
898
+ x: number;
899
+ y: number;
900
+ clientX: number;
901
+ clientY: number;
902
+ }
837
903
  /** External content pasted from the system clipboard. Carries no point. */
838
904
  interface PasteEvent extends EventModifiers {
839
905
  kind: 'paste';
@@ -848,7 +914,7 @@ interface PasteEvent extends EventModifiers {
848
914
  * The `affordance?` field on {@link PointerDownEvent} is widened to `unknown`
849
915
  * in this package — the kit narrows it back to `AffordanceHit` at consumption.
850
916
  */
851
- type InputEvent = KeyEvent | KeyHeldEvent | WheelEvent | PinchEvent | PointerDownEvent | PointerMoveEvent | HoverEvent | PointerUpEvent | PointerCancelEvent | ClickEvent | DoubleClickEvent | ContextMenuEvent | LongPressEvent | MultiTouchEvent | MultiTouchTapEvent | DropEvent | PasteEvent;
917
+ type InputEvent = KeyEvent | KeyHeldEvent | WheelEvent | PinchEvent | PointerDownEvent | PointerMoveEvent | HoverEvent | PointerUpEvent | PointerCancelEvent | ClickEvent | DoubleClickEvent | ContextMenuEvent | LongPressEvent | MultiTouchEvent | MultiTouchTapEvent | DropEvent | CarryEvent | PasteEvent;
852
918
 
853
919
  /**
854
920
  * The stylus fields `src` carries, as a spread-able partial: a sample that
@@ -856,29 +922,8 @@ type InputEvent = KeyEvent | KeyHeldEvent | WheelEvent | PinchEvent | PointerDow
856
922
  */
857
923
  declare function stylusOf(src: PointerSampleStylus): PointerSampleStylus;
858
924
 
859
- /** Pure gesture matcher — no React, no state, no DOM. */
925
+ /** Target matching: a `TargetSpec` against what an event classified under its point. */
860
926
 
861
- /** The modifier-state fields `matchModifiers` reads. Structural, so a DOM
862
- * `KeyboardEvent` or `PointerEvent` satisfies it directly. */
863
- type ModifiersEvent = {
864
- altKey: boolean;
865
- ctrlKey: boolean;
866
- metaKey: boolean;
867
- shiftKey: boolean;
868
- };
869
- /**
870
- * Strict modifier match.
871
- *
872
- * - Omitted or `false`: MUST NOT be held.
873
- * - `true`: MUST be held.
874
- * - `'optional'`: accepts either held or unheld.
875
- * - `mod` is platform-aware: matches `metaKey` on mac, `ctrlKey` elsewhere,
876
- * and the *other* platform key is forbidden. Callers should not combine
877
- * `mod` with `meta`/`ctrl`.
878
- */
879
- declare function matchModifiers(e: ModifiersEvent, mods: ModSpec | undefined, isMac: boolean): boolean;
880
- /** Case-insensitive key match; supports string or string[] (any-of). */
881
- declare function matchKey(eventKey: string, specKey: string | string[]): boolean;
882
927
  /**
883
928
  * A {@link TargetSpec} resolved to its form, with the parts each form encodes
884
929
  * in its string pulled out. This is the one enumeration of the target forms:
@@ -932,6 +977,30 @@ declare function parseTargetSpec(spec: TargetSpec): TargetSpecForm | null;
932
977
  * silent wildcard.
933
978
  */
934
979
  declare function matchTarget(target: unknown, specTarget: TargetSpec | undefined, bodyTarget?: string, bodyKind?: string): boolean;
980
+
981
+ /** Pure gesture matcher — no React, no state, no DOM. */
982
+
983
+ /** The modifier-state fields `matchModifiers` reads. Structural, so a DOM
984
+ * `KeyboardEvent` or `PointerEvent` satisfies it directly. */
985
+ type ModifiersEvent = {
986
+ altKey: boolean;
987
+ ctrlKey: boolean;
988
+ metaKey: boolean;
989
+ shiftKey: boolean;
990
+ };
991
+ /**
992
+ * Strict modifier match.
993
+ *
994
+ * - Omitted or `false`: MUST NOT be held.
995
+ * - `true`: MUST be held.
996
+ * - `'optional'`: accepts either held or unheld.
997
+ * - `mod` is platform-aware: matches `metaKey` on mac, `ctrlKey` elsewhere,
998
+ * and the *other* platform key is forbidden. Callers should not combine
999
+ * `mod` with `meta`/`ctrl`.
1000
+ */
1001
+ declare function matchModifiers(e: ModifiersEvent, mods: ModSpec | undefined, isMac: boolean): boolean;
1002
+ /** Case-insensitive key match; supports string or string[] (any-of). */
1003
+ declare function matchKey(eventKey: string, specKey: string | string[]): boolean;
935
1004
  /** Per-tool gesture-lifecycle state at match time. `selfChannel` is the tool
936
1005
  * id `'&'` resolves to — the tool that owns the binding being evaluated — or
937
1006
  * `null` for a binding without an owning tool, in which case no `'&'` atom
@@ -979,6 +1048,126 @@ declare function matchIngestTypes(items: readonly {
979
1048
  */
980
1049
  declare function isApplePlatform(): boolean;
981
1050
 
1051
+ /** One key of a shortcut as a keyboard prints it. */
1052
+ interface KeyLegend {
1053
+ /** Glyph drawn for the key (modifier or key). */
1054
+ label: string;
1055
+ /** When true, the key may be held but is not required to trigger the action
1056
+ * (e.g. an optional modifier). Defaults to false. */
1057
+ optional?: boolean;
1058
+ }
1059
+ /** Logical modifier name. Matches the kit's `ModifierKey` in shape — kept as a
1060
+ * local type so `weasel-ui` stays independent of `@weasel-js/core`. */
1061
+ type LogicalMod = 'mod' | 'shift' | 'alt' | 'ctrl' | 'meta';
1062
+ /** Detected (or overridden) platform. `'mod'` resolves to ⌘ on macOS,
1063
+ * Ctrl on Windows / Linux. */
1064
+ type Platform = 'macos' | 'windows' | 'linux';
1065
+ /** Visual form of a modifier or named-key label.
1066
+ *
1067
+ * - `'auto'` (default): the per-entry, per-platform default — whatever
1068
+ * label is actually printed on the physical key. macOS keys show the
1069
+ * Apple glyphs (⌘ ⌥ ⌃ ⇧ ⎋ ↵ ⇥). Windows keys mostly show text
1070
+ * (Ctrl, Alt, Shift) BUT the Win key shows its logo (⊞) and the
1071
+ * context-menu key shows ▤. Linux mirrors Windows with Super (⊞).
1072
+ * - `'symbol'` forces a glyph wherever the key has one: Apple's on macOS,
1073
+ * ISO/IEC 9995-7's elsewhere (⎈ Ctrl, ⎇ Alt, ⎋ Esc). A key with no
1074
+ * symbol still falls back to text.
1075
+ * - `'text'` always spells the label out (Cmd / Option / Esc / Enter). */
1076
+ type LegendStyle = 'auto' | 'symbol' | 'text';
1077
+ /** A modifier to render, named platform-independently. */
1078
+ interface LogicalModSpec {
1079
+ name: LogicalMod;
1080
+ /** Marks this modifier as optional (may be held but isn't required).
1081
+ * Forwarded as the resulting `KeyLegend.optional`. */
1082
+ optional?: boolean;
1083
+ }
1084
+ /** Options for {@link keySpecsFromMods}. */
1085
+ interface KeySpecsFromModsOptions {
1086
+ /** Override OS detection. Defaults to {@link detectPlatform}. */
1087
+ platform?: Platform;
1088
+ /** Defaults to `'auto'`. Pass `'symbol'` or `'text'` to force a specific
1089
+ * style regardless of platform. */
1090
+ legend?: LegendStyle;
1091
+ /** Labels that replace the platform and legend tables', by a modifier's
1092
+ * logical name (`mod`, `shift`, `alt`, `ctrl`, `meta`) or a key's
1093
+ * `KeyboardEvent.key` (`Escape`, `Home`, `z`): a whole keyboard's worth
1094
+ * for a layout, a locale, or a keyboard of one's own. */
1095
+ labels?: KeyLabels;
1096
+ }
1097
+ /** Key labels by modifier name or `KeyboardEvent.key`. See {@link KeySpecsFromModsOptions.labels}. */
1098
+ type KeyLabels = Readonly<Record<string, string>>;
1099
+ /** Every label {@link keySpecsFromMods} can produce, on any platform and legend. */
1100
+ declare const MODIFIER_LABELS: ReadonlySet<string>;
1101
+ /** The platform whose key legends to draw. Whether `mod` is ⌘ comes from
1102
+ * `isApplePlatform`, the same read the dispatcher
1103
+ * matches `mod` with, so a keycap names the key that fires. With no
1104
+ * `navigator` (a server render) it answers `'macos'`, the kit's docs
1105
+ * convention; an unrecognized browser reads as `'linux'`. */
1106
+ declare function detectPlatform(): Platform;
1107
+ /**
1108
+ * Map a list of **logical** modifiers (`'mod'`, `'shift'`, …) to **visual**
1109
+ * `KeyLegend`s appropriate for the current platform — `'mod'` means "Cmd on
1110
+ * Mac, Ctrl on Win/Linux".
1111
+ *
1112
+ * ```ts
1113
+ * <KeySequence
1114
+ * keys={keySpecsFromMods([{ name: 'mod' }, { name: 'shift', optional: true }])}
1115
+ * />
1116
+ * ```
1117
+ */
1118
+ declare function keySpecsFromMods(mods: readonly LogicalModSpec[], opts?: KeySpecsFromModsOptions): readonly KeyLegend[];
1119
+ /**
1120
+ * Render a raw `KeyboardEvent.key` value as a platform-appropriate
1121
+ * `KeyLegend`. Named non-modifier keys (Esc, Enter, Tab, Space, Backspace,
1122
+ * Delete, arrows) get the platform's conventional form. Letter / digit /
1123
+ * other keys pass through upper-cased.
1124
+ *
1125
+ * ```ts
1126
+ * keySpecFromKey('Escape', { platform: 'macos' }) // { label: '⎋' }
1127
+ * keySpecFromKey('Escape', { platform: 'windows' }) // { label: 'Esc' }
1128
+ * keySpecFromKey('p') // { label: 'P' }
1129
+ * ```
1130
+ *
1131
+ * Symbol mode off macOS draws ISO/IEC 9995-7's symbols where the key
1132
+ * has one, though Windows and Linux keyboards print the text form.
1133
+ */
1134
+ declare function keySpecFromKey(raw: string, opts?: KeySpecsFromModsOptions & {
1135
+ optional?: boolean;
1136
+ }): KeyLegend;
1137
+
1138
+ /** A keyboard shortcut as the formatters read it: `@weasel-js/menu`'s `MenuShortcut`. */
1139
+ type ShortcutInput = MenuShortcut;
1140
+ /** Options for {@link formatShortcut} and {@link formatShortcutParts}. */
1141
+ interface FormatShortcutOptions {
1142
+ /** Defaults to the detected platform. */
1143
+ platform?: Platform;
1144
+ }
1145
+ /** Format a shortcut as an array of display chips, one per key: the
1146
+ * modifiers, then the key, each as the platform's keyboard prints it, so
1147
+ * `mod` is `⌘` on macOS and `Ctrl` elsewhere. UIs that want a boxed per-key
1148
+ * look render each chip in its own element. `shift: 'optional'` is treated
1149
+ * as falsy. */
1150
+ declare function formatShortcutParts(b: ShortcutInput | undefined, opts?: FormatShortcutOptions): readonly string[] | undefined;
1151
+ /** Format a shortcut as a single display string. Order: mod, shift, alt,
1152
+ * key. On macOS the glyphs run together (`⌘⇧Z`); elsewhere the keys are
1153
+ * spelled out and joined with `+` (`Ctrl+Shift+Z`). Returns `undefined` for
1154
+ * `undefined` input so callers can `??` a fallback. When `key` is an array,
1155
+ * the first element is used. `shift: 'optional'` is treated as falsy. */
1156
+ declare function formatShortcut(b: ShortcutInput | undefined, opts?: FormatShortcutOptions): string | undefined;
1157
+
1158
+ /**
1159
+ * A shortcut as key legends, one per key, as `@weasel-js/ui`'s `KeySequence` draws them: mod, shift, alt, then the
1160
+ * key, each in the platform's own legend (`⌘` on macOS, `Ctrl` elsewhere).
1161
+ * `formatShortcutParts` is these labels as strings. `shift: 'optional'`
1162
+ * becomes an optional key; a key list
1163
+ * renders its first key. Returns `undefined` for no shortcut.
1164
+ *
1165
+ * ```tsx
1166
+ * <KeySequence keys={keySpecsFromShortcut({ key: 'z', mod: true, shift: true })} />
1167
+ * ```
1168
+ */
1169
+ declare function keySpecsFromShortcut(shortcut: ShortcutInput | undefined, opts?: KeySpecsFromModsOptions): readonly KeyLegend[] | undefined;
1170
+
982
1171
  /**
983
1172
  * The nearest element `from` sits inside that can host a portalled overlay,
984
1173
  * or `null` when there is none and the body is the only answer.
@@ -1008,7 +1197,7 @@ declare function fixedSeat(from: Element | null | undefined): FixedSeat;
1008
1197
  * [1] mods — count of required modifier keys (shift/alt/ctrl/meta/mod).
1009
1198
  * `'optional'` does NOT count.
1010
1199
  * [2] phase — how much the spec's `phase` narrows; see `phaseRank`.
1011
- * [3] exact — per-kind tiebreak: 2 for a drop/paste spec with a
1200
+ * [3] exact — per-kind tiebreak: 2 for a drop/carry/paste spec with a
1012
1201
  * non-empty `types` MIME filter, else 1.
1013
1202
  *
1014
1203
  * Identical tuples fall back to registration order in the matcher's
@@ -1083,6 +1272,8 @@ interface PressFacts extends EventModifiers {
1083
1272
  affordance?: unknown;
1084
1273
  bodyTarget?: BodyTarget;
1085
1274
  bodyKind?: BodyKind;
1275
+ /** Replayed onto the double click in place of `bodyTarget` and `bodyKind`. */
1276
+ doubleClickBody?: DoubleClickBody;
1086
1277
  }
1087
1278
  /** Where and how a press was released, for the click it turned out to be. */
1088
1279
  interface ReleaseFacts extends EventModifiers {
@@ -1176,6 +1367,8 @@ interface PressSample extends PointerSample {
1176
1367
  affordance?: unknown;
1177
1368
  bodyTarget?: BodyTarget;
1178
1369
  bodyKind?: BodyKind;
1370
+ /** What a double click this press completes lands on, where not the body hit. */
1371
+ doubleClickBody?: DoubleClickBody;
1179
1372
  }
1180
1373
 
1181
1374
  /**
@@ -1214,6 +1407,14 @@ interface PointerRecognizerOptions {
1214
1407
  toSurface?: (client: Point) => Point;
1215
1408
  /** Long-press hold time in ms, read at each press. Default `LONG_PRESS_MS`. */
1216
1409
  longPressMs?: () => number | undefined;
1410
+ /** Longest gap in ms between two clicks of a double click, read at each click. Default `DOUBLE_CLICK_MAX_MS`. */
1411
+ doubleClickMs?: () => number | undefined;
1412
+ /** Farthest two clicks of a double click may land apart, in CSS px. Default `DOUBLE_CLICK_MAX_PX`. */
1413
+ doubleClickPx?: () => number | undefined;
1414
+ /** Farthest a multitouch tap's fingers may travel, in CSS px. Default `TAP_THRESHOLD_PX`. */
1415
+ tapPx?: () => number | undefined;
1416
+ /** Travel in CSS px that makes a press a drag, read at each move. Default `DRAG_THRESHOLD_PX`. */
1417
+ dragPx?: () => number | undefined;
1217
1418
  clicks?: ClickMemory;
1218
1419
  observer?: RecognizerObserver;
1219
1420
  }
@@ -1250,6 +1451,22 @@ declare const CONTROL_SELECTOR: string;
1250
1451
  */
1251
1452
  declare function isInControlWithin(target: EventTarget | null, host: Element): boolean;
1252
1453
 
1454
+ /**
1455
+ * Which pointers are down on an element: pressed on it or inside it, with any
1456
+ * button, and not yet released. The one answer to "is this move a hover" for
1457
+ * everything listening on that element.
1458
+ */
1459
+ interface HeldPointers {
1460
+ count(): number;
1461
+ /** Stop reading. The element's listeners go with its last reader. */
1462
+ release(): void;
1463
+ }
1464
+ /**
1465
+ * Read the pointers held on `element`. Every reader of one element shares one
1466
+ * set of listeners, so they cannot disagree.
1467
+ */
1468
+ declare function heldPointers(element: Element): HeldPointers;
1469
+
1253
1470
  /** Returns true when the target takes typed text: an input, a textarea, or a contenteditable element. */
1254
1471
  declare function isEditableTarget(target: EventTarget | null): boolean;
1255
1472
  /**
@@ -1268,4 +1485,4 @@ declare function activatesFocusedControl(e: KeyboardEvent): boolean;
1268
1485
  */
1269
1486
  declare function focusedControlOwnsKey(e: KeyboardEvent): boolean;
1270
1487
 
1271
- export { ACTIVE_SIGILS, type BodyClassification, type BodyKind, type BodyTarget, CONTROL_SELECTOR, type ChannelRef, type ClaimableGesture, type ClickEvent, type ClickMemory, type ClickObserver, type ClickSpec, type ContextMenuEvent, type ContextMenuSpec, DOUBLE_CLICK_MAX_MS, DOUBLE_CLICK_MAX_PX, DRAG_THRESHOLD_PX, type DescribeRouteOptions, type DoubleClickEvent, type DoubleClickSpec, type DragSpec, type DropEvent, type DropSpec, type EventModifiers, type FixedSeat, GESTURE_DESCRIPTORS, type GestureArgSpec, type GestureDescriptor, type GestureName, type GestureSpec, type HeldPress, type HoverEvent, type HoverSpec, type IngestItem, type InputEvent, type KeyEvent, type KeyHeldEvent, type KeyHeldSpec, type KeySpec, LONG_PRESS_MS, type LongPressEvent, type LongPressSpec, MOD_NAME_SET, type ModRequirement, type ModSpec, type ModifierCombo, type ModifierKey, type ModifiersEvent, type MultiTouchEvent, type MultiTouchGeometry, type MultiTouchSpec, type MultiTouchTapEvent, type MultiTouchTapSpec, type OptionalMod, type ParsedKeyRoute, type ParsedModifiers, type ParsedRoute, type PasteEvent, type PasteSpec, type PhaseAtom, type PhaseContext, type PhaseSpec, type PinchEvent, type PinchSpec, type Point, type PointerCancelEvent, type PointerDownEvent, type PointerDownSpec, type PointerIdentity, type PointerMoveEvent, type PointerRecognizer, type PointerRecognizerOptions, type PointerSample, type PointerSampleStylus, type PointerUpEvent, type PressFacts, type PressSample, RESERVED_ID_NAMES, RESERVED_ID_PREFIXES, RESERVED_SIGILS, ROUTE_FIELD_DEFINITIONS, ROUTE_TERMS, type RecognizerClock, type RecognizerObserver, type RecognizerSink, type ReleaseFacts, type ReleaseOutcome, type RouteDescriptionPart, type RouteFieldName, type RoutePhase, type RouteTermLabel, TAP_THRESHOLD_PX, type TargetPredicate, type TargetSpec, type TargetSpecForm, UNKNOWN_MIME, VALID_MOD_NAMES, type WheelEvent, type WheelSpec, activatesFocusedControl, bodyPredicate, canonicalModifiers, clickEvents, collapseShiftPairs, createPointerRecognizer, describeRoute, describeRouteParts, fixedSeat, focusedControlOwnsKey, formatKeyRoute, formatPhaseAtom, formatRoute, getGestureDescriptor, hitPredicate, isApplePlatform, isEditableTarget, isInControlWithin, isKnownGestureName, keyRouteToSpec, longPressEvents, matchIngestTypes, matchKey, matchModifiers, matchPhase, matchSpec, matchTarget, mimeMatchesGlob, modifierComboToParsed, mods, multiTouchGeometry, nearestPortalHost, parseKeyRoute, parseRoute, parseTargetSpec, pastDragThreshold, routeGestureForSpecKind, routeToSpec, specKindForRouteGesture, specificity, stylusOf };
1488
+ export { ACTIVE_SIGILS, type BodyClassification, type BodyKind, type BodyTarget, CONTROL_SELECTOR, type CarryEvent, type CarrySpec, type ChannelRef, type ClaimableGesture, type ClickEvent, type ClickMemory, type ClickObserver, type ClickSpec, type ContextMenuEvent, type ContextMenuSpec, DOUBLE_CLICK_MAX_MS, DOUBLE_CLICK_MAX_PX, DRAG_THRESHOLD_PX, type DescribeRouteOptions, type DoubleClickBody, type DoubleClickEvent, type DoubleClickSpec, type DragSpec, type DropEvent, type DropSpec, type EventModifiers, type FixedSeat, type FormatShortcutOptions, GESTURE_DESCRIPTORS, type GestureArgSpec, type GestureDescriptor, type GestureName, type GestureSpec, type HeldPointers, type HeldPress, type HoverEvent, type HoverSpec, type HoverWhere, type IngestItem, type InputEvent, type KeyEvent, type KeyHeldEvent, type KeyHeldSpec, type KeyLabels, type KeyLegend, type KeySpec, type KeySpecsFromModsOptions, LONG_PRESS_MS, type LegendStyle, type LogicalMod, type LogicalModSpec, type LongPressEvent, type LongPressSpec, MODIFIER_LABELS, MOD_NAME_SET, type ModRequirement, type ModSpec, type ModifierCombo, type ModifierKey, type ModifiersEvent, type MultiTouchEvent, type MultiTouchGeometry, type MultiTouchSpec, type MultiTouchTapEvent, type MultiTouchTapSpec, type OptionalMod, type ParsedKeyRoute, type ParsedModifiers, type ParsedRoute, type PasteEvent, type PasteSpec, type PhaseAtom, type PhaseContext, type PhaseSpec, type PinchEvent, type PinchSpec, type Platform, type Point, type PointerCancelEvent, type PointerDownEvent, type PointerDownSpec, type PointerIdentity, type PointerMoveEvent, type PointerRecognizer, type PointerRecognizerOptions, type PointerSample, type PointerSampleStylus, type PointerUpEvent, type PressFacts, type PressSample, RESERVED_ID_NAMES, RESERVED_ID_PREFIXES, RESERVED_SIGILS, ROUTE_FIELD_DEFINITIONS, ROUTE_TERMS, type RecognizerClock, type RecognizerObserver, type RecognizerSink, type ReleaseFacts, type ReleaseOutcome, type RouteDescriptionPart, type RouteFieldName, type RoutePhase, type RouteTermLabel, type ShortcutInput, TAP_THRESHOLD_PX, type TargetPredicate, type TargetSpec, type TargetSpecForm, UNKNOWN_MIME, VALID_MOD_NAMES, type WheelEvent, type WheelSpec, activatesFocusedControl, bodyPredicate, canonicalModifiers, clickEvents, collapseShiftPairs, createPointerRecognizer, describeRoute, describeRouteParts, detectPlatform, fixedSeat, focusedControlOwnsKey, formatKeyRoute, formatPhaseAtom, formatRoute, formatShortcut, formatShortcutParts, getGestureDescriptor, heldPointers, hitPredicate, isApplePlatform, isEditableTarget, isInControlWithin, isKnownGestureName, keyRouteToSpec, keySpecFromKey, keySpecsFromMods, keySpecsFromShortcut, longPressEvents, matchIngestTypes, matchKey, matchModifiers, matchPhase, matchSpec, matchTarget, mimeMatchesGlob, modifierComboToParsed, mods, multiTouchGeometry, nearestPortalHost, parseKeyRoute, parseRoute, parseTargetSpec, pastDragThreshold, routeGestureForSpecKind, routeToSpec, specKindForRouteGesture, specificity, stylusOf };