#![forbid(unsafe_code)]
use crate::mouse::MouseResult;
use crate::{StatefulWidget, Widget, clear_text_area, draw_text_span};
use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
use ftui_core::geometry::Rect;
use ftui_render::frame::{Frame, HitId, HitRegion};
use ftui_style::Style;
use ftui_text::display_width;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarOrientation {
#[default]
VerticalRight,
VerticalLeft,
HorizontalBottom,
HorizontalTop,
}
pub const SCROLLBAR_PART_TRACK: u64 = 0;
pub const SCROLLBAR_PART_THUMB: u64 = 1;
pub const SCROLLBAR_PART_BEGIN: u64 = 2;
pub const SCROLLBAR_PART_END: u64 = 3;
#[derive(Debug, Clone, Default)]
pub struct Scrollbar<'a> {
orientation: ScrollbarOrientation,
thumb_style: Style,
track_style: Style,
begin_symbol: Option<&'a str>,
end_symbol: Option<&'a str>,
track_symbol: Option<&'a str>,
thumb_symbol: Option<&'a str>,
hit_id: Option<HitId>,
}
impl<'a> Scrollbar<'a> {
#[must_use]
pub fn new(orientation: ScrollbarOrientation) -> Self {
Self {
orientation,
thumb_style: Style::default(),
track_style: Style::default(),
begin_symbol: None,
end_symbol: None,
track_symbol: None,
thumb_symbol: None,
hit_id: None,
}
}
#[must_use]
pub fn thumb_style(mut self, style: Style) -> Self {
self.thumb_style = style;
self
}
#[must_use]
pub fn track_style(mut self, style: Style) -> Self {
self.track_style = style;
self
}
#[must_use]
pub fn symbols(
mut self,
track: &'a str,
thumb: &'a str,
begin: Option<&'a str>,
end: Option<&'a str>,
) -> Self {
self.track_symbol = Some(track);
self.thumb_symbol = Some(thumb);
self.begin_symbol = begin;
self.end_symbol = end;
self
}
#[must_use]
pub fn hit_id(mut self, id: HitId) -> Self {
self.hit_id = Some(id);
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ScrollbarState {
pub content_length: usize,
pub position: usize,
pub viewport_length: usize,
pub drag_anchor: Option<usize>,
track_layout: Option<TrackLayout>,
}
#[derive(Debug, Clone, Copy)]
struct TrackLayout {
rect: Rect,
is_vertical: bool,
}
impl ScrollbarState {
#[must_use]
pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
Self {
content_length,
position,
viewport_length,
drag_anchor: None,
track_layout: None,
}
}
fn calc_thumb_geometry(&self, track_len: usize) -> (usize, usize) {
if track_len == 0 {
return (0, 0);
}
if self.content_length == 0 {
return (0, track_len);
}
let viewport_ratio = self.viewport_length as f64 / self.content_length as f64;
let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
let thumb_size = thumb_size.min(track_len);
let max_pos = self.content_length.saturating_sub(self.viewport_length);
let pos_ratio = if max_pos == 0 {
0.0
} else {
self.position.min(max_pos) as f64 / max_pos as f64
};
let available_track = track_len.saturating_sub(thumb_size);
let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
(thumb_offset, thumb_size)
}
pub fn handle_mouse(
&mut self,
event: &MouseEvent,
hit: Option<(HitId, HitRegion, u64)>,
expected_id: HitId,
) -> MouseResult {
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some((id, HitRegion::Scrollbar, data)) = hit
&& id == expected_id
{
let part = data >> 56;
match part {
SCROLLBAR_PART_BEGIN => {
self.scroll_up(1);
return MouseResult::Scrolled;
}
SCROLLBAR_PART_END => {
self.scroll_down(1);
return MouseResult::Scrolled;
}
SCROLLBAR_PART_THUMB => {
let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
let track_pos = (data & 0x0FFF_FFFF) as usize;
let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
return MouseResult::Scrolled;
}
SCROLLBAR_PART_TRACK => {
let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
let track_pos = (data & 0x0FFF_FFFF) as usize;
if track_len == 0 {
return MouseResult::Ignored;
}
let (_, thumb_size) = self.calc_thumb_geometry(track_len);
let available = track_len.saturating_sub(thumb_size);
let denom = available.max(1);
let target_thumb_top = track_pos.saturating_sub(thumb_size / 2);
let clamped_top = target_thumb_top.min(denom);
let max_pos = self.content_length.saturating_sub(self.viewport_length);
self.position = if max_pos == 0 {
0
} else {
let num = (clamped_top as u128) * (max_pos as u128);
let pos = (num + (denom as u128 / 2)) / denom as u128;
pos as usize
};
let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
return MouseResult::Scrolled;
}
_ => {}
}
}
MouseResult::Ignored
}
MouseEventKind::Drag(MouseButton::Left) => {
let hit_data = if let Some((id, HitRegion::Scrollbar, data)) = hit
&& id == expected_id
{
let part = data >> 56;
if matches!(part, SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB) {
let len = ((data >> 28) & 0x0FFF_FFFF) as usize;
let pos = (data & 0x0FFF_FFFF) as usize;
Some((len, pos))
} else {
None
}
} else {
None
};
let (track_len, track_pos) = if let Some((len, pos)) = hit_data {
(len, pos)
} else if self.drag_anchor.is_some()
&& let Some(layout) = self.track_layout
{
let rel = if layout.is_vertical {
event.y.saturating_sub(layout.rect.y)
} else {
event.x.saturating_sub(layout.rect.x)
};
let len = if layout.is_vertical {
layout.rect.height as usize
} else {
layout.rect.width as usize
};
let pos = rel.min(len.saturating_sub(1) as u16) as usize;
(len, pos)
} else {
return MouseResult::Ignored;
};
if track_len == 0 {
return MouseResult::Ignored;
}
let (_, thumb_size) = self.calc_thumb_geometry(track_len);
let available = track_len.saturating_sub(thumb_size);
let denom = available.max(1);
let anchor = self.drag_anchor.unwrap_or(thumb_size / 2);
let target_thumb_top = track_pos.saturating_sub(anchor);
let clamped_top = target_thumb_top.min(denom);
let max_pos = self.content_length.saturating_sub(self.viewport_length);
self.position = if max_pos == 0 {
0
} else {
let num = (clamped_top as u128) * (max_pos as u128);
let pos = (num + (denom as u128 / 2)) / denom as u128;
pos as usize
};
MouseResult::Scrolled
}
MouseEventKind::Up(MouseButton::Left) => {
let was_dragging = self.drag_anchor.take().is_some();
if was_dragging {
MouseResult::Scrolled
} else {
MouseResult::Ignored
}
}
MouseEventKind::ScrollUp => {
self.scroll_up(3);
MouseResult::Scrolled
}
MouseEventKind::ScrollDown => {
self.scroll_down(3);
MouseResult::Scrolled
}
_ => MouseResult::Ignored,
}
}
pub fn scroll_up(&mut self, lines: usize) {
self.position = self.position.saturating_sub(lines);
}
pub fn scroll_down(&mut self, lines: usize) {
let max_pos = self.content_length.saturating_sub(self.viewport_length);
self.position = self.position.saturating_add(lines).min(max_pos);
}
}
impl<'a> StatefulWidget for Scrollbar<'a> {
type State = ScrollbarState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "Scrollbar",
x = area.x,
y = area.y,
w = area.width,
h = area.height
)
.entered();
if !frame.buffer.degradation.render_decorative() {
state.track_layout = None;
clear_text_area(frame, area, Style::default());
return;
}
if area.is_empty() || state.content_length == 0 {
state.track_layout = None;
clear_text_area(frame, area, Style::default());
return;
}
let is_vertical = match self.orientation {
ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
};
let length = if is_vertical { area.height } else { area.width } as usize;
if length == 0 {
state.track_layout = None;
return;
}
let start_offset = if let Some(s) = self.begin_symbol {
if is_vertical { 1 } else { display_width(s) }
} else {
0
};
let end_offset = if let Some(s) = self.end_symbol {
if is_vertical { 1 } else { display_width(s) }
} else {
0
};
let track_len = length.saturating_sub(start_offset + end_offset);
let (thumb_offset, thumb_size) = state.calc_thumb_geometry(track_len);
let track_char = self
.track_symbol
.unwrap_or(if is_vertical { "│" } else { "─" });
let thumb_char = self.thumb_symbol.unwrap_or("█");
let begin_char = self
.begin_symbol
.unwrap_or(if is_vertical { "▲" } else { "◄" });
let end_char = self
.end_symbol
.unwrap_or(if is_vertical { "▼" } else { "►" });
let max_w = display_width(track_char)
.max(display_width(thumb_char))
.max(1);
let track_rect = if is_vertical {
let x = match self.orientation {
ScrollbarOrientation::VerticalRight => {
area.right().saturating_sub(max_w as u16).max(area.left())
}
ScrollbarOrientation::VerticalLeft => area.left(),
_ => unreachable!(),
};
Rect::new(
x,
area.top().saturating_add(start_offset as u16),
max_w as u16,
track_len as u16,
)
} else {
let y = match self.orientation {
ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
ScrollbarOrientation::HorizontalTop => area.top(),
_ => unreachable!(),
};
Rect::new(
area.left().saturating_add(start_offset as u16),
y,
track_len as u16,
1,
)
};
state.track_layout = Some(TrackLayout {
rect: track_rect,
is_vertical,
});
let mut next_draw_index = 0;
for i in 0..length {
if i < next_draw_index {
continue;
}
let (symbol, part, rel_pos) = if i < start_offset {
(begin_char, SCROLLBAR_PART_BEGIN, 0)
} else if i >= length.saturating_sub(end_offset) {
(end_char, SCROLLBAR_PART_END, 0)
} else {
let track_idx = i - start_offset;
let is_thumb = track_idx >= thumb_offset && track_idx < thumb_offset + thumb_size;
if is_thumb {
(thumb_char, SCROLLBAR_PART_THUMB, track_idx)
} else {
(track_char, SCROLLBAR_PART_TRACK, track_idx)
}
};
let symbol_width = display_width(symbol);
if is_vertical {
next_draw_index = i + 1;
} else {
next_draw_index = i + symbol_width;
}
let style = if !frame.buffer.degradation.apply_styling() {
Style::default()
} else if part == SCROLLBAR_PART_THUMB {
self.thumb_style
} else {
self.track_style
};
let (x, y) = if is_vertical {
let x = match self.orientation {
ScrollbarOrientation::VerticalRight => area
.right()
.saturating_sub(symbol_width.max(1) as u16)
.max(area.left()),
ScrollbarOrientation::VerticalLeft => area.left(),
_ => unreachable!(),
};
(x, area.top().saturating_add(i as u16))
} else {
let y = match self.orientation {
ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
ScrollbarOrientation::HorizontalTop => area.top(),
_ => unreachable!(),
};
(area.left().saturating_add(i as u16), y)
};
if x < area.right() && y < area.bottom() {
draw_text_span(frame, x, y, symbol, style, area.right());
if let Some(id) = self.hit_id {
let data = (part << 56)
| ((track_len as u64 & 0x0FFF_FFFF) << 28)
| (rel_pos as u64 & 0x0FFF_FFFF);
let hit_w = (symbol_width.max(1) as u16).min(area.right().saturating_sub(x));
frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
}
}
}
}
}
impl<'a> Widget for Scrollbar<'a> {
fn render(&self, area: Rect, frame: &mut Frame) {
let mut state = ScrollbarState::default();
StatefulWidget::render(self, area, frame, &mut state);
}
}
impl ftui_a11y::Accessible for Scrollbar<'_> {
fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
let id = crate::a11y_node_id(area);
let orientation = match self.orientation {
ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => "vertical",
ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => {
"horizontal"
}
};
let name = format!("{orientation} scrollbar");
let node = A11yNodeInfo::new(id, A11yRole::ScrollBar, area)
.with_name(name)
.with_state(A11yState {
..A11yState::default()
});
vec![node]
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::grapheme_pool::GraphemePool;
#[test]
fn scrollbar_empty_area() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
}
#[test]
fn scrollbar_zero_content() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
state.content_length = 0;
StatefulWidget::render(&sb, area, &mut frame, &mut state);
for y in 0..10u16 {
assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
}
assert!(state.track_layout.is_none());
}
#[test]
fn scrollbar_vertical_right_renders() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let top_cell = frame.buffer.get(0, 0).unwrap();
assert!(top_cell.content.as_char().is_some());
}
#[test]
fn scrollbar_vertical_left_renders() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let top_cell = frame.buffer.get(0, 0).unwrap();
assert!(top_cell.content.as_char().is_some());
}
#[test]
fn scrollbar_horizontal_renders() {
let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let left_cell = frame.buffer.get(0, 0).unwrap();
assert!(left_cell.content.as_char().is_some());
}
#[test]
fn scrollbar_thumb_moves_with_position() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool1 = GraphemePool::new();
let mut frame1 = Frame::new(1, 10, &mut pool1);
let mut state1 = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
let mut pool2 = GraphemePool::new();
let mut frame2 = Frame::new(1, 10, &mut pool2);
let mut state2 = ScrollbarState::new(100, 90, 10);
StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
let thumb_char = '█';
let thumb_pos_1 = (0..10u16)
.find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
let thumb_pos_2 = (0..10u16)
.find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
}
#[test]
fn scrollbar_state_constructor() {
let state = ScrollbarState::new(200, 50, 20);
assert_eq!(state.content_length, 200);
assert_eq!(state.position, 50);
assert_eq!(state.viewport_length, 20);
}
#[test]
fn scrollbar_content_fits_viewport() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
let mut state = ScrollbarState::new(5, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let thumb_char = '█';
for y in 0..10u16 {
assert_eq!(
frame.buffer.get(0, y).unwrap().content.as_char(),
Some(thumb_char)
);
}
}
#[test]
fn scrollbar_horizontal_top_renders() {
let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let left_cell = frame.buffer.get(0, 0).unwrap();
assert!(left_cell.content.as_char().is_some());
}
#[test]
fn scrollbar_custom_symbols() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
".",
"#",
Some("^"),
Some("v"),
);
let area = Rect::new(0, 0, 1, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 5, &mut pool);
let mut state = ScrollbarState::new(50, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let mut chars: Vec<Option<char>> = Vec::new();
for y in 0..5u16 {
chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
}
assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
}
#[test]
fn scrollbar_position_clamped_beyond_max() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
let mut state = ScrollbarState::new(100, 500, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let thumb_char = '█';
let thumb_pos = (0..10u16)
.find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
assert!(thumb_pos.is_some());
}
#[test]
fn scrollbar_state_default() {
let state = ScrollbarState::default();
assert_eq!(state.content_length, 0);
assert_eq!(state.position, 0);
assert_eq!(state.viewport_length, 0);
}
#[test]
fn scrollbar_widget_trait_renders() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 5, &mut pool);
Widget::render(&sb, area, &mut frame);
}
#[test]
fn scrollbar_orientation_default_is_vertical_right() {
assert_eq!(
ScrollbarOrientation::default(),
ScrollbarOrientation::VerticalRight
);
}
#[test]
fn degradation_essential_only_skips_entirely() {
use ftui_render::budget::DegradationLevel;
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
frame.buffer.degradation = DegradationLevel::EssentialOnly;
let mut state = ScrollbarState::new(100, 0, 10);
frame.buffer.degradation = DegradationLevel::Full;
StatefulWidget::render(&sb, area, &mut frame, &mut state);
frame.buffer.degradation = DegradationLevel::EssentialOnly;
StatefulWidget::render(&sb, area, &mut frame, &mut state);
for y in 0..10u16 {
assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
}
assert!(state.track_layout.is_none());
}
#[test]
fn degradation_skeleton_skips_entirely() {
use ftui_render::budget::DegradationLevel;
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
frame.buffer.degradation = DegradationLevel::Skeleton;
let mut state = ScrollbarState::new(100, 0, 10);
frame.buffer.degradation = DegradationLevel::Full;
StatefulWidget::render(&sb, area, &mut frame, &mut state);
frame.buffer.degradation = DegradationLevel::Skeleton;
StatefulWidget::render(&sb, area, &mut frame, &mut state);
for y in 0..10u16 {
assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
}
assert!(state.track_layout.is_none());
}
#[test]
fn degradation_full_renders_scrollbar() {
use ftui_render::budget::DegradationLevel;
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
frame.buffer.degradation = DegradationLevel::Full;
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let top_cell = frame.buffer.get(0, 0).unwrap();
assert!(top_cell.content.as_char().is_some());
}
#[test]
fn degradation_simple_borders_still_renders() {
use ftui_render::budget::DegradationLevel;
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let area = Rect::new(0, 0, 1, 10);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 10, &mut pool);
frame.buffer.degradation = DegradationLevel::SimpleBorders;
let mut state = ScrollbarState::new(100, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let top_cell = frame.buffer.get(0, 0).unwrap();
assert!(top_cell.content.as_char().is_some());
}
#[test]
fn scrollbar_wide_symbols_horizontal() {
let sb =
Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
let area = Rect::new(0, 0, 4, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(4, 1, &mut pool);
let mut state = ScrollbarState::new(10, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let c0 = frame.buffer.get(0, 0).unwrap();
assert!(!c0.is_empty() && !c0.is_continuation()); let c1 = frame.buffer.get(1, 0).unwrap();
assert!(c1.is_continuation());
let c2 = frame.buffer.get(2, 0).unwrap();
assert!(!c2.is_empty() && !c2.is_continuation()); let c3 = frame.buffer.get(3, 0).unwrap();
assert!(c3.is_continuation());
}
#[test]
fn scrollbar_wide_symbols_vertical() {
let sb =
Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
let area = Rect::new(0, 0, 2, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(2, 2, &mut pool);
let mut state = ScrollbarState::new(10, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let r0_c0 = frame.buffer.get(0, 0).unwrap();
assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); let r0_c1 = frame.buffer.get(1, 0).unwrap();
assert!(r0_c1.is_continuation());
let r1_c0 = frame.buffer.get(0, 1).unwrap();
assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); let r1_c1 = frame.buffer.get(1, 1).unwrap();
assert!(r1_c1.is_continuation()); }
#[test]
fn scrollbar_wide_symbol_clips_drawing_and_hits_to_area() {
let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
.symbols("🔴", "👍", None, None)
.hit_id(HitId::new(1));
let area = Rect::new(0, 0, 3, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
let mut state = ScrollbarState::new(3, 0, 3);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let outside = frame.buffer.get(3, 0).unwrap();
assert!(outside.is_empty(), "cell outside area should remain empty");
assert!(frame.hit_test(3, 0).is_none(), "no hit outside area");
}
#[test]
fn scrollbar_wide_symbol_vertical_clips_drawing_and_hits_to_area() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
.symbols("🔴", "👍", None, None)
.hit_id(HitId::new(1));
let area = Rect::new(0, 0, 1, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(2, 2, &mut pool);
let mut state = ScrollbarState::new(10, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let outside = frame.buffer.get(1, 0).unwrap();
assert!(outside.is_empty(), "cell outside area should remain empty");
assert!(frame.hit_test(1, 0).is_none(), "no hit outside area");
}
#[test]
fn scrollbar_vertical_right_never_draws_left_of_area_for_wide_symbols() {
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.symbols("🔴", "👍", None, None)
.hit_id(HitId::new(1));
let area = Rect::new(2, 0, 1, 2);
let mut pool = GraphemePool::new();
let mut frame = Frame::with_hit_grid(4, 2, &mut pool);
let mut state = ScrollbarState::new(10, 0, 10);
StatefulWidget::render(&sb, area, &mut frame, &mut state);
let outside = frame.buffer.get(1, 0).unwrap();
assert!(outside.is_empty(), "cell left of area should remain empty");
assert!(frame.hit_test(1, 0).is_none(), "no hit left of area");
}
use crate::mouse::MouseResult;
use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
#[test]
fn scrollbar_state_begin_button() {
let mut state = ScrollbarState::new(100, 10, 20);
let data = SCROLLBAR_PART_BEGIN << 56;
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
let result = state.handle_mouse(&event, hit, HitId::new(1));
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.position, 9);
}
#[test]
fn scrollbar_state_end_button() {
let mut state = ScrollbarState::new(100, 10, 20);
let data = SCROLLBAR_PART_END << 56;
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
let result = state.handle_mouse(&event, hit, HitId::new(1));
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.position, 11);
}
#[test]
fn scrollbar_state_track_click() {
let mut state = ScrollbarState::new(100, 0, 20);
let track_len = 20u64;
let track_pos = 10u64;
let data = (SCROLLBAR_PART_TRACK << 56)
| ((track_len & 0x0FFF_FFFF) << 28)
| (track_pos & 0x0FFF_FFFF);
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
let result = state.handle_mouse(&event, hit, HitId::new(1));
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.position, 40);
}
#[test]
fn scrollbar_state_track_click_clamps() {
let mut state = ScrollbarState::new(100, 0, 20);
let track_len = 20u64;
let track_pos = 95u64;
let data = (SCROLLBAR_PART_TRACK << 56)
| ((track_len & 0x0FFF_FFFF) << 28)
| (track_pos & 0x0FFF_FFFF);
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
let result = state.handle_mouse(&event, hit, HitId::new(1));
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.position, 80); }
#[test]
fn scrollbar_state_thumb_drag_updates_position() {
let mut state = ScrollbarState::new(100, 0, 20);
let track_len = 20u64;
let track_pos = 19u64;
let data = (SCROLLBAR_PART_THUMB << 56)
| ((track_len & 0x0FFF_FFFF) << 28)
| (track_pos & 0x0FFF_FFFF);
let event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 0);
let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
let result = state.handle_mouse(&event, hit, HitId::new(1));
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.position, 80);
}
#[test]
fn scrollbar_state_scroll_wheel_up() {
let mut state = ScrollbarState::new(100, 10, 20);
let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
let result = state.handle_mouse(&event, None, HitId::new(1));
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.position, 7);
}
#[test]
fn scrollbar_state_scroll_wheel_down() {
let mut state = ScrollbarState::new(100, 10, 20);
let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
let result = state.handle_mouse(&event, None, HitId::new(1));
assert_eq!(result, MouseResult::Scrolled);
assert_eq!(state.position, 13);
}
#[test]
fn scrollbar_state_scroll_down_clamps() {
let mut state = ScrollbarState::new(100, 78, 20);
state.scroll_down(5);
assert_eq!(state.position, 80); }
#[test]
fn scrollbar_state_scroll_up_clamps() {
let mut state = ScrollbarState::new(100, 2, 20);
state.scroll_up(5);
assert_eq!(state.position, 0);
}
#[test]
fn scrollbar_state_wrong_id_ignored() {
let mut state = ScrollbarState::new(100, 10, 20);
let data = SCROLLBAR_PART_BEGIN << 56;
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
let hit = Some((HitId::new(99), HitRegion::Scrollbar, data));
let result = state.handle_mouse(&event, hit, HitId::new(1));
assert_eq!(result, MouseResult::Ignored);
assert_eq!(state.position, 10);
}
#[test]
fn scrollbar_state_right_click_ignored() {
let mut state = ScrollbarState::new(100, 10, 20);
let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
let result = state.handle_mouse(&event, None, HitId::new(1));
assert_eq!(result, MouseResult::Ignored);
}
}