use rdom_core::{Dom, NodeId};
use crate::ext::TuiExt;
use crate::layout::{LayoutRect, Overflow};
use crate::node::TuiNodeExt;
use crate::render::{Buffer, Rect, Style};
use crate::style::{Color, ComputedStyle};
const FALLBACK_TRACK_V: &str = "│";
const FALLBACK_TRACK_H: &str = "─";
const FALLBACK_THUMB_V: &str = "┃";
const FALLBACK_THUMB_H: &str = "━";
#[derive(Debug, Clone, Copy)]
enum ScrollbarAxis {
Vertical,
Horizontal,
}
fn track_cell<'a>(
pseudo: Option<&'a crate::style::ComputedStyle>,
axis: ScrollbarAxis,
) -> (&'a str, Style) {
let fallback = match axis {
ScrollbarAxis::Vertical => FALLBACK_TRACK_V,
ScrollbarAxis::Horizontal => FALLBACK_TRACK_H,
};
if let Some(p) = pseudo {
let glyph: &'a str = p.content.as_deref().unwrap_or(fallback);
let mut style = Style::new();
if p.bg != Color::Reset {
style = style.bg(p.bg);
}
if p.fg != Color::Reset {
style = style.fg(p.fg);
}
(glyph, style)
} else {
(fallback, Style::new().bg(Color::Rgb(169, 169, 169)))
}
}
fn thumb_cell<'a>(
pseudo: Option<&'a crate::style::ComputedStyle>,
axis: ScrollbarAxis,
) -> (&'a str, Style) {
let fallback = match axis {
ScrollbarAxis::Vertical => FALLBACK_THUMB_V,
ScrollbarAxis::Horizontal => FALLBACK_THUMB_H,
};
if let Some(p) = pseudo {
let glyph: &'a str = p.content.as_deref().unwrap_or(fallback);
let mut style = Style::new();
if p.bg != Color::Reset {
style = style.bg(p.bg);
}
if p.fg != Color::Reset {
style = style.fg(p.fg);
}
(glyph, style)
} else {
(
fallback,
Style::new()
.fg(Color::Rgb(128, 128, 128))
.bg(Color::Rgb(169, 169, 169)),
)
}
}
pub(super) fn paint_scrollbars(
dom: &Dom<TuiExt>,
id: NodeId,
computed: &ComputedStyle,
buf: &mut Buffer,
clip: Rect,
) {
let Some(ext) = dom.node(id).tui_ext() else {
return;
};
let content_layout = ext.content_layout;
let padding_box = rdom_style::layout::compute_padding_box(ext.layout, computed.border);
let (scroll_x, scroll_y) = (ext.scroll_x, ext.scroll_y);
let (content_w, content_h) = (ext.scroll_content_width, ext.scroll_content_height);
let y_paints = matches!(computed.overflow_y, Overflow::Scroll | Overflow::Auto)
&& should_paint(
computed.overflow_y,
content_layout.height as usize,
content_h,
);
let x_paints = matches!(computed.overflow_x, Overflow::Scroll | Overflow::Auto)
&& should_paint(
computed.overflow_x,
content_layout.width as usize,
content_w,
);
if y_paints {
let (track_glyph, track_style) =
track_cell(ext.computed_scrollbar.as_ref(), ScrollbarAxis::Vertical);
let (thumb_glyph, thumb_style) = thumb_cell(
ext.computed_scrollbar_thumb.as_ref(),
ScrollbarAxis::Vertical,
);
paint_vertical_scrollbar(
buf,
content_layout,
padding_box,
x_paints,
computed.overflow_y,
scroll_y,
content_h,
clip,
track_glyph,
track_style,
thumb_glyph,
thumb_style,
);
}
if x_paints {
let (track_glyph, track_style) =
track_cell(ext.computed_scrollbar.as_ref(), ScrollbarAxis::Horizontal);
let (thumb_glyph, thumb_style) = thumb_cell(
ext.computed_scrollbar_thumb.as_ref(),
ScrollbarAxis::Horizontal,
);
paint_horizontal_scrollbar(
buf,
content_layout,
padding_box,
y_paints,
computed.overflow_x,
scroll_x,
content_w,
clip,
track_glyph,
track_style,
thumb_glyph,
thumb_style,
);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_vertical_scrollbar(
buf: &mut Buffer,
content: LayoutRect,
padding_box: LayoutRect,
has_h_scrollbar: bool,
overflow: Overflow,
scroll_offset: usize,
content_size: usize,
clip: Rect,
track_glyph: &str,
track_style: Style,
thumb_glyph: &str,
thumb_style: Style,
) {
let track_x_signed = content.x + content.width as i32;
let track_x = track_x_signed as i64;
if track_x < clip.x as i64 || track_x >= clip.right() as i64 {
return;
}
let track_x = track_x as u16;
let track_top = content.y.max(padding_box.y).max(clip.y as i32);
let mut track_bottom = (content.y + content.height as i32)
.min(padding_box.y + padding_box.height as i32)
.min(clip.bottom() as i32);
if has_h_scrollbar {
track_bottom -= 1;
}
if track_bottom <= track_top {
return;
}
let track_len = (track_bottom - track_top) as u16;
let viewport = content.height;
if !should_paint(overflow, viewport as usize, content_size) {
return;
}
let (thumb_size, thumb_off) =
thumb_geometry(track_len, viewport as usize, content_size, scroll_offset);
for i in 0..track_len {
let y = track_top as u16 + i;
let in_thumb = i >= thumb_off && i < thumb_off + thumb_size;
let (ch, style) = if in_thumb {
(thumb_glyph, thumb_style)
} else {
(track_glyph, track_style)
};
buf.set_symbol(track_x, y, ch, style);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_horizontal_scrollbar(
buf: &mut Buffer,
content: LayoutRect,
padding_box: LayoutRect,
has_v_scrollbar: bool,
overflow: Overflow,
scroll_offset: usize,
content_size: usize,
clip: Rect,
track_glyph: &str,
track_style: Style,
thumb_glyph: &str,
thumb_style: Style,
) {
let track_y_signed = content.y + content.height as i32;
let track_y = track_y_signed as i64;
if track_y < clip.y as i64 || track_y >= clip.bottom() as i64 {
return;
}
let track_y = track_y as u16;
let track_left = content.x.max(padding_box.x).max(clip.x as i32);
let mut track_right = (content.x + content.width as i32)
.min(padding_box.x + padding_box.width as i32)
.min(clip.right() as i32);
if has_v_scrollbar {
track_right -= 1;
}
if track_right <= track_left {
return;
}
let track_len = (track_right - track_left) as u16;
let viewport = content.width;
if !should_paint(overflow, viewport as usize, content_size) {
return;
}
let (thumb_size, thumb_off) =
thumb_geometry(track_len, viewport as usize, content_size, scroll_offset);
for i in 0..track_len {
let x = track_left as u16 + i;
let in_thumb = i >= thumb_off && i < thumb_off + thumb_size;
let (ch, style) = if in_thumb {
(thumb_glyph, thumb_style)
} else {
(track_glyph, track_style)
};
buf.set_symbol(x, track_y, ch, style);
}
}
pub(crate) fn should_paint(overflow: Overflow, viewport: usize, content: usize) -> bool {
match overflow {
Overflow::Scroll => true,
Overflow::Auto => content > viewport,
_ => false,
}
}
pub(crate) fn thumb_geometry(
track: u16,
viewport: usize,
content: usize,
scroll_offset: usize,
) -> (u16, u16) {
if content == 0 || content <= viewport {
return (track, 0);
}
let content = content.max(1);
let thumb_size = (track as usize * viewport / content).max(1) as u16;
let thumb_size = thumb_size.min(track);
let travel = content.saturating_sub(viewport);
let track_travel = track.saturating_sub(thumb_size) as usize;
let thumb_off = (scroll_offset * track_travel)
.checked_div(travel)
.unwrap_or(0)
.min(track_travel) as u16;
(thumb_size, thumb_off)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumb_fills_track_when_content_fits() {
let (size, off) = thumb_geometry(10, 20, 15, 0);
assert_eq!(size, 10);
assert_eq!(off, 0);
}
#[test]
fn thumb_half_size_when_content_twice_viewport() {
let (size, off) = thumb_geometry(10, 10, 20, 0);
assert_eq!(size, 5);
assert_eq!(off, 0);
}
#[test]
fn thumb_at_bottom_when_scrolled_to_end() {
let (size, off) = thumb_geometry(10, 10, 20, 10);
assert_eq!(size, 5);
assert_eq!(off, 5);
}
#[test]
fn thumb_min_size_1() {
let (size, _) = thumb_geometry(5, 5, 10_000, 0);
assert_eq!(size, 1);
}
#[test]
fn should_paint_auto_hides_when_content_fits() {
assert!(!should_paint(Overflow::Auto, 10, 10));
assert!(!should_paint(Overflow::Auto, 10, 5));
assert!(should_paint(Overflow::Auto, 10, 11));
}
#[test]
fn should_paint_scroll_always_shows() {
assert!(should_paint(Overflow::Scroll, 10, 5));
assert!(should_paint(Overflow::Scroll, 10, 10));
assert!(should_paint(Overflow::Scroll, 10, 100));
}
}