rdom-tui 0.2.0

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
//! Scrollbar mouse interaction — click-on-track to page, drag
//! the thumb to scroll.
//!
//! Companion to `render::paint_pass::scrollbar` (which paints the
//! track + thumb). Hit-testing and drag share the same geometry
//! math from that module so click targets match what's rendered.
//!
//! Hooks into `router::mouse`:
//!
//! - On `mousedown`: call [`hit`] to see if the click landed on a
//!   scrollbar. If it did, either [`page`] (track click) or
//!   [`begin_drag`] (thumb click). When begin_drag fires, it
//!   engages pointer capture so subsequent mousemove/mouseup
//!   route back here.
//! - On `mousemove` while `router.scrollbar_drag` is set:
//!   [`extend_drag`] adjusts the scroll offset proportionally to
//!   the cursor's movement along the track.
//! - On `mouseup`: the router's existing pointer-capture release
//!   auto-triggers. [`end_drag`] clears the drag record.

use rdom_core::NodeId;

use crate::TuiDom;
use crate::layout::{LayoutRect, Overflow};
use crate::node::TuiNodeExt;
use crate::render::paint_pass::scrollbar::{should_paint, thumb_geometry};
use crate::runtime::router::Router;

/// Which scrollbar axis a user is interacting with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAxis {
    Vertical,
    Horizontal,
}

/// What part of a scrollbar got clicked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarPart {
    /// Mouse on the track above / left of the thumb — page back.
    TrackBefore,
    /// Mouse on the thumb — start a drag.
    Thumb,
    /// Mouse on the track below / right of the thumb — page forward.
    TrackAfter,
}

/// Result of a scrollbar hit test.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollbarHit {
    pub element: NodeId,
    pub axis: ScrollAxis,
    pub part: ScrollbarPart,
    /// Cursor offset along the scrollbar track in cells (from track
    /// start). Used by `begin_drag` to compute the thumb-relative
    /// anchor so dragging doesn't snap the thumb.
    pub cursor_along_track: u16,
}

/// Per-session drag state; lives on `Router` between events.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ScrollbarDrag {
    element: NodeId,
    axis: ScrollAxis,
    /// Cursor position along the track at `mousedown`.
    initial_cursor: u16,
    /// Scroll offset at `mousedown`.
    initial_scroll: usize,
}

/// Check whether `(x, y)` lands on a scrollbar rendered for any
/// ancestor starting at `path_inner` (the hit-test path, innermost-
/// first). Returns the first match walking outward — which is the
/// same element the user perceives as the scrollbar owner.
pub(crate) fn hit(dom: &TuiDom, path: &[NodeId], x: u16, y: u16) -> Option<ScrollbarHit> {
    // Walk the path outward — scrollbars belong to the scrollable
    // container, and its content_layout's gutter is OUTSIDE content
    // but INSIDE its outer rect, so the container is the last path
    // element (or one of its ancestors) containing the point.
    for &id in path.iter().rev() {
        if let Some(h) = check_element(dom, id, x, y) {
            return Some(h);
        }
    }
    None
}

fn check_element(dom: &TuiDom, id: NodeId, x: u16, y: u16) -> Option<ScrollbarHit> {
    let ext = dom.node(id).tui_ext()?;
    let content = ext.content_layout;
    let computed = dom.node(id).computed()?;
    // CSS Overflow 3 §3: scrollbar gutter is inside the padding-box.
    // Column position uses `content_layout` (gutter already accounted
    // for by `reserve_scrollbar_gutter`); track extent is clamped to
    // padding-box so a click on a border-row column doesn't register
    // as a scrollbar hit under M5.5b border-collapse.
    let padding_box = rdom_style::layout::compute_padding_box(ext.layout, computed.border);

    let y_reserves = matches!(computed.overflow_y, Overflow::Scroll | Overflow::Auto);
    let x_reserves = matches!(computed.overflow_x, Overflow::Scroll | Overflow::Auto);

    // Vertical scrollbar sits in the column just right of
    // content.x + content.width. Horizontal sits in the row just
    // below content.y + content.height.
    let v_col = content.x + content.width as i32;
    let h_row = content.y + content.height as i32;
    let in_v_col = x as i32 == v_col;
    let in_h_row = y as i32 == h_row;

    // Vertical track spans y in [content.y, content.y + height) clamped
    // to padding-box rows; minus one row for the corner if horizontal
    // also reserves.
    let v_top = content.y.max(padding_box.y);
    let mut v_bottom =
        (content.y + content.height as i32).min(padding_box.y + padding_box.height as i32);
    if x_reserves {
        v_bottom -= 1;
    }
    let in_v_rows = (y as i32) >= v_top && (y as i32) < v_bottom;

    let h_left = content.x.max(padding_box.x);
    let mut h_right =
        (content.x + content.width as i32).min(padding_box.x + padding_box.width as i32);
    if y_reserves {
        h_right -= 1;
    }
    let in_h_cols = (x as i32) >= h_left && (x as i32) < h_right;

    if y_reserves && in_v_col && in_v_rows {
        let track_len = (v_bottom - v_top) as u16;
        let viewport = content.height;
        let content_size = ext.scroll_content_height;
        if !should_paint(computed.overflow_y, viewport as usize, content_size) {
            return None;
        }
        let (thumb_size, thumb_off) =
            thumb_geometry(track_len, viewport as usize, content_size, ext.scroll_y);
        let cursor_along = (y as i32 - v_top) as u16;
        return Some(ScrollbarHit {
            element: id,
            axis: ScrollAxis::Vertical,
            part: classify(cursor_along, thumb_off, thumb_size),
            cursor_along_track: cursor_along,
        });
    }

    if x_reserves && in_h_row && in_h_cols {
        let track_len = (h_right - h_left) as u16;
        let viewport = content.width;
        let content_size = ext.scroll_content_width;
        if !should_paint(computed.overflow_x, viewport as usize, content_size) {
            return None;
        }
        let (thumb_size, thumb_off) =
            thumb_geometry(track_len, viewport as usize, content_size, ext.scroll_x);
        let cursor_along = (x as i32 - h_left) as u16;
        return Some(ScrollbarHit {
            element: id,
            axis: ScrollAxis::Horizontal,
            part: classify(cursor_along, thumb_off, thumb_size),
            cursor_along_track: cursor_along,
        });
    }

    None
}

fn classify(cursor: u16, thumb_off: u16, thumb_size: u16) -> ScrollbarPart {
    if cursor < thumb_off {
        ScrollbarPart::TrackBefore
    } else if cursor < thumb_off + thumb_size {
        ScrollbarPart::Thumb
    } else {
        ScrollbarPart::TrackAfter
    }
}

/// Handle a `mousedown` on a scrollbar. Routes to page (for track
/// clicks) or starts a drag (for thumb clicks). Returns `true`
/// when the scrollbar consumed the event — caller should then
/// skip downstream default actions like focus-on-click.
pub(crate) fn handle_mousedown(router: &mut Router, dom: &mut TuiDom, hit: ScrollbarHit) -> bool {
    match hit.part {
        ScrollbarPart::TrackBefore | ScrollbarPart::TrackAfter => {
            page(dom, hit);
            true
        }
        ScrollbarPart::Thumb => {
            begin_drag(router, dom, hit);
            true
        }
    }
}

/// Track click — page scroll by one viewport in the appropriate
/// direction. `TrackBefore` scrolls toward the start; `TrackAfter`
/// toward the end.
fn page(dom: &mut TuiDom, hit: ScrollbarHit) {
    let (viewport, current_scroll) = scroll_metrics(dom, hit.element, hit.axis);
    let sign: i32 = match hit.part {
        ScrollbarPart::TrackBefore => -1,
        ScrollbarPart::TrackAfter => 1,
        ScrollbarPart::Thumb => return,
    };
    let delta = viewport as i32 * sign;
    set_scroll(dom, hit.element, hit.axis, current_scroll as i32 + delta);
}

/// Begin a thumb-drag session. Engages pointer capture on the
/// scrollbar owner so follow-up mousemove/mouseup route there,
/// then records the starting cursor and scroll offset so
/// [`extend_drag`] can compute relative movement.
fn begin_drag(router: &mut Router, dom: &mut TuiDom, hit: ScrollbarHit) {
    let (_, initial_scroll) = scroll_metrics(dom, hit.element, hit.axis);
    let _ = dom.set_pointer_capture(hit.element);
    router.scrollbar_drag = Some(ScrollbarDrag {
        element: hit.element,
        axis: hit.axis,
        initial_cursor: hit.cursor_along_track,
        initial_scroll,
    });
}

/// Extend an in-progress thumb drag to the cursor's current
/// position. Converts the cursor's delta along the track into a
/// scroll delta using the track ↔ content ratio. Returns `true`
/// when the scroll actually changed (caller requests redraw).
pub(crate) fn extend_drag(router: &Router, dom: &mut TuiDom, mouse_x: u16, mouse_y: u16) -> bool {
    let Some(drag) = router.scrollbar_drag else {
        return false;
    };
    let ext = match dom.node(drag.element).tui_ext() {
        Some(e) => e,
        None => return false,
    };
    // Drag math (cursor-delta → scroll-delta) reads from the padding-
    // box per CSS Overflow 3 §3; the track lives in the padding-box,
    // not `content_layout`.
    let border = dom
        .node(drag.element)
        .computed()
        .map(|c| c.border)
        .unwrap_or_default();
    let content = rdom_style::layout::compute_padding_box(ext.layout, border);
    let (viewport, content_size, track_len) = match drag.axis {
        ScrollAxis::Vertical => {
            let x_reserves = dom
                .node(drag.element)
                .computed()
                .is_some_and(|c| matches!(c.overflow_x, Overflow::Scroll | Overflow::Auto));
            let adj = if x_reserves { 1 } else { 0 };
            (
                content.height as usize,
                ext.scroll_content_height,
                content.height.saturating_sub(adj),
            )
        }
        ScrollAxis::Horizontal => {
            let y_reserves = dom
                .node(drag.element)
                .computed()
                .is_some_and(|c| matches!(c.overflow_y, Overflow::Scroll | Overflow::Auto));
            let adj = if y_reserves { 1 } else { 0 };
            (
                content.width as usize,
                ext.scroll_content_width,
                content.width.saturating_sub(adj),
            )
        }
    };

    let cursor_now = match drag.axis {
        ScrollAxis::Vertical => mouse_y as i32 - content.y,
        ScrollAxis::Horizontal => mouse_x as i32 - content.x,
    };
    let cursor_delta = cursor_now - drag.initial_cursor as i32;
    let travel = content_size.saturating_sub(viewport);
    if travel == 0 || track_len == 0 {
        return false;
    }
    let (thumb_size, _) = thumb_geometry(track_len, viewport, content_size, drag.initial_scroll);
    let track_travel = track_len.saturating_sub(thumb_size) as i32;
    if track_travel == 0 {
        return false;
    }
    let scroll_delta = (cursor_delta as i64 * travel as i64 / track_travel as i64) as i32;
    let new_scroll = (drag.initial_scroll as i32 + scroll_delta).max(0);
    let before = match drag.axis {
        ScrollAxis::Vertical => ext.scroll_y,
        ScrollAxis::Horizontal => ext.scroll_x,
    };
    let actually_set = set_scroll(dom, drag.element, drag.axis, new_scroll);
    actually_set != before
}

/// Clear the drag record. Pointer capture is released by the
/// router's existing mouseup path (browser-faithful auto-release).
pub(crate) fn end_drag(router: &mut Router) {
    router.scrollbar_drag = None;
}

// ── Helpers ─────────────────────────────────────────────────────────

/// `(viewport_size_in_cells, current_scroll_offset)` for a given
/// element + axis. Viewport = padding-box per CSS Overflow 3 §3.
fn scroll_metrics(dom: &TuiDom, element: NodeId, axis: ScrollAxis) -> (u16, usize) {
    let ext = match dom.node(element).tui_ext() {
        Some(e) => e,
        None => return (0, 0),
    };
    let border = dom
        .node(element)
        .computed()
        .map(|c| c.border)
        .unwrap_or_default();
    let pb = rdom_style::layout::compute_padding_box(ext.layout, border);
    match axis {
        ScrollAxis::Vertical => (pb.height, ext.scroll_y),
        ScrollAxis::Horizontal => (pb.width, ext.scroll_x),
    }
}

/// Set the scroll offset for `element` on `axis`, clamped to
/// `[0, content - viewport]`. Returns the clamped value actually
/// written. Viewport = padding-box per CSS Overflow 3 §3.
/// Scroll the nearest vertically-scrollable ancestor of `node` so the
/// viewport-coord region `reveal` becomes visible. Uses
/// `block: "nearest"` alignment that never hides `reveal`'s TOP edge,
/// so a navigation cursor row stays anchored — the ARIA listbox/tree
/// pattern, and the browser's focus scroll-into-view. Vertical axis
/// only for now; nested scroll containers resolve to the nearest one
/// (sufficient for current widgets — revisit if a nested-scroller case
/// appears).
///
/// Reuses [`set_scroll`], so the clamp to `[0, max]` and the `scroll`
/// event dispatch are shared with wheel / scrollbar interaction.
pub(crate) fn scroll_into_view(dom: &mut TuiDom, node: NodeId, reveal: LayoutRect) {
    let mut cur = dom.node(node).parent_node().map(|p| p.id());
    while let Some(id) = cur {
        if is_vertical_scroll_container(dom, id) {
            ensure_visible_vertical(dom, id, reveal);
            return;
        }
        cur = dom.node(id).parent_node().map(|p| p.id());
    }
}

/// `true` when `id` clips on the Y axis and has more content than its
/// scrollport can show (i.e. there's somewhere to scroll to).
fn is_vertical_scroll_container(dom: &TuiDom, id: NodeId) -> bool {
    let Some(ext) = dom.node(id).tui_ext() else {
        return false;
    };
    let overflow_y = dom
        .node(id)
        .computed()
        .map(|c| c.overflow_y)
        .unwrap_or(Overflow::Visible);
    if matches!(overflow_y, Overflow::Visible) {
        return false;
    }
    let border = dom
        .node(id)
        .computed()
        .map(|c| c.border)
        .unwrap_or_default();
    let pb = rdom_style::layout::compute_padding_box(ext.layout, border);
    ext.scroll_content_height > pb.height as usize
}

/// Adjust `container`'s vertical scroll so `reveal` is in the
/// scrollport, anchoring `reveal`'s top edge (never scroll so far down
/// that the top leaves the view).
fn ensure_visible_vertical(dom: &mut TuiDom, container: NodeId, reveal: LayoutRect) {
    let (port_top, port_bottom, cur_scroll) = {
        let Some(ext) = dom.node(container).tui_ext() else {
            return;
        };
        let border = dom
            .node(container)
            .computed()
            .map(|c| c.border)
            .unwrap_or_default();
        let pb = rdom_style::layout::compute_padding_box(ext.layout, border);
        (pb.y, pb.y + pb.height as i32, ext.scroll_y as i32)
    };
    let r_top = reveal.y;
    let r_bottom = reveal.y + reveal.height as i32;
    let delta = if r_top < port_top {
        // Region above the scrollport — scroll up to reveal its top.
        r_top - port_top
    } else if r_bottom > port_bottom {
        // Region below — scroll down just enough, but never past the
        // top edge (so a region taller than the port aligns its top).
        (r_bottom - port_bottom).min(r_top - port_top).max(0)
    } else {
        0
    };
    if delta != 0 {
        set_scroll(dom, container, ScrollAxis::Vertical, cur_scroll + delta);
    }
}

fn set_scroll(dom: &mut TuiDom, element: NodeId, axis: ScrollAxis, value: i32) -> usize {
    let (viewport, content_size) = {
        let ext = match dom.node(element).tui_ext() {
            Some(e) => e,
            None => return 0,
        };
        let border = dom
            .node(element)
            .computed()
            .map(|c| c.border)
            .unwrap_or_default();
        let pb = rdom_style::layout::compute_padding_box(ext.layout, border);
        match axis {
            ScrollAxis::Vertical => (pb.height as usize, ext.scroll_content_height),
            ScrollAxis::Horizontal => (pb.width as usize, ext.scroll_content_width),
        }
    };
    let max = content_size.saturating_sub(viewport) as i32;
    let clamped = value.clamp(0, max) as usize;
    let changed = if let Some(ext) = dom.node_mut(element).ext_mut() {
        match axis {
            ScrollAxis::Vertical => {
                let changed = ext.scroll_y != clamped;
                ext.scroll_y = clamped;
                changed
            }
            ScrollAxis::Horizontal => {
                let changed = ext.scroll_x != clamped;
                ext.scroll_x = clamped;
                changed
            }
        }
    } else {
        false
    };
    if changed {
        // M5 D5: scrollbar drag dispatches `scroll` like wheel +
        // programmatic mutation. Only fires when the offset
        // actually moved (dragging at the rail end is a no-op).
        // `scroll`: bubbles, NOT cancelable per HTML.
        let mut tui = crate::TuiEvent::new("scroll");
        tui.event.cancelable = false;
        let _ = crate::TuiDispatchExt::dispatch_tui_event(dom, element, &mut tui);
    }
    clamped
}

#[allow(dead_code)]
fn _layout_rect_unused(_: LayoutRect) {}

#[cfg(test)]
mod tests;