use alloc::string::String;
use alloc::vec::Vec;
use denise::Pen;
use denise::icon::Icon;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role, Theme};
use denise_text::{TextEngine, TextStyle};
use crate::widget::{
Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{
Align, ClickPair, Intent, RowKind, columns, draw_aligned, focus_ring, hovered_row, row_colors,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListItem {
text: String,
leading: String,
icon: Option<&'static Icon>,
trailing: String,
enabled: bool,
}
impl ListItem {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
leading: String::new(),
icon: None,
trailing: String::new(),
enabled: true,
}
}
pub fn with_leading(mut self, leading: impl Into<String>) -> Self {
self.leading = leading.into();
self
}
pub fn with_leading_icon(mut self, icon: &'static Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn with_trailing(mut self, trailing: impl Into<String>) -> Self {
self.trailing = trailing.into();
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
#[inline]
pub fn text(&self) -> &str {
&self.text
}
#[inline]
pub fn leading(&self) -> &str {
&self.leading
}
#[inline]
pub const fn leading_icon(&self) -> Option<&'static Icon> {
self.icon
}
#[inline]
pub fn trailing(&self) -> &str {
&self.trailing
}
#[inline]
pub const fn is_enabled(&self) -> bool {
self.enabled
}
fn trailing_width(&self, engine: &mut TextEngine, style: TextStyle) -> i32 {
if self.trailing.is_empty() {
0
} else {
engine.measure_line(style, &self.trailing)
}
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
}
pub fn set_trailing(&mut self, trailing: impl Into<String>) {
self.trailing = trailing.into();
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
}
impl From<&str> for ListItem {
fn from(text: &str) -> Self {
Self::new(text)
}
}
impl From<String> for ListItem {
fn from(text: String) -> Self {
Self::new(text)
}
}
#[derive(Clone, Debug)]
pub struct List<M> {
items: Vec<ListItem>,
selected: Option<usize>,
hovered: Option<usize>,
row_height: Option<i32>,
selection: Option<fn(usize) -> M>,
activation: Option<fn(usize) -> M>,
single_click: bool,
clicks: ClickPair,
role: Role,
style: TextStyle,
}
impl<M> List<M> {
pub fn new(
items: impl IntoIterator<Item = impl Into<ListItem>>,
message: fn(usize) -> M,
) -> Self {
Self {
items: items.into_iter().map(Into::into).collect(),
selected: None,
hovered: None,
row_height: None,
selection: Some(message),
activation: None,
single_click: false,
clicks: ClickPair::default(),
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn inert(items: impl IntoIterator<Item = impl Into<ListItem>>) -> Self {
Self {
items: items.into_iter().map(Into::into).collect(),
selected: None,
hovered: None,
row_height: None,
selection: None,
activation: None,
single_click: false,
clicks: ClickPair::default(),
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn on_activate(mut self, message: fn(usize) -> M) -> Self {
self.activation = Some(message);
self
}
pub fn activate_on_click(mut self) -> Self {
self.single_click = true;
self
}
pub fn with_selected(mut self, index: Option<usize>) -> Self {
self.set_selected(index);
self
}
pub fn with_row_height(mut self, height: i32) -> Self {
self.row_height = Some(height.max(1));
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[inline]
pub const fn selected(&self) -> Option<usize> {
self.selected
}
#[inline]
pub fn selected_item(&self) -> Option<&ListItem> {
self.items.get(self.selected?)
}
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = index.filter(|index| *index < self.items.len());
}
#[inline]
pub fn items(&self) -> &[ListItem] {
&self.items
}
pub fn set_items(&mut self, items: impl IntoIterator<Item = impl Into<ListItem>>) {
self.items = items.into_iter().map(Into::into).collect();
self.set_selected(self.selected);
self.clicks.forget();
}
pub fn set_row_enabled(&mut self, index: usize, enabled: bool) {
if let Some(item) = self.items.get_mut(index) {
item.set_enabled(enabled);
}
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
pub fn row_height(&self, theme: &Theme) -> i32 {
self.row_height.unwrap_or(theme.metrics.size_field).max(1)
}
pub fn visible_rows(&self, theme: &Theme, height: i32) -> usize {
if height <= 0 {
return 0;
}
(height / self.row_height(theme)) as usize
}
pub fn preferred_height(&self, theme: &Theme) -> i32 {
let rows = self.items.len().max(1) as i64;
(i64::from(self.row_height(theme)) * rows).min(i64::from(i32::MAX)) as i32
}
pub fn preferred_width(&self, engine: &mut TextEngine) -> i32 {
let pad = padding(self.style.size_px);
let mut widest = |pick: fn(&ListItem) -> &str| -> i32 {
self.items
.iter()
.map(|item| engine.measure_line(self.style, pick(item)))
.max()
.unwrap_or(0)
};
let trailing = widest(ListItem::trailing);
let text = widest(ListItem::text);
let leading = self.leading_width(engine);
let gap = |width: i32| if width > 0 { pad } else { 0 };
pad * 2 + leading + gap(leading) + text + trailing + gap(trailing)
}
const fn icon_side(&self) -> i32 {
self.style.size_px as i32
}
fn leading_width(&self, engine: &mut TextEngine) -> i32 {
let text = self
.items
.iter()
.map(|item| engine.measure_line(self.style, &item.leading))
.max()
.unwrap_or(0);
if self.items.iter().any(|item| item.icon.is_some()) {
text.max(self.icon_side())
} else {
text
}
}
fn enabled_row_at(&self, bounds: Rect, row_height: i32, point: Point) -> Option<usize> {
let index = row_at(bounds, row_height, self.items.len(), point)?;
self.items.get(index).filter(|item| item.enabled)?;
Some(index)
}
fn classify_click(&mut self, row: usize, at_ms: u64) -> Intent {
self.clicks.classify(row, at_ms, self.single_click)
}
fn select(&mut self, target: Option<usize>, ctx: &mut EventCtx<'_, M>) -> Handled {
let Some(target) = target else {
return Handled::Yes;
};
if self.selected == Some(target) {
return Handled::Yes;
}
self.selected = Some(target);
ctx.reveal(row_rect(ctx.bounds, self.row_height(ctx.theme), target));
if let Some(message) = self.selection {
ctx.emit(message(target));
}
Handled::Yes
}
fn activate(&mut self, row: usize, ctx: &mut EventCtx<'_, M>) -> Handled {
if let Some(message) = self.activation {
ctx.emit(message(row));
}
Handled::Yes
}
}
#[inline]
const fn padding(size_px: u16) -> i32 {
let half = size_px as i32 / 2;
if half < 4 { 4 } else { half }
}
fn first_enabled(items: &[ListItem]) -> Option<usize> {
items.iter().position(ListItem::is_enabled)
}
fn last_enabled(items: &[ListItem]) -> Option<usize> {
items.iter().rposition(ListItem::is_enabled)
}
fn step(items: &[ListItem], from: Option<usize>, forward: bool) -> Option<usize> {
let count = items.len();
if count == 0 {
return None;
}
let Some(from) = from else {
return if forward {
first_enabled(items)
} else {
last_enabled(items)
};
};
let mut index = from.min(count - 1);
loop {
index = if forward {
if index + 1 >= count {
return None;
}
index + 1
} else {
if index == 0 {
return None;
}
index - 1
};
if items[index].enabled {
return Some(index);
}
}
}
fn row_rect(bounds: Rect, row_height: i32, index: usize) -> Rect {
let height = row_height.max(1);
let index = index.min(i32::MAX as usize) as i64;
let ceiling = i64::from(i32::MAX - height);
let y = (i64::from(bounds.y) + i64::from(height) * index).min(ceiling) as i32;
Rect::new(bounds.x, y, bounds.width, height)
}
fn row_at(bounds: Rect, row_height: i32, count: usize, point: Point) -> Option<usize> {
if count == 0 || !bounds.contains(point) {
return None;
}
let index = (i64::from(point.y - bounds.y) / i64::from(row_height.max(1))) as usize;
(index < count).then_some(index)
}
impl<M: 'static> Widget<M> for List<M> {
fn describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
Measured::both(
self.preferred_width(ctx.text),
self.preferred_height(ctx.theme),
)
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() || self.items.is_empty() {
return;
}
let (backdrop, _) = row_colors(ctx.theme, ctx.state, self.role, RowKind::Resting, true);
canvas.fill_rect(bounds, backdrop);
let row_height = self.row_height(ctx.theme);
let pad = padding(self.style.size_px);
let leading_width = self.leading_width(ctx.text);
let radius = ctx.theme.radius(Radius::Field);
let hovered = hovered_row(ctx.state, self.hovered);
for (index, item) in self.items.iter().enumerate() {
let row = row_rect(bounds, row_height, index);
if row.y >= bounds.bottom() {
break;
}
let kind = if self.selected == Some(index) {
RowKind::Selected
} else if hovered == Some(index) {
RowKind::Hovered
} else {
RowKind::Resting
};
let (fill, content) = row_colors(ctx.theme, ctx.state, self.role, kind, item.enabled);
if kind != RowKind::Resting {
canvas.fill_rounded_rect(row, radius, fill);
}
if kind == RowKind::Selected && ctx.state.contains(VisualState::FOCUSED) {
focus_ring(ctx.theme, row, radius, canvas);
}
let (leading, label, trailing) = columns(
row,
pad,
leading_width,
item.trailing_width(ctx.text, self.style),
);
if let Some(icon) = item.icon {
let side = self.icon_side().min(leading.width).min(row.height).max(1);
let box_ = Rect::new(
leading.x + (leading.width - side) / 2,
row.y + (row.height - side) / 2,
side,
side,
);
let back = if kind == RowKind::Resting {
backdrop
} else {
fill
};
canvas.draw_icon(icon, box_, content, back);
}
let leading_text = if item.icon.is_some() {
""
} else {
item.leading.as_str()
};
for (box_of, text, align) in [
(leading, leading_text, Align::Start),
(label, item.text.as_str(), Align::Start),
(trailing, item.trailing.as_str(), Align::End),
] {
if text.is_empty() || box_of.is_empty() {
continue;
}
let mut column = canvas.with_clip(box_of);
draw_aligned(
&mut column,
ctx.text,
self.style,
box_of,
(align, Align::Center),
text,
content,
);
}
}
if ctx.state.contains(VisualState::FOCUSED) && self.selected.is_none() {
focus_ring(ctx.theme, bounds, radius, canvas);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
if self.items.is_empty() {
return Handled::No;
}
let row_height = self.row_height(ctx.theme);
match event {
Event::Input(InputEvent::PointerMoved { position }) => {
let row = self.enabled_row_at(ctx.bounds, row_height, *position);
if row == self.hovered {
return Handled::No;
}
self.hovered = row;
Handled::Yes
}
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
position,
..
})
| Event::Input(InputEvent::TouchUp {
position,
cancelled: false,
..
}) => {
let Some(row) = self.enabled_row_at(ctx.bounds, row_height, *position) else {
return Handled::No;
};
let intent = self.classify_click(row, ctx.now_ms);
let handled = self.select(Some(row), ctx);
if intent == Intent::Activate {
self.activate(row, ctx);
}
handled
}
Event::Input(InputEvent::Key {
code,
state: ElementState::Down,
..
}) if ctx.state.contains(VisualState::FOCUSED) => match code {
KeyCode::ArrowDown => {
let target = step(&self.items, self.selected, true);
self.select(target, ctx)
}
KeyCode::ArrowUp => {
let target = step(&self.items, self.selected, false);
self.select(target, ctx)
}
KeyCode::Home => {
let target = first_enabled(&self.items);
self.select(target, ctx)
}
KeyCode::End => {
let target = last_enabled(&self.items);
self.select(target, ctx)
}
KeyCode::Enter | KeyCode::NumpadEnter => match self.selected {
Some(row) if self.items.get(row).is_some_and(ListItem::is_enabled) => {
self.activate(row, ctx)
}
_ => Handled::No,
},
_ => Handled::No,
},
_ => Handled::No,
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
self.items.iter().any(ListItem::is_enabled)
}
}
impl<M> Describe for List<M> {
const KIND: &'static str = "list";
const DOC: &'static str = "Rows in a column, one of them selected.";
const GROUP: Group = Group::Data;
const ICON: &'static denise::icon::Icon = &super::icons::LIST;
const PROPERTIES: &'static [Property] = &[
Property::new(
"item",
PropertyKind::List,
"The rows, as `item` child nodes. Real data: a navigation list's rows are the form's own.",
),
Property::new(
"selected",
PropertyKind::Int {
min: 0,
max: i32::MAX,
},
"The selected row. An index no row has selects nothing, rather than the nearest row.",
),
Property::new(
"on-select",
PropertyKind::Message(Payload::Index),
"Emitted with the row's index whenever the selection moves.",
),
Property::new(
"on-activate",
PropertyKind::Message(Payload::Index),
"Emitted on Enter, or a double press.",
),
Property::new(
"activate-on-click",
PropertyKind::Bool,
"A single press activates as well as selects, for rows that are commands.",
),
Property::new(
"row-height",
PropertyKind::Int { min: 16, max: 200 },
"Height of every row in logical pixels, overriding the theme's field height.",
)
.in_pixels(),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour role of the selected row.",
),
Property::new(
"size",
PropertyKind::Int { min: 6, max: 96 },
"Text size in logical pixels.",
)
.in_pixels(),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"selected" => Value::Int(i32::try_from(self.selected?).unwrap_or(i32::MAX)),
"activate-on-click" => Value::Bool(self.single_click),
"row-height" => Value::Int(self.row_height?),
"role" => Value::role(self.role),
"size" => Value::Int(i32::from(self.style.size_px)),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"selected" => self.set_selected(Some(value.as_index()?)),
"on-select" | "on-activate" | "item" => return Err(Mismatch::Supplied),
"activate-on-click" => self.single_click = value.as_bool()?,
"row-height" => self.row_height = Some(value.as_int()?.max(1)),
"role" => self.role = value.as_role()?,
"size" => self.style.size_px = value.as_size()?,
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::style::DOUBLE_CLICK_MS;
use denise::theme;
fn items() -> Vec<ListItem> {
alloc::vec![
ListItem::new("Nettverk"),
ListItem::new("Skjerm").with_trailing("70 %"),
ListItem::new("Om"),
]
}
fn list() -> List<usize> {
List::new(items(), |index| index)
}
#[test]
fn rows_are_a_fixed_height_stacked_from_the_top() {
let bounds = Rect::new(10, 20, 200, 500);
let mut previous = bounds.y;
for index in 0..8 {
let row = row_rect(bounds, 36, index);
assert_eq!(row.y, previous, "row {index} does not follow the last");
assert_eq!(row.height, 36, "row {index} is not the height it was given");
assert_eq!(row.x, bounds.x);
assert_eq!(row.right(), bounds.right());
previous = row.bottom();
}
assert_eq!(row_rect(bounds, 36, 0), row_rect(bounds, 36, 0));
}
#[test]
fn an_absurdly_long_list_neither_overflows_nor_panics() {
let bounds = Rect::new(0, 0, 300, 400);
let row = row_rect(bounds, 36, usize::MAX / 2);
assert!(row.height > 0);
assert!(row.bottom() >= row.y, "the rectangle inverted");
assert!(
row.y >= bounds.bottom(),
"an unreachable row should be past it"
);
}
#[test]
fn a_point_below_the_last_row_is_not_the_last_row() {
let bounds = Rect::new(10, 20, 200, 400);
let count = 3;
for index in 0..count {
let row = row_rect(bounds, 36, index);
for y in row.y..row.bottom() {
assert_eq!(
row_at(bounds, 36, count, Point::new(bounds.x + 5, y)),
Some(index),
"y {y} should be row {index}"
);
}
}
assert_eq!(
row_at(bounds, 36, count, Point::new(15, 20 + 36 * 3)),
None,
"the first pixel past the last row belongs to no row"
);
assert_eq!(row_at(bounds, 36, count, Point::new(5, 30)), None, "left");
assert_eq!(row_at(bounds, 36, count, Point::new(15, 5)), None, "above");
assert_eq!(row_at(bounds, 36, 0, Point::new(15, 30)), None, "no rows");
}
#[test]
fn the_selection_stops_at_the_ends_rather_than_wrapping() {
let items = items();
assert_eq!(step(&items, Some(0), true), Some(1));
assert_eq!(step(&items, Some(1), true), Some(2));
assert_eq!(
step(&items, Some(2), true),
None,
"past the last row must stay on the last row"
);
assert_eq!(step(&items, Some(2), false), Some(1));
assert_eq!(
step(&items, Some(0), false),
None,
"and before the first must stay on the first"
);
}
#[test]
fn the_first_press_with_nothing_selected_lands_on_the_near_end() {
let items = items();
assert_eq!(step(&items, None, true), Some(0));
assert_eq!(step(&items, None, false), Some(2));
}
#[test]
fn disabled_rows_are_skipped_in_both_directions() {
let items = alloc::vec![
ListItem::new("first"),
ListItem::new("a").disabled(),
ListItem::new("b").disabled(),
ListItem::new("middle"),
ListItem::new("c").disabled(),
ListItem::new("last"),
];
assert_eq!(step(&items, Some(0), true), Some(3), "over a run of two");
assert_eq!(step(&items, Some(3), true), Some(5), "over a run of one");
assert_eq!(step(&items, Some(5), false), Some(3));
assert_eq!(step(&items, Some(3), false), Some(0));
assert_eq!(first_enabled(&items), Some(0));
assert_eq!(last_enabled(&items), Some(5));
}
#[test]
fn a_disabled_row_at_the_end_is_not_reachable() {
let items = alloc::vec![ListItem::new("only"), ListItem::new("tail").disabled(),];
assert_eq!(step(&items, Some(0), true), None);
assert_eq!(last_enabled(&items), Some(0));
assert_eq!(step(&items, None, false), Some(0), "End skips the tail");
}
#[test]
fn a_list_with_nothing_selectable_is_inert_rather_than_broken() {
let items = alloc::vec![ListItem::new("a").disabled(), ListItem::new("b").disabled(),];
assert_eq!(step(&items, None, true), None);
assert_eq!(step(&items, None, false), None);
assert_eq!(step(&items, Some(0), true), None);
assert_eq!(first_enabled(&items), None);
assert_eq!(last_enabled(&items), None);
let list: List<usize> = List::new(items, |index| index);
assert!(!Widget::<usize>::focusable(&list));
}
#[test]
fn an_empty_list_is_inert_rather_than_broken() {
let mut list: List<usize> = List::inert(Vec::<ListItem>::new());
assert_eq!(list.selected(), None);
assert_eq!(list.selected_item(), None);
assert!(!Widget::<usize>::focusable(&list));
list.set_selected(Some(0));
assert_eq!(list.selected(), None);
assert_eq!(step(&[], None, true), None);
assert!(list.preferred_height(&theme::DARK) > 0);
}
#[test]
fn a_disabled_row_is_inert_to_the_mouse() {
let list: List<usize> = List::new(
alloc::vec![
ListItem::new("on"),
ListItem::new("off").disabled(),
ListItem::new("on again"),
],
|index| index,
);
let bounds = Rect::new(0, 0, 200, 200);
let at = |index: i32| Point::new(10, index * 36 + 5);
assert_eq!(list.enabled_row_at(bounds, 36, at(0)), Some(0));
assert_eq!(
list.enabled_row_at(bounds, 36, at(1)),
None,
"a click on a disabled row selects nothing"
);
assert_eq!(list.enabled_row_at(bounds, 36, at(2)), Some(2));
}
#[test]
fn two_clicks_on_one_row_inside_the_window_are_an_activation() {
let mut list = list();
assert_eq!(list.classify_click(1, 1_000), Intent::Select);
assert_eq!(
list.classify_click(1, 1_000 + DOUBLE_CLICK_MS),
Intent::Activate,
"the edge of the window is still inside it"
);
assert_eq!(
list.classify_click(1, 1_000 + DOUBLE_CLICK_MS),
Intent::Select
);
}
#[test]
fn a_pair_needs_one_row_and_a_short_gap() {
let mut slow = list();
assert_eq!(slow.classify_click(0, 0), Intent::Select);
assert_eq!(
slow.classify_click(0, DOUBLE_CLICK_MS + 1),
Intent::Select,
"a slow second click is a second selection, not an activation"
);
let mut other = list();
assert_eq!(other.classify_click(0, 100), Intent::Select);
assert_eq!(
other.classify_click(2, 110),
Intent::Select,
"two rows are never a pair"
);
}
#[test]
fn a_single_click_list_activates_on_the_first_tap() {
let mut list = list().activate_on_click();
assert_eq!(list.classify_click(0, 0), Intent::Activate);
assert_eq!(list.classify_click(0, 5_000), Intent::Activate);
}
#[test]
fn replacing_the_rows_forgets_a_half_finished_pair() {
let mut list = list();
assert_eq!(list.classify_click(1, 0), Intent::Select);
list.set_items(["helt", "andre", "rader"]);
assert_eq!(
list.classify_click(1, 10),
Intent::Select,
"a click on a new row should not complete an old pair"
);
}
#[test]
fn the_selection_is_always_a_row_that_exists() {
let mut list = list();
list.set_selected(Some(2));
assert_eq!(list.selected_item().map(ListItem::text), Some("Om"));
list.set_selected(Some(99));
assert_eq!(
list.selected(),
None,
"an index that is not there is nothing"
);
list.set_selected(Some(2));
list.set_items(["bare én"]);
assert_eq!(list.selected(), None, "a shorter list drops the selection");
}
#[test]
fn hover_clears_when_the_pointer_leaves_even_though_no_event_arrives() {
assert_eq!(hovered_row(VisualState::HOVERED, Some(2)), Some(2));
assert_eq!(
hovered_row(VisualState::NONE, Some(2)),
None,
"a remembered row must not survive the pointer leaving"
);
assert_eq!(
hovered_row(VisualState::FOCUSED | VisualState::HOVERED, Some(0)),
Some(0)
);
assert_eq!(hovered_row(VisualState::HOVERED, None), None);
}
#[test]
fn every_row_keeps_its_text_readable_in_every_theme() {
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for role in [Role::Primary, Role::Secondary, Role::Accent, Role::Neutral] {
for state in [
VisualState::NONE,
VisualState::HOVERED,
VisualState::PRESSED,
VisualState::FOCUSED,
VisualState::DISABLED,
] {
for kind in [RowKind::Resting, RowKind::Hovered, RowKind::Selected] {
for enabled in [true, false] {
let (fill, text) = row_colors(&theme, state, role, kind, enabled);
let ratio = contrast_x100(fill, text);
assert!(
ratio >= AA_LARGE,
"{} {role:?} {state:?} {kind:?} enabled={enabled}: text on \
row is {ratio}, floor is {AA_LARGE}",
theme.name
);
}
}
}
}
}
}
#[test]
fn a_disabled_list_still_shows_which_row_is_selected() {
for theme in Theme::BUILT_IN {
for role in [Role::Primary, Role::Secondary, Role::Accent] {
let (resting, _) =
row_colors(&theme, VisualState::DISABLED, role, RowKind::Resting, true);
let (selected, _) =
row_colors(&theme, VisualState::DISABLED, role, RowKind::Selected, true);
assert_ne!(
resting, selected,
"{} {role:?}: a disabled list forgot which row was chosen",
theme.name
);
}
}
}
#[test]
fn a_hovered_row_looks_different_from_a_resting_one() {
for theme in Theme::BUILT_IN {
let resting = row_colors(
&theme,
VisualState::NONE,
Role::Primary,
RowKind::Resting,
true,
);
let hovered = row_colors(
&theme,
VisualState::NONE,
Role::Primary,
RowKind::Hovered,
true,
);
let selected = row_colors(
&theme,
VisualState::NONE,
Role::Primary,
RowKind::Selected,
true,
);
assert_ne!(resting.0, hovered.0, "{}: hover is invisible", theme.name);
assert_ne!(
resting.0, selected.0,
"{}: selection is invisible",
theme.name
);
}
}
#[test]
fn hovering_the_list_does_not_tint_every_row_in_it() {
for theme in Theme::BUILT_IN {
let idle = row_colors(
&theme,
VisualState::NONE,
Role::Primary,
RowKind::Resting,
true,
);
let pointer_inside = row_colors(
&theme,
VisualState::HOVERED | VisualState::PRESSED,
Role::Primary,
RowKind::Resting,
true,
);
assert_eq!(idle, pointer_inside, "{}", theme.name);
}
}
#[test]
fn a_disabled_list_does_not_mute_a_colour_that_has_nothing_left_to_give() {
for theme in Theme::BUILT_IN {
let (_, enabled_row) = row_colors(
&theme,
VisualState::NONE,
Role::Primary,
RowKind::Resting,
true,
);
let (_, disabled_row) = row_colors(
&theme,
VisualState::NONE,
Role::Primary,
RowKind::Resting,
false,
);
assert_ne!(
enabled_row, disabled_row,
"{}: a row nobody can choose should look like it",
theme.name
);
let (_, in_a_disabled_list) = row_colors(
&theme,
VisualState::DISABLED,
Role::Primary,
RowKind::Resting,
false,
);
let (_, whole_list) = row_colors(
&theme,
VisualState::DISABLED,
Role::Primary,
RowKind::Resting,
true,
);
assert_eq!(
in_a_disabled_list, whole_list,
"{}: a row in a disabled list was muted below its own floor",
theme.name
);
}
}
#[test]
fn no_width_produces_an_inverted_column() {
for width in 0..300 {
let row = Rect::new(5, 0, width, 36);
let (leading, label, trailing) = columns(row, 8, 30, 40);
for (name, part) in [
("leading", leading),
("label", label),
("trailing", trailing),
] {
assert!(part.width >= 0, "width {width}: {name} inverted");
assert_eq!(part.height, row.height, "width {width}: {name}");
}
}
}
#[test]
fn the_columns_are_in_order_when_there_is_room_for_them() {
let row = Rect::new(5, 0, 300, 36);
let (leading, label, trailing) = columns(row, 8, 30, 40);
assert!(
leading.x >= row.x,
"the leading column starts inside the row"
);
assert!(
leading.right() <= label.x,
"the label overlaps the leading column"
);
assert!(
label.right() <= trailing.x,
"the label overlaps the trailing column"
);
assert!(trailing.right() <= row.right());
assert!(label.width > 0, "the label got nothing");
}
#[test]
fn a_bare_row_gives_the_whole_width_to_its_label() {
let row = Rect::new(0, 0, 200, 36);
let (leading, label, trailing) = columns(row, 8, 0, 0);
assert_eq!(leading.width, 0);
assert_eq!(trailing.width, 0);
assert_eq!(label.x, 8);
assert_eq!(label.right(), 192);
}
#[test]
fn the_preferred_size_is_the_rows_and_the_widest_of_each_column() {
let mut engine = TextEngine::new();
let list = list();
assert_eq!(
list.preferred_height(&theme::DARK),
theme::DARK.metrics.size_field * 3
);
assert!(
list.preferred_height(&theme::DARK.with_metrics(denise::theme::Metrics::TOUCH))
> list.preferred_height(&theme::DARK)
);
let style = TextStyle::built_in(16);
let pad = padding(16);
let widest_label = ["Nettverk", "Skjerm", "Om"]
.iter()
.map(|text| engine.measure_line(style, text))
.max()
.expect("three labels");
let trailing = engine.measure_line(style, "70 %");
assert_eq!(
list.preferred_width(&mut engine),
pad * 2 + widest_label + trailing + pad,
"no leading column, so no gap for one"
);
}
#[test]
fn the_visible_row_count_is_what_actually_fits() {
let list = list().with_row_height(40);
assert_eq!(list.visible_rows(&theme::DARK, 200), 5);
assert_eq!(
list.visible_rows(&theme::DARK, 199),
4,
"a part row does not count"
);
assert_eq!(list.visible_rows(&theme::DARK, 0), 0);
assert_eq!(list.visible_rows(&theme::DARK, -10), 0);
assert_eq!(list.row_height(&theme::DARK), 40);
}
#[test]
fn a_leading_icon_widens_the_leading_column_to_its_side() {
use crate::widgets::icons;
let mut engine = TextEngine::new();
let bare = list();
assert_eq!(bare.leading_width(&mut engine), 0, "no icons, no column");
let mut iconed: List<usize> = List::new(
alloc::vec![
ListItem::new("button").with_leading_icon(&icons::BUTTON),
ListItem::new("label"),
],
|index| index,
);
assert_eq!(iconed.items()[0].leading_icon(), Some(&icons::BUTTON));
assert_eq!(iconed.items()[1].leading_icon(), None);
assert_eq!(
iconed.leading_width(&mut engine),
16,
"the column is the icon's side at the default text size"
);
iconed.set_style(TextStyle::built_in(24));
assert_eq!(iconed.leading_width(&mut engine), 24, "and it scales");
}
#[test]
fn a_leading_icon_is_part_of_the_preferred_width() {
use crate::widgets::icons;
let mut engine = TextEngine::new();
let plain: List<usize> = List::new(["button"], |index| index);
let iconed: List<usize> = List::new(
[ListItem::new("button").with_leading_icon(&icons::BUTTON)],
|index| index,
);
assert_eq!(
iconed.preferred_width(&mut engine),
plain.preferred_width(&mut engine) + 16 + padding(16),
"the glyph and its gap"
);
}
#[test]
fn a_bare_string_is_a_row() {
let list: List<usize> = List::new(["Auto", "Manuell"], |index| index);
assert_eq!(list.items().len(), 2);
assert_eq!(list.items()[1].text(), "Manuell");
assert!(list.items()[0].is_enabled());
assert_eq!(list.items()[0].leading(), "");
assert_eq!(list.items()[0].trailing(), "");
}
#[test]
fn a_row_can_be_disabled_after_the_list_is_built() {
let mut list = list();
assert!(Widget::<usize>::focusable(&list));
list.set_row_enabled(1, false);
assert!(!list.items()[1].is_enabled());
assert_eq!(step(list.items(), Some(0), true), Some(2));
list.set_row_enabled(99, false);
}
}