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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAxis {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarPart {
TrackBefore,
Thumb,
TrackAfter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollbarHit {
pub element: NodeId,
pub axis: ScrollAxis,
pub part: ScrollbarPart,
pub cursor_along_track: u16,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ScrollbarDrag {
element: NodeId,
axis: ScrollAxis,
initial_cursor: u16,
initial_scroll: usize,
}
pub(crate) fn hit(dom: &TuiDom, path: &[NodeId], x: u16, y: u16) -> Option<ScrollbarHit> {
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()?;
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);
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;
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
}
}
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
}
}
}
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);
}
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,
});
}
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,
};
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
}
pub(crate) fn end_drag(router: &mut Router) {
router.scrollbar_drag = None;
}
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),
}
}
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());
}
}
const AUTOSCROLL_EDGE_ZONE: i32 = 2;
const AUTOSCROLL_MAX_STEP: i32 = 3;
fn scroll_container_from_hit(dom: &TuiDom, x: u16, y: u16) -> Option<NodeId> {
use crate::runtime::hit_test::HitTestExt;
let hit = dom.hit_test(x, y)?;
let mut cur = Some(hit);
loop {
let id = cur?;
if is_vertical_scroll_container(dom, id) {
return Some(id);
}
cur = dom.node(id).parent_node().map(|p| p.id());
}
}
pub(crate) fn resolve_autoscroll_container(
dom: &TuiDom,
captured: NodeId,
pointer: (u16, u16),
) -> Option<NodeId> {
scroll_container_from_hit(dom, pointer.0, pointer.1).or_else(|| {
let cap = dom.node(captured).tui_ext()?.layout;
if cap.width == 0 || cap.height == 0 {
return None;
}
let cx = (pointer.0 as i32).clamp(cap.x, cap.x + cap.width as i32 - 1) as u16;
let cy = (pointer.1 as i32).clamp(cap.y, cap.y + cap.height as i32 - 1) as u16;
scroll_container_from_hit(dom, cx, cy)
})
}
pub(crate) fn autoscroll_step_for(
dom: &TuiDom,
container: NodeId,
pointer: (u16, u16),
) -> Option<(ScrollAxis, i32)> {
let ext = dom.node(container).tui_ext()?;
let border = dom
.node(container)
.computed()
.map(|c| c.border)
.unwrap_or_default();
let pb = rdom_style::layout::compute_padding_box(ext.layout, border);
let (viewport, offset, content) = (pb.height as usize, ext.scroll_y, ext.scroll_content_height);
let py = pointer.1 as i32;
let zone = AUTOSCROLL_EDGE_ZONE.max(1);
let last_row = pb.y + pb.height as i32 - 1;
let into_bottom = py - (last_row - (zone - 1));
if into_bottom >= 0 && offset + viewport < content {
return Some((
ScrollAxis::Vertical,
(into_bottom + 1).clamp(1, AUTOSCROLL_MAX_STEP),
));
}
let into_top = (pb.y + (zone - 1)) - py;
if into_top >= 0 && offset > 0 {
return Some((
ScrollAxis::Vertical,
-((into_top + 1).clamp(1, AUTOSCROLL_MAX_STEP)),
));
}
None
}
pub(crate) fn autoscroll_step(
dom: &mut TuiDom,
container: NodeId,
axis: ScrollAxis,
step: i32,
) -> bool {
let before = match dom.node(container).tui_ext() {
Some(e) => match axis {
ScrollAxis::Vertical => e.scroll_y,
ScrollAxis::Horizontal => e.scroll_x,
},
None => return false,
} as i32;
let after = set_scroll(dom, container, axis, before + step) as i32;
after != before
}
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
}
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 {
r_top - port_top
} else if r_bottom > port_bottom {
(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 {
let mut tui = crate::TuiEvent::new("scroll");
tui.event.cancelable = false;
let _ = crate::TuiDispatchExt::dispatch_tui_event(dom, element, &mut tui);
}
clamped
}
fn is_horizontal_scroll_container(dom: &TuiDom, id: NodeId) -> bool {
let Some(ext) = dom.node(id).tui_ext() else {
return false;
};
let overflow_x = dom
.node(id)
.computed()
.map(|c| c.overflow_x)
.unwrap_or(Overflow::Visible);
if matches!(overflow_x, 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_width > pb.width as usize
}
pub(crate) fn handle_scroll_key(dom: &mut TuiDom, key: crossterm::event::KeyEvent) -> bool {
use crossterm::event::{KeyCode, KeyModifiers};
let Some(focused) = dom.focused() else {
return false;
};
let mut cur = Some(focused);
let el = loop {
match cur {
Some(id)
if is_vertical_scroll_container(dom, id)
|| is_horizontal_scroll_container(dom, id) =>
{
break id;
}
Some(id) => cur = dom.node(id).parent_node().map(|p| p.id()),
None => return false,
}
};
let vert = is_vertical_scroll_container(dom, el);
let horiz = is_horizontal_scroll_container(dom, el);
let (vh, vscroll) = scroll_metrics(dom, el, ScrollAxis::Vertical);
let page = (vh as i32).max(1);
match key.code {
KeyCode::Down if vert => {
set_scroll(dom, el, ScrollAxis::Vertical, vscroll as i32 + 1);
true
}
KeyCode::Up if vert => {
set_scroll(dom, el, ScrollAxis::Vertical, vscroll as i32 - 1);
true
}
KeyCode::PageDown if vert => {
set_scroll(dom, el, ScrollAxis::Vertical, vscroll as i32 + page);
true
}
KeyCode::PageUp if vert => {
set_scroll(dom, el, ScrollAxis::Vertical, vscroll as i32 - page);
true
}
KeyCode::Char(' ') if vert => {
let dir = if key.modifiers.contains(KeyModifiers::SHIFT) {
-page
} else {
page
};
set_scroll(dom, el, ScrollAxis::Vertical, vscroll as i32 + dir);
true
}
KeyCode::Home if vert => {
set_scroll(dom, el, ScrollAxis::Vertical, 0);
true
}
KeyCode::End if vert => {
set_scroll(dom, el, ScrollAxis::Vertical, i32::MAX);
true
}
KeyCode::Right if horiz => {
let (_, hscroll) = scroll_metrics(dom, el, ScrollAxis::Horizontal);
set_scroll(dom, el, ScrollAxis::Horizontal, hscroll as i32 + 1);
true
}
KeyCode::Left if horiz => {
let (_, hscroll) = scroll_metrics(dom, el, ScrollAxis::Horizontal);
set_scroll(dom, el, ScrollAxis::Horizontal, hscroll as i32 - 1);
true
}
_ => false,
}
}
#[allow(dead_code)]
fn _layout_rect_unused(_: LayoutRect) {}
#[cfg(test)]
mod tests;