use crate::event::Rect;
use crate::primitives::scrollbar::Scrollbar;
use crate::types::{Decoration, Icon, Modifiers, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListView {
pub id: WidgetId,
#[serde(default)]
pub title: Option<StyledText>,
pub items: Vec<ListItem>,
pub selected_idx: usize,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub has_focus: bool,
#[serde(default)]
pub bordered: bool,
#[serde(default)]
pub h_scroll: usize,
#[serde(default)]
pub max_content_width: Option<usize>,
#[serde(default)]
pub show_v_scrollbar: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListItem {
pub text: StyledText,
#[serde(default)]
pub icon: Option<Icon>,
#[serde(default)]
pub detail: Option<StyledText>,
#[serde(default)]
pub decoration: Decoration,
}
pub fn hscrollbar_track(rect: Rect, bordered: bool, char_w: f32, row_h: f32) -> Rect {
if bordered {
Rect::new(
rect.x + char_w,
rect.y + (rect.height - 2.0 * row_h).max(0.0),
(rect.width - 2.0 * char_w).max(0.0),
row_h,
)
} else {
Rect::new(
rect.x,
rect.y + (rect.height - row_h).max(0.0),
rect.width,
row_h,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ListItemMeasure {
pub height: f32,
}
impl ListItemMeasure {
pub fn new(height: f32) -> Self {
Self { height }
}
pub fn from_metrics(m: &crate::backend::Metrics) -> Self {
Self::new(m.line_height)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleListItem {
pub item_idx: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListViewHit {
Title,
Item(usize),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ListViewLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub title_bounds: Option<Rect>,
pub visible_items: Vec<VisibleListItem>,
pub hit_regions: Vec<(Rect, ListViewHit)>,
pub resolved_scroll_offset: usize,
}
impl ListViewLayout {
pub fn hit_test(&self, x: f32, y: f32) -> ListViewHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
ListViewHit::Empty
}
}
impl ListView {
pub fn layout<F>(
&self,
viewport_width: f32,
viewport_height: f32,
title_height: f32,
measure_item: F,
) -> ListViewLayout
where
F: Fn(usize) -> ListItemMeasure,
{
let mut visible_items: Vec<VisibleListItem> = Vec::new();
let mut hit_regions: Vec<(Rect, ListViewHit)> = Vec::new();
let (inset_x, inset_y, item_w, items_h_max) = if self.bordered {
let iw = (viewport_width - 2.0).max(0.0);
let ih = (viewport_height - 2.0).max(0.0);
(1.0, 1.0, iw, ih)
} else {
(0.0, 0.0, viewport_width, viewport_height)
};
let title_bounds = if self.title.is_some() && title_height > 0.0 {
if self.bordered {
let title_h = title_height.min(viewport_height);
let bounds = Rect::new(0.0, 0.0, viewport_width, title_h);
hit_regions.push((bounds, ListViewHit::Title));
Some(bounds)
} else {
let title_h = title_height.min(viewport_height);
let bounds = Rect::new(0.0, 0.0, viewport_width, title_h);
hit_regions.push((bounds, ListViewHit::Title));
Some(bounds)
}
} else {
None
};
let items_y_start = if self.bordered {
if title_height > 0.0 {
title_height.max(inset_y)
} else {
inset_y
}
} else {
title_bounds.map(|b| b.y + b.height).unwrap_or(0.0)
};
let resolved_scroll_offset =
crate::primitives::scrollbar::clamp_scroll_offset(self.scroll_offset, self.items.len());
let items_y_end = if self.bordered {
inset_y + items_h_max
} else {
viewport_height
};
let mut y = items_y_start;
for i in resolved_scroll_offset..self.items.len() {
if y >= items_y_end {
break;
}
let m = measure_item(i);
let remaining = items_y_end - y;
let height = m.height.min(remaining).max(0.0);
if height <= 0.0 {
break;
}
let bounds = Rect::new(inset_x, y, item_w, height);
visible_items.push(VisibleListItem {
item_idx: i,
bounds,
});
hit_regions.push((bounds, ListViewHit::Item(i)));
y += m.height;
}
ListViewLayout {
viewport_width,
viewport_height,
title_bounds,
visible_items,
hit_regions,
resolved_scroll_offset,
}
}
pub fn hscrollbar(&self, area: Rect, row_height: f32) -> Option<Scrollbar> {
let total = self.max_content_width? as f32;
let visible_w = if self.bordered {
(area.width - 2.0).max(0.0)
} else {
area.width
};
if total <= visible_w {
return None;
}
let track = hscrollbar_track(area, self.bordered, 1.0, row_height);
Some(Scrollbar::horizontal(
self.id.clone(),
track,
self.h_scroll as f32,
total,
visible_w,
row_height,
))
}
pub fn vscrollbar(&self, area: Rect, row_height: f32) -> Option<Scrollbar> {
if !self.show_v_scrollbar {
return None;
}
let total = self.items.len() as f32;
if total == 0.0 {
return None;
}
let (track_x, track_y, track_h) = if self.bordered {
let x = area.x + area.width - 2.0 * row_height;
let y = area.y + row_height;
let h = (area.height - 2.0 * row_height).max(0.0);
(x, y, h)
} else {
let title_h = if self.title.is_some() {
row_height
} else {
0.0
};
let x = area.x + area.width - row_height;
let y = area.y + title_h;
let h = (area.height - title_h).max(0.0);
(x, y, h)
};
if track_h <= 0.0 {
return None;
}
let visible = (track_h / row_height).floor();
if total <= visible {
return None;
}
let track = Rect::new(track_x, track_y, row_height, track_h);
Some(Scrollbar::vertical(
self.id.clone(),
track,
self.scroll_offset as f32,
total,
visible,
row_height,
))
}
}
#[cfg(test)]
mod hscrollbar_tests {
use super::*;
fn list(content_width: usize, h_scroll: usize, max: bool) -> ListView {
ListView {
id: WidgetId::new("l"),
title: None,
items: vec![ListItem {
text: StyledText::plain(&"x".repeat(content_width)),
detail: None,
icon: None,
decoration: Decoration::default(),
}],
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll,
max_content_width: max.then_some(content_width),
show_v_scrollbar: false,
}
}
#[test]
fn none_when_max_content_width_unset() {
let l = list(100, 0, false);
assert!(l.hscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0).is_none());
}
#[test]
fn none_when_content_fits() {
let l = list(20, 0, true);
assert!(l.hscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0).is_none());
}
#[test]
fn flat_track_spans_bottom_row() {
let l = list(40, 0, true);
let sb = l
.hscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.x, 0.0);
assert_eq!(sb.track.width, 20.0);
assert_eq!(sb.track.y, 9.0);
assert_eq!(sb.track.height, 1.0);
assert_eq!(sb.thumb_start, 0.0);
assert!(sb.thumb_len > 0.0 && sb.thumb_len < sb.track.width);
}
#[test]
fn bordered_track_inset_above_bottom_border() {
let mut l = list(40, 0, true);
l.bordered = true;
let sb = l
.hscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.x, 1.0);
assert_eq!(sb.track.width, 18.0);
assert_eq!(sb.track.y, 8.0);
}
#[test]
fn h_scroll_advances_thumb() {
let at_zero = list(40, 0, true)
.hscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.unwrap();
let scrolled = list(40, 10, true)
.hscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.unwrap();
assert!(
scrolled.thumb_start > at_zero.thumb_start,
"scrolling right should move the thumb right"
);
}
}
#[cfg(test)]
mod hscrollbar_track_tests {
use super::*;
#[test]
fn flat_track_spans_full_width_bottom_row() {
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
let track = hscrollbar_track(rect, false, 8.0, 16.0);
assert_eq!(track.x, 0.0);
assert_eq!(track.width, 200.0);
assert_eq!(track.y, 84.0); assert_eq!(track.height, 16.0);
}
#[test]
fn bordered_track_insets_one_char_width_each_side_and_sits_above_border() {
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
let track = hscrollbar_track(rect, true, 8.0, 16.0);
assert_eq!(track.x, 8.0);
assert_eq!(track.width, 184.0); assert_eq!(track.y, 68.0); assert_eq!(track.height, 16.0);
}
#[test]
fn bordered_track_never_overlaps_border_columns() {
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
let track = hscrollbar_track(rect, true, 8.0, 16.0);
assert!(track.x >= rect.x + 8.0);
assert!(track.x + track.width <= rect.x + rect.width - 8.0);
assert!(track.y + track.height <= rect.y + rect.height - 16.0);
}
}
#[cfg(test)]
mod vscrollbar_tests {
use super::*;
fn vlist(n_items: usize, scroll_offset: usize) -> ListView {
ListView {
id: WidgetId::new("l"),
title: None,
items: (0..n_items)
.map(|i| ListItem {
text: StyledText::plain(&format!("item {}", i)),
detail: None,
icon: None,
decoration: Decoration::default(),
})
.collect(),
selected_idx: 0,
scroll_offset,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: true,
}
}
#[test]
fn none_when_show_v_scrollbar_false() {
let mut l = vlist(50, 0);
l.show_v_scrollbar = false;
assert!(l.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0).is_none());
}
#[test]
fn none_when_items_fit_in_viewport() {
let l = vlist(5, 0);
assert!(l.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0).is_none());
}
#[test]
fn none_when_items_exactly_fill_viewport() {
let l = vlist(10, 0);
assert!(l.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0).is_none());
}
#[test]
fn flat_track_spans_right_column() {
let l = vlist(20, 0);
let sb = l
.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.x, 19.0);
assert_eq!(sb.track.y, 0.0);
assert_eq!(sb.track.width, 1.0);
assert_eq!(sb.track.height, 10.0);
assert_eq!(sb.thumb_start, 0.0);
assert!(sb.thumb_len > 0.0 && sb.thumb_len < sb.track.height);
}
#[test]
fn flat_track_below_title_row() {
let mut l = vlist(20, 0);
l.title = Some(StyledText::plain("Title"));
let sb = l
.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.y, 1.0);
assert_eq!(sb.track.height, 9.0);
}
#[test]
fn bordered_track_inset_inside_right_border() {
let mut l = vlist(20, 0);
l.bordered = true;
let sb = l
.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.x, 18.0);
assert_eq!(sb.track.y, 1.0);
assert_eq!(sb.track.height, 8.0);
}
#[test]
fn scroll_offset_advances_thumb() {
let at_zero = vlist(30, 0)
.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.unwrap();
let scrolled = vlist(30, 15)
.vscrollbar(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0)
.unwrap();
assert!(
scrolled.thumb_start > at_zero.thumb_start,
"scrolling down should move the thumb down"
);
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{ListView, ListViewLayout};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::types::Decoration;
use crate::Rect;
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint(
list: &ListView,
area: Rect,
list_layout: &ListViewLayout,
line_height: f32,
nerd_fonts_enabled: bool,
supports_border: bool,
supports_hscrollbar: bool,
surface: &mut dyn PaintSurface,
theme: &Theme,
) {
if area.width <= 0.0 || area.height <= 0.0 {
return;
}
let base_bg = if list.bordered {
theme.surface_bg
} else {
theme.background
};
let border_inset = if list.bordered && supports_border {
1.0
} else {
0.0
};
let title_overlay = list.bordered && supports_border;
surface.surface_push_clip(area);
surface.surface_fill_rect(area, base_bg);
let char_w = surface.surface_measure_text("M").0.max(1.0);
let h_off_px = list.h_scroll as f32 * char_w;
let visible_px = (area.width - border_inset * 2.0).max(0.0);
let needs_hscrollbar = supports_hscrollbar
&& list
.max_content_width
.is_some_and(|n| n as f32 * char_w > visible_px);
if title_overlay {
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());
let (tw, th) = surface.surface_measure_text(&label);
let title_x = area.x + 8.0;
let title_y = area.y + (line_height - th) / 2.0;
surface.surface_fill_rect(
Rect::new(title_x - 2.0, area.y, tw + 4.0, line_height),
base_bg,
);
surface.surface_draw_text_run(
Rect::new(title_x, title_y, tw, th),
&label,
theme.title_fg,
);
}
} else if let (Some(title_bounds), Some(title)) =
(list_layout.title_bounds, list.title.as_ref())
{
let tb = Rect::new(
area.x + title_bounds.x,
area.y + title_bounds.y,
title_bounds.width,
title_bounds.height,
);
surface.surface_fill_rect(tb, theme.header_bg);
let title_text: String = title.spans.iter().map(|s| s.text.as_str()).collect();
let (_, text_h) = surface.surface_measure_text(&title_text);
surface.surface_draw_text_run(
Rect::new(
tb.x + 2.0,
tb.y + (tb.height - text_h) / 2.0,
tb.width,
text_h,
),
&title_text,
theme.header_fg,
);
}
let item_x_offset = area.x + border_inset;
let item_y_offset = area.y + border_inset;
for vis_item in &list_layout.visible_items {
let item = &list.items[vis_item.item_idx];
let row_x = item_x_offset + vis_item.bounds.x;
let row_y = item_y_offset + vis_item.bounds.y;
let row_w = vis_item.bounds.width;
let row_h = vis_item.bounds.height;
let is_selected = vis_item.item_idx == list.selected_idx && list.has_focus;
let decoration_fg = match item.decoration {
Decoration::Error => theme.error_fg,
Decoration::Warning => theme.warning_fg,
Decoration::Muted => theme.muted_fg,
Decoration::Header => theme.header_fg,
_ => theme.surface_fg,
};
let row_bg = if is_selected {
theme.selected_bg
} else if matches!(item.decoration, Decoration::Header) {
theme.header_bg
} else {
base_bg
};
let row_rect = Rect::new(row_x, row_y, row_w, row_h);
surface.surface_fill_rect(row_rect, row_bg);
surface.surface_push_clip(row_rect);
let mut cursor_x = row_x + 2.0 - h_off_px;
if let Some(ref icon) = item.icon {
let glyph = if nerd_fonts_enabled {
icon.glyph.as_str()
} else {
icon.fallback.as_str()
};
let (iw, ih) = surface.surface_measure_text(glyph);
surface.surface_draw_text_run(
Rect::new(cursor_x, row_y + (row_h - ih) / 2.0, iw, ih),
glyph,
decoration_fg,
);
cursor_x += iw + 6.0;
}
let detail_info = item.detail.as_ref().map(|detail| {
let detail_text: String = detail.spans.iter().map(|s| s.text.as_str()).collect();
let (dw, _) = surface.surface_measure_text(&detail_text);
(detail_text, dw)
});
let detail_reserve = detail_info.as_ref().map(|(_, dw)| *dw + 8.0).unwrap_or(0.0);
let text_right_limit = row_x + row_w - detail_reserve - 4.0;
for span in &item.text.spans {
if cursor_x >= text_right_limit {
break;
}
let span_fg = span.fg.unwrap_or(decoration_fg);
if let Some(sbg) = span.bg {
let (sw, _) = surface.surface_measure_text_styled(&span.text, span.bold);
surface.surface_fill_rect(
Rect::new(cursor_x, row_y, sw.min(text_right_limit - cursor_x), row_h),
sbg,
);
}
let (sw, sh) = surface.surface_measure_text_styled(&span.text, span.bold);
surface.surface_draw_text_run_styled(
Rect::new(cursor_x, row_y + (row_h - sh) / 2.0, sw, sh),
&span.text,
span_fg,
span.bold,
false,
false,
1.0,
);
cursor_x += sw;
}
surface.surface_pop_clip();
if let Some((detail_text, dw)) = detail_info {
let dx = row_x + row_w - dw - 4.0;
if dx > cursor_x {
let (_, dh) = surface.surface_measure_text(&detail_text);
surface.surface_draw_text_run(
Rect::new(dx, row_y + (row_h - dh) / 2.0, dw, dh),
&detail_text,
theme.muted_fg,
);
}
}
}
if needs_hscrollbar {
let content_px = list.max_content_width.unwrap_or(0) as f32 * char_w;
let track_y = if list.bordered {
area.y + area.height - border_inset - line_height
} else {
area.y + area.height - line_height
};
let (track_x, track_w) = if list.bordered {
(
area.x + border_inset,
(area.width - 2.0 * border_inset).max(0.0),
)
} else {
(area.x, area.width)
};
let hsb_track = Rect::new(track_x, track_y, track_w, line_height);
let hsb = crate::primitives::scrollbar::Scrollbar::horizontal(
list.id.clone(),
hsb_track,
list.h_scroll as f32 * char_w,
content_px,
visible_px,
line_height,
);
crate::primitives::scrollbar::native_surface_paint::paint(&hsb, surface, theme);
}
if let Some(vsb) = list.vscrollbar(area, line_height) {
crate::primitives::scrollbar::native_surface_paint::paint(&vsb, surface, theme);
}
surface.surface_pop_clip();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::list::ListItem;
use crate::types::{Color, Icon, StyledText, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
texts: Vec<(Rect, String, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.texts.push((rect, text.to_string(), color));
}
fn surface_draw_line(&mut self, _from: Point, _to: Point, _color: Color, _sw: f32) {}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
use crate::event::Point;
fn item(label: &str) -> ListItem {
ListItem {
text: StyledText::plain(label.to_string()),
icon: None,
detail: None,
decoration: Decoration::Normal,
}
}
fn vlist(n_items: usize) -> ListView {
ListView {
id: WidgetId::new("l"),
title: None,
items: (0..n_items).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: true,
}
}
const LINE_HEIGHT: f32 = 16.0;
const AREA: Rect = Rect::new(0.0, 0.0, 100.0, 80.0);
#[test]
fn paints_vertical_scrollbar_track_when_enabled() {
let list = vlist(50); let layout = list.layout(AREA.width, AREA.height, 0.0, |_| {
super::super::ListItemMeasure::new(LINE_HEIGHT)
});
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
false,
true,
true,
&mut surface,
&theme,
);
let expected = list
.vscrollbar(AREA, LINE_HEIGHT)
.expect("50 rows in an 80px/16px viewport must need a v-scrollbar");
assert!(
surface
.fills
.iter()
.any(|(r, _)| (r.x - expected.track.x).abs() < 0.01
&& (r.y - expected.track.y).abs() < 0.01
&& (r.width - expected.track.width).abs() < 0.01
&& (r.height - expected.track.height).abs() < 0.01),
"expected a fill at the v-scrollbar track {:?}, got fills: {:?}",
expected.track,
surface.fills
);
}
#[test]
fn no_vertical_scrollbar_fill_when_show_v_scrollbar_false() {
let mut list = vlist(50);
list.show_v_scrollbar = false;
let layout = list.layout(AREA.width, AREA.height, 0.0, |_| {
super::super::ListItemMeasure::new(LINE_HEIGHT)
});
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
false,
true,
true,
&mut surface,
&theme,
);
assert!(list.vscrollbar(AREA, LINE_HEIGHT).is_none());
for (r, _) in &surface.fills {
assert!(
r.x + r.width <= AREA.x + AREA.width + 0.01,
"unexpected fill past the list's right edge: {:?}",
r
);
}
}
#[test]
fn no_hscrollbar_fill_when_supports_hscrollbar_false() {
let mut list = vlist(3);
list.max_content_width = Some(1000); list.show_v_scrollbar = false;
let layout = list.layout(AREA.width, AREA.height, 0.0, |_| {
super::super::ListItemMeasure::new(LINE_HEIGHT)
});
let theme = Theme::default();
let mut supported = RecordingSurface::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
false,
true,
true,
&mut supported,
&theme,
);
assert!(
!supported.fills.is_empty(),
"sanity: supports_hscrollbar: true must paint something for an \
overflowing max_content_width"
);
let mut unsupported = RecordingSurface::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
false,
true,
false,
&mut unsupported,
&theme,
);
let hscrollbar_track_y = AREA.y + AREA.height - LINE_HEIGHT;
for (r, _) in &unsupported.fills {
assert!(
(r.y - hscrollbar_track_y).abs() > 0.01,
"supports_hscrollbar: false must not paint a fill at the \
h-scrollbar track's y ({hscrollbar_track_y}), got {:?}",
r
);
}
}
#[test]
fn selected_row_paints_selected_bg() {
let mut list = vlist(3);
list.selected_idx = 1;
list.has_focus = true;
let layout = list.layout(AREA.width, AREA.height, 0.0, |_| {
super::super::ListItemMeasure::new(LINE_HEIGHT)
});
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
false,
true,
true,
&mut surface,
&theme,
);
let row1 = layout
.visible_items
.iter()
.find(|v| v.item_idx == 1)
.expect("row 1 visible");
let row1_rect = Rect::new(
row1.bounds.x,
row1.bounds.y,
row1.bounds.width,
row1.bounds.height,
);
assert!(
surface
.fills
.iter()
.any(|(r, c)| (r.x - row1_rect.x).abs() < 0.01
&& (r.y - row1_rect.y).abs() < 0.01
&& *c == theme.selected_bg),
"selected row must paint theme.selected_bg at its own bounds"
);
}
#[test]
fn selected_row_paints_no_chevron_glyph() {
let mut list = vlist(3);
list.selected_idx = 1;
list.has_focus = true;
let layout = list.layout(AREA.width, AREA.height, 0.0, |_| {
super::super::ListItemMeasure::new(LINE_HEIGHT)
});
let mut surface = RecordingSurface::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
false,
true,
true,
&mut surface,
&Theme::default(),
);
assert!(
!surface.texts.iter().any(|(_, t, _)| t.contains('▶')),
"no chevron glyph should be painted on GUI; texts were {:?}",
surface.texts,
);
}
#[test]
fn icon_uses_nerd_glyph_or_ascii_fallback() {
let mut list = vlist(1);
list.items[0].icon = Some(Icon::new("nf-glyph", "F"));
let layout = list.layout(AREA.width, AREA.height, 0.0, |_| {
super::super::ListItemMeasure::new(LINE_HEIGHT)
});
let theme = Theme::default();
let mut nerd_surface = RecordingSurface::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
true,
true,
true,
&mut nerd_surface,
&theme,
);
assert!(nerd_surface.texts.iter().any(|(_, t, _)| t == "nf-glyph"));
let mut ascii_surface = RecordingSurface::default();
paint(
&list,
AREA,
&layout,
LINE_HEIGHT,
false,
true,
true,
&mut ascii_surface,
&theme,
);
assert!(ascii_surface.texts.iter().any(|(_, t, _)| t == "F"));
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ListViewEvent {
SelectionChanged { idx: usize },
ItemActivated { idx: usize },
KeyPressed { key: String, modifiers: Modifiers },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_view_roundtrip_serde() {
let list = ListView {
id: WidgetId::new("quickfix"),
title: Some(StyledText::plain("QUICKFIX (3 items)")),
items: vec![
ListItem {
text: StyledText::plain("src/main.rs:12: unused variable"),
icon: None,
detail: None,
decoration: Decoration::Warning,
},
ListItem {
text: StyledText::plain("src/lib.rs:4: missing import"),
icon: None,
detail: Some(StyledText::plain("E0425")),
decoration: Decoration::Error,
},
],
selected_idx: 1,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
};
let json = serde_json::to_string(&list).unwrap();
let back: ListView = serde_json::from_str(&json).unwrap();
assert_eq!(list, back);
}
fn make_list_item(text: &str) -> ListItem {
ListItem {
text: StyledText::plain(text),
icon: None,
detail: None,
decoration: Decoration::Normal,
}
}
fn make_list(
title: Option<&str>,
items: Vec<ListItem>,
selected: usize,
scroll: usize,
) -> ListView {
ListView {
id: WidgetId::new("l"),
title: title.map(StyledText::plain),
items,
selected_idx: selected,
scroll_offset: scroll,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
#[test]
fn list_view_layout_empty() {
let list = make_list(None, vec![], 0, 0);
let layout = list.layout(40.0, 10.0, 0.0, |_| ListItemMeasure::new(1.0));
assert_eq!(layout.visible_items.len(), 0);
assert!(layout.title_bounds.is_none());
assert_eq!(layout.hit_test(5.0, 5.0), ListViewHit::Empty);
}
#[test]
fn list_view_layout_title_reserves_first_row() {
let list = make_list(
Some("QUICKFIX"),
(0..3)
.map(|i| make_list_item(&format!("item{i}")))
.collect(),
0,
0,
);
let layout = list.layout(40.0, 10.0, 1.0, |_| ListItemMeasure::new(1.0));
assert!(layout.title_bounds.is_some());
let tb = layout.title_bounds.unwrap();
assert_eq!(tb.y, 0.0);
assert_eq!(tb.height, 1.0);
assert_eq!(layout.visible_items[0].bounds.y, 1.0);
assert_eq!(layout.visible_items[0].item_idx, 0);
assert_eq!(layout.hit_test(10.0, 0.5), ListViewHit::Title);
assert_eq!(layout.hit_test(10.0, 1.5), ListViewHit::Item(0));
}
#[test]
fn list_view_layout_no_title_starts_at_zero() {
let list = make_list(
None,
(0..2).map(|i| make_list_item(&format!("i{i}"))).collect(),
0,
0,
);
let layout = list.layout(40.0, 10.0, 0.0, |_| ListItemMeasure::new(1.0));
assert!(layout.title_bounds.is_none());
assert_eq!(layout.visible_items[0].bounds.y, 0.0);
assert_eq!(layout.hit_test(10.0, 0.5), ListViewHit::Item(0));
}
#[test]
fn list_view_layout_scroll_offset_skips_items_not_title() {
let list = make_list(
Some("HEADER"),
(0..5).map(|i| make_list_item(&format!("i{i}"))).collect(),
0,
2, );
let layout = list.layout(40.0, 10.0, 1.0, |_| ListItemMeasure::new(1.0));
assert_eq!(layout.title_bounds.unwrap().y, 0.0);
assert_eq!(layout.visible_items[0].item_idx, 2);
assert_eq!(layout.visible_items[0].bounds.y, 1.0);
}
#[test]
fn list_view_layout_viewport_overflow_clips_last() {
let list = make_list(
None,
(0..10).map(|i| make_list_item(&format!("i{i}"))).collect(),
0,
0,
);
let layout = list.layout(40.0, 5.0, 0.0, |_| ListItemMeasure::new(2.0));
assert_eq!(layout.visible_items.len(), 3);
assert_eq!(layout.visible_items[2].bounds.height, 1.0);
}
#[test]
fn list_view_layout_pixel_units_with_title() {
let list = make_list(
Some("DIAGNOSTICS"),
(0..5).map(|i| make_list_item(&format!("d{i}"))).collect(),
0,
0,
);
let layout = list.layout(300.0, 100.0, 20.0, |_| ListItemMeasure::new(18.5));
let tb = layout.title_bounds.unwrap();
assert_eq!(tb.height, 20.0);
assert_eq!(layout.visible_items[0].bounds.y, 20.0);
assert_eq!(layout.visible_items[0].bounds.height, 18.5);
assert_eq!(layout.hit_test(100.0, 29.0), ListViewHit::Item(0));
assert_eq!(layout.hit_test(100.0, 39.0), ListViewHit::Item(1));
}
#[test]
fn list_view_layout_bordered_insets_items() {
let mut list = make_list(
Some("Open Tabs"),
(0..3).map(|i| make_list_item(&format!("tab{i}"))).collect(),
0,
0,
);
list.bordered = true;
let layout = list.layout(20.0, 6.0, 1.0, |_| ListItemMeasure::new(1.0));
let tb = layout.title_bounds.unwrap();
assert_eq!(tb.y, 0.0);
assert_eq!(tb.width, 20.0);
let i0 = layout.visible_items[0].bounds;
assert_eq!(i0.x, 1.0);
assert_eq!(i0.y, 1.0);
assert_eq!(i0.width, 18.0);
assert!(layout.visible_items.iter().all(|v| v.bounds.y < 5.0));
}
#[test]
fn list_view_layout_bordered_no_title_starts_at_one() {
let mut list = make_list(
None,
(0..3).map(|i| make_list_item(&format!("r{i}"))).collect(),
0,
0,
);
list.bordered = true;
let layout = list.layout(10.0, 6.0, 0.0, |_| ListItemMeasure::new(1.0));
assert!(layout.title_bounds.is_none());
assert_eq!(layout.visible_items[0].bounds.y, 1.0);
}
}