rdom-tui 0.3.3

Terminal rendering layer for rdom-core — flexbox layout, TUI styles, key/mouse events. Use rdom-core directly for headless DOM manipulation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
//! Mouse-event routing — `mousedown`, `mouseup`, `mousemove`,
//! click synthesis on common ancestor, hover transitions, wheel
//! auto-scroll.

use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};

use crate::layout::Overflow;
use crate::node::TuiNodeExt;
use crate::runtime::hit_test::HitTestExt;
use crate::style::ComputedStyle;
use crate::{TuiDispatchExt, TuiDom, TuiEvent};

use super::{RouteOutcome, Router};

/// Dispatch `ev` to `target`, accumulating any listener-requested
/// repaint ([`EventCtx::request_redraw`](rdom_core::EventCtx::request_redraw))
/// into the router. `Router::route` folds the accumulated flag into the
/// `RouteOutcome` once per mouse event.
fn dispatch(router: &mut Router, dom: &mut TuiDom, target: crate::NodeId, ev: &mut TuiEvent) {
    let _ = dom.dispatch_tui_event(target, ev);
    router.pending_redraw |= ev.event.redraw_requested();
}

/// Top-level entry for mouse events. Dispatches by kind.
pub(super) fn route_mouse(
    router: &mut Router,
    dom: &mut TuiDom,
    mouse: MouseEvent,
) -> RouteOutcome {
    match mouse.kind {
        MouseEventKind::Down(MouseButton::Left) => handle_down(router, dom, mouse),
        MouseEventKind::Up(MouseButton::Left) => handle_up(router, dom, mouse),
        MouseEventKind::Down(MouseButton::Right) => handle_right_down(router, dom, mouse),
        MouseEventKind::Up(MouseButton::Right) => handle_nonleft_up(router, dom, mouse),
        MouseEventKind::Down(MouseButton::Middle) => handle_nonleft_down(router, dom, mouse),
        MouseEventKind::Up(MouseButton::Middle) => handle_nonleft_up(router, dom, mouse),
        MouseEventKind::Moved | MouseEventKind::Drag(MouseButton::Left) => {
            handle_move(router, dom, mouse)
        }
        MouseEventKind::ScrollUp
        | MouseEventKind::ScrollDown
        | MouseEventKind::ScrollLeft
        | MouseEventKind::ScrollRight => handle_wheel(router, dom, mouse),
        // Right/middle drag — same hit-test-and-dispatch as Moved,
        // but only when button is held. v1 routes both as plain
        // mousemove (no special drag semantics for non-left buttons).
        MouseEventKind::Drag(_) => handle_move(router, dom, mouse),
    }
}

/// Right-button mousedown: fire `mousedown` first (every button
/// fires mousedown per UI Events), then `contextmenu`. Cancelling
/// `mousedown` does NOT suppress `contextmenu` — the two are
/// independent dispatches per HTML.
fn handle_right_down(router: &mut Router, dom: &mut TuiDom, mouse: MouseEvent) -> RouteOutcome {
    let Some(target) = dom.hit_test(mouse.column, mouse.row) else {
        return RouteOutcome::default();
    };
    let mut tui_down = TuiEvent::mousedown(mouse);
    dispatch(router, dom, target, &mut tui_down);

    let mut tui_ctx = TuiEvent::contextmenu(mouse);
    dispatch(router, dom, target, &mut tui_ctx);
    RouteOutcome::default()
}

/// Non-left mousedown (middle button): fire `mousedown`. No
/// associated default action; no click synthesis (browsers only
/// synthesize click for the left button).
fn handle_nonleft_down(router: &mut Router, dom: &mut TuiDom, mouse: MouseEvent) -> RouteOutcome {
    let Some(target) = dom.hit_test(mouse.column, mouse.row) else {
        return RouteOutcome::default();
    };
    let mut tui = TuiEvent::mousedown(mouse);
    dispatch(router, dom, target, &mut tui);
    RouteOutcome::default()
}

/// Non-left mouseup (right or middle button): fire `mouseup`.
/// No click synthesis, no pointer-capture release path
/// (capture is left-button-only in v1).
fn handle_nonleft_up(router: &mut Router, dom: &mut TuiDom, mouse: MouseEvent) -> RouteOutcome {
    let Some(target) = dom.hit_test(mouse.column, mouse.row) else {
        return RouteOutcome::default();
    };
    let mut tui = TuiEvent::mouseup(mouse);
    dispatch(router, dom, target, &mut tui);
    RouteOutcome::default()
}

/// `mousedown` with the left button. Hit-tests, remembers the
/// target as `down_target`, dispatches a bubbling `mousedown`,
/// and — unless the handler called `prevent_default` —
/// focus-on-click: walk up from hit to the nearest
/// `tabindex`-carrying ancestor and focus it (firing
/// blur/focusout + focus/focusin events).
fn handle_down(router: &mut Router, dom: &mut TuiDom, mouse: MouseEvent) -> RouteOutcome {
    // Register this click for multi-click tracking before any
    // dispatch — ensures consistent counting even if a handler
    // calls prevent_default on the mousedown.
    router.register_click(&mouse);

    let hit = dom.hit_test(mouse.column, mouse.row);
    router.down_target = hit;
    crate::rdom_trace!(
        "handle_down: hit_test({}, {}) = {:?}; pre-capture={:?}",
        mouse.column,
        mouse.row,
        hit,
        dom.pointer_capture()
    );

    let Some(target) = hit else {
        return RouteOutcome::default();
    };

    let mut tui = TuiEvent::mousedown(mouse);
    dispatch(router, dom, target, &mut tui);

    // Default actions run only when the handler didn't cancel them.
    // Browsers fire focus + selection-begin on mousedown, both off
    // the same event — so a single prevent_default() suppresses both.
    let mut redraw = false;
    if !tui.event.default_prevented() {
        // Scrollbar click check first — if the mousedown landed on
        // a scrollbar's thumb or track, that beats focus / selection
        // defaults (matches browser behavior where clicking a
        // scrollbar doesn't focus the container or start a text
        // selection).
        let path = dom.hit_test_path(mouse.column, mouse.row);
        if let Some(sb_hit) = crate::runtime::scrollbar::hit(dom, &path, mouse.column, mouse.row)
            && crate::runtime::scrollbar::handle_mousedown(router, dom, sb_hit)
        {
            return RouteOutcome {
                redraw_requested: true,
                quit_requested: false,
            };
        }

        if let Some(focusable) = crate::runtime::focus::nearest_focusable_ancestor(dom, target) {
            let prev = dom.focused();
            crate::runtime::focus::focus_node(dom, Some(focusable));
            if prev != Some(focusable) {
                redraw = true;
            }
        }

        // Drag-select default action: if the click landed on
        // selectable text, set a caret selection at that position
        // and engage pointer capture so subsequent moves extend it.
        let drag_started = crate::runtime::selection::drag::begin(router, dom, mouse);
        crate::rdom_trace!(
            "handle_down: selection::drag::begin returned {drag_started}; \
             post-capture={:?} selection_drag={:?}",
            dom.pointer_capture(),
            router.selection_drag.is_some()
        );
        if drag_started {
            redraw = true;

            // Multi-click promotion: two fast clicks on the same
            // word expand to a word-select, three to a line-select.
            // `register_click` was called up top — we just consume
            // its count here. Only apply when a drag actually began
            // (matches drag-begin's user-select:none gating).
            let count = router.last_click.map(|c| c.count).unwrap_or(1);
            let changed = match count {
                2 => crate::runtime::selection::multiclick::expand_to_word(dom),
                3 => crate::runtime::selection::multiclick::expand_to_line(dom),
                _ => false,
            };
            if changed {
                redraw = true;
            }
        }
    }

    RouteOutcome {
        redraw_requested: redraw,
        quit_requested: false,
    }
}

/// `mouseup` with the left button. Dispatches `mouseup` to the
/// hit target; if `down_target` is set, also dispatches a
/// synthesized `click` to the common ancestor of down+up targets
/// (matches HTML semantics).
///
/// **Pointer capture**: while `dom.pointer_capture()` is set, both
/// the `mouseup` and synthesized `click` route to the captured
/// element regardless of the cursor's actual position. The capture
/// is auto-released on `mouseup` (also browser-faithful).
fn handle_up(router: &mut Router, dom: &mut TuiDom, mouse: MouseEvent) -> RouteOutcome {
    let captured = dom.pointer_capture();
    let hit = dom.hit_test(mouse.column, mouse.row);
    let down_target = router.down_target.take();
    crate::rdom_trace!(
        "handle_up: hit={hit:?} down_target={down_target:?} captured={captured:?} \
         selection_drag={} scrollbar_drag={}",
        router.selection_drag.is_some(),
        router.scrollbar_drag.is_some()
    );

    // Routing target for mouseup: captured element (if any) else hit.
    let up_target = captured.or(hit);

    if let Some(target) = up_target {
        let mut tui_up = TuiEvent::mouseup(mouse);
        dispatch(router, dom, target, &mut tui_up);
    }

    // Click synthesis.
    //   With capture: click fires on the captured element (regardless
    //     of where the cursor is). The common-ancestor dance is moot —
    //     capture says "all follow-up events are mine."
    //   Without capture: click fires on the common ancestor of
    //     mousedown's target and mouseup's hit target (HTML spec).
    //     Requires both down_target and hit to exist.
    let click_target = if let Some(cap) = captured {
        Some(cap)
    } else if let (Some(down), Some(up)) = (down_target, hit) {
        dom.common_ancestor(down, up)
    } else {
        None
    };
    if let Some(target) = click_target {
        let mut tui_click = TuiEvent::click(mouse);
        tui_click.event = tui_click.event.clone().with_synthetic(true);
        dispatch(router, dom, target, &mut tui_click);

        // Multi-click promotion: fire `dblclick` on the second
        // click of a sequence, AFTER the regular click event.
        // `register_click` recorded the running count on the
        // matching mousedown; we consume it here. Only the 2nd
        // click promotes — a 3rd click in the same sequence is a
        // triple-click gesture (selection extends to a line); a
        // 4th wraps the counter back to 1 and starts a new pair,
        // so dblclick will fire again on a fresh second click.
        // Synthetic per UI Events §5.10.
        let count = router.last_click.map(|c| c.count).unwrap_or(0);
        if count == 2 {
            let mut tui_dbl = TuiEvent::dblclick(mouse);
            tui_dbl.event = tui_dbl.event.clone().with_synthetic(true);
            dispatch(router, dom, target, &mut tui_dbl);
        }
    }

    // Auto-release the pointer. Browser-faithful: capture ends on
    // the next mouseup unless the handler explicitly re-captures.
    if captured.is_some() {
        crate::rdom_trace!("handle_up: auto-releasing pointer_capture (was {captured:?})");
        dom.release_pointer_capture();
    }

    // End any drag-selection in progress. Selection itself stays —
    // clicks / taps without drag leave a collapsed selection (caret)
    // at the click position, matching browser behavior.
    crate::runtime::selection::drag::end(router);
    crate::runtime::scrollbar::end_drag(router);
    crate::rdom_trace!(
        "handle_up: end of fn — capture={:?} hovered={:?}",
        dom.pointer_capture(),
        dom.hovered()
    );

    RouteOutcome::default()
}

/// `mousemove` (or drag with left button held). Hit-tests; if
/// the hit differs from `hover_target`, fires `mouseout` on the
/// old target, `mouseover` on the new, and updates
/// `dom.set_hovered` so the `:hover` pseudo-class cascade picks
/// up the change on the next cascade pass.
///
/// **Pointer capture**: while `dom.pointer_capture()` is set, the
/// `mousemove` routes to the captured element regardless of the
/// cursor's position. Hover transitions are also suppressed —
/// `:hover` stays on whatever it was; the browser treats the
/// captured element as the implicit hover target. This matches
/// what drag-interaction apps (slider scrubbing, resize handles,
/// rubber-band selection) need: the captured handler sees every
/// move, and the rest of the UI doesn't flicker its hover state.
fn handle_move(router: &mut Router, dom: &mut TuiDom, mouse: MouseEvent) -> RouteOutcome {
    // Stale pointer-capture release: a `Moved` event (button NOT
    // held) arriving while `pointer_capture` is set means the
    // mouseup that would have released it never reached us — the
    // user released the button outside the terminal window, or the
    // terminal lost focus mid-drag and the mouseup was delivered to
    // a different window. crossterm doesn't surface focus events
    // unless `EnableFocusChange` is opted in, so we can't watch for
    // FocusLost to clean up; instead we treat the next button-less
    // motion as evidence the drag is over.
    //
    // Browser-faithful: the W3C Pointer Events spec fires
    // `pointercancel` when the implementation determines a pointer
    // is unlikely to produce any more events (window blur during
    // drag is the canonical case). `pointercancel` releases capture
    // and ends any in-progress drag — exactly what we do here, just
    // detected via the absence of a button on the follow-up motion.
    //
    // Without this fix, hover updates were dead until the user
    // clicked an element to force the mouseup auto-release path
    // (`handle_up` → `release_pointer_capture`).
    if dom.pointer_capture().is_some() && matches!(mouse.kind, MouseEventKind::Moved) {
        crate::rdom_trace!(
            "handle_move: stale capture detected (Moved with capture={:?}) — releasing",
            dom.pointer_capture()
        );
        dom.release_pointer_capture();
        crate::runtime::selection::drag::end(router);
        crate::runtime::scrollbar::end_drag(router);
    }

    // Pointer capture path: route to captured, no hover updates.
    if let Some(captured) = dom.pointer_capture() {
        crate::rdom_trace!("handle_move: capture branch — routing to {captured:?}, hover skipped");
        let mut tui = TuiEvent::mousemove(mouse);
        dispatch(router, dom, captured, &mut tui);

        // Drag-select default action: extend selection focus to the
        // current cursor position. Runs alongside the mousemove
        // dispatch so a handler that wants custom drag behavior can
        // `prevent_default` on `selectstart` (Phase 6.5.5) — but not
        // on mousemove itself, since browsers don't wire it that way.
        let mut redraw = false;
        if let Some(anchor_flow) = router.selection_drag
            && crate::runtime::selection::drag::extend(dom, mouse, anchor_flow)
        {
            redraw = true;
        }
        // Scrollbar drag default action: if a thumb drag is active,
        // translate the cursor delta along the track into a scroll
        // offset change on the captured scrollbar owner. Runs during
        // the pointer-capture path because mousedown on a thumb
        // engages capture on the scrollbar element.
        if router.scrollbar_drag.is_some()
            && crate::runtime::scrollbar::extend_drag(router, dom, mouse.column, mouse.row)
        {
            redraw = true;
        }
        return RouteOutcome {
            redraw_requested: redraw,
            quit_requested: false,
        };
    }

    let hit = dom.hit_test(mouse.column, mouse.row);
    if crate::runtime::trace::enabled() {
        // Resolve tag + class for human-readable trace.
        let info = hit
            .map(|id| {
                let n = dom.node(id);
                let tag = n.tag_name().unwrap_or("?");
                let cls = n.get_attribute("class").unwrap_or("");
                format!("{id:?} <{tag} class=\"{cls}\">")
            })
            .unwrap_or_else(|| "None".to_string());
        crate::rdom_trace!(
            "handle_move: hit_test({}, {}) = {info}  (prev hover_target={:?})",
            mouse.column,
            mouse.row,
            router.hover_target
        );
    }

    // Dispatch mousemove on the current hit.
    if let Some(target) = hit {
        let mut tui = TuiEvent::mousemove(mouse);
        dispatch(router, dom, target, &mut tui);
    }

    // Hover transition?
    let changed = hit != router.hover_target;
    if !changed {
        crate::rdom_trace!(
            "handle_move: no hover change ({:?} == hover_target); returning no-op",
            hit
        );
        return RouteOutcome::default();
    }

    let prev = router.hover_target;
    router.hover_target = hit;
    crate::rdom_trace!(
        "handle_move: hover transition {prev:?} -> {hit:?}; calling set_hovered + redraw"
    );

    // mouseout on the previous hover target.
    if let Some(old) = prev {
        let mut tui_out = TuiEvent::mouseout(mouse);
        tui_out.event = tui_out.event.clone().with_synthetic(true);
        dispatch(router, dom, old, &mut tui_out);
    }
    // mouseover on the new hover target.
    if let Some(new) = hit {
        let mut tui_over = TuiEvent::mouseover(mouse);
        tui_over.event = tui_over.event.clone().with_synthetic(true);
        dispatch(router, dom, new, &mut tui_over);
    }
    // Update Dom-level hover state so cascade picks up :hover.
    dom.set_hovered(hit);

    RouteOutcome {
        redraw_requested: true,
        quit_requested: false,
    }
}

/// Wheel (scroll) event. Dispatches a cancelable `wheel` event on
/// the hit target, then — unless `prevent_default` was called —
/// walks ancestors for the nearest `overflow: Scroll | Auto`
/// container and adjusts its scroll offset.
///
/// Scroll amount: **1 cell per wheel tick**. Some terminals send
/// a wheel tick per line, some per "notch"; terminal emulator
/// conventions vary. 1 cell is the most predictable default;
/// higher rates can come from `App::wheel_scroll_lines(n)` in a
/// later iteration.
///
/// Clamping: saturating-sub prevents underflow below zero on the
/// low end. The upper bound is deferred until the layout pass
/// populates `ext.scroll_content_{width,height}` (currently left
/// unbounded — apps that want a cap can listen for `wheel` and
/// `prevent_default` past their limit).
fn handle_wheel(router: &mut Router, dom: &mut TuiDom, mouse: MouseEvent) -> RouteOutcome {
    let Some(target) = dom.hit_test(mouse.column, mouse.row) else {
        return RouteOutcome::default();
    };

    // Dispatch wheel first — a handler may cancel the default
    // scroll by calling `ctx.event.prevent_default()`.
    let mut tui = TuiEvent::wheel(mouse);
    dispatch(router, dom, target, &mut tui);
    if tui.event.default_prevented() {
        return RouteOutcome::default();
    }

    // Translate kind → (dx, dy) in cells. Positive dy = scroll
    // forward (user sees content that was below the fold).
    let (dx, dy): (i32, i32) = match mouse.kind {
        MouseEventKind::ScrollUp => (0, -1),
        MouseEventKind::ScrollDown => (0, 1),
        MouseEventKind::ScrollLeft => (-1, 0),
        MouseEventKind::ScrollRight => (1, 0),
        // Unreachable given route_mouse's match; match-exhaustive
        // compiler insists.
        _ => return RouteOutcome::default(),
    };

    // Walk ancestors for the nearest scrollable container. First
    // match wins — no nested-scroll chaining in v1.
    // Which axis is this wheel event moving? crossterm emits
    // wheel events with a single axis set (either (0, ±1) or
    // (±1, 0)), so a scrollable ancestor must match the
    // relevant axis's overflow.
    let wants_y = dy != 0;
    let wants_x = dx != 0;

    let mut cur = Some(target);
    while let Some(id) = cur {
        let computed = dom
            .node(id)
            .computed()
            .cloned()
            .unwrap_or_else(ComputedStyle::initial);
        let y_scrollable = matches!(computed.overflow_y, Overflow::Scroll | Overflow::Auto);
        let x_scrollable = matches!(computed.overflow_x, Overflow::Scroll | Overflow::Auto);
        if (wants_y && y_scrollable) || (wants_x && x_scrollable) {
            // Capture pre-mutation offsets so we can detect change
            // and dispatch a `scroll` event only when offsets
            // actually moved (matches HTML — at-the-bottom wheel
            // ticks are no-ops and don't fire scroll).
            //
            // Viewport size is the padding-box (CSS Overflow 3 §3
            // scrollport), not `content_layout` — the two diverge
            // under M5.5b border-collapse.
            let border = dom
                .node(id)
                .computed()
                .map(|c| c.border)
                .unwrap_or_default();
            let (old_x, old_y, new_x, new_y) = if let Some(ext) = dom.node_mut(id).ext_mut() {
                let pb = rdom_style::layout::compute_padding_box(ext.layout, border);
                let old_x = ext.scroll_x;
                let old_y = ext.scroll_y;
                if wants_y && y_scrollable {
                    let max_y = ext.scroll_content_height.saturating_sub(pb.height as usize);
                    apply_scroll(&mut ext.scroll_y, dy, max_y);
                }
                if wants_x && x_scrollable {
                    let max_x = ext.scroll_content_width.saturating_sub(pb.width as usize);
                    apply_scroll(&mut ext.scroll_x, dx, max_x);
                }
                (old_x, old_y, ext.scroll_x, ext.scroll_y)
            } else {
                (0, 0, 0, 0)
            };
            if old_x != new_x || old_y != new_y {
                // `scroll`: bubbles, NOT cancelable per HTML.
                let mut tui_scroll = TuiEvent::new("scroll");
                tui_scroll.event.cancelable = false;
                dispatch(router, dom, id, &mut tui_scroll);
                return RouteOutcome {
                    redraw_requested: true,
                    quit_requested: false,
                };
            }
            return RouteOutcome::default();
        }
        cur = dom.node(id).parent_node().map(|p| p.id());
    }

    // No scrollable ancestor — event bubbled but nothing scrolled.
    RouteOutcome::default()
}

/// Adjust a `usize` scroll offset by a signed `i32` delta, clamped
/// to `[0, max]`. `max` is the maximum legal scroll position —
/// `scroll_content_size - viewport_size`, saturating at 0 when
/// content fits. Matches the browser's wheel-scroll behavior: at
/// the bottom of the content, further wheel-down does nothing.
fn apply_scroll(offset: &mut usize, delta: i32, max: usize) {
    if delta > 0 {
        *offset = offset.saturating_add(delta as usize).min(max);
    } else if delta < 0 {
        *offset = offset.saturating_sub((-delta) as usize);
    }
}

#[cfg(test)]
mod tests;