Skip to content

Commit 5a83d15

Browse files
Merge pull request #69 from RtlZeroMemory/mouse-input-hardening
Harden mouse input routing contracts with deterministic coverage
2 parents d4a5e7f + cb49e31 commit 5a83d15

8 files changed

Lines changed: 1692 additions & 161 deletions

File tree

docs/guide/input-and-focus.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,15 @@ In widget mode, key routing is:
125125

126126
## Mouse Input
127127

128-
Rezi supports mouse interaction when the terminal supports mouse tracking. Mouse events are routed through the same focus system as keyboard input:
128+
Mouse input shares the same focus state as keyboard navigation:
129129

130-
- **Click** any focusable widget to focus it. Clicking a button also activates its `onPress` callback.
131-
- **Scroll wheel** scrolls focused or hovered scrollable widgets (VirtualList, CodeEditor, LogsConsole, DiffViewer).
132-
- **Drag** split pane dividers to resize panels.
133-
- **Click** a modal backdrop to close the modal (when `closeOnBackdrop` is enabled).
130+
- Mouse down transfers focus immediately to the hit, enabled focusable widget.
131+
- Button activation is press/release based (down + up on the same enabled button).
132+
- Wheel routing prefers a `VirtualList` under cursor, then focused scrollables; wheel deltas are step-based and clamped.
133+
- `SplitPane` dividers use dedicated drag routing (split panes are not focusable targets).
134+
- Mouse events do not run keybinding chord matching directly.
134135

135-
Mouse and keyboard input can be freely mixed. Clicking a widget updates the same focus state that Tab navigation uses.
136-
137-
For the complete mouse support reference, see the [Mouse Support](mouse-support.md) guide.
136+
For complete click/scroll/drag and hit-testing invariants, see [Mouse Support](mouse-support.md).
138137

139138
## Event Handling
140139

docs/guide/mouse-support.md

Lines changed: 54 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -1,176 +1,77 @@
11
# Mouse Support
22

3-
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.
3+
Rezi enables mouse routing when terminal capabilities report mouse support.
44

5-
No configuration is required. Mouse support is detected at startup and works alongside keyboard navigation.
5+
## Mouse Input
66

7-
## Terminal Detection
7+
### Click model
88

9-
The Zireael engine detects mouse support at startup through `terminalCaps.supportsMouse`. Most modern terminals support mouse tracking:
9+
- On mouse down, Rezi hit-tests and immediately moves focus to the hit widget if it is focusable and enabled.
10+
- Activation uses a press/release model:
11+
- Mouse down captures `pressedId` only for pressable targets (currently button IDs).
12+
- Mouse up emits `action: "press"` only if release lands on the same enabled pressable ID.
13+
- Releasing elsewhere (or disabling/removing the target) cancels the press.
14+
- Non-pressable focusable widgets still take focus on mouse down but do not emit the generic `"press"` action.
15+
- `SplitPane` is intentionally not focusable; divider drag is handled by dedicated mouse logic.
1016

11-
- **Supported:** iTerm2, Alacritty, kitty, WezTerm, Windows Terminal, GNOME Terminal, Konsole, tmux, VS Code integrated terminal
12-
- **Not supported:** Some legacy terminals and bare `xterm` configurations
17+
### Scroll routing and boundaries
1318

14-
If the terminal doesn't support mouse tracking, Rezi falls back to keyboard-only navigation. Your app works either way — no conditional code needed.
19+
Wheel routing order:
1520

16-
## What Works with Mouse
21+
1. `VirtualList` under cursor (`hitTestTargetId`), else focused `VirtualList`.
22+
2. If no `VirtualList` handled the event: focused `CodeEditor`, `LogsConsole`, then `DiffViewer`.
23+
3. If none match, the wheel event is ignored.
1724

18-
### Clicking to Focus and Activate
19-
20-
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:
21-
22-
```typescript
23-
ui.button({
24-
id: "save",
25-
label: "Save",
26-
onPress: () => save(), // Fires on click or Enter/Space
27-
})
28-
```
25+
Wheel step size (where implemented):
2926

30-
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.
27+
- `VirtualList`: `wheelY * 3` lines
28+
- `CodeEditor`: `wheelY * 3` vertical lines, `wheelX * 3` horizontal columns
29+
- `LogsConsole`: `wheelY * 3` lines
30+
- `DiffViewer`: `wheelY * 3` lines
3131

32-
### Scroll Wheel
32+
Clamping:
3333

34-
The mouse wheel scrolls any scrollable widget that is focused or under the cursor:
34+
- `VirtualList.scrollTop`: `[0, max(0, totalHeight - viewportHeight)]`
35+
- `CodeEditor.scrollTop`: `[0, max(0, lineCount - viewportHeight)]`
36+
- `CodeEditor.scrollLeft`: `>= 0`
37+
- `LogsConsole.scrollTop` / `DiffViewer.scrollTop`: `[0, max(0, contentLines - viewportHeight)]`
3538

36-
| Widget | Scroll Behavior |
37-
|--------|----------------|
38-
| [VirtualList](../widgets/virtual-list.md) | Scrolls items (3 lines per tick) |
39-
| [CodeEditor](../widgets/code-editor.md) | Scrolls vertically and horizontally |
40-
| [LogsConsole](../widgets/logs-console.md) | Scrolls log entries |
41-
| [DiffViewer](../widgets/diff-viewer.md) | Scrolls diff content |
42-
| [Table](../widgets/table.md) | Scrolls rows (when virtualized) |
39+
### SplitPane drag lifecycle
4340

44-
Scroll callbacks (`onScroll`) fire for both keyboard navigation and mouse wheel input.
41+
- Drag starts on primary-button mouse down in divider hit area (`dividerSize` plus 1-cell expansion on each side).
42+
- Runtime captures pointer start position and panel start sizes.
43+
- On move/drag, delta is applied via `handleDividerDrag(...)`:
44+
- Only the two panels adjacent to the dragged divider resize.
45+
- Delta is clamped against both panels' `minSizes` and `maxSizes`.
46+
- The panel-pair total size is preserved.
47+
- `onResize` fires on each drag update (absolute cells or percentages, based on `sizeMode`).
48+
- Mouse up ends drag and clears drag state. If pane metadata disappears mid-drag, drag state is canceled.
49+
- When `collapsible` and `onCollapse` are set, divider double-click (`<= 500ms`) toggles collapse for the side clicked.
4550

46-
### Dragging Split Pane Dividers
51+
### Mouse + keyboard interaction
4752

48-
[SplitPane](../widgets/split-pane.md) dividers can be dragged with the mouse to resize panels:
53+
- Clicking transfers focus through the same focus state used by Tab/Shift+Tab.
54+
- Focus transfer triggers normal blur behavior for the previously focused input (`onBlur`).
55+
- Keybinding chord state is managed by key routing rules (key events, timeout, mode switch). Mouse events do not run the chord matcher directly.
4956

50-
```typescript
51-
ui.splitPane(
52-
{
53-
id: "main",
54-
direction: "horizontal",
55-
sizes: state.sizes,
56-
onResize: (sizes) => app.update((s) => ({ ...s, sizes })),
57-
},
58-
[Sidebar(), Editor()]
59-
)
60-
```
57+
### Hit-testing priority
6158

62-
Mouse down on the divider starts the drag. Moving the mouse updates panel sizes in real-time. Releasing the mouse ends the drag.
59+
Mouse routing priority:
6360

64-
### Clicking Modal Backdrops
61+
1. Top open dropdown (consumes mouse input and blocks lower layers while open).
62+
2. Layer hit-test using resolved layer order (higher `zIndex` first; ties resolved by later registration/order).
63+
3. Base widget hit-test (`hitTestFocusable`) with ancestor clip intersection and deterministic overlap tie-break:
64+
- depth-first preorder traversal (children left-to-right)
65+
- later tree-order nodes win overlap ties
6566

66-
Modals with `closeOnBackdrop: true` (the default) close when the user clicks the backdrop area:
67+
Modal/layer blocking invariants:
6768

68-
```typescript
69-
ui.modal({
70-
id: "confirm",
71-
title: "Are you sure?",
72-
content: ui.text("This will delete the item."),
73-
closeOnBackdrop: true, // Default — click backdrop to close
74-
onClose: () => app.update((s) => ({ ...s, showModal: false })),
75-
actions: [
76-
ui.button({ id: "yes", label: "Yes" }),
77-
ui.button({ id: "no", label: "No" }),
78-
],
79-
})
80-
```
69+
- The topmost modal blocks input to all lower layers and base widgets, including points outside modal bounds.
70+
- Backdrop visual style (`none`/`dim`/`opaque`) does not change blocking semantics.
71+
- If `closeOnBackdrop` is enabled and `onClose` exists, backdrop mouse down closes the blocking layer.
8172

82-
When a modal layer blocks input, mouse events to widgets below the modal are blocked entirely.
73+
## Related
8374

84-
### Toast Action Buttons
85-
86-
[Toast](../widgets/toast.md) notifications with action buttons can be clicked:
87-
88-
```typescript
89-
app.update((s) => ({
90-
...s,
91-
toasts: [...s.toasts, {
92-
id: "saved",
93-
message: "File saved",
94-
type: "success",
95-
action: { label: "Undo", onAction: () => undoSave() }, // Clickable
96-
}],
97-
}));
98-
```
99-
100-
## How Mouse Routing Works
101-
102-
Mouse events flow through a deterministic pipeline:
103-
104-
```
105-
Terminal mouse input
106-
|
107-
v
108-
Zireael engine (detects & encodes mouse events)
109-
|
110-
v
111-
ZREV event batch (binary protocol)
112-
|
113-
v
114-
Hit testing — which widget is under (x, y)?
115-
|
116-
v
117-
Layer check — is a modal blocking input?
118-
|
119-
v
120-
Mouse router — focus, press/release, scroll, drag
121-
|
122-
v
123-
Widget callbacks (onPress, onScroll, onResize, etc.)
124-
```
125-
126-
### Hit Testing
127-
128-
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.
129-
130-
Disabled widgets are excluded from hit testing — they cannot receive mouse events.
131-
132-
### Press and Release
133-
134-
Mouse routing uses a simple state machine:
135-
136-
1. **Mouse down** on a focusable widget: focus moves to that widget and it becomes the "pressed" target
137-
2. **Mouse up** on the same widget: the press action fires (e.g., `onPress` callback)
138-
3. **Mouse up** on a different widget: the press is cancelled, no action fires
139-
140-
This ensures accidental clicks don't trigger actions when the user drags away from a button.
141-
142-
## Mouse Events in the Event System
143-
144-
Raw mouse events are available through `app.onEvent()`:
145-
146-
```typescript
147-
app.onEvent((ev) => {
148-
if (ev.kind === "engine" && ev.event.kind === "mouse") {
149-
const { x, y, mouseKind, wheelY } = ev.event;
150-
// mouseKind: 1=move, 2=press, 3=release, 4=drag, 5=scroll
151-
}
152-
});
153-
```
154-
155-
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.
156-
157-
## Keyboard + Mouse
158-
159-
Mouse support is additive. All keyboard navigation continues to work:
160-
161-
| Action | Keyboard | Mouse |
162-
|--------|----------|-------|
163-
| Focus a widget | Tab / Shift+Tab | Click |
164-
| Activate a button | Enter / Space | Click |
165-
| Scroll a list | Arrow keys / Page Up/Down | Scroll wheel |
166-
| Resize split panes | *(not available)* | Drag divider |
167-
| Close modal | Escape | Click backdrop |
168-
| Navigate options | Arrow keys | Click option |
169-
170-
Users can freely mix keyboard and mouse input. Focus state is shared — clicking a widget updates the same focus ring that Tab navigation uses.
171-
172-
## Next Steps
173-
174-
- [Input & Focus](input-and-focus.md) — Keyboard navigation and focus management
175-
- [Widget Catalog](../widgets/index.md) — Browse all widgets and their mouse interactions
176-
- [Terminal Capabilities](../backend/terminal-caps.md) — How terminal features are detected
75+
- [Input & Focus](input-and-focus.md)
76+
- [Layers](../widgets/layers.md)
77+
- [SplitPane](../widgets/split-pane.md)

packages/core/src/app/createApp.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
getMode,
5353
registerBindings,
5454
registerModes,
55+
resetChordState,
5556
routeKeyEvent,
5657
setMode,
5758
} from "../keybindings/index.js";
@@ -729,6 +730,17 @@ export function createApp<S>(
729730
ev.kind === "key" || ev.kind === "text" || ev.kind === "paste" || ev.kind === "mouse";
730731
if (mode === "widget" && isWidgetRoutableEvent) {
731732
if (keybindingsEnabled) {
733+
if (
734+
ev.kind === "mouse" &&
735+
ev.mouseKind === 3 &&
736+
keybindingState.chordState.pendingKeys.length > 0
737+
) {
738+
keybindingState = Object.freeze({
739+
...keybindingState,
740+
chordState: resetChordState(),
741+
});
742+
}
743+
732744
// Route key events through keybinding system first
733745
if (ev.kind === "key") {
734746
const bypass = widgetRenderer.shouldBypassKeybindings(ev);

packages/core/src/keybindings/__tests__/keybinding.conflicts.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,56 @@ describe("routing semantics", () => {
383383
});
384384

385385
describe("app routing precedence", () => {
386+
test("mouse click clears pending chord before the next key", async () => {
387+
const backend = new StubBackend();
388+
let chordHits = 0;
389+
390+
const app = createApp({ backend, initialState: 0 });
391+
app.keys({
392+
"g g": () => {
393+
chordHits++;
394+
},
395+
});
396+
app.view(() => ui.text("No focusable widgets"));
397+
398+
await app.start();
399+
await pushEvents(backend, [{ kind: "resize", timeMs: 1, cols: 40, rows: 10 }]);
400+
await settleNextFrame(backend);
401+
402+
await pushEvents(backend, [{ kind: "key", timeMs: 2, key: KEY_G, action: "down" }]);
403+
await pushEvents(backend, [
404+
{ kind: "mouse", timeMs: 3, x: 0, y: 0, mouseKind: 3, buttons: 1 },
405+
{ kind: "mouse", timeMs: 4, x: 0, y: 0, mouseKind: 4, buttons: 0 },
406+
]);
407+
await pushEvents(backend, [{ kind: "key", timeMs: 5, key: KEY_G, action: "down" }]);
408+
409+
assert.equal(chordHits, 0);
410+
});
411+
412+
test("mouse up does not clear chord started after mouse down", async () => {
413+
const backend = new StubBackend();
414+
let chordHits = 0;
415+
416+
const app = createApp({ backend, initialState: 0 });
417+
app.keys({
418+
"g g": () => {
419+
chordHits++;
420+
},
421+
});
422+
app.view(() => ui.text("No focusable widgets"));
423+
424+
await app.start();
425+
await pushEvents(backend, [{ kind: "resize", timeMs: 1, cols: 40, rows: 10 }]);
426+
await settleNextFrame(backend);
427+
428+
await pushEvents(backend, [{ kind: "mouse", timeMs: 2, x: 0, y: 0, mouseKind: 3, buttons: 1 }]);
429+
await pushEvents(backend, [{ kind: "key", timeMs: 3, key: KEY_G, action: "down" }]);
430+
await pushEvents(backend, [{ kind: "mouse", timeMs: 4, x: 0, y: 0, mouseKind: 4, buttons: 0 }]);
431+
await pushEvents(backend, [{ kind: "key", timeMs: 5, key: KEY_G, action: "down" }]);
432+
433+
assert.equal(chordHits, 1);
434+
});
435+
386436
test("app-level keybinding consumes Enter before widget-level button routing", async () => {
387437
const backend = new StubBackend();
388438
let keybindingHits = 0;

0 commit comments

Comments
 (0)