use std::rc::Rc; use gpui::{ App, Bounds, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, }; use super::{ Choice, options::{OnValues, float, option_row, surface}, select::{field_button, field_text}, }; use crate::{ primitives::{Icon, IconName, tab_stop}, theme::{ActiveTheme, ControlSize, IconSize}, }; /// A node of a cascade: a value, its label, and the nodes under it. #[derive(Clone, Debug, PartialEq)] pub struct Cascade { pub value: SharedString, pub label: SharedString, pub children: Vec, } impl Cascade { pub fn new(value: impl Into, label: impl Into) -> Self { Self { value: value.into(), label: label.into(), children: Vec::new(), } } pub fn child(mut self, node: Cascade) -> Self { self.children.push(node); self } } /// The columns a trail opens: the roots, then the children of each step with any. fn columns<'a>(roots: &'a [Cascade], trail: &[usize]) -> Vec<&'a [Cascade]> { let mut out = vec![roots]; for &ix in trail { let node = &out[out.len() - 1][ix]; if node.children.is_empty() { break; } out.push(&node.children); } out } /// Row indices along `path`, as far as it matches. fn trail_of(roots: &[Cascade], path: &[SharedString]) -> Vec { let mut trail = Vec::new(); let mut level = roots; for value in path { let Some(ix) = level.iter().position(|node| node.value == *value) else { break; }; trail.push(ix); level = &level[ix].children; } trail } /// `trail` cut back to rows that still exist; never empty. fn fit_trail(roots: &[Cascade], trail: &[usize]) -> Vec { let mut fitted = Vec::new(); let mut level = roots; for &ix in trail { let Some(node) = level.get(ix) else { break; }; fitted.push(ix); level = &node.children; } if fitted.is_empty() { fitted.push(0); } fitted } /// The nodes a trail passes through. fn nodes<'a>(roots: &'a [Cascade], trail: &[usize]) -> Vec<&'a Cascade> { let mut level = roots; trail .iter() .map(|&ix| { let node = &level[ix]; level = &node.children; node }) .collect() } #[derive(Default)] struct Trail { open: bool, trail: Vec, anchor: Bounds, } fn show(state: &Entity, open: bool, trail: Vec, cx: &mut App) { state.update(cx, |state, cx| { state.open = open; state.trail = trail; cx.notify(); }); } type PickTrail = Rc, &mut Window, &mut App)>; /// A select over a tree: each column opens the next, and a leaf picks the path. #[derive(IntoElement)] pub struct Cascader { id: ElementId, roots: Vec, selected: Vec, placeholder: SharedString, size: ControlSize, on_change: Option, } impl Cascader { pub fn new(id: impl Into, roots: impl IntoIterator) -> Self { Self { id: id.into(), roots: roots.into_iter().collect(), selected: Vec::new(), placeholder: SharedString::from("Choose…"), size: ControlSize::default(), on_change: None, } } /// The chosen path, root first. pub fn selected(mut self, path: impl IntoIterator>) -> Self { self.selected = path.into_iter().map(Into::into).collect(); self } pub fn placeholder(mut self, text: impl Into) -> Self { self.placeholder = text.into(); self } pub fn size(mut self, size: ControlSize) -> Self { self.size = size; self } pub fn on_change( mut self, handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, ) -> Self { self.on_change = Some(Rc::new(handler)); self } } impl RenderOnce for Cascader { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { assert!( !self.roots.is_empty(), "cascader {:?} has no roots", self.id ); let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); let focused = focus.is_focused(window); let state = window.use_keyed_state((self.id.clone(), "trail"), cx, |_, _| Trail::default()); if state.read(cx).open && !focused { state.update(cx, |state, _| state.open = false); } let roots = Rc::new(self.roots); let start = { let trail = trail_of(&roots, &self.selected); if trail.is_empty() { vec![0] } else { trail } }; let kept = state.read(cx).trail.clone(); let fitted = fit_trail(&roots, &kept); if fitted != kept { state.update(cx, |state, _| state.trail = fitted); } let (open, trail, anchor) = { let state = state.read(cx); (state.open, state.trail.clone(), state.anchor) }; let pick: PickTrail = { let (id, roots, state, on_change) = ( self.id.clone(), roots.clone(), state.clone(), self.on_change, ); Rc::new(move |trail, window, cx| { let path: Vec = nodes(&roots, &trail) .iter() .map(|node| node.value.clone()) .collect(); log::info!("cascader {id:?}: {path:?}"); show(&state, false, trail, cx); if let Some(on_change) = &on_change { on_change(&path, window, cx); } }) }; let theme = cx.theme(); let colors = &theme.colors; let shown: Vec = nodes(&roots, &trail_of(&roots, &self.selected)) .iter() .map(|node| node.label.clone()) .collect(); let (click, keys, measure) = (state.clone(), state.clone(), state.clone()); let (enter, key_roots, key_start) = (pick.clone(), roots.clone(), start.clone()); let lists = open.then(|| { let columns = columns(&roots, &trail); let deepest = columns.iter().map(|column| column.len()).max().unwrap_or(0); let lists = columns.iter().enumerate().map(|(level, column)| { let rows = column.iter().enumerate().map(|(ix, node)| { let (hover, pick) = (state.clone(), pick.clone()); let at: Vec = trail[..level].iter().copied().chain([ix]).collect(); let leaf = node.children.is_empty(); let choice = Choice::new(node.value.clone(), node.label.clone()); option_row( ("row", ix), &choice, trail.get(level) == Some(&ix), None, cx, ) .when(!leaf, |row| { row.child( Icon::new(IconName::ChevronRight) .size(IconSize::Xs) .color(colors.fg_subtle), ) }) .on_hover({ let at = at.clone(); move |hovered, _, cx| { if *hovered && hover.read(cx).trail != at { show(&hover, true, at.clone(), cx); } } }) .when(leaf, |row| { row.on_click(move |_, window, cx| pick(at.clone(), window, cx)) }) }); div() .id(("column", level)) .w(theme.tooltip_max_width() * 0.75) .max_h(theme.list_max_height()) .overflow_y_scroll() .p_1() .flex() .flex_col() .when(level > 0, |list| { list.border_l_1().border_color(colors.border) }) .children(rows) }); let close = state.clone(); float( anchor, deepest, surface((self.id.clone(), "columns"), cx) .flex() .on_mouse_down_out(move |_, _, cx| { let trail = close.read(cx).trail.clone(); show(&close, false, trail, cx) }) .children(lists), window, cx, ) }); field_button(self.id, &focus, self.size, false, window, cx) .on_click(move |event, _, cx| { if matches!(event, ClickEvent::Mouse(_)) { show(&click, !open, start.clone(), cx) } }) .on_key_down(move |event, window, cx| { let key = event.keystroke.key.as_str(); let mut trail = keys.read(cx).trail.clone(); if !open { if matches!(key, "down" | "up" | "enter" | "space") { cx.stop_propagation(); show(&keys, true, key_start.clone(), cx); } return; } let columns = columns(&key_roots, &trail); let level = trail.len() - 1; let (at, count) = (trail[level], columns[level].len()); let node = &columns[level][at]; match key { "down" => trail[level] = (at + 1) % count, "up" => trail[level] = (at + count - 1) % count, "right" if !node.children.is_empty() => trail.push(0), "left" if level > 0 => { trail.pop(); } "enter" | "space" if node.children.is_empty() => { cx.stop_propagation(); return enter(trail, window, cx); } "enter" | "space" => trail.push(0), "escape" => { cx.stop_propagation(); return show(&keys, false, trail, cx); } _ => return, } cx.stop_propagation(); show(&keys, true, trail, cx); }) .child(field_text( (!shown.is_empty()).then(|| SharedString::from(shown.join(" / "))), self.placeholder, false, cx, )) .child( Icon::new(IconName::ChevronsUpDown) .size(IconSize::Xs) .color(colors.fg_subtle), ) .child( canvas( move |bounds, _, cx| { if measure.read(cx).anchor != bounds { measure.update(cx, |state, _| state.anchor = bounds); } }, |_, _, _, _| {}, ) .absolute() .top_0() .left_0() .size_full(), ) .children(lists) } } #[cfg(test)] mod tests { use gpui::SharedString; use super::{Cascade, columns, fit_trail, nodes, trail_of}; fn world() -> Vec { vec![ Cascade::new("asia", "Asia").child( Cascade::new("jp", "Japan") .child(Cascade::new("tyo", "Tokyo")) .child(Cascade::new("osa", "Osaka")), ), Cascade::new("eu", "Europe").child(Cascade::new("pt", "Portugal")), ] } #[test] fn a_trail_opens_one_column_per_parent() { let roots = world(); assert_eq!(columns(&roots, &[0]).len(), 2); assert_eq!(columns(&roots, &[0, 0]).len(), 3); assert_eq!(columns(&roots, &[0, 0, 1]).len(), 3); } #[test] fn a_trail_is_cut_back_to_rows_that_exist() { let roots = world(); assert_eq!(fit_trail(&roots, &[0, 0, 1]), [0, 0, 1]); assert_eq!(fit_trail(&roots, &[1, 5]), [1]); assert_eq!(fit_trail(&roots, &[9]), [0]); assert_eq!(fit_trail(&roots, &[]), [0]); } #[test] fn paths_and_trails_round_trip() { let roots = world(); let path: Vec = vec!["asia".into(), "jp".into(), "osa".into()]; let trail = trail_of(&roots, &path); assert_eq!(trail, [0, 0, 1]); let labels: Vec<_> = nodes(&roots, &trail) .iter() .map(|node| node.label.clone()) .collect(); assert_eq!(labels, ["Asia", "Japan", "Osaka"]); assert_eq!(trail_of(&roots, &["eu".into(), "xx".into()]), [1]); } }