-
Notifications
You must be signed in to change notification settings - Fork 16
Harden mouse input routing contracts with deterministic coverage #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
9537076
test(core): add deterministic mouse scroll and split-pane drag suites
RtlZeroMemory 1f540c4
test(core): add deterministic mouse click lifecycle edge tests
RtlZeroMemory 7d982b2
fix(core): clear pending key chord on mouse click
RtlZeroMemory 8ddc27f
docs(guide): document mouse input invariants
RtlZeroMemory 8bcc73a
test(runtime): add deterministic mixed mouse/hit-test suite
RtlZeroMemory d77b4ad
test(runtime): cover chord reset on click in mixed suite
RtlZeroMemory 1197ed9
test(runtime): format mouse hardening suites
RtlZeroMemory cb49e31
fix(core): reset pending chord only on mouse down
RtlZeroMemory File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,176 +1,77 @@ | ||
| # Mouse Support | ||
|
|
||
| Rezi has built-in mouse support. When the terminal supports mouse tracking, Rezi automatically enables it — clicks focus and activate widgets, the scroll wheel navigates lists and editors, and split pane dividers can be dragged to resize. | ||
| Rezi enables mouse routing when terminal capabilities report mouse support. | ||
|
|
||
| No configuration is required. Mouse support is detected at startup and works alongside keyboard navigation. | ||
| ## Mouse Input | ||
|
|
||
| ## Terminal Detection | ||
| ### Click model | ||
|
|
||
| The Zireael engine detects mouse support at startup through `terminalCaps.supportsMouse`. Most modern terminals support mouse tracking: | ||
| - On mouse down, Rezi hit-tests and immediately moves focus to the hit widget if it is focusable and enabled. | ||
| - Activation uses a press/release model: | ||
| - Mouse down captures `pressedId` only for pressable targets (currently button IDs). | ||
| - Mouse up emits `action: "press"` only if release lands on the same enabled pressable ID. | ||
| - Releasing elsewhere (or disabling/removing the target) cancels the press. | ||
| - Non-pressable focusable widgets still take focus on mouse down but do not emit the generic `"press"` action. | ||
| - `SplitPane` is intentionally not focusable; divider drag is handled by dedicated mouse logic. | ||
|
|
||
| - **Supported:** iTerm2, Alacritty, kitty, WezTerm, Windows Terminal, GNOME Terminal, Konsole, tmux, VS Code integrated terminal | ||
| - **Not supported:** Some legacy terminals and bare `xterm` configurations | ||
| ### Scroll routing and boundaries | ||
|
|
||
| If the terminal doesn't support mouse tracking, Rezi falls back to keyboard-only navigation. Your app works either way — no conditional code needed. | ||
| Wheel routing order: | ||
|
|
||
| ## What Works with Mouse | ||
| 1. `VirtualList` under cursor (`hitTestTargetId`), else focused `VirtualList`. | ||
| 2. If no `VirtualList` handled the event: focused `CodeEditor`, `LogsConsole`, then `DiffViewer`. | ||
| 3. If none match, the wheel event is ignored. | ||
|
|
||
| ### Clicking to Focus and Activate | ||
|
|
||
| Clicking any focusable widget (button, input, select, checkbox, etc.) moves focus to it. Clicking a button also activates it, just like pressing Enter or Space: | ||
|
|
||
| ```typescript | ||
| ui.button({ | ||
| id: "save", | ||
| label: "Save", | ||
| onPress: () => save(), // Fires on click or Enter/Space | ||
| }) | ||
| ``` | ||
| Wheel step size (where implemented): | ||
|
|
||
| The click model uses press-and-release: mouse down captures the target, mouse up on the same target triggers the action. If the user drags away before releasing, no action fires. This matches how buttons work on the web and in native UIs. | ||
| - `VirtualList`: `wheelY * 3` lines | ||
| - `CodeEditor`: `wheelY * 3` vertical lines, `wheelX * 3` horizontal columns | ||
| - `LogsConsole`: `wheelY * 3` lines | ||
| - `DiffViewer`: `wheelY * 3` lines | ||
|
|
||
| ### Scroll Wheel | ||
| Clamping: | ||
|
|
||
| The mouse wheel scrolls any scrollable widget that is focused or under the cursor: | ||
| - `VirtualList.scrollTop`: `[0, max(0, totalHeight - viewportHeight)]` | ||
| - `CodeEditor.scrollTop`: `[0, max(0, lineCount - viewportHeight)]` | ||
| - `CodeEditor.scrollLeft`: `>= 0` | ||
| - `LogsConsole.scrollTop` / `DiffViewer.scrollTop`: `[0, max(0, contentLines - viewportHeight)]` | ||
|
|
||
| | Widget | Scroll Behavior | | ||
| |--------|----------------| | ||
| | [VirtualList](../widgets/virtual-list.md) | Scrolls items (3 lines per tick) | | ||
| | [CodeEditor](../widgets/code-editor.md) | Scrolls vertically and horizontally | | ||
| | [LogsConsole](../widgets/logs-console.md) | Scrolls log entries | | ||
| | [DiffViewer](../widgets/diff-viewer.md) | Scrolls diff content | | ||
| | [Table](../widgets/table.md) | Scrolls rows (when virtualized) | | ||
| ### SplitPane drag lifecycle | ||
|
|
||
| Scroll callbacks (`onScroll`) fire for both keyboard navigation and mouse wheel input. | ||
| - Drag starts on primary-button mouse down in divider hit area (`dividerSize` plus 1-cell expansion on each side). | ||
| - Runtime captures pointer start position and panel start sizes. | ||
| - On move/drag, delta is applied via `handleDividerDrag(...)`: | ||
| - Only the two panels adjacent to the dragged divider resize. | ||
| - Delta is clamped against both panels' `minSizes` and `maxSizes`. | ||
| - The panel-pair total size is preserved. | ||
| - `onResize` fires on each drag update (absolute cells or percentages, based on `sizeMode`). | ||
| - Mouse up ends drag and clears drag state. If pane metadata disappears mid-drag, drag state is canceled. | ||
| - When `collapsible` and `onCollapse` are set, divider double-click (`<= 500ms`) toggles collapse for the side clicked. | ||
|
|
||
| ### Dragging Split Pane Dividers | ||
| ### Mouse + keyboard interaction | ||
|
|
||
| [SplitPane](../widgets/split-pane.md) dividers can be dragged with the mouse to resize panels: | ||
| - Clicking transfers focus through the same focus state used by Tab/Shift+Tab. | ||
| - Focus transfer triggers normal blur behavior for the previously focused input (`onBlur`). | ||
| - Keybinding chord state is managed by key routing rules (key events, timeout, mode switch). Mouse events do not run the chord matcher directly. | ||
|
|
||
| ```typescript | ||
| ui.splitPane( | ||
| { | ||
| id: "main", | ||
| direction: "horizontal", | ||
| sizes: state.sizes, | ||
| onResize: (sizes) => app.update((s) => ({ ...s, sizes })), | ||
| }, | ||
| [Sidebar(), Editor()] | ||
| ) | ||
| ``` | ||
| ### Hit-testing priority | ||
|
|
||
| Mouse down on the divider starts the drag. Moving the mouse updates panel sizes in real-time. Releasing the mouse ends the drag. | ||
| Mouse routing priority: | ||
|
|
||
| ### Clicking Modal Backdrops | ||
| 1. Top open dropdown (consumes mouse input and blocks lower layers while open). | ||
| 2. Layer hit-test using resolved layer order (higher `zIndex` first; ties resolved by later registration/order). | ||
| 3. Base widget hit-test (`hitTestFocusable`) with ancestor clip intersection and deterministic overlap tie-break: | ||
| - depth-first preorder traversal (children left-to-right) | ||
| - later tree-order nodes win overlap ties | ||
|
|
||
| Modals with `closeOnBackdrop: true` (the default) close when the user clicks the backdrop area: | ||
| Modal/layer blocking invariants: | ||
|
|
||
| ```typescript | ||
| ui.modal({ | ||
| id: "confirm", | ||
| title: "Are you sure?", | ||
| content: ui.text("This will delete the item."), | ||
| closeOnBackdrop: true, // Default — click backdrop to close | ||
| onClose: () => app.update((s) => ({ ...s, showModal: false })), | ||
| actions: [ | ||
| ui.button({ id: "yes", label: "Yes" }), | ||
| ui.button({ id: "no", label: "No" }), | ||
| ], | ||
| }) | ||
| ``` | ||
| - The topmost modal blocks input to all lower layers and base widgets, including points outside modal bounds. | ||
| - Backdrop visual style (`none`/`dim`/`opaque`) does not change blocking semantics. | ||
| - If `closeOnBackdrop` is enabled and `onClose` exists, backdrop mouse down closes the blocking layer. | ||
|
|
||
| When a modal layer blocks input, mouse events to widgets below the modal are blocked entirely. | ||
| ## Related | ||
|
|
||
| ### Toast Action Buttons | ||
|
|
||
| [Toast](../widgets/toast.md) notifications with action buttons can be clicked: | ||
|
|
||
| ```typescript | ||
| app.update((s) => ({ | ||
| ...s, | ||
| toasts: [...s.toasts, { | ||
| id: "saved", | ||
| message: "File saved", | ||
| type: "success", | ||
| action: { label: "Undo", onAction: () => undoSave() }, // Clickable | ||
| }], | ||
| })); | ||
| ``` | ||
|
|
||
| ## How Mouse Routing Works | ||
|
|
||
| Mouse events flow through a deterministic pipeline: | ||
|
|
||
| ``` | ||
| Terminal mouse input | ||
| | | ||
| v | ||
| Zireael engine (detects & encodes mouse events) | ||
| | | ||
| v | ||
| ZREV event batch (binary protocol) | ||
| | | ||
| v | ||
| Hit testing — which widget is under (x, y)? | ||
| | | ||
| v | ||
| Layer check — is a modal blocking input? | ||
| | | ||
| v | ||
| Mouse router — focus, press/release, scroll, drag | ||
| | | ||
| v | ||
| Widget callbacks (onPress, onScroll, onResize, etc.) | ||
| ``` | ||
|
|
||
| ### Hit Testing | ||
|
|
||
| When a mouse event arrives, Rezi performs a depth-first traversal of the widget tree to find which focusable widget contains the cursor position. If multiple widgets overlap, the last one in document order (topmost) wins. | ||
|
|
||
| Disabled widgets are excluded from hit testing — they cannot receive mouse events. | ||
|
|
||
| ### Press and Release | ||
|
|
||
| Mouse routing uses a simple state machine: | ||
|
|
||
| 1. **Mouse down** on a focusable widget: focus moves to that widget and it becomes the "pressed" target | ||
| 2. **Mouse up** on the same widget: the press action fires (e.g., `onPress` callback) | ||
| 3. **Mouse up** on a different widget: the press is cancelled, no action fires | ||
|
|
||
| This ensures accidental clicks don't trigger actions when the user drags away from a button. | ||
|
|
||
| ## Mouse Events in the Event System | ||
|
|
||
| Raw mouse events are available through `app.onEvent()`: | ||
|
|
||
| ```typescript | ||
| app.onEvent((ev) => { | ||
| if (ev.kind === "engine" && ev.event.kind === "mouse") { | ||
| const { x, y, mouseKind, wheelY } = ev.event; | ||
| // mouseKind: 1=move, 2=press, 3=release, 4=drag, 5=scroll | ||
| } | ||
| }); | ||
| ``` | ||
|
|
||
| Most applications don't need raw mouse events — widget callbacks (`onPress`, `onScroll`, etc.) handle the common cases. Raw events are useful for custom widgets or debugging. | ||
|
|
||
| ## Keyboard + Mouse | ||
|
|
||
| Mouse support is additive. All keyboard navigation continues to work: | ||
|
|
||
| | Action | Keyboard | Mouse | | ||
| |--------|----------|-------| | ||
| | Focus a widget | Tab / Shift+Tab | Click | | ||
| | Activate a button | Enter / Space | Click | | ||
| | Scroll a list | Arrow keys / Page Up/Down | Scroll wheel | | ||
| | Resize split panes | *(not available)* | Drag divider | | ||
| | Close modal | Escape | Click backdrop | | ||
| | Navigate options | Arrow keys | Click option | | ||
|
|
||
| Users can freely mix keyboard and mouse input. Focus state is shared — clicking a widget updates the same focus ring that Tab navigation uses. | ||
|
|
||
| ## Next Steps | ||
|
|
||
| - [Input & Focus](input-and-focus.md) — Keyboard navigation and focus management | ||
| - [Widget Catalog](../widgets/index.md) — Browse all widgets and their mouse interactions | ||
| - [Terminal Capabilities](../backend/terminal-caps.md) — How terminal features are detected | ||
| - [Input & Focus](input-and-focus.md) | ||
| - [Layers](../widgets/layers.md) | ||
| - [SplitPane](../widgets/split-pane.md) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Including
mouseKind === 4here can cancel a chord that started after the click began: if a user presses and holds mouse, types the first key of a multi-key binding, then releases mouse, this branch clearspendingKeyson mouse-up and the next key no longer matches. The fix for stale pre-click chords only needs to clear on down (or otherwise distinguish up events that belong to a click that happened before the chord started).Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in cb49e31.
Changes:
mouseKind === 3(mouse-down) increateApp.mouse up does not clear chord started after mouse downinpackages/core/src/keybindings/__tests__/keybinding.conflicts.test.ts.Validation:
npm -w @rezi-ui/core run buildnode --test --test-concurrency=1 packages/core/dist/keybindings/__tests__/keybinding.conflicts.test.jsnpm run lintAll pass.