use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color as RatatuiColor;
use super::{draw_styled_text, ratatui_color, set_cell};
use crate::primitives::list::{ListItemMeasure, ListView, ListViewLayout};
use crate::theme::Theme;
use crate::types::{Decoration, StyledText};
pub fn tui_list_layout(area: Rect, list: &ListView) -> ListViewLayout {
let area_f = crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
);
let hscrollbar = list.hscrollbar(area_f, 1.0);
let needs_hscrollbar = hscrollbar.is_some();
let v_area_f = if needs_hscrollbar {
crate::event::Rect::new(
area_f.x,
area_f.y,
area_f.width,
(area_f.height - 1.0).max(0.0),
)
} else {
area_f
};
let vscrollbar = list.vscrollbar(v_area_f, 1.0);
let needs_vscrollbar = vscrollbar.is_some();
let viewport_h = if needs_hscrollbar {
(area.height as f32 - 1.0).max(0.0)
} else {
area.height as f32
};
let viewport_w = if needs_vscrollbar {
(area.width as f32 - 1.0).max(0.0)
} else {
area.width as f32
};
let title_h = if list.title.is_some() { 1.0 } else { 0.0 };
list.layout(viewport_w, viewport_h, title_h, |_| {
ListItemMeasure::new(1.0)
})
}
pub fn draw_list(
buf: &mut Buffer,
area: Rect,
list: &ListView,
theme: &Theme,
nerd_fonts_enabled: bool,
) {
if area.width == 0 || area.height == 0 {
return;
}
let hdr_fg = ratatui_color(theme.header_fg);
let hdr_bg = ratatui_color(theme.header_bg);
let fg = ratatui_color(theme.surface_fg);
let sel_bg = ratatui_color(theme.selected_bg);
let row_bg = if list.bordered {
ratatui_color(theme.surface_bg)
} else {
ratatui_color(theme.background)
};
let dim_fg = ratatui_color(theme.muted_fg);
let error_fg = ratatui_color(theme.error_fg);
let warn_fg = ratatui_color(theme.warning_fg);
let border_fg = ratatui_color(theme.border_fg);
let title_fg = ratatui_color(theme.title_fg);
let h_scroll = list.h_scroll;
let layout = tui_list_layout(area, list);
let area_f = crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
);
let hscrollbar = list.hscrollbar(area_f, 1.0);
let v_area_f = if hscrollbar.is_some() {
crate::event::Rect::new(
area_f.x,
area_f.y,
area_f.width,
(area_f.height - 1.0).max(0.0),
)
} else {
area_f
};
let vscrollbar = list.vscrollbar(v_area_f, 1.0);
if list.bordered {
let top_y = area.y;
for col in 0..area.width {
let cx = area.x + col;
let ch = if col == 0 {
'╭'
} else if col + 1 == area.width {
'╮'
} else {
'─'
};
set_cell(buf, cx, top_y, ch, border_fg, row_bg);
}
if let Some(ref title) = list.title {
let title_text: String = title.spans.iter().map(|s| s.text.as_str()).collect();
let label = format!(" {} ", title_text.trim());
for (i, ch) in label.chars().enumerate() {
let cx = area.x + 2 + i as u16;
if cx + 1 >= area.x + area.width {
break;
}
set_cell(buf, cx, top_y, ch, title_fg, row_bg);
}
}
if area.height >= 2 {
let bot_y = area.y + area.height - 1;
for col in 0..area.width {
let cx = area.x + col;
let ch = if col == 0 {
'╰'
} else if col + 1 == area.width {
'╯'
} else {
'─'
};
set_cell(buf, cx, bot_y, ch, border_fg, row_bg);
}
}
for row in (area.y + 1)..(area.y + area.height - 1) {
set_cell(buf, area.x, row, '│', border_fg, row_bg);
set_cell(buf, area.x + area.width - 1, row, '│', border_fg, row_bg);
for col in 1..(area.width - 1) {
set_cell(buf, area.x + col, row, ' ', fg, row_bg);
}
}
} else if let Some(title_bounds) = layout.title_bounds {
if let Some(ref title) = list.title {
let y = area.y + title_bounds.y.round() as u16;
for x in area.x..area.x + area.width {
set_cell(buf, x, y, ' ', hdr_fg, hdr_bg);
}
draw_styled_text(
buf,
area,
y,
1,
title,
hdr_fg,
hdr_bg,
Decoration::Normal,
dim_fg,
);
}
}
for visible_item in &layout.visible_items {
let item = &list.items[visible_item.item_idx];
let y = area.y + visible_item.bounds.y.round() as u16;
let item_x = area.x + visible_item.bounds.x.round() as u16;
let item_w = visible_item.bounds.width.round() as u16;
let item_area = Rect {
x: item_x,
y,
width: item_w,
height: 1,
};
let is_selected = visible_item.item_idx == list.selected_idx && list.has_focus;
let bg = if is_selected { sel_bg } else { row_bg };
let decoration_fg = match item.decoration {
Decoration::Error => error_fg,
Decoration::Warning => warn_fg,
Decoration::Muted => dim_fg,
_ => fg,
};
for x in item_x..item_x + item_w {
set_cell(buf, x, y, ' ', decoration_fg, bg);
}
let mut vcol: usize = 0;
let prefix = if is_selected { "▶ " } else { " " };
for ch in prefix.chars() {
vcol = put_char_scrolled(
buf,
vcol,
h_scroll,
item_x,
item_w,
y,
ch,
decoration_fg,
bg,
);
}
if let Some(ref icon) = item.icon {
let glyph = if nerd_fonts_enabled {
icon.glyph.as_str()
} else {
icon.fallback.as_str()
};
for ch in glyph.chars() {
vcol = put_char_scrolled(
buf,
vcol,
h_scroll,
item_x,
item_w,
y,
ch,
decoration_fg,
bg,
);
}
vcol = put_char_scrolled(
buf,
vcol,
h_scroll,
item_x,
item_w,
y,
' ',
decoration_fg,
bg,
);
}
let vcol_after_text = render_spans_scrolled(
buf,
vcol,
h_scroll,
item_x,
item_w,
y,
&item.text,
decoration_fg,
bg,
item.decoration,
dim_fg,
);
let text_buf_col_end = if vcol_after_text > h_scroll {
(vcol_after_text - h_scroll).min(item_w as usize)
} else {
0
};
if let Some(ref detail) = item.detail {
let detail_w: usize = detail.spans.iter().map(|s| s.text.chars().count()).sum();
let start = (item_w as usize).saturating_sub(detail_w + 1);
if start > text_buf_col_end + 1 {
draw_styled_text(
buf,
item_area,
y,
start,
detail,
dim_fg,
bg,
Decoration::Muted,
dim_fg,
);
}
}
}
if let Some(ref hsb) = hscrollbar {
let hsb_bg = if list.bordered {
theme.surface_bg
} else {
theme.background
};
super::draw_scrollbar(buf, hsb, theme, hsb_bg);
}
if let Some(ref vsb) = vscrollbar {
let vsb_bg = if list.bordered {
theme.surface_bg
} else {
theme.background
};
super::draw_scrollbar(buf, vsb, theme, vsb_bg);
}
}
#[allow(clippy::too_many_arguments)]
fn put_char_scrolled(
buf: &mut Buffer,
vcol: usize,
h_scroll: usize,
item_x: u16,
item_w: u16,
y: u16,
ch: char,
fg: RatatuiColor,
bg: RatatuiColor,
) -> usize {
if vcol >= h_scroll {
let buf_col = (vcol - h_scroll) as u16;
if buf_col < item_w {
set_cell(buf, item_x + buf_col, y, ch, fg, bg);
}
}
vcol + 1
}
#[allow(clippy::too_many_arguments)]
fn render_spans_scrolled(
buf: &mut Buffer,
vcol_start: usize,
h_scroll: usize,
item_x: u16,
item_w: u16,
y: u16,
text: &StyledText,
default_fg: RatatuiColor,
bg: RatatuiColor,
decoration: Decoration,
dim_fg: RatatuiColor,
) -> usize {
let mut vcol = vcol_start;
for span in &text.spans {
let span_fg = if let Some(c) = span.fg {
ratatui_color(c)
} else if matches!(decoration, Decoration::Muted) {
dim_fg
} else {
default_fg
};
let span_bg = span.bg.map(ratatui_color).unwrap_or(bg);
for ch in span.text.chars() {
vcol = put_char_scrolled(buf, vcol, h_scroll, item_x, item_w, y, ch, span_fg, span_bg);
}
}
vcol
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::list::{ListItem, ListView};
use crate::types::{Color, StyledSpan, StyledText, WidgetId};
fn item(text: &str, dec: Decoration) -> ListItem {
ListItem {
text: StyledText {
spans: vec![StyledSpan::plain(text)],
},
detail: None,
icon: None,
decoration: dec,
}
}
fn make_list(selected: usize) -> ListView {
ListView {
id: WidgetId::new("list"),
title: None,
items: vec![
item("alpha", Decoration::Normal),
item("beta", Decoration::Normal),
item("gamma", Decoration::Normal),
],
selected_idx: selected,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
buf[(x, y)].symbol().chars().next().unwrap_or(' ')
}
#[test]
fn paints_three_items_with_selection_marker() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
let list = make_list(1);
draw_list(
&mut buf,
Rect::new(0, 0, 20, 5),
&list,
&Theme::default(),
false,
);
assert_eq!(cell_char(&buf, 0, 1), '▶');
assert_eq!(cell_char(&buf, 0, 0), ' ');
assert_eq!(cell_char(&buf, 0, 2), ' ');
}
#[test]
fn no_selection_marker_when_unfocused() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
let mut list = make_list(1);
list.has_focus = false;
draw_list(
&mut buf,
Rect::new(0, 0, 20, 5),
&list,
&Theme::default(),
false,
);
for y in 0..3 {
assert_ne!(cell_char(&buf, 0, y), '▶');
}
}
#[test]
fn bordered_paints_corner_glyphs() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let mut list = make_list(0);
list.bordered = true;
list.title = Some(StyledText {
spans: vec![StyledSpan::plain("Picker")],
});
draw_list(
&mut buf,
Rect::new(0, 0, 10, 5),
&list,
&Theme::default(),
false,
);
assert_eq!(cell_char(&buf, 0, 0), '╭');
assert_eq!(cell_char(&buf, 9, 0), '╮');
assert_eq!(cell_char(&buf, 0, 4), '╰');
assert_eq!(cell_char(&buf, 9, 4), '╯');
}
#[test]
fn decoration_error_uses_error_fg() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
let list = ListView {
id: WidgetId::new("list"),
title: None,
items: vec![item("oops", Decoration::Error)],
selected_idx: 0,
scroll_offset: 0,
has_focus: false,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
};
let theme = Theme {
error_fg: Color::rgb(255, 0, 0),
..Theme::default()
};
draw_list(&mut buf, Rect::new(0, 0, 20, 3), &list, &theme, false);
assert_eq!(cell_char(&buf, 2, 0), 'o');
let fg = buf[(2u16, 0u16)].fg;
assert_eq!(fg, ratatui::style::Color::Rgb(255, 0, 0));
}
#[test]
fn zero_size_is_a_no_op() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let list = make_list(0);
draw_list(
&mut buf,
Rect::new(0, 0, 0, 5),
&list,
&Theme::default(),
false,
);
assert_eq!(cell_char(&buf, 0, 0), ' ');
}
#[test]
fn hscroll_zero_renders_identically_to_default() {
let mut buf_ref = Buffer::empty(Rect::new(0, 0, 20, 3));
let mut buf_new = Buffer::empty(Rect::new(0, 0, 20, 3));
let list_ref = make_list(0); let list_new = ListView {
h_scroll: 0,
max_content_width: None,
..make_list(0)
};
draw_list(
&mut buf_ref,
Rect::new(0, 0, 20, 3),
&list_ref,
&Theme::default(),
false,
);
draw_list(
&mut buf_new,
Rect::new(0, 0, 20, 3),
&list_new,
&Theme::default(),
false,
);
assert_eq!(buf_ref, buf_new, "h_scroll=0 must produce identical output");
assert_eq!(cell_char(&buf_new, 0, 0), '▶');
assert_eq!(cell_char(&buf_new, 2, 0), 'a');
}
#[test]
fn hscroll_skips_prefix_and_reveals_text_at_col_zero() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
let mut list = make_list(1); list.h_scroll = 2;
draw_list(
&mut buf,
Rect::new(0, 0, 20, 3),
&list,
&Theme::default(),
false,
);
assert_eq!(
cell_char(&buf, 0, 0),
'a',
"first text char at buf col 0 after skipping prefix"
);
assert_eq!(
cell_char(&buf, 0, 1),
'b',
"selected item text visible at buf col 0"
);
}
#[test]
fn hscroll_partial_prefix_visible() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
let mut list = make_list(1); list.h_scroll = 1;
draw_list(
&mut buf,
Rect::new(0, 0, 20, 3),
&list,
&Theme::default(),
false,
);
assert_eq!(cell_char(&buf, 0, 0), ' ');
assert_eq!(cell_char(&buf, 1, 0), 'a');
}
#[test]
fn hscrollbar_visible_when_content_wider_than_area() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let mut list = make_list(0);
list.max_content_width = Some(20);
draw_list(
&mut buf,
Rect::new(0, 0, 10, 5),
&list,
&Theme::default(),
false,
);
let has_sb = (0..10u16).any(|x| matches!(cell_char(&buf, x, 4), '▁' | '▄'));
assert!(
has_sb,
"horizontal scrollbar expected on last row when content overflows"
);
assert_eq!(cell_char(&buf, 0, 0), '▶');
}
#[test]
fn hscrollbar_hidden_when_content_fits() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
let mut list = make_list(0);
list.max_content_width = Some(5);
draw_list(
&mut buf,
Rect::new(0, 0, 20, 5),
&list,
&Theme::default(),
false,
);
let has_sb = (0..20u16).any(|x| matches!(cell_char(&buf, x, 4), '▁' | '▄'));
assert!(
!has_sb,
"no horizontal scrollbar expected when content fits in viewport"
);
}
#[test]
fn hscrollbar_inside_bordered_box() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 6));
let mut list = make_list(0);
list.bordered = true;
list.max_content_width = Some(30);
draw_list(
&mut buf,
Rect::new(0, 0, 10, 6),
&list,
&Theme::default(),
false,
);
assert_eq!(cell_char(&buf, 0, 5), '╰');
assert_eq!(cell_char(&buf, 9, 5), '╯');
let has_sb = (1..9u16).any(|x| matches!(cell_char(&buf, x, 4), '▁' | '▄'));
assert!(
has_sb,
"horizontal scrollbar expected at y=4 inside bordered box"
);
}
#[test]
fn detail_pinned_when_hscroll_active() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
let list = ListView {
id: WidgetId::new("list"),
title: None,
items: vec![ListItem {
text: StyledText {
spans: vec![StyledSpan::plain("abcdefghijklmnop")],
},
detail: Some(StyledText {
spans: vec![StyledSpan::plain("det")],
}),
icon: None,
decoration: Decoration::Normal,
}],
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 8,
max_content_width: None,
show_v_scrollbar: false,
};
draw_list(
&mut buf,
Rect::new(0, 0, 20, 1),
&list,
&Theme::default(),
false,
);
assert_eq!(
cell_char(&buf, 0, 0),
'g',
"first visible main-text char at buf col 0"
);
assert_eq!(cell_char(&buf, 16, 0), 'd', "detail 'd' at col 16");
assert_eq!(cell_char(&buf, 17, 0), 'e', "detail 'e' at col 17");
assert_eq!(cell_char(&buf, 18, 0), 't', "detail 't' at col 18");
assert_ne!(
cell_char(&buf, 15, 0),
'p',
"main text must not bleed into detail area"
);
}
#[test]
fn hscrollbar_max_content_none_never_shows_scrollbar() {
let mut buf = Buffer::empty(Rect::new(0, 0, 5, 3));
let mut list = make_list(0);
list.max_content_width = None;
draw_list(
&mut buf,
Rect::new(0, 0, 5, 3),
&list,
&Theme::default(),
false,
);
let has_sb = (0..5u16).any(|x| matches!(cell_char(&buf, x, 2), '▁' | '▄'));
assert!(!has_sb, "no scrollbar when max_content_width is None");
}
fn make_vlist(n: usize, scroll_offset: usize) -> ListView {
ListView {
items: (0..n)
.map(|i| item(&format!("item {}", i), Decoration::Normal))
.collect(),
scroll_offset,
show_v_scrollbar: true,
..make_list(0)
}
}
#[test]
fn vscrollbar_visible_when_items_exceed_height() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let list = make_vlist(20, 0);
draw_list(
&mut buf,
Rect::new(0, 0, 10, 5),
&list,
&Theme::default(),
false,
);
let has_sb = (0..5u16).any(|y| matches!(cell_char(&buf, 9, y), '█' | '░'));
assert!(
has_sb,
"vertical scrollbar expected in rightmost column when items overflow"
);
assert_eq!(
cell_char(&buf, 0, 0),
'▶',
"selection marker must remain intact"
);
}
#[test]
fn vscrollbar_hidden_when_show_false() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let mut list = make_vlist(20, 0);
list.show_v_scrollbar = false;
draw_list(
&mut buf,
Rect::new(0, 0, 10, 5),
&list,
&Theme::default(),
false,
);
let has_sb = (0..5u16).any(|y| matches!(cell_char(&buf, 9, y), '█' | '░'));
assert!(!has_sb, "no vertical scrollbar when show_v_scrollbar=false");
}
#[test]
fn vscrollbar_items_do_not_paint_into_scrollbar_column() {
let long_text = "abcdefghijklmnop";
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let list = ListView {
items: (0..20)
.map(|_| item(long_text, Decoration::Normal))
.collect(),
show_v_scrollbar: true,
..make_list(0)
};
draw_list(
&mut buf,
Rect::new(0, 0, 10, 5),
&list,
&Theme::default(),
false,
);
let col9_is_sb = (0..5u16).any(|y| matches!(cell_char(&buf, 9, y), '█' | '░'));
assert!(col9_is_sb, "rightmost column should be the scrollbar");
assert_eq!(
cell_char(&buf, 0, 0),
'▶',
"selection marker at col 0 must be intact"
);
}
#[test]
fn vscrollbar_hidden_when_all_items_fit() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let list = make_vlist(3, 0);
draw_list(
&mut buf,
Rect::new(0, 0, 10, 5),
&list,
&Theme::default(),
false,
);
let has_sb = (0..5u16).any(|y| matches!(cell_char(&buf, 9, y), '█' | '░'));
assert!(!has_sb, "no scrollbar when all items fit");
}
#[test]
fn vscrollbar_thumb_moves_down_as_scroll_offset_increases() {
let area = Rect::new(0, 0, 10, 10);
let mut buf_top = Buffer::empty(area);
let mut buf_bot = Buffer::empty(area);
draw_list(
&mut buf_top,
area,
&make_vlist(30, 0),
&Theme::default(),
false,
);
draw_list(
&mut buf_bot,
area,
&make_vlist(30, 20),
&Theme::default(),
false,
);
let first_thumb = |buf: &Buffer| (0..10u16).find(|&y| cell_char(buf, 9, y) == '█');
let thumb_top = first_thumb(&buf_top).expect("thumb '█' must paint at scroll_offset=0");
let thumb_bot = first_thumb(&buf_bot).expect("thumb '█' must paint at scroll_offset=20");
assert!(
thumb_bot > thumb_top,
"v-scrollbar thumb must move DOWN as scroll_offset grows \
(got top={thumb_top}, bottom={thumb_bot}) — guards the \
'thumb hardcoded at gutter top' failure class (Rule 6)",
);
}
}
#[cfg(test)]
mod multi_select_driver_tests {
use crate::primitives::list::{ListItem, ListView};
use crate::runner::{AppLogic, Reaction};
use crate::tui::testing::TuiDriver;
use crate::{
apply_selection_gesture, Backend, Decoration, Key, ListViewEvent, Modifiers, NamedKey,
Rect, SelectionGesture, StyledText, UiEvent, WidgetId,
};
const VIEWPORT_W: u16 = 40;
const VIEWPORT_H: u16 = 10;
fn item(label: &str) -> ListItem {
ListItem {
text: StyledText::plain(label.to_string()),
icon: None,
detail: None,
decoration: Decoration::Normal,
}
}
fn sample_list() -> ListView {
ListView {
id: WidgetId::new("l"),
title: None,
items: (0..5).map(|i| item(&format!("row {i}"))).collect(),
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
struct ListApp {
list: ListView,
cursor: usize,
selected: Vec<usize>,
anchor: usize,
events: Vec<ListViewEvent>,
}
impl ListApp {
fn new() -> Self {
Self {
list: sample_list(),
cursor: 0,
selected: Vec::new(),
anchor: 0,
events: Vec::new(),
}
}
}
impl AppLogic for ListApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_list(
Rect::new(0.0, 0.0, VIEWPORT_W as f32, VIEWPORT_H as f32),
&self.list,
);
}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
let UiEvent::KeyPressed { key, modifiers, .. } = event else {
return Reaction::Continue;
};
let last = self.list.items.len().saturating_sub(1);
match key {
Key::Named(NamedKey::Down) | Key::Named(NamedKey::Up) => {
let delta: isize = if key == Key::Named(NamedKey::Down) {
1
} else {
-1
};
self.cursor = (self.cursor as isize + delta).clamp(0, last as isize) as usize;
if !modifiers.shift {
return Reaction::Redraw;
}
let indices = apply_selection_gesture(
&self.selected,
self.anchor,
self.cursor,
SelectionGesture::Range,
);
self.selected = indices.clone();
self.events.push(ListViewEvent::SelectionSet { indices });
Reaction::Redraw
}
Key::Char(' ') => {
let indices = apply_selection_gesture(
&self.selected,
self.anchor,
self.cursor,
SelectionGesture::Toggle,
);
self.selected = indices.clone();
self.anchor = self.cursor;
self.events.push(ListViewEvent::SelectionSet { indices });
Reaction::Redraw
}
_ => Reaction::Continue,
}
}
}
fn key_event(key: Key, modifiers: Modifiers) -> UiEvent {
UiEvent::KeyPressed {
key,
modifiers,
repeat: false,
}
}
fn driver() -> TuiDriver<ListApp> {
TuiDriver::new(ListApp::new(), VIEWPORT_W, VIEWPORT_H)
}
#[test]
fn plain_arrow_moves_the_cursor_without_changing_selection() {
let mut driver = driver();
driver.dispatch(key_event(Key::Named(NamedKey::Down), Modifiers::default()));
assert_eq!(driver.app().cursor, 1);
assert_eq!(driver.app().selected, Vec::<usize>::new());
}
#[test]
fn space_toggles_rows_into_a_multi_selection() {
let mut driver = driver();
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
driver.dispatch(key_event(Key::Named(NamedKey::Down), Modifiers::default()));
driver.dispatch(key_event(Key::Named(NamedKey::Down), Modifiers::default()));
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
assert_eq!(driver.app().selected, vec![0, 2]);
driver.dispatch(key_event(Key::Named(NamedKey::Up), Modifiers::default()));
driver.dispatch(key_event(Key::Named(NamedKey::Up), Modifiers::default()));
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
assert_eq!(driver.app().selected, vec![2]);
assert_eq!(
driver.app().events.last(),
Some(&ListViewEvent::SelectionSet { indices: vec![2] })
);
}
#[test]
fn shift_arrow_extends_a_range_from_the_anchor() {
let mut driver = driver();
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
let shift = Modifiers {
shift: true,
..Default::default()
};
driver.dispatch(key_event(Key::Named(NamedKey::Down), shift));
driver.dispatch(key_event(Key::Named(NamedKey::Down), shift));
driver.dispatch(key_event(Key::Named(NamedKey::Down), shift));
assert_eq!(driver.app().selected, vec![0, 1, 2, 3]);
assert_eq!(
driver.app().events.last(),
Some(&ListViewEvent::SelectionSet {
indices: vec![0, 1, 2, 3]
})
);
}
}