use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_string};
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::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const ROW_HEIGHT: u32 = 26;
const LEVEL_WIDTH: u32 = 160;
const LEVEL_INDENT: i32 = LEVEL_WIDTH as i32;
impl Cascader {
fn open_depth(&self) -> usize {
self.expanded_path.len()
}
fn visible_level_count(&self) -> usize {
self.open_depth() + 1
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CascaderLoadState {
#[default]
Ready,
Loading,
Failed,
}
pub struct CascaderOption {
pub id: String,
pub label: String,
pub children: Vec<CascaderOption>,
pub load_state: CascaderLoadState,
}
impl CascaderOption {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
children: Vec::new(),
load_state: CascaderLoadState::Ready,
}
}
pub fn branch(
id: impl Into<String>,
label: impl Into<String>,
children: Vec<CascaderOption>,
) -> Self {
Self { id: id.into(), label: label.into(), children, load_state: CascaderLoadState::Ready }
}
pub fn loading(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
children: Vec::new(),
load_state: CascaderLoadState::Loading,
}
}
pub fn child(mut self, option: CascaderOption) -> Self {
self.children.push(option);
self.load_state = CascaderLoadState::Ready;
self
}
pub fn is_leaf(&self) -> bool {
self.children.is_empty() && self.load_state == CascaderLoadState::Ready
}
pub fn is_loading(&self) -> bool {
self.load_state == CascaderLoadState::Loading
}
}
pub struct Cascader {
base: BaseWidget,
options: Vec<CascaderOption>,
selected_path: Vec<usize>,
expanded_path: Vec<usize>,
expanded: bool,
separator: String,
filterable: bool,
filter: String,
pub selection_changed: Signal1<Vec<usize>>,
}
impl Cascader {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Cascader, geometry, "Cascader"),
options: Vec::new(),
selected_path: Vec::new(),
expanded_path: Vec::new(),
expanded: false,
separator: "/".to_string(),
filterable: false,
filter: String::new(),
selection_changed: Signal1::new(),
}
}
pub fn set_options(&mut self, options: Vec<CascaderOption>) {
self.options = options;
self.expanded_path.clear();
self.filter.clear();
if !self.selected_path.is_empty() && self.resolve(&self.selected_path).is_none() {
self.selected_path.clear();
}
self.base.request_redraw();
}
pub fn options(&self) -> &[CascaderOption] {
&self.options
}
pub fn selected_path(&self) -> &[usize] {
&self.selected_path
}
pub fn set_separator(&mut self, separator: &str) {
self.separator = separator.to_string();
self.base.request_redraw();
}
pub fn separator(&self) -> &str {
&self.separator
}
pub fn is_expanded(&self) -> bool {
self.expanded
}
pub fn expand(&mut self) {
if self.expanded {
return;
}
self.expanded_path = self.selected_path.clone();
self.expanded = true;
self.filter.clear();
self.base.request_redraw();
}
pub fn collapse(&mut self) {
if !self.expanded {
return;
}
self.expanded = false;
self.expanded_path.clear();
self.filter.clear();
self.base.request_redraw();
}
pub fn is_filterable(&self) -> bool {
self.filterable
}
pub fn set_filterable(&mut self, filterable: bool) {
self.filterable = filterable;
self.filter.clear();
self.base.request_redraw();
}
pub fn filter(&self) -> &str {
&self.filter
}
pub fn visible_options_at(&self, level: usize) -> &[CascaderOption] {
let Some(siblings) = self.siblings_at(level) else {
return &[];
};
if self.filter.is_empty() {
return siblings;
}
if level != self.expanded_path.len() {
return siblings;
}
let needle = self.filter.to_lowercase();
let _ = needle;
siblings
}
pub fn filtered_indices(&self, level: usize) -> Vec<usize> {
let Some(siblings) = self.siblings_at(level) else {
return Vec::new();
};
if self.filter.is_empty() || level != self.expanded_path.len() {
return (0..siblings.len()).collect();
}
let needle = self.filter.to_lowercase();
siblings
.iter()
.enumerate()
.filter(|(_, option)| option.label.to_lowercase().contains(&needle))
.map(|(index, _)| index)
.collect()
}
fn siblings_at(&self, level: usize) -> Option<&[CascaderOption]> {
if level > self.expanded_path.len() {
return None;
}
let mut current: &[CascaderOption] = &self.options;
for depth in 0..level {
let index = *self.expanded_path.get(depth)?;
current = ¤t.get(index)?.children;
}
Some(current)
}
pub fn resolve(&self, path: &[usize]) -> Option<&CascaderOption> {
let mut current: &[CascaderOption] = &self.options;
let mut found: Option<&CascaderOption> = None;
for index in path {
let option = current.get(*index)?;
found = Some(option);
current = &option.children;
}
found
}
pub fn is_leaf_path(&self, path: &[usize]) -> bool {
self.resolve(path).is_some_and(CascaderOption::is_leaf)
}
pub fn complete_load(&mut self, path: &[usize], children: Vec<CascaderOption>) -> bool {
let Some(existing) = self.resolve(path) else {
return false;
};
if !existing.children.is_empty() {
return false;
}
if existing.load_state == CascaderLoadState::Ready {
return false;
}
let Some(option) = self.option_at_path_mut(path) else {
return false;
};
if children.is_empty() {
option.load_state = CascaderLoadState::Failed;
} else {
option.children = children;
option.load_state = CascaderLoadState::Ready;
}
self.base.request_redraw();
true
}
pub fn fail_load(&mut self, path: &[usize]) -> bool {
self.complete_load(path, Vec::new())
}
pub fn is_loading_path(&self, path: &[usize]) -> bool {
self.resolve(path).is_some_and(|option| option.is_loading())
}
pub fn choose_at(&mut self, level: usize, option_index: usize) -> bool {
if self.siblings_at(level).and_then(|s| s.get(option_index)).is_none() {
return false;
}
self.choose(level, option_index);
true
}
fn option_at_path_mut(&mut self, path: &[usize]) -> Option<&mut CascaderOption> {
let mut index = path.first().copied()?;
let mut current = self.options.get_mut(index)?;
for &next in &path[1..] {
index = next;
current = current.children.get_mut(index)?;
}
Some(current)
}
pub fn set_selected_path(&mut self, path: Vec<usize>) -> bool {
if !self.is_leaf_path(&path) {
return false;
}
if self.selected_path == path {
return true;
}
self.selected_path = path;
self.selection_changed.emit(self.selected_path.clone());
self.base.request_redraw();
true
}
pub fn clear_selected(&mut self) {
if self.selected_path.is_empty() {
return;
}
self.selected_path.clear();
self.selection_changed.emit(Vec::new());
self.base.request_redraw();
}
pub fn selected_labels(&self) -> Vec<String> {
let mut labels = Vec::new();
let mut current: &[CascaderOption] = &self.options;
for index in &self.selected_path {
let Some(option) = current.get(*index) else {
break;
};
labels.push(option.label.clone());
current = &option.children;
}
labels
}
pub fn display_text(&self) -> String {
self.selected_labels().join(&self.separator)
}
fn field_rect(&self) -> Rect {
self.geometry()
}
fn level_rect(&self, level: usize) -> Rect {
let rect = self.geometry();
let rows = self.filtered_indices(level).len() as u32;
let height = (rows * ROW_HEIGHT).max(ROW_HEIGHT);
Rect::new(
rect.x + (level as i32) * LEVEL_INDENT,
rect.y + rect.height as i32,
LEVEL_WIDTH,
height,
)
}
fn row_at(&self, level: usize, pos: Point) -> Option<usize> {
let level_rect = self.level_rect(level);
if !level_rect.contains_point(pos) {
return None;
}
let offset = pos.y - level_rect.y;
if offset < 0 {
return None;
}
let row = (offset as u32 / ROW_HEIGHT) as usize;
let visible = self.filtered_indices(level);
visible.get(row).copied()
}
fn level_at(&self, pos: Point) -> Option<usize> {
(0..self.visible_level_count())
.rev()
.find(|level| self.level_rect(*level).contains_point(pos))
}
fn choose(&mut self, level: usize, option_index: usize) {
let Some(option) = self.siblings_at(level).and_then(|s| s.get(option_index)) else {
return;
};
let is_leaf = option.is_leaf();
self.expanded_path.truncate(level);
self.expanded_path.push(option_index);
self.filter.clear();
if is_leaf {
let path = self.expanded_path.clone();
self.expanded = false;
self.expanded_path.clear();
self.set_selected_path(path);
} else {
self.base.request_redraw();
}
}
}
impl Widget for Cascader {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(200, 32)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Cascader {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"value" => Ok(CapabilityValue::String(self.display_text())),
"depth" => Ok(CapabilityValue::UInt(self.selected_path.len() as u64)),
"expanded" => Ok(CapabilityValue::Bool(self.is_expanded())),
"separator" => Ok(CapabilityValue::String(self.separator().to_string())),
"filterable" => Ok(CapabilityValue::Bool(self.is_filterable())),
"filter" => Ok(CapabilityValue::String(self.filter().to_string())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"expanded" => {
if expect_bool(value)? {
self.expand();
} else {
self.collapse();
}
Ok(())
}
"separator" => {
let separator = expect_string(value)?;
self.set_separator(&separator);
Ok(())
}
"filterable" => {
self.set_filterable(expect_bool(value)?);
Ok(())
}
"value" | "depth" => Err(CapabilityAccessError::ReadOnlyProperty),
"filter" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"value",
"depth",
"expanded",
"separator",
"filterable",
"filter",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"expand" => {
self.expand();
Ok(())
}
"collapse" => {
self.collapse();
Ok(())
}
"set_options" | "set_selected_path" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for Cascader {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
self.draw_field(context, rect);
if self.expanded {
self.draw_overlay(context);
}
}
}
impl Cascader {
fn draw_field(&self, context: &mut RenderContext, rect: Rect) {
let style = self.style();
let background = style.background_color.unwrap_or(Color::WHITE);
let border = style.border_color.unwrap_or(Color::rgb(190, 192, 198));
let text_color = style.text_color.unwrap_or(Color::rgb(40, 44, 52));
context.fill_rounded_rect(rect, 4, background);
context.draw_rounded_rect_stroke(rect, 4, border, 1);
let text = self.display_text();
let display = if text.is_empty() { "Select…".to_string() } else { text };
let color = if self.display_text().is_empty() {
Color::rgb(150, 154, 162)
} else {
text_color
};
context.draw_text(
Point::new(rect.x + 8, rect.y + 21),
&display,
&Font::simple("Sans", 12.0),
color,
HorizontalAlignment::Left,
);
let cx = rect.x + rect.width as i32 - 14;
let cy = rect.y + rect.height as i32 / 2;
let (from_y, to_y) = if self.expanded { (cy + 3, cy - 3) } else { (cy - 3, cy + 3) };
context.draw_line_stroke(Point::new(cx - 4, to_y), Point::new(cx, from_y), text_color, 1);
context.draw_line_stroke(Point::new(cx, from_y), Point::new(cx + 4, to_y), text_color, 1);
}
fn draw_overlay(&self, context: &mut RenderContext) {
for level in 0..self.visible_level_count() {
let column = self.level_rect(level);
let visible = self.filtered_indices(level);
self.draw_level(context, level, column, &visible);
}
}
fn draw_level(
&self,
context: &mut RenderContext,
level: usize,
column: Rect,
visible: &[usize],
) {
context.fill_rounded_rect(column, 4, Color::WHITE);
context.draw_rounded_rect_stroke(column, 4, Color::rgb(190, 192, 198), 1);
let Some(siblings) = self.siblings_at(level) else {
return;
};
let current = self.expanded_path.get(level).copied();
for (row, &option_index) in visible.iter().enumerate() {
let Some(option) = siblings.get(option_index) else {
continue;
};
let row_rect = Rect::new(
column.x,
column.y + (row as i32) * ROW_HEIGHT as i32,
column.width,
ROW_HEIGHT,
);
if option_index == current.unwrap_or(usize::MAX) {
context.fill_rect(row_rect, Color::rgb(232, 240, 254));
}
context.draw_text(
Point::new(row_rect.x + 8, row_rect.y + 18),
&option.label,
&Font::simple("Sans", 12.0),
Color::rgb(40, 44, 52),
HorizontalAlignment::Left,
);
if option.is_loading() {
context.draw_text(
Point::new(row_rect.x + row_rect.width as i32 - 18, row_rect.y + 18),
"...",
&Font::simple("Sans", 12.0),
Color::rgb(140, 144, 152),
HorizontalAlignment::Left,
);
} else if !option.is_leaf() {
let ax = row_rect.x + row_rect.width as i32 - 12;
let ay = row_rect.y + ROW_HEIGHT as i32 / 2;
context.draw_line_stroke(
Point::new(ax - 2, ay - 4),
Point::new(ax + 2, ay),
Color::rgb(140, 144, 152),
1,
);
context.draw_line_stroke(
Point::new(ax + 2, ay),
Point::new(ax - 2, ay + 4),
Color::rgb(140, 144, 152),
1,
);
}
}
}
}
impl EventHandler for Cascader {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button } if *button == 1 => {
let field = self.field_rect();
if field.contains_point(*pos) {
if self.expanded {
self.collapse();
} else {
self.expand();
}
return;
}
if self.expanded {
if let Some(level) = self.level_at(*pos) {
if let Some(option_index) = self.row_at(level, *pos) {
self.choose(level, option_index);
} else {
self.collapse();
}
} else {
self.collapse();
}
}
}
Event::KeyDown((8, _)) if self.filterable && self.expanded => {
self.filter.pop();
self.base.request_redraw();
}
Event::KeyDown((key, _)) | Event::KeyPress { key, .. } => match *key {
27 => self.collapse(), 40 => self.move_browsed(1),
38 => self.move_browsed(-1),
_ => {}
},
Event::TextInput { text }
if self.filterable && self.expanded && text.chars().all(|ch| !ch.is_control()) =>
{
self.filter.push_str(text);
self.base.request_redraw();
}
_ => {}
}
}
}
impl Cascader {
fn move_browsed(&mut self, delta: i32) {
if !self.expanded {
return;
}
let level = self.expanded_path.len().saturating_sub(1);
let visible = self.filtered_indices(level);
if visible.is_empty() {
return;
}
let current = self
.expanded_path
.get(level)
.and_then(|index| visible.iter().position(|visible| visible == index));
let next = match current {
Some(position) => (position as i32 + delta).clamp(0, visible.len() as i32 - 1) as usize,
None => {
if delta > 0 {
0
} else {
visible.len() - 1
}
}
};
if let Some(&option_index) = visible.get(next) {
self.expanded_path.truncate(level);
self.expanded_path.push(option_index);
self.base.request_redraw();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{PaintBackend, SoftwarePaintBackend};
fn tree() -> Vec<CascaderOption> {
let district = |prefix: &str| {
CascaderOption::branch(
format!("{prefix}-d"),
format!("{prefix} District"),
vec![
CascaderOption::new(format!("{prefix}-d1"), format!("{prefix} D1")),
CascaderOption::new(format!("{prefix}-d2"), format!("{prefix} D2")),
],
)
};
let city = |prefix: &str| {
CascaderOption::branch(
format!("{prefix}-c"),
format!("{prefix} City"),
vec![district(prefix)],
)
};
vec![
CascaderOption::branch("p1", "Province 1", vec![city("Alpha"), city("Beta")]),
CascaderOption::branch("p2", "Province 2", vec![city("Gamma")]),
]
}
fn cascader() -> Cascader {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(tree());
c
}
fn render(c: &mut Cascader, size: Size) -> Vec<u8> {
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
c.draw(&mut context);
backend.end_frame();
backend.frame_rgba().to_vec()
}
#[test]
fn cascader_creation_defaults() {
let c = Cascader::new(Rect::new(0, 0, 200, 32));
assert_eq!(c.kind(), WidgetKind::Cascader);
assert!(c.options().is_empty());
assert!(c.selected_path().is_empty());
assert!(!c.is_expanded());
assert_eq!(c.separator(), "/");
assert!(!c.is_filterable());
assert_eq!(c.display_text(), "");
}
#[test]
fn cascader_resolves_a_path_to_its_option() {
let c = cascader();
assert_eq!(c.resolve(&[0]).map(|o| o.label.as_str()), Some("Province 1"));
assert_eq!(c.resolve(&[0, 1, 0]).map(|o| o.label.as_str()), Some("Beta District"));
assert_eq!(c.resolve(&[0, 0, 0]).map(|o| o.label.as_str()), Some("Alpha District"));
assert!(c.resolve(&[9]).is_none());
assert!(c.resolve(&[0, 9]).is_none());
assert!(c.resolve(&[]).is_none());
}
#[test]
fn cascader_distinguishes_leaves_from_branches() {
let c = cascader();
assert!(!c.is_leaf_path(&[0]));
assert!(!c.is_leaf_path(&[0, 0]));
assert!(!c.is_leaf_path(&[0, 0, 0]));
assert!(c.is_leaf_path(&[0, 0, 0, 0]));
assert!(c.is_leaf_path(&[1, 0, 0, 1]));
}
#[test]
fn cascader_set_selected_path_refuses_a_branch() {
let mut c = cascader();
assert!(!c.set_selected_path(vec![0]), "a branch is not a value");
assert!(c.selected_path().is_empty(), "a refused path must not be stored");
assert!(c.set_selected_path(vec![0, 0, 0, 1]));
assert_eq!(c.selected_path(), &[0, 0, 0, 1]);
}
#[test]
fn cascader_display_text_joins_the_path() {
let mut c = cascader();
c.set_selected_path(vec![0, 1, 0, 0]);
assert_eq!(c.display_text(), "Province 1/Beta City/Beta District/Beta D1");
assert_eq!(
c.selected_labels(),
vec!["Province 1", "Beta City", "Beta District", "Beta D1"]
);
c.set_separator(" › ");
assert_eq!(c.display_text(), "Province 1 › Beta City › Beta District › Beta D1");
c.set_separator("");
assert_eq!(c.display_text(), "Province 1Beta CityBeta DistrictBeta D1");
}
#[test]
fn cascader_selection_changed_signal_carries_the_path() {
let mut c = cascader();
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Vec<usize>>::new()));
let sink = seen.clone();
c.selection_changed.connect(move |path| {
if let Ok(mut guard) = sink.lock() {
guard.push((*path).clone());
}
});
c.set_selected_path(vec![0, 0, 0, 0]);
c.set_selected_path(vec![1, 0, 0, 1]);
c.set_selected_path(vec![1, 0, 0, 1]);
assert_eq!(
*seen.lock().expect("signal lock poisoned"),
vec![vec![0, 0, 0, 0], vec![1, 0, 0, 1]]
);
}
#[test]
fn cascader_clear_selected_emits_an_empty_path() {
let mut c = cascader();
c.set_selected_path(vec![0, 0, 0, 0]);
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Vec<usize>>::new()));
let sink = seen.clone();
c.selection_changed.connect(move |path| {
if let Ok(mut guard) = sink.lock() {
guard.push((*path).clone());
}
});
c.clear_selected();
assert!(c.selected_path().is_empty());
assert_eq!(*seen.lock().expect("signal lock poisoned"), vec![Vec::<usize>::new()]);
c.clear_selected();
assert_eq!(seen.lock().expect("signal lock poisoned").len(), 1);
}
#[test]
fn cascader_set_options_drops_a_selection_that_no_longer_resolves() {
let mut c = cascader();
assert!(c.set_selected_path(vec![1, 0, 0, 1]));
c.set_options(vec![CascaderOption::branch("only", "Only", Vec::new())]);
assert!(c.selected_path().is_empty());
}
#[test]
fn cascader_set_options_keeps_a_still_valid_selection() {
let mut c = cascader();
assert!(c.set_selected_path(vec![0, 0, 0, 0]));
c.set_options(tree());
assert_eq!(c.selected_path(), &[0, 0, 0, 0], "the same tree still resolves");
}
#[test]
fn cascader_click_on_the_field_toggles_the_overlay() {
let mut c = cascader();
c.handle_event(&Event::mouse_press(50, 16, 1));
assert!(c.is_expanded(), "first click opens");
c.handle_event(&Event::mouse_press(50, 16, 1));
assert!(!c.is_expanded(), "second click closes");
}
#[test]
fn cascader_clicking_a_branch_reveals_the_next_level() {
let mut c = cascader();
c.expand();
assert_eq!(c.open_depth(), 0, "nothing walked yet");
assert_eq!(c.visible_level_count(), 1, "only level 0 is on screen");
let level = c.level_rect(0);
c.handle_event(&Event::mouse_press(level.x + 10, level.y + 10, 1));
assert_eq!(c.expanded_path, vec![0], "the branch was walked");
assert_eq!(c.visible_level_count(), 2, "a branch revealed the next column");
assert!(c.is_expanded());
assert!(c.selected_path().is_empty(), "a branch is not committed");
let visible = c.visible_options_at(1);
assert_eq!(visible.len(), 2);
assert_eq!(visible[0].label, "Alpha City");
}
#[test]
fn cascader_clicking_a_leaf_commits_and_closes() {
let mut c = cascader();
c.expand();
for level in 0..3 {
let rect = c.level_rect(level);
c.handle_event(&Event::mouse_press(rect.x + 10, rect.y + 10, 1));
}
assert!(c.is_expanded(), "still browsing at level 3");
let rect = c.level_rect(3);
c.handle_event(&Event::mouse_press(rect.x + 10, rect.y + 10, 1));
assert!(!c.is_expanded(), "committing closes the overlay");
assert_eq!(c.selected_path(), &[0, 0, 0, 0]);
assert_eq!(c.display_text(), "Province 1/Alpha City/Alpha District/Alpha D1");
}
#[test]
fn cascader_choosing_from_an_earlier_level_discards_the_deeper_path() {
let mut c = cascader();
c.expand();
let l0 = c.level_rect(0);
c.handle_event(&Event::mouse_press(l0.x + 10, l0.y + 10, 1));
let l1 = c.level_rect(1);
c.handle_event(&Event::mouse_press(l1.x + 10, l1.y + 10, 1));
assert_eq!(c.expanded_path, vec![0, 0]);
let l0 = c.level_rect(0);
c.handle_event(&Event::mouse_press(l0.x + 10, l0.y + 10 + ROW_HEIGHT as i32, 1));
assert_eq!(c.expanded_path, vec![1], "the deeper choice was discarded");
assert_eq!(c.visible_level_count(), 2, "two columns remain on screen");
assert_eq!(c.visible_options_at(1)[0].label, "Gamma City");
}
#[test]
fn cascader_escape_closes_without_committing() {
let mut c = cascader();
c.expand();
let l0 = c.level_rect(0);
c.handle_event(&Event::mouse_press(l0.x + 10, l0.y + 10, 1));
assert_eq!(c.expanded_path.len(), 1);
c.handle_event(&Event::KeyDown((27, 0)));
assert!(!c.is_expanded());
assert!(c.selected_path().is_empty(), "cancel is not a selection");
}
#[test]
fn cascader_collapse_drops_the_browsed_path_but_not_the_value() {
let mut c = cascader();
c.set_selected_path(vec![0, 0, 0, 0]);
c.expand();
let l0 = c.level_rect(0);
c.handle_event(&Event::mouse_press(l0.x + 10, l0.y + 10 + ROW_HEIGHT as i32, 1));
assert_eq!(c.expanded_path.len(), 1);
c.collapse();
assert!(!c.is_expanded());
assert_eq!(c.selected_path(), &[0, 0, 0, 0], "the committed value survives");
}
#[test]
fn cascader_reopening_resumes_at_the_selection() {
let mut c = cascader();
c.set_selected_path(vec![1, 0, 0, 1]);
c.expand();
assert_eq!(
c.expanded_path,
vec![1, 0, 0, 1],
"opening resumes where the selection left off, not at the root"
);
assert_eq!(c.visible_level_count(), 5);
}
#[test]
fn cascader_click_outside_closes() {
let mut c = cascader();
c.expand();
c.handle_event(&Event::mouse_press(5000, 5000, 1));
assert!(!c.is_expanded());
}
#[test]
fn cascader_arrow_keys_move_within_the_visible_rows() {
let mut c = cascader();
c.expand();
c.handle_event(&Event::KeyDown((40, 0)));
assert_eq!(c.expanded_path, vec![0]);
c.handle_event(&Event::KeyDown((40, 0)));
assert_eq!(c.expanded_path, vec![1]);
c.handle_event(&Event::KeyDown((40, 0)));
assert_eq!(c.expanded_path, vec![1]);
c.handle_event(&Event::KeyDown((38, 0)));
assert_eq!(c.expanded_path, vec![0]);
}
#[test]
fn cascader_arrow_keys_do_nothing_when_closed() {
let mut c = cascader();
c.handle_event(&Event::KeyDown((40, 0)));
assert!(c.expanded_path.is_empty());
assert!(c.selected_path().is_empty());
}
#[test]
fn cascader_disabled_ignores_clicks() {
let mut c = cascader();
c.set_enabled(false);
c.handle_event(&Event::mouse_press(50, 16, 1));
assert!(!c.is_expanded());
}
#[test]
fn cascader_filter_narrows_the_browsed_level() {
let mut c = cascader();
c.set_filterable(true);
c.expand();
assert_eq!(c.filtered_indices(0), vec![0, 1]);
c.filter = "2".to_string();
assert_eq!(c.filtered_indices(0), vec![1], "only Province 2 matches");
}
#[test]
fn cascader_filter_does_not_apply_to_unbrowsed_levels() {
let mut c = cascader();
c.set_filterable(true);
c.expand();
assert_eq!(c.filtered_indices(0), vec![0, 1]);
let l0 = c.level_rect(0);
c.handle_event(&Event::mouse_press(l0.x + 10, l0.y + 10, 1));
c.filter = "Beta".to_string();
assert_eq!(c.filtered_indices(0), vec![0, 1], "level 0 is not browsed");
assert_eq!(c.filtered_indices(1), vec![1], "only Beta City matches at level 1");
}
#[test]
fn cascader_filter_off_ignores_typing() {
let mut c = cascader();
c.expand();
c.handle_event(&Event::TextInput { text: "x".to_string() });
assert_eq!(c.filter(), "");
}
#[test]
fn cascader_choosing_clears_the_filter() {
let mut c = cascader();
c.set_filterable(true);
c.expand();
c.filter = "1".to_string();
let l0 = c.level_rect(0);
c.handle_event(&Event::mouse_press(l0.x + 10, l0.y + 10, 1));
assert_eq!(c.filter(), "", "a fresh level starts unfiltered");
}
#[test]
fn cascader_draw_closed_paints_without_panicking() {
let mut c = cascader();
let rgba = render(&mut c, Size::new(200, 32));
assert!(!rgba.is_empty());
}
#[test]
fn cascader_open_overlay_differs_from_the_closed_frame() {
let mut c = cascader();
let closed = render(&mut c, Size::new(400, 200));
c.expand();
let open = render(&mut c, Size::new(400, 200));
assert_ne!(closed, open, "opening the overlay must be visible");
}
#[test]
fn cascader_draw_zero_geometry_does_not_panic() {
let mut c = cascader();
c.expand();
let rgba = render(&mut c, Size::new(4, 4));
assert!(!rgba.is_empty());
}
#[test]
fn cascader_placeholder_differs_from_a_value() {
let mut c = cascader();
let placeholder = render(&mut c, Size::new(200, 32));
c.set_selected_path(vec![0, 0, 0, 0]);
let with_value = render(&mut c, Size::new(200, 32));
assert_ne!(placeholder, with_value, "a value must look different from the hint");
}
#[test]
fn cascader_properties_round_trip() {
let mut c = cascader();
assert_eq!(c.get("expanded").unwrap(), CapabilityValue::Bool(false));
c.set("expanded", CapabilityValue::Bool(true)).unwrap();
assert!(c.is_expanded());
c.set("separator", CapabilityValue::String(" :: ".to_string())).unwrap();
assert_eq!(c.separator(), " :: ");
c.set("filterable", CapabilityValue::Bool(true)).unwrap();
assert!(c.is_filterable());
assert!(c.set("expanded", CapabilityValue::UInt(1)).is_err());
assert!(c.set("separator", CapabilityValue::Bool(true)).is_err());
}
#[test]
fn cascader_derived_properties_are_read_only() {
let mut c = cascader();
c.set_selected_path(vec![0, 0, 0, 0]);
assert_eq!(c.get("depth").unwrap(), CapabilityValue::UInt(4));
assert_eq!(
c.get("value").unwrap(),
CapabilityValue::String("Province 1/Alpha City/Alpha District/Alpha D1".to_string())
);
for name in ["value", "depth", "filter"] {
assert_eq!(
c.set(name, CapabilityValue::UInt(1)),
Err(CapabilityAccessError::ReadOnlyProperty),
"{name} must be read-only"
);
}
}
fn pending_tree() -> Vec<CascaderOption> {
vec![CascaderOption::loading("remote", "Remote")]
}
#[test]
fn a_loading_branch_is_not_a_selectable_value() {
let option = CascaderOption::loading("r", "Remote");
assert!(option.children.is_empty());
assert!(!option.is_leaf(), "a pending branch is not a value");
assert!(option.is_loading());
}
#[test]
fn a_loading_path_is_not_committable() {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(pending_tree());
assert!(c.is_loading_path(&[0]));
assert!(!c.is_leaf_path(&[0]));
assert!(!c.set_selected_path(vec![0]), "a pending branch must not commit");
assert_eq!(c.selected_path(), &[] as &[usize]);
}
#[test]
fn completing_a_load_makes_the_branch_selectable() {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(pending_tree());
assert!(c.complete_load(
&[0],
vec![CascaderOption::new("r-a", "A"), CascaderOption::new("r-b", "B"),]
));
assert!(!c.is_loading_path(&[0]), "the flag must clear on success");
assert_eq!(c.resolve(&[0]).map(|o| o.children.len()), Some(2));
assert!(c.choose_at(0, 0), "the branch is openable now");
assert_eq!(c.visible_options_at(1).len(), 2, "the fetched level is browsable");
assert!(c.set_selected_path(vec![0, 1]), "a fetched leaf is committable now");
assert_eq!(c.display_text(), "Remote/B");
}
#[test]
fn an_empty_load_marks_the_branch_failed_not_leaf() {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(pending_tree());
assert!(c.fail_load(&[0]));
let option = c.resolve(&[0]).expect("the branch still exists");
assert_eq!(option.load_state, CascaderLoadState::Failed);
assert!(!option.is_leaf(), "a failed branch is still not a value");
assert!(!option.is_loading(), "it is no longer waiting");
assert!(!c.set_selected_path(vec![0]));
}
#[test]
fn completing_a_load_twice_is_refused() {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(pending_tree());
assert!(c.complete_load(&[0], vec![CascaderOption::new("x", "X")]));
assert!(!c.complete_load(&[0], vec![CascaderOption::new("y", "Y")]));
assert_eq!(c.resolve(&[0]).map(|o| o.children.len()), Some(1), "the first wins");
assert_eq!(c.resolve(&[0, 0]).map(|o| o.label.as_str()), Some("X"));
}
#[test]
fn completing_a_synchronous_leaf_is_refused() {
let mut c = cascader();
assert!(!c.complete_load(&[0, 0, 0, 0], vec![CascaderOption::new("z", "Z")]));
assert_eq!(c.resolve(&[0, 0, 0, 0]).map(|o| o.children.len()), Some(0));
}
#[test]
fn completing_an_unknown_path_is_refused() {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(pending_tree());
assert!(!c.complete_load(&[9], vec![CascaderOption::new("x", "X")]));
assert!(!c.complete_load(&[], vec![CascaderOption::new("x", "X")]));
assert!(c.is_loading_path(&[0]), "the pending branch is untouched");
}
#[test]
fn adding_a_child_to_a_pending_branch_clears_the_flag() {
let option = CascaderOption::loading("r", "Remote").child(CascaderOption::new("a", "A"));
assert!(!option.is_loading());
assert!(!option.is_leaf(), "it has children, so it is a branch");
}
#[test]
fn a_pending_branch_draws_an_ellipsis_not_a_chevron() {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(pending_tree());
c.expand();
let pending_frame = render(&mut c, Size::new(400, 240));
let mut loaded = Cascader::new(Rect::new(0, 0, 200, 32));
loaded.set_options(vec![CascaderOption::branch(
"r",
"Remote",
vec![CascaderOption::new("a", "A")],
)]);
loaded.expand();
let loaded_frame = render(&mut loaded, Size::new(400, 240));
assert_ne!(
pending_frame, loaded_frame,
"a pending branch must not render identically to a loaded one"
);
assert!(
pending_frame.iter().any(|&b| b != 255),
"the pending frame must actually paint something"
);
}
#[test]
fn expanding_into_a_pending_branch_leaves_the_next_column_empty() {
let mut c = Cascader::new(Rect::new(0, 0, 200, 32));
c.set_options(pending_tree());
c.expand();
assert!(c.choose_at(0, 0));
assert!(c.is_expanded(), "it is a branch, so the overlay stays open");
assert!(c.visible_options_at(1).is_empty());
assert_eq!(c.selected_path(), &[] as &[usize], "nothing was committed");
}
}