use crate::core::Color;
use crate::core::HorizontalAlignment;
use crate::core::Rect;
use crate::render::RenderContext;
use crate::signal::{ConnectionScope, GenericSignal, Signal1};
use crate::widget::capability::access::{selection_mode_to_str, view_mode_to_str};
use crate::widget::capability::coercion::{
expect_horizontal_alignment, expect_selection_mode, expect_usize, expect_view_mode,
horizontal_alignment_to_str,
};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, ControlMetrics};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use std::sync::Arc;
const LIST_INSET: u32 = 2;
const LIST_TEXT_INSET: i32 = 2;
pub const LIST_ROW_HEIGHT: u32 = dimensions::MENU_ROW_HEIGHT;
const LIST_ICON_MIN_CELL: u32 = 48;
const LIST_THUMB_MIN_CELL: u32 = 72;
const LIST_THUMB_CAPTION: u32 = 18;
#[derive(Debug, Clone, Copy)]
struct ListLayout {
content: Rect,
row_height: u32,
columns: u32,
text_inset: i32,
label_below: bool,
}
impl ListLayout {
fn cell_width(&self) -> u32 {
(self.content.width / self.columns.max(1)).max(1)
}
fn caption_height(&self) -> u32 {
LIST_THUMB_CAPTION.min(self.row_height.max(1)).max(1)
}
}
pub trait ListModel: Send + Sync {
fn row_count(&self) -> usize;
fn data(&self, row: usize) -> Option<String>;
fn data_changed_signal(&self) -> Option<&GenericSignal> {
None
}
}
pub struct VecListModel {
items: Vec<String>,
data_changed: GenericSignal,
}
impl VecListModel {
pub fn new(items: Vec<String>) -> Self {
Self { items, data_changed: GenericSignal::new() }
}
pub fn data_changed_signal(&self) -> &GenericSignal {
&self.data_changed
}
pub fn append(&mut self, item: String) {
self.items.push(item);
self.data_changed.emit();
}
pub fn remove(&mut self, index: usize) -> Option<String> {
if index < self.items.len() {
let item = self.items.remove(index);
self.data_changed.emit();
Some(item)
} else {
None
}
}
pub fn clear(&mut self) {
self.items.clear();
self.data_changed.emit();
}
}
impl ListModel for VecListModel {
fn row_count(&self) -> usize {
self.items.len()
}
fn data(&self, row: usize) -> Option<String> {
self.items.get(row).cloned()
}
fn data_changed_signal(&self) -> Option<&GenericSignal> {
Some(&self.data_changed)
}
}
pub struct SelectionModel {
mode: SelectionMode,
selected_rows: Vec<usize>,
current_row: Option<usize>,
}
crate::impl_default_via_new!(SelectionModel);
impl SelectionModel {
pub fn new() -> Self {
Self { mode: SelectionMode::Single, selected_rows: Vec::new(), current_row: None }
}
pub fn set_mode(&mut self, mode: SelectionMode) {
self.mode = mode;
self.normalize();
}
pub fn mode(&self) -> SelectionMode {
self.mode
}
pub fn select_row(&mut self, row: usize) {
match self.mode {
SelectionMode::Single => {
self.selected_rows.clear();
self.selected_rows.push(row);
self.current_row = Some(row);
}
SelectionMode::Multi => {
if !self.selected_rows.contains(&row) {
self.selected_rows.push(row);
}
self.current_row = Some(row);
}
SelectionMode::Extended => {
self.selected_rows.push(row);
self.current_row = Some(row);
}
SelectionMode::None => {}
}
}
pub fn clear(&mut self) {
self.selected_rows.clear();
self.current_row = None;
}
pub fn is_selectable(&self) -> bool {
self.mode != SelectionMode::None
}
pub fn current_row(&self) -> Option<usize> {
self.current_row
}
pub fn rows(&self) -> Vec<usize> {
self.selected_rows.clone()
}
fn normalize(&mut self) {
if self.mode == SelectionMode::Single && self.selected_rows.len() > 1 {
if let Some(&last) = self.selected_rows.last() {
self.selected_rows = vec![last];
} else {
self.selected_rows.clear();
}
}
}
}
pub use crate::widget::input_widgets::listbox::SelectionMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
#[default]
List,
Icon,
Details,
Thumbnails,
}
pub struct ListView {
base: BaseWidget,
model: Option<Arc<dyn ListModel>>,
model_connection_scope: ConnectionScope,
selection: SelectionModel,
focused_row: Option<usize>,
hovered_row: Option<usize>,
view_mode: ViewMode,
alignment: crate::core::Alignment,
pub selection_changed: Signal1<usize>,
pub focused_row_changed: Signal1<Option<usize>>,
}
impl ListView {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::ListView, geometry, "ListView"),
model: None,
model_connection_scope: ConnectionScope::new(),
selection: SelectionModel::new(),
focused_row: None,
hovered_row: None,
view_mode: ViewMode::default(),
alignment: crate::core::Alignment::Left,
selection_changed: Signal1::new(),
focused_row_changed: Signal1::new(),
}
}
pub fn set_model(&mut self, model: Arc<dyn ListModel>) {
self.model_connection_scope = ConnectionScope::new();
if let Some(data_changed) = model.data_changed_signal() {
let redraw = self.base.redraw_requested_signal().clone();
let layout = self.base.layout_requested_signal().clone();
data_changed.connect_scoped(&self.model_connection_scope, move || {
redraw.emit();
layout.emit();
});
}
self.model = Some(model);
self.normalize_projection_state();
self.base.request_layout();
self.base.request_redraw();
}
pub fn has_model(&self) -> bool {
self.model.is_some()
}
pub fn model_ref(&self) -> Option<&Arc<dyn ListModel>> {
self.model.as_ref()
}
pub fn row_count(&self) -> usize {
self.model.as_ref().map(|m| m.row_count()).unwrap_or(0)
}
pub fn item(&self, row: usize) -> Option<String> {
self.model.as_ref().and_then(|m| m.data(row))
}
pub fn select_row(&mut self, row: usize) -> bool {
if row < self.row_count() {
self.selection.select_row(row);
self.selection_changed.emit(row);
self.set_focused_row(row);
true
} else {
false
}
}
pub fn clear_selection(&mut self) {
self.selection.clear();
}
pub fn set_focused_row(&mut self, row: usize) -> bool {
if row >= self.row_count() {
return false;
}
if self.focused_row == Some(row) {
return true;
}
self.focused_row = Some(row);
self.focused_row_changed.emit(self.focused_row);
true
}
pub fn clear_focused_row(&mut self) {
if self.focused_row.is_none() {
return;
}
self.focused_row = None;
self.focused_row_changed.emit(None);
}
pub fn focused_row(&self) -> Option<usize> {
self.focused_row.filter(|row| *row < self.row_count())
}
pub fn selected_row(&self) -> Option<usize> {
self.selection.current_row().filter(|row| *row < self.row_count())
}
pub fn selected_rows(&self) -> Vec<usize> {
self.selection.rows().into_iter().filter(|row| *row < self.row_count()).collect()
}
pub fn set_selection_mode(&mut self, mode: SelectionMode) {
self.selection.set_mode(mode);
}
pub fn selection_mode(&self) -> SelectionMode {
self.selection.mode()
}
pub fn view_mode(&self) -> ViewMode {
self.view_mode
}
pub fn set_view_mode(&mut self, mode: ViewMode) {
self.view_mode = mode;
self.base.request_redraw();
}
pub fn alignment(&self) -> crate::core::Alignment {
self.alignment
}
pub fn set_alignment(&mut self, alignment: crate::core::Alignment) {
if alignment.to_horizontal().is_none() || self.alignment == alignment {
return;
}
self.alignment = alignment;
self.base.request_redraw();
}
fn normalize_projection_state(&mut self) {
let row_count = self.row_count();
self.selection.selected_rows.retain(|row| *row < row_count);
self.selection.current_row = self.selection.current_row.filter(|row| *row < row_count);
self.focused_row = self.focused_row.filter(|row| *row < row_count);
}
#[allow(dead_code)]
fn visible_row_count(&self) -> usize {
let layout = self.list_layout();
(layout.content.height / layout.row_height.max(1)) as usize
}
fn list_layout(&self) -> ListLayout {
let content = ControlMetrics::band_inset(self.base.geometry(), LIST_INSET);
match self.view_mode {
ViewMode::List => ListLayout {
content,
row_height: LIST_ROW_HEIGHT,
columns: 1,
text_inset: LIST_TEXT_INSET,
label_below: false,
},
ViewMode::Icon => {
let columns = (content.width / LIST_ICON_MIN_CELL).max(1);
ListLayout {
content,
row_height: content.width / columns,
columns,
text_inset: LIST_TEXT_INSET,
label_below: true,
}
}
ViewMode::Details => ListLayout {
content,
row_height: LIST_ROW_HEIGHT * 2,
columns: 1,
text_inset: LIST_TEXT_INSET,
label_below: false,
},
ViewMode::Thumbnails => {
let columns = (content.width / LIST_THUMB_MIN_CELL).max(1);
ListLayout {
content,
row_height: content.width / columns + LIST_THUMB_CAPTION,
columns,
text_inset: LIST_TEXT_INSET,
label_below: true,
}
}
}
}
fn row_rect(&self, index: usize) -> Option<Rect> {
let layout = self.list_layout();
let line = index as u32 / layout.columns;
let y = layout.content.y + (layout.row_height * line) as i32;
if y + layout.row_height as i32 > layout.content.y + layout.content.height as i32 {
return None;
}
Some(Rect::new(layout.content.x, y, layout.content.width, layout.row_height))
}
fn item_rect(&self, index: usize) -> Option<Rect> {
let row = self.row_rect(index)?;
let layout = self.list_layout();
if layout.columns <= 1 {
return Some(row);
}
let column = index as u32 % layout.columns;
let cell = layout.cell_width();
Some(Rect::new(row.x + (cell * column) as i32, row.y, cell, row.height))
}
fn row_at_point(&self, point: crate::core::Point) -> Option<usize> {
let layout = self.list_layout();
if !layout.content.contains_point(point) {
return None;
}
let line = (point.y - layout.content.y) as u32 / layout.row_height.max(1);
let column = if layout.columns <= 1 {
0
} else {
let column = (point.x - layout.content.x).max(0) as u32 / layout.cell_width();
if column >= layout.columns {
return None;
}
column
};
let index = (line * layout.columns + column) as usize;
(index < self.row_count() && self.row_rect(index).is_some()).then_some(index)
}
fn select_row_at_point(&mut self, point: crate::core::Point) {
let Some(index) = self.row_at_point(point) else { return };
self.focused_row = Some(index);
self.selection.select_row(index);
if let Some(row) = self.focused_row {
self.selection_changed.emit(row);
self.focused_row_changed.emit(Some(row));
}
}
}
impl Widget for ListView {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(
crate::widget::metrics::dimensions::LIST_VIEW_DEFAULT_WIDTH,
crate::widget::metrics::dimensions::LIST_VIEW_DEFAULT_HEIGHT,
)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for ListView {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"has_model" => Ok(CapabilityValue::Bool(self.has_model())),
"row_count" => Ok(CapabilityValue::UInt(self.row_count() as u64)),
"focused_row" => match self.focused_row() {
Some(row) => Ok(CapabilityValue::UInt(row as u64)),
None => Ok(CapabilityValue::Null),
},
"selection_mode" => Ok(CapabilityValue::String(
selection_mode_to_str(self.selection_mode()).to_string(),
)),
"view_mode" => {
Ok(CapabilityValue::String(view_mode_to_str(self.view_mode()).to_string()))
}
"alignment" => Ok(CapabilityValue::String(
horizontal_alignment_to_str(self.alignment()).to_string(),
)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"focused_row" => match value {
CapabilityValue::Null => {
self.clear_focused_row();
Ok(())
}
other => {
let row = expect_usize(other)?;
if self.set_focused_row(row) {
Ok(())
} else {
Err(CapabilityAccessError::OutOfRange)
}
}
},
"selection_mode" => {
self.set_selection_mode(expect_selection_mode(value)?);
Ok(())
}
"view_mode" => {
self.set_view_mode(expect_view_mode(value)?);
Ok(())
}
"alignment" => {
self.set_alignment(expect_horizontal_alignment(value)?);
Ok(())
}
"has_model" => Err(CapabilityAccessError::ReadOnlyProperty),
"row_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"has_model",
"row_count",
"focused_row",
"selection_mode",
"view_mode",
"alignment",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"clear_selection" => {
self.clear_selection();
Ok(())
}
"clear_focused_row" => {
self.clear_focused_row();
Ok(())
}
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for ListView {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("list_view");
let surface = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != surface)
.unwrap_or_else(|| surface.blend(&ink, 0.20));
let accent = crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.primary)
.unwrap_or(Color::PRIMARY);
let focused_bg = surface.blend(&accent, 0.30);
let hovered_bg = surface.blend(&accent, 0.12);
context.face(
rect,
surface,
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
context.draw_rect(rect, border);
if self.model.is_some() {
let layout = self.list_layout();
let row_count = self.row_count();
let current_row = self.focused_row;
let font = crate::core::Font::default();
for i in 0..row_count {
let Some(cell) = self.item_rect(i) else { break };
if Some(i) == current_row {
context.fill_rect(cell, focused_bg);
} else if Some(i) == self.hovered_row {
context.fill_rect(cell, hovered_bg);
}
if let Some(text) = self.model.as_ref().and_then(|model| model.data(i)) {
if !text.is_empty() {
let (band, align) = if layout.label_below {
let caption = cell.height.min(layout.caption_height()).max(1);
(
Rect::new(
cell.x + layout.text_inset,
cell.y + (cell.height - caption) as i32,
cell.width.saturating_sub(layout.text_inset as u32),
caption,
),
HorizontalAlignment::Center,
)
} else {
(
Rect::new(
cell.x + layout.text_inset,
cell.y,
cell.width.saturating_sub(layout.text_inset as u32),
cell.height,
),
self.alignment.to_horizontal().unwrap_or(HorizontalAlignment::Left),
)
};
context.draw_text_fitted(
context.text_line(band, &font),
&text,
&font,
ink,
align,
);
}
}
}
}
}
}
impl crate::event::EventHandler for ListView {
fn handle_event(&mut self, event: &crate::event::Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
crate::event::Event::MousePress { pos, button } if *button == 1 => {
self.select_row_at_point(*pos);
}
crate::event::Event::MouseMove { pos } => {
let hovered = self.row_at_point(*pos);
if hovered != self.hovered_row {
self.hovered_row = hovered;
self.base.request_redraw();
}
}
crate::event::Event::MouseLeave { .. } => {
if self.hovered_row.take().is_some() {
self.base.request_redraw();
}
}
#[cfg(feature = "touch")]
crate::event::Event::Tap { pos } => {
self.select_row_at_point(*pos);
}
_ => { }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(device_profile, feature = "desktop"))]
use crate::event::EventHandler as _;
use std::sync::Arc;
struct StaticListModel;
impl ListModel for StaticListModel {
fn row_count(&self) -> usize {
2
}
fn data(&self, row: usize) -> Option<String> {
match row {
0 => Some("A".to_string()),
1 => Some("B".to_string()),
_ => None,
}
}
}
#[test]
fn out_of_range_row_is_reported_as_out_of_range_not_unsupported() {
use crate::widget::capability::types::CapabilityAccessError;
let mut view = ListView::new(Rect::new(0, 0, 100, 80));
view.set_model(Arc::new(StaticListModel));
assert_eq!(view.row_count(), 2, "the fixture must have rows to be out of range of");
assert_eq!(
view.set("focused_row", CapabilityValue::UInt(99)),
Err(CapabilityAccessError::OutOfRange),
"an index past the last row is the caller's argument, not a capability gap"
);
assert_eq!(view.set("focused_row", CapabilityValue::UInt(1)), Ok(()));
assert_eq!(view.focused_row(), Some(1));
}
#[test]
#[cfg(all(device_profile, feature = "desktop"))]
fn hovering_a_row_highlights_that_row_only() {
let _guard = crate::style::theme_test_guard();
crate::widget::census::install_preset_appearances();
crate::theme::global_theme_manager().set_appearance(crate::theme::AppearanceMode::Light);
let mut view = ListView::new(Rect::new(0, 0, 200, 120));
view.set_model(Arc::new(StaticListModel));
let resting = row_fill(&mut view, 1).expect("row 1 paints a fill");
let over_row_1 = row_midpoint(&view, 1).expect("row 1 is visible");
view.handle_event(&crate::event::Event::MouseMove { pos: over_row_1 });
let hovered = row_fill(&mut view, 1).expect("row 1 still paints a fill");
assert_ne!(
hovered, resting,
"the row under the pointer must be visible as hovered; both fills were {resting}"
);
let other = row_fill(&mut view, 0).expect("row 0 paints a fill");
let other_resting = {
let mut fresh = ListView::new(Rect::new(0, 0, 200, 120));
fresh.set_model(Arc::new(StaticListModel));
row_fill(&mut fresh, 0).expect("row 0 paints a fill")
};
assert_eq!(other, other_resting, "a hover on row 1 must not restyle row 0");
view.handle_event(&crate::event::Event::MouseLeave { pos: over_row_1 });
let left = row_fill(&mut view, 1).expect("row 1 still paints a fill");
assert_eq!(left, resting, "leaving must clear the hover, not latch it");
}
#[cfg(all(device_profile, feature = "desktop"))]
fn row_midpoint(view: &ListView, index: usize) -> Option<crate::core::Point> {
let row = view.row_rect(index)?;
Some(crate::core::Point::new(row.x + row.width as i32 / 2, row.y + row.height as i32 / 2))
}
#[cfg(all(device_profile, feature = "desktop"))]
fn row_fill(view: &mut ListView, index: usize) -> Option<String> {
let rect = view.geometry();
let at = row_midpoint(view, index)?;
let svg = crate::widget::svg::render_widget_to_svg(view, rect);
let mut best: Option<(u32, String)> = None;
for element in svg.split("/>") {
let Some(open) = element.find("<rect ") else { continue };
let element = &element[open + "<rect ".len()..];
let attr = |name: &str| -> Option<i32> {
let key = format!("{name}=\"");
let at = element.find(&key)? + key.len();
let to = element[at..].find('"')? + at;
element[at..to].parse().ok()
};
let (Some(x), Some(y), Some(w), Some(h)) =
(attr("x"), attr("y"), attr("width"), attr("height"))
else {
continue;
};
if at.x < x || at.x >= x + w || at.y < y || at.y >= y + h {
continue;
}
let Some(fill_at) = element.find("fill=\"") else { continue };
let from = fill_at + "fill=\"".len();
let Some(to) = element[from..].find('"') else { continue };
let area = (w as u32).saturating_mul(h as u32);
if best.as_ref().map(|(a, _)| area < *a).unwrap_or(true) {
best = Some((area, element[from..from + to].to_string()));
}
}
best.map(|(_, fill)| fill)
}
#[test]
fn list_view_model_binding_roundtrip() {
let mut view = ListView::new(Rect::new(0, 0, 100, 80));
assert!(!view.has_model());
assert!(view.model_ref().is_none());
view.set_model(Arc::new(StaticListModel));
assert!(view.has_model());
assert!(view.model_ref().is_some());
assert_eq!(view.row_count(), 2);
assert_eq!(view.item(99), None);
}
#[test]
fn the_rows_tile_the_content_box_in_sequence() {
let view = ListView::new(Rect::new(0, 0, 200, 120));
let first = view.row_rect(0).expect("the first row is visible");
assert_eq!(first.height, LIST_ROW_HEIGHT);
for index in 0..view.visible_row_count() {
let row = view.row_rect(index).expect("a visible index has a row");
assert_eq!(row.x, first.x, "the rows share one column");
assert_eq!(row.width, first.width);
}
for index in 1..view.visible_row_count() {
let previous = view.row_rect(index - 1).expect("previous is visible");
let row = view.row_rect(index).expect("this index is visible");
assert_eq!(
row.y,
previous.y + previous.height as i32,
"each row follows the previous one's own band: index {index}"
);
}
assert!(view.row_rect(view.visible_row_count()).is_none());
}
#[test]
fn a_press_selects_the_row_it_is_painted_on() {
let mut view = ListView::new(Rect::new(0, 0, 200, 120));
view.set_model(Arc::new(VecListModel::new((0..8).map(|n| n.to_string()).collect())));
for index in 0..view.visible_row_count().min(view.row_count()) {
let row = view.row_rect(index).expect("visible");
let centre = crate::core::Point::new(row.x + 1, row.y + row.height as i32 / 2);
assert_eq!(
view.row_at_point(centre),
Some(index),
"the centre of the painted row {index} must resolve to that row: {row:?}"
);
}
assert_eq!(view.row_at_point(crate::core::Point::new(0, 0)), None);
}
#[test]
fn every_view_mode_lays_out_its_own_grid_and_hit_tests_to_match() {
const WIDTH: u32 = 200;
const HEIGHT: u32 = 120;
let layout_of = |mode: ViewMode| {
let mut view = ListView::new(Rect::new(0, 0, WIDTH, HEIGHT));
view.set_view_mode(mode);
view.set_model(Arc::new(VecListModel::new((0..12).map(|n| n.to_string()).collect())));
view
};
let list = layout_of(ViewMode::List).list_layout();
let icons = layout_of(ViewMode::Icon).list_layout();
let details = layout_of(ViewMode::Details).list_layout();
let thumbs = layout_of(ViewMode::Thumbnails).list_layout();
for (name, layout) in [("Icon", icons), ("Details", details), ("Thumbnails", thumbs)] {
assert_ne!(
(layout.row_height, layout.columns, layout.label_below),
(list.row_height, list.columns, list.label_below),
"{name} must lay out differently from List"
);
}
assert_eq!(list.row_height, LIST_ROW_HEIGHT);
assert_eq!(list.columns, 1);
assert!(list.content.width > 0 && list.content.height > 0);
assert!(icons.columns > 1, "200 px holds more than one 48 px icon tile");
assert!(thumbs.columns > 1, "200 px holds more than one 72 px thumbnail");
assert_eq!(details.columns, 1);
assert_eq!(icons.row_height, icons.cell_width());
assert_eq!(thumbs.row_height, thumbs.cell_width() + LIST_THUMB_CAPTION);
assert_eq!(details.row_height, LIST_ROW_HEIGHT * 2);
for (name, mode) in [
("List", ViewMode::List),
("Icon", ViewMode::Icon),
("Details", ViewMode::Details),
("Thumbnails", ViewMode::Thumbnails),
] {
let view = layout_of(mode);
let mut checked = 0;
for index in 0..view.row_count() {
let Some(cell) = view.item_rect(index) else { break };
let centre = crate::core::Point::new(
cell.x + cell.width as i32 / 2,
cell.y + cell.height as i32 / 2,
);
assert_eq!(
view.row_at_point(centre),
Some(index),
"{name}: the centre of tile {index} at {cell:?} must hit-test back to it"
);
checked += 1;
}
assert!(
checked > 1,
"{name}: the fixture must lay out more than one item to be a grid"
);
}
let icons_view = layout_of(ViewMode::Icon);
let first = icons_view.item_rect(0).expect("visible");
let second = icons_view.item_rect(1).expect("visible");
assert_eq!(second.y, first.y, "the first two icon tiles share a line");
assert_eq!(second.x, first.x + first.width as i32, "and sit side by side");
assert!(
second.x < first.x + icons_view.list_layout().content.width as i32,
"the second tile is still inside the content box"
);
let wrapped = icons_view
.item_rect(icons_view.list_layout().columns as usize)
.expect("the second line fits");
assert_eq!(wrapped.x, first.x, "a wrapped item starts the next line");
assert_eq!(wrapped.y, first.y + first.height as i32);
}
#[test]
fn a_tile_caption_is_a_strip_at_the_foot_of_its_tile() {
let mut view = ListView::new(Rect::new(0, 0, 200, 120));
view.set_view_mode(ViewMode::Thumbnails);
let layout = view.list_layout();
assert!(layout.label_below, "the grid modes caption their tiles");
let caption = layout.caption_height();
assert!(caption > 0, "a caption with no height would draw nothing");
assert!(
caption < layout.row_height,
"the caption must leave room for the picture above it"
);
let tile = view.item_rect(0).expect("visible");
assert_eq!(
tile.y + (tile.height - caption) as i32,
tile.y + layout.row_height as i32 - caption as i32,
"the caption strip is flush with the tile's foot"
);
let mut list = ListView::new(Rect::new(0, 0, 200, 120));
list.set_view_mode(ViewMode::List);
assert!(!list.list_layout().label_below, "List labels sit beside their row");
}
}