From db3b993cac09f563243db6f42f59056b07f89e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 19:59:11 -0400 Subject: [PATCH] T13b Trees: Tree, TreeNode, TreeSelect, Outline, Disclosure (review pending: codex 401) --- AGENTS.md | 3 + TASKS.md | 1 + examples/gallery/pages/lists/mod.rs | 21 ++ examples/gallery/pages/lists/trees.rs | 271 +++++++++++++++++++ src/data_display/records.rs | 36 +-- src/forms/inline.rs | 62 +++-- src/forms/mod.rs | 4 +- src/forms/options.rs | 15 ++ src/layout/collapse.rs | 34 +-- src/lists/mod.rs | 4 + src/lists/outline.rs | 150 +++++++++++ src/lists/select.rs | 4 +- src/lists/tree/mod.rs | 367 ++++++++++++++++++++++++++ src/lists/tree/model.rs | 357 +++++++++++++++++++++++++ src/lists/tree/row.rs | 285 ++++++++++++++++++++ src/lists/tree/select.rs | 158 +++++++++++ src/lists/tree/tests.rs | 207 +++++++++++++++ src/primitives/disclosure.rs | 65 +++++ src/primitives/mod.rs | 2 + src/theme/tokens.rs | 5 + tasks/ch01-10.md | 2 +- tasks/ch11-20.md | 10 +- 22 files changed, 1969 insertions(+), 94 deletions(-) create mode 100644 examples/gallery/pages/lists/trees.rs create mode 100644 src/lists/outline.rs create mode 100644 src/lists/tree/mod.rs create mode 100644 src/lists/tree/model.rs create mode 100644 src/lists/tree/row.rs create mode 100644 src/lists/tree/select.rs create mode 100644 src/lists/tree/tests.rs create mode 100644 src/primitives/disclosure.rs diff --git a/AGENTS.md b/AGENTS.md index c918e75..f9a2700 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A layer over pressable content that must still pass scrolls to its ancestors uses `block_mouse_except_scroll`; `occlude` hides them from scrolls too. - Trackpad momentum arrives as `TouchPhase::Moved` after `Ended`, so a gesture acts only between `Started` and `Ended` (`lists::SwipeableListItem`). - Quartz scroll events carry no window. The harness hands them to the window itself, placed in window points (`Step::Swipe`). +- A chevron that opens and closes is `primitives::Disclosure`. +- `uniform_list` does not stretch its rows; each row sets `w_full`. +- Chapters import only earlier chapters, so a select of tree nodes lives in `lists` (`TreeSelect`) and reaches `forms::listing`. - Reduced motion shortens animations to 1ms on the wall clock. Tests sleep past it between refreshes to see where one ends. - The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots. - An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`. diff --git a/TASKS.md b/TASKS.md index a024ce6..b5ce823 100644 --- a/TASKS.md +++ b/TASKS.md @@ -189,3 +189,4 @@ Per-component lines live in `tasks/`. Tags there: | T12c | 1 | — | pending: codex still logged out | | T12d | 1 | — | pending: codex still logged out | | T13a | 1 | — | pending: codex still logged out | +| T13b | 1 | — | pending: codex still logged out | diff --git a/examples/gallery/pages/lists/mod.rs b/examples/gallery/pages/lists/mod.rs index 75f53d7..6d26ec0 100644 --- a/examples/gallery/pages/lists/mod.rs +++ b/examples/gallery/pages/lists/mod.rs @@ -1,5 +1,6 @@ mod long; mod rows; +mod trees; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -25,6 +26,22 @@ const SCRIPT: &[Step] = &[ Step::Swipe("swipe-row", -120.0), Step::Wait(500), Step::Shot("swiped-open"), + Step::DownAt("tree", 40.0, 102.0), + Step::UpAt("tree", 40.0, 102.0), + Step::Key("right"), + Step::Wait(200), + Step::Shot("tree-open"), + Step::DownAt("tree", 60.0, 214.0), + Step::DragTo("tree", 60.0, 46.0), + Step::Wait(100), + Step::Shot("tree-dragging"), + Step::UpAt("tree", 60.0, 46.0), + Step::Wait(200), + Step::Shot("tree-moved"), + Step::DownAt("outline", 40.0, 150.0), + Step::UpAt("outline", 40.0, 150.0), + Step::Wait(500), + Step::Shot("outline-moved"), Step::Rest, ]; @@ -37,5 +54,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(long::virtual_list(cx)) .child(long::infinite(window, cx)) .child(long::grouped(cx)) + .child(trees::tree(window, cx)) + .child(trees::checkbox(window, cx)) + .child(trees::select(window, cx)) + .child(trees::outline(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/lists/trees.rs b/examples/gallery/pages/lists/trees.rs new file mode 100644 index 0000000..ccb2b44 --- /dev/null +++ b/examples/gallery/pages/lists/trees.rs @@ -0,0 +1,271 @@ +use std::time::Duration; + +use ely_gpui_component::{ + lists::{DropAt, Outline, Tree, TreeNode, TreeSelect}, + primitives::IconName, + theme::{ActiveTheme, Radius}, + typography::Caption, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +/// A file in the demo project: key, name, parent, whether it is a folder, and whether its children are still to come. +#[derive(Clone)] +struct Item { + key: SharedString, + name: SharedString, + parent: Option, + folder: bool, + pending: bool, +} + +fn item(key: &str, name: &str, parent: Option<&str>, folder: bool) -> Item { + Item { + key: key.to_string().into(), + name: name.to_string().into(), + parent: parent.map(|parent| parent.to_string().into()), + folder, + pending: false, + } +} + +fn project() -> Vec { + let mut assets = item("assets", "assets", Some("app"), true); + assets.pending = true; + vec![ + item("app", "ely-notes", None, true), + item("src", "src", Some("app"), true), + item("main", "main.rs", Some("src"), false), + item("ui", "ui", Some("src"), true), + item("sidebar", "sidebar.rs", Some("ui"), false), + item("editor", "editor.rs", Some("ui"), false), + assets, + item("readme", "README.md", Some("app"), false), + item("cargo", "Cargo.toml", Some("app"), false), + ] +} + +fn build(items: &[Item], parent: Option<&SharedString>) -> Vec { + items + .iter() + .filter(|item| item.parent.as_ref() == parent) + .map(|item| { + let node = TreeNode::new(item.key.clone(), item.name.clone()).icon(if item.folder { + IconName::Folder + } else { + IconName::FileText + }); + match (item.folder, item.pending) { + (true, true) => node.pending(), + (true, false) => node.children(build(items, Some(&item.key))), + _ => node, + } + }) + .collect() +} + +/// Moves `key` before, after or into `target`, keeping sibling order by list order. +fn moved(items: &mut Vec, key: &SharedString, target: &SharedString, at: DropAt) { + let from = items + .iter() + .position(|item| item.key == *key) + .expect("a known file"); + let mut item = items.remove(from); + let to = items + .iter() + .position(|item| item.key == *target) + .expect("a known target"); + match at { + DropAt::Inside => { + item.parent = Some(target.clone()); + items.push(item); + } + DropAt::Before | DropAt::After => { + item.parent = items[to].parent.clone(); + items.insert(if at == DropAt::Before { to } else { to + 1 }, item); + } + } +} + +fn change(state: &Entity>, cx: &mut App, edit: impl FnOnce(&mut Vec)) { + let mut next = state.read(cx).clone(); + edit(&mut next); + set(state, next, cx); +} + +pub fn tree(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let files = keep("tree-files", project, window, cx); + let picked = keep("tree-picked", Vec::::new, window, cx); + let nodes = build(files.read(cx), None); + let (now, pick) = (picked.read(cx).clone(), picked.clone()); + let (renamed, dropped, loading) = (files.clone(), files.clone(), files); + let theme = cx.theme(); + section( + "Tree / TreeView / VirtualTree", + "Folders open and close; Right, Left, Up and Down walk them, F2 renames, and a drag lands before, after or into a folder. Shift and Cmd widen the pick. Assets loads its children the first time it opens. Rows draw only in view, so large trees stay quick.", + cx, + ) + .child(probe( + "tree", + div() + .w(px(320.)) + .h(px(300.)) + .p_1() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .child( + Tree::new("tree", nodes) + .open(["app", "src"]) + .multiple() + .selected(now.clone()) + .on_select(move |keys, _, cx| set(&pick, keys.to_vec(), cx)) + .on_rename(move |key, name, _, cx| { + change(&renamed, cx, |items| { + if let Some(item) = items.iter_mut().find(|item| item.key == *key) { + item.name = name.clone(); + } + }) + }) + .on_move(move |key, target, at, _, cx| change(&dropped, cx, |items| moved(items, key, target, at))) + .on_load(move |key, _, cx| { + let (loading, key) = (loading.clone(), key.clone()); + cx.spawn(async move |cx| { + cx.background_executor().timer(Duration::from_millis(800)).await; + cx.update(|cx| { + change(&loading, cx, |items| { + if let Some(folder) = items.iter_mut().find(|item| item.key == key) { + folder.pending = false; + } + items.push(item("logo", "logo.svg", Some(&key), false)); + items.push(item("inter", "Inter.ttf", Some(&key), false)); + }) + }) + .ok(); + }) + .detach(); + }) + .size_full(), + ), + )) + .child(Caption::new(match now.len() { + 0 => "Nothing picked.".to_string(), + _ => format!("Picked: {}", now.join(", ")), + })) +} + +pub fn checkbox(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let granted = keep("grants", || vec![SharedString::from("read")], window, cx); + let (now, store) = (granted.read(cx).clone(), granted.clone()); + let branch = + |key: &'static str, name: &'static str, leaves: [(&'static str, &'static str); 2]| { + TreeNode::new(key, name).children(leaves.map(|(key, name)| TreeNode::new(key, name))) + }; + let theme = cx.theme(); + section( + "CheckboxTree", + "A box on every row. A branch's box checks all it holds, and shows a dash when only some are.", + cx, + ) + .child( + div() + .w(px(320.)) + .h(px(200.)) + .p_1() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .child( + Tree::new( + "grants-tree", + [ + branch("documents", "Documents", [("read", "Read"), ("write", "Write")]), + branch("billing", "Billing", [("invoices", "See invoices"), ("plan", "Change the plan")]), + ], + ) + .open(["documents", "billing"]) + .checked(now.clone()) + .on_check(move |keys, _, cx| set(&store, keys.to_vec(), cx)) + .size_full(), + ), + ) + .child(Caption::new(format!("Granted: {}", now.join(", ")))) +} + +pub fn select(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let team = keep("team", || SharedString::from("apps"), window, cx); + let (now, store) = (team.read(cx).clone(), team.clone()); + section( + "TreeSelect", + "A select whose choices are a tree, each indented under its parent.", + cx, + ) + .child( + div().w(px(260.)).child( + TreeSelect::new( + "team-select", + [ + TreeNode::new("design", "Design") + .icon(IconName::PenTool) + .children([ + TreeNode::new("brand", "Brand"), + TreeNode::new("product", "Product"), + ]), + TreeNode::new("engineering", "Engineering") + .icon(IconName::Code) + .children([ + TreeNode::new("platform", "Platform"), + TreeNode::new("apps", "Apps").children([ + TreeNode::new("ios", "iOS"), + TreeNode::new("macos", "macOS"), + ]), + ]), + TreeNode::new("operations", "Operations").icon(IconName::Wrench), + ], + ) + .selected(now) + .on_change(move |key, _, cx| set(&store, key.clone(), cx)), + ), + ) +} + +pub fn outline(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let current = keep( + "outline-current", + || SharedString::from("install"), + window, + cx, + ); + let (now, store) = (current.read(cx).clone(), current.clone()); + const HEADINGS: [(&str, &str, usize); 8] = [ + ("intro", "Introduction", 1), + ("install", "Installation", 2), + ("needs", "Requirements", 3), + ("start", "Quick start", 2), + ("components", "Components", 1), + ("buttons", "Buttons", 2), + ("forms", "Forms", 2), + ("theme", "Theming", 1), + ]; + section( + "Outline / NestedList", + "A document's headings, each level stepping in. The marker glides to the section you pick.", + cx, + ) + .child(probe( + "outline", + div().w(px(240.)).child( + HEADINGS + .iter() + .fold(Outline::new("outline"), |outline, (key, title, level)| { + outline.item(*key, *title, *level) + }) + .current(now) + .on_select(move |key, _, cx| set(&store, key.clone(), cx)), + ), + )) +} diff --git a/src/data_display/records.rs b/src/data_display/records.rs index b981360..41f0b7b 100644 --- a/src/data_display/records.rs +++ b/src/data_display/records.rs @@ -1,14 +1,12 @@ use gpui::{ - Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement, - IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, - Window, div, prelude::*, radians, + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use crate::{ layout::{Collapsible, seeded::use_seeded}, - motion, - primitives::{Icon, IconName}, - theme::{ActiveTheme, ControlSize, IconSize, TextSize}, + primitives::Disclosure, + theme::{ActiveTheme, ControlSize, TextSize}, }; /// Labels and their values, one pair a row with the labels in a quiet column, or stacked with each label above its value. @@ -149,15 +147,6 @@ impl RenderOnce for PropertyGrid { ) }) .collect(); - let turns: Vec<_> = folds - .iter() - .enumerate() - .map(|(ix, fold)| { - let shown = fold.read(cx).value; - motion::changes((self.id.clone(), format!("turns-{ix}")), shown, window, cx) - }) - .collect(); - let length = motion::duration(motion::FAST, cx); let theme = cx.theme(); let colors = &theme.colors; let row_height = theme.control_height(ControlSize::Md); @@ -171,22 +160,7 @@ impl RenderOnce for PropertyGrid { |(ix, PropertyGroup { title, rows, .. })| { let shown = folds[ix].read(cx).value; let (toggle, key) = (folds[ix].clone(), title.clone()); - let turn = |share: f32| radians(share * std::f32::consts::FRAC_PI_2); - let chevron = Icon::new(IconName::ChevronRight) - .size(IconSize::Xs) - .color(colors.fg_subtle); - let chevron = match turns[ix] { - 0 => chevron - .rotate(turn(if shown { 1.0 } else { 0.0 })) - .into_any_element(), - changes => chevron - .with_animation( - (id.clone(), format!("chevron-{ix}-{changes}")), - Animation::new(length).with_easing(motion::ease_out_cubic), - move |icon, t| icon.rotate(turn(if shown { t } else { 1.0 - t })), - ) - .into_any_element(), - }; + let chevron = Disclosure::new((id.clone(), format!("chevron-{ix}")), shown); div() .when(ix > 0, |group| { group.border_t_1().border_color(colors.border) diff --git a/src/forms/inline.rs b/src/forms/inline.rs index 763a316..56b49da 100644 --- a/src/forms/inline.rs +++ b/src/forms/inline.rs @@ -12,17 +12,44 @@ use crate::{ theme::{ActiveTheme, ControlSize, IconSize, Radius}, }; -type OnCommit = Rc; +pub(crate) type OnCommit = Rc; +/// A text being edited in place: its field while open, and who hears the kept text. #[derive(Default)] -struct Editing { +pub(crate) struct Editing { field: Option<(Entity, Subscription)>, - on_commit: Option, + pub on_commit: Option, } impl Editing { + /// Opens a field on `value`, all of it selected and focused; Enter or leaving keeps it. + pub(crate) fn begin(state: &Entity, value: String, window: &mut Window, cx: &mut App) { + let field = cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text(value, cx); + input + }); + let all = field.read(cx).text().len(); + field.update(cx, |input, cx| input.select(0..all, cx)); + window.focus(&field.read(cx).focus().clone()); + let owner = state.clone(); + let events = window.subscribe(&field, cx, move |_, event, window, cx| { + if matches!(event, InputEvent::Submit | InputEvent::Blur) { + owner.update(cx, |editing, cx| editing.finish(false, window, cx)); + } + }); + state.update(cx, |editing, cx| { + editing.field = Some((field, events)); + cx.notify(); + }); + } + + pub(crate) fn field(&self) -> Option> { + self.field.as_ref().map(|(field, _)| field.clone()) + } + /// Ends editing; keeps the text unless `cancel`. - fn finish(&mut self, cancel: bool, window: &mut Window, cx: &mut Context) { + pub(crate) fn finish(&mut self, cancel: bool, window: &mut Window, cx: &mut Context) { let Some((field, _)) = self.field.take() else { return; }; @@ -76,12 +103,7 @@ impl RenderOnce for InlineEdit { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default()); state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone()); - if let Some(field) = state - .read(cx) - .field - .as_ref() - .map(|(field, _)| field.clone()) - { + if let Some(field) = state.read(cx).field() { let escape = state.clone(); return div() .id(self.id) @@ -117,25 +139,7 @@ impl RenderOnce for InlineEdit { .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) .on_click(move |_, window, cx| { log::info!("inline edit: editing"); - let value = value.to_string(); - let field = cx.new(|cx| { - let mut input = TextInput::new(window, cx); - input.set_text(value, cx); - input - }); - let all = field.read(cx).text().len(); - field.update(cx, |input, cx| input.select(0..all, cx)); - window.focus(&field.read(cx).focus().clone()); - let owner = start.clone(); - let events = window.subscribe(&field, cx, move |_, event, window, cx| { - if matches!(event, InputEvent::Submit | InputEvent::Blur) { - owner.update(cx, |editing, cx| editing.finish(false, window, cx)); - } - }); - start.update(cx, |editing, cx| { - editing.field = Some((field, events)); - cx.notify(); - }); + Editing::begin(&start, value.to_string(), window, cx); }) .child(if empty { self.placeholder } else { self.value }) .child( diff --git a/src/forms/mod.rs b/src/forms/mod.rs index bc5dabc..bb3d9fc 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -48,6 +48,7 @@ pub use avatar::AvatarUpload; pub use card::{CheckboxCard, RadioCard}; pub use cascade::{Cascade, Cascader}; pub use catalogs::FontPicker; +pub(crate) use check::check_mark; pub use check::{CheckState, Checkbox, CheckboxGroup}; pub use checked::{EmailInput, UrlInput, is_email, is_url}; pub use chips::ChoiceChips; @@ -64,6 +65,7 @@ pub use files::{DropZone, FileInput}; pub use glyphs::{EmojiPicker, IconPicker}; pub use group::{InputAddon, InputGroup}; pub use hotkey::HotkeyInput; +pub(crate) use inline::Editing; pub use inline::InlineEdit; pub use input::{Input, PasswordInput}; pub use knob::Knob; @@ -82,7 +84,7 @@ pub use radio::{Radio, RadioGroup}; pub use rating::Rating; pub use search::SearchInput; pub use select::Select; -pub(crate) use select::{Listing, listing}; +pub(crate) use select::{Listing, field_button, field_text, listing}; pub use signature::{SignaturePad, Stroke}; pub use slider::{RangeSlider, Slider}; pub use stepper::Stepper; diff --git a/src/forms/options.rs b/src/forms/options.rs index 50799bb..745ce0d 100644 --- a/src/forms/options.rs +++ b/src/forms/options.rs @@ -20,6 +20,7 @@ pub struct Choice { pub icon: Option, pub note: Option, pub disabled: bool, + pub depth: usize, } impl Choice { @@ -30,9 +31,16 @@ impl Choice { icon: None, note: None, disabled: false, + depth: 0, } } + /// How far it sits under a parent, for choices that come from a tree. + pub fn depth(mut self, depth: usize) -> Self { + self.depth = depth; + self + } + pub fn icon(mut self, icon: IconName) -> Self { self.icon = Some(icon); self @@ -108,6 +116,13 @@ pub(crate) fn option_row( }), ) }) + .when(choice.depth > 0, |row| { + row.child( + div() + .flex_none() + .w(theme.tree_indent() * choice.depth as f32), + ) + }) .when_some(choice.icon, |row, icon| { row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)) }) diff --git a/src/layout/collapse.rs b/src/layout/collapse.rs index c614017..bab3a3d 100644 --- a/src/layout/collapse.rs +++ b/src/layout/collapse.rs @@ -3,13 +3,13 @@ use std::time::Instant; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, - Styled, Window, canvas, div, radians, + Styled, Window, canvas, div, }; use smallvec::SmallVec; use crate::{ motion, - primitives::{FocusRing, Icon, IconName}, + primitives::{Disclosure, FocusRing}, theme::{ActiveTheme, IconSize, TextSize}, }; @@ -126,7 +126,6 @@ impl ParentElement for AccordionItem { #[derive(Default)] struct Folds { open: Vec, - toggles: Vec, } /// Stacked items that fold; one open at a time unless `multiple`. @@ -171,25 +170,21 @@ impl RenderOnce for Accordion { let first_open = self.first_open; let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Folds { open: (0..count).map(|ix| first_open && ix == 0).collect(), - toggles: vec![0; count], }); if state.read(cx).open.len() != count { - state.update(cx, |folds, _| { - folds.open.resize(count, false); - folds.toggles.resize(count, 0); - }); + state.update(cx, |folds, _| folds.open.resize(count, false)); } let theme = cx.theme(); let (border, muted) = (theme.colors.border, theme.colors.fg_muted); let title_size = theme.text_size(TextSize::Base); let multiple = self.multiple; - let duration = motion::duration(motion::FAST, cx); + let id = self.id.clone(); let folds = state.read(cx); let rows: Vec<_> = self .items .into_iter() .enumerate() - .map(|(ix, item)| (ix, item, folds.open[ix], folds.toggles[ix])) + .map(|(ix, item)| (ix, item, folds.open[ix])) .collect(); div() .id(self.id) @@ -197,19 +192,11 @@ impl RenderOnce for Accordion { .flex_col() .border_t_1() .border_color(border) - .children(rows.into_iter().map(|(ix, item, open, toggles)| { + .children(rows.into_iter().map(|(ix, item, open)| { let toggle = state.clone(); - let chevron = Icon::new(IconName::ChevronRight) + let chevron = Disclosure::new((id.clone(), format!("chevron-{ix}")), open) .size(IconSize::Sm) - .color(muted) - .with_animation( - ("chevron", toggles), - Animation::new(duration).with_easing(motion::ease_out_cubic), - move |icon, t| { - let turn = if open { t } else { 1.0 - t }; - icon.rotate(radians(turn * std::f32::consts::FRAC_PI_2)) - }, - ); + .color(muted); div() .border_b_1() .border_color(border) @@ -239,10 +226,7 @@ impl RenderOnce for Accordion { } else { *open && (multiple || !now) }; - if next != *open { - *open = next; - folds.toggles[other] += 1; - } + *open = next; } cx.notify(); }) diff --git a/src/lists/mod.rs b/src/lists/mod.rs index 97dc023..eeeede8 100644 --- a/src/lists/mod.rs +++ b/src/lists/mod.rs @@ -1,15 +1,19 @@ mod grouped; mod item; mod long; +mod outline; mod select; mod sortable; mod swipe; #[cfg(test)] mod tests; +mod tree; pub use grouped::GroupedList; pub use item::{List, ListItem}; pub use long::{InfiniteList, VirtualList}; +pub use outline::Outline; pub use select::SelectableList; pub use sortable::SortableList; pub use swipe::{SwipeAction, SwipeableListItem}; +pub use tree::{DropAt, Tree, TreeNode, TreeSelect}; diff --git a/src/lists/outline.rs b/src/lists/outline.rs new file mode 100644 index 0000000..cd49b51 --- /dev/null +++ b/src/lists/outline.rs @@ -0,0 +1,150 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, +}; + +use crate::{ + motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide}, + theme::{ActiveTheme, TextSize}, +}; + +type OnKey = Rc; + +/// A document's headings, each level stepping in, beside a rail whose marker glides to the section in view. A press asks the owner to go there; the owner says which is current. +#[derive(IntoElement)] +pub struct Outline { + id: ElementId, + items: Vec<(SharedString, SharedString, usize)>, + current: Option, + on_select: Option, +} + +impl Outline { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + items: Vec::new(), + current: None, + on_select: None, + } + } + + /// A heading under `key`, its title, and its level, counted from one. + pub fn item( + mut self, + key: impl Into, + title: impl Into, + level: usize, + ) -> Self { + assert!(level >= 1, "heading levels start at one"); + self.items.push((key.into(), title.into(), level)); + self + } + + pub fn current(mut self, key: impl Into) -> Self { + self.current = Some(key.into()); + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Outline { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let keys: Vec = self.items.iter().map(|(key, _, _)| key.clone()).collect(); + let sliding = self + .current + .as_ref() + .map(|current| slide((self.id.clone(), "slide"), &keys, current, window, cx)); + let duration = motion::duration(motion::SLOW, cx); + let theme = cx.theme(); + let colors = &theme.colors; + let marker = sliding + .as_ref() + .and_then(|(_, marker)| marker.as_ref()) + .map( + |Marker { + from, + to, + generation, + }| { + let (from, to) = (*from, *to); + div() + .absolute() + .left_0() + .border_l_2() + .border_color(colors.accent) + .with_animation( + (self.id.clone(), format!("marker-{generation}")), + Animation::new(duration), + move |bar, t| { + let (top, height) = glide(from, to, t); + bar.top(top).h(height) + }, + ) + }, + ); + let on_select = self.on_select; + let rows = self + .items + .into_iter() + .enumerate() + .map(|(ix, (key, title, level))| { + let here = self.current.as_ref() == Some(&key); + let (select, id) = (on_select.clone(), self.id.clone()); + div() + .id((self.id.clone(), format!("item-{key}"))) + .relative() + .py_1() + .pl_3() + .cursor_pointer() + .text_color(if here { colors.fg } else { colors.fg_muted }) + .when(here, |row| row.font_weight(FontWeight::MEDIUM)) + .hover(|style| style.text_color(colors.fg)) + .on_click(move |_, window, cx| { + log::info!("outline {id:?}: {key}"); + if let Some(select) = &select { + select(&key, window, cx); + } + }) + .child( + div() + .ml(theme.tree_indent() * (level - 1) as f32) + .child(title), + ) + .children( + sliding + .as_ref() + .map(|(state, _)| measure_item(state.clone(), ix, Axis::Vertical)), + ) + }); + div() + .relative() + .text_size(theme.text_size(TextSize::Sm)) + .children( + sliding + .as_ref() + .map(|(state, _)| measure_origin(state.clone(), Axis::Vertical)), + ) + .child( + div() + .absolute() + .left_0() + .top_0() + .bottom_0() + .border_l_1() + .border_color(colors.border), + ) + .children(marker) + .children(rows) + } +} diff --git a/src/lists/select.rs b/src/lists/select.rs index 6893ef3..5b8b4d5 100644 --- a/src/lists/select.rs +++ b/src/lists/select.rs @@ -10,7 +10,7 @@ use crate::primitives::tab_stop; /// How a press or a key picks rows. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Pick { +pub(super) enum Pick { One, Toggle, Range, @@ -18,7 +18,7 @@ enum Pick { } /// The selection after picking row `at`, a range reaching back to `anchor`; in row order. -fn picked( +pub(super) fn picked( keys: &[SharedString], selected: &[SharedString], anchor: usize, diff --git a/src/lists/tree/mod.rs b/src/lists/tree/mod.rs new file mode 100644 index 0000000..2e903a7 --- /dev/null +++ b/src/lists/tree/mod.rs @@ -0,0 +1,367 @@ +mod model; +mod row; +mod select; +#[cfg(test)] +mod tests; + +use std::{collections::HashSet, rc::Rc}; + +use gpui::{ + App, Div, ElementId, EntityId, InteractiveElement, IntoElement, ParentElement, RenderOnce, + ScrollStrategy, SharedString, StyleRefinement, Styled, UniformListScrollHandle, Window, div, + uniform_list, +}; + +pub(crate) use model::{Children, Shown, rows}; +pub use model::{DropAt, TreeNode}; +pub use select::TreeSelect; + +use self::{ + model::{Move, find, step, toggled}, + row::{Rows, draw}, +}; +use super::select::{Pick, picked}; +use crate::{forms::Editing, layout::seeded::use_seeded, primitives::tab_stop}; + +type OnKeys = Rc; +type OnKey = Rc; +type OnRename = Rc; +type OnMove = Rc; +type OnRow = Rc; +type OnPick = Rc; +type OnToggle = Rc; + +/// The keyboard's row, where a Shift range starts, the scroll that follows, pending nodes already asked for, where a drag would land, and the node being renamed. +#[derive(Default)] +struct Nav { + cursor: usize, + anchor: usize, + scroll: UniformListScrollHandle, + asked: HashSet, + drop: Option<(SharedString, DropAt)>, + renaming: Option, +} + +/// A drag of one tree's node, named by its owner. +pub(crate) struct TreeDrag { + owner: EntityId, + key: SharedString, + label: SharedString, +} + +/// Nodes that open and close, drawn a row at a time so large trees stay quick. Right and Left open, close and climb; Up and Down walk; Enter opens a node or activates a leaf; F2 renames. With `multiple`, Cmd and Shift widen the pick. With `checked`, boxes check whole branches. Nodes drag before, after or into others. Give it a height. +#[derive(IntoElement)] +pub struct Tree { + id: ElementId, + base: Div, + nodes: Vec, + open: Vec, + selected: Vec, + multiple: bool, + checked: Option>, + on_select: Option, + on_check: Option, + on_activate: Option, + on_load: Option, + on_rename: Option, + on_move: Option, +} + +impl Tree { + pub fn new(id: impl Into, nodes: impl IntoIterator) -> Self { + Self { + id: id.into(), + base: div(), + nodes: nodes.into_iter().collect(), + open: Vec::new(), + selected: Vec::new(), + multiple: false, + checked: None, + on_select: None, + on_check: None, + on_activate: None, + on_load: None, + on_rename: None, + on_move: None, + } + } + + /// The nodes open at first; a new list from the owner replaces what was opened since. + pub fn open(mut self, keys: impl IntoIterator>) -> Self { + self.open = keys.into_iter().map(Into::into).collect(); + self + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> Self { + self.selected = keys.into_iter().map(Into::into).collect(); + self + } + + pub fn multiple(mut self) -> Self { + self.multiple = true; + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + /// Shows a box on each row; `keys` are the checked leaves. + pub fn checked(mut self, keys: impl IntoIterator>) -> Self { + self.checked = Some(keys.into_iter().map(Into::into).collect()); + self + } + + /// Gets the checked leaves after a box is pressed. + pub fn on_check( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_check = Some(Rc::new(handler)); + self + } + + /// Enter or a double press on a leaf. + pub fn on_activate( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_activate = Some(Rc::new(handler)); + self + } + + /// Asked once for a pending node's children, the first time it opens. + pub fn on_load( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_load = Some(Rc::new(handler)); + self + } + + /// Gets a node's key and its new name, after F2. + pub fn on_rename( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_rename = Some(Rc::new(handler)); + self + } + + /// Gets a dragged node, the node it landed against, and where. + pub fn on_move( + mut self, + handler: impl Fn(&SharedString, &SharedString, DropAt, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_move = Some(Rc::new(handler)); + self + } +} + +impl Styled for Tree { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for Tree { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id.clone(); + let focus = tab_stop((id.clone(), "focus").into(), true, window, cx); + let opened = use_seeded((id.clone(), "open"), self.open, window, cx); + let nav = window.use_keyed_state((id.clone(), "nav"), cx, |_, _| Nav::default()); + let editing = window.use_keyed_state((id.clone(), "rename"), cx, |_, _| Editing::default()); + if nav.read(cx).drop.is_some() && !cx.has_active_drag() { + nav.update(cx, |nav, _| nav.drop = None); + } + if nav.read(cx).renaming.is_some() && editing.read(cx).field().is_none() { + nav.update(cx, |nav, _| nav.renaming = None); + window.focus(&focus); + } + let open: HashSet = opened.read(cx).value.iter().cloned().collect(); + let nodes = Rc::new(self.nodes); + let shown = Rc::new(rows(&nodes, &open)); + for row in shown.iter().filter(|row| row.shown == Shown::Loading) { + let key = shown[row.parent.expect("a wait has a parent")] + .key() + .cloned() + .expect("a node"); + if nav.read(cx).asked.contains(&key) { + continue; + } + nav.update(cx, |nav, _| nav.asked.insert(key.clone())); + if let Some(on_load) = self.on_load.clone() { + log::info!("tree {id:?}: asking for {key}'s children"); + window.defer(cx, move |window, cx| on_load(&key, window, cx)); + } + } + let count = shown.len(); + let at = nav.read(cx).cursor.min(count.saturating_sub(1)); + let keys: Rc<[SharedString]> = shown + .iter() + .map(|row| row.key().cloned().unwrap_or_default()) + .collect(); + let selected = Rc::new(self.selected); + let (multiple, checked) = (self.multiple, self.checked.map(Rc::new)); + let toggle: OnToggle = { + let (opened, id) = (opened.clone(), id.clone()); + Rc::new(move |key, open, cx| { + log::info!( + "tree {id:?}: {} {key}", + if open { "opened" } else { "closed" } + ); + opened.update(cx, |seeded, cx| { + seeded.value.retain(|known| known != key); + if open { + seeded.value.push(key.clone()); + } + cx.notify(); + }) + }) + }; + let pick: OnPick = { + let (nav, keys, selected, on_select) = + (nav.clone(), keys.clone(), selected.clone(), self.on_select); + Rc::new(move |ix, how, window, cx| { + if keys[ix].is_empty() { + return; + } + let how = match (multiple, how) { + (true, how) => how, + (false, Pick::All) => return, + (false, _) => Pick::One, + }; + let next: Vec<_> = picked(&keys, &selected, nav.read(cx).anchor, ix, how) + .into_iter() + .filter(|key| !key.is_empty()) + .collect(); + nav.update(cx, |nav, cx| { + nav.cursor = ix; + if how != Pick::Range { + nav.anchor = ix; + } + cx.notify(); + }); + if let Some(on_select) = &on_select { + on_select(&next, window, cx); + } + }) + }; + let check: Option = checked.clone().map(|checked| { + let (nodes, on_check) = (nodes.clone(), self.on_check); + Rc::new( + move |key: &SharedString, window: &mut Window, cx: &mut App| { + let node = find(&nodes, key).expect("a checked row is a node"); + let next = toggled(node, &checked); + if let Some(on_check) = &on_check { + on_check(&next, window, cx); + } + }, + ) as OnKey + }); + let rename: OnRow = { + let (nav, editing, shown, on_rename) = + (nav.clone(), editing.clone(), shown.clone(), self.on_rename); + Rc::new(move |ix, window, cx| { + let (Some(on_rename), Shown::Node { key, label, .. }) = + (on_rename.clone(), &shown[ix].shown) + else { + return; + }; + let key = key.clone(); + nav.update(cx, |nav, _| nav.renaming = Some(key.clone())); + editing.update(cx, |editing, _| { + editing.on_commit = Some(Rc::new(move |name, window, cx| { + on_rename(&key, name, window, cx) + })) + }); + Editing::begin(&editing, label.to_string(), window, cx); + }) + }; + let activate: OnRow = { + let (shown, toggle, on_activate) = (shown.clone(), toggle.clone(), self.on_activate); + Rc::new(move |ix, window, cx| match &shown[ix].shown { + Shown::Node { + key, + opens: true, + open, + .. + } => toggle(key, !open, cx), + Shown::Node { key, .. } => { + if let Some(on_activate) = &on_activate { + on_activate(key, window, cx); + } + } + Shown::Loading => {} + }) + }; + let scroll = nav.read(cx).scroll.clone(); + let data = Rows { + id: id.clone(), + owner: nav.entity_id(), + nodes: nodes.clone(), + shown: shown.clone(), + selected, + checked, + current: focus.is_focused(window).then_some(at), + nav: nav.clone(), + editing: editing.clone(), + focus: focus.clone(), + pick: pick.clone(), + toggle: toggle.clone(), + check: check.clone(), + activate: activate.clone(), + on_move: self.on_move, + }; + let list = uniform_list((id.clone(), "rows"), count, move |range, window, cx| { + draw(&data, range, window, cx) + }) + .track_scroll(scroll) + .size_full(); + self.base + .id(self.id) + .track_focus(&focus) + .on_key_down(move |event, window, cx| { + if count == 0 || nav.read(cx).renaming.is_some() { + return; + } + let at = nav.read(cx).cursor.min(count - 1); + let held = &event.keystroke.modifiers; + let key = event.keystroke.key.as_str(); + match key { + "f2" => rename(at, window, cx), + "enter" => activate(at, window, cx), + "space" => match (&check, &shown[at].shown) { + (Some(check), Shown::Node { key, .. }) => check(key, window, cx), + _ => pick(at, Pick::Toggle, window, cx), + }, + "a" if held.platform => pick(at, Pick::All, window, cx), + _ => match step(&shown, at, key) { + Some(Move::To(to)) => { + pick( + to, + if held.shift { Pick::Range } else { Pick::One }, + window, + cx, + ); + let strategy = if to < at { + ScrollStrategy::Top + } else { + ScrollStrategy::Bottom + }; + nav.read(cx).scroll.scroll_to_item(to, strategy); + } + Some(Move::Open(key)) => toggle(&key, true, cx), + Some(Move::Close(key)) => toggle(&key, false, cx), + None => return, + }, + } + cx.stop_propagation(); + }) + .child(list) + } +} diff --git a/src/lists/tree/model.rs b/src/lists/tree/model.rs new file mode 100644 index 0000000..0891563 --- /dev/null +++ b/src/lists/tree/model.rs @@ -0,0 +1,357 @@ +use std::collections::HashSet; + +use gpui::SharedString; + +use crate::{forms::CheckState, primitives::IconName}; + +/// What a node holds under it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Children { + Leaf, + Known(Vec), + /// Children still to come; opening the node asks the owner for them. + Pending, +} + +/// One node of a tree: its key, its label, an icon, and its children. +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub(crate) key: SharedString, + pub(crate) label: SharedString, + pub(crate) icon: Option, + pub(crate) children: Children, +} + +impl TreeNode { + /// A leaf, until it gains children. + pub fn new(key: impl Into, label: impl Into) -> Self { + Self { + key: key.into(), + label: label.into(), + icon: None, + children: Children::Leaf, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn child(mut self, node: TreeNode) -> Self { + match &mut self.children { + Children::Known(children) => children.push(node), + _ => self.children = Children::Known(vec![node]), + } + self + } + + /// Makes it a node that opens, even with no children yet. + pub fn children(mut self, nodes: impl IntoIterator) -> Self { + let mut known = match self.children { + Children::Known(known) => known, + _ => Vec::new(), + }; + known.extend(nodes); + self.children = Children::Known(known); + self + } + + /// Children still to come: opening it runs the tree's `on_load` until the owner supplies them. + pub fn pending(mut self) -> Self { + self.children = Children::Pending; + self + } + + pub(crate) fn opens(&self) -> bool { + self.children != Children::Leaf + } + + /// The leaf keys at and under this node. + pub(crate) fn leaves(&self) -> Vec { + match &self.children { + Children::Known(children) => children.iter().flat_map(TreeNode::leaves).collect(), + _ => vec![self.key.clone()], + } + } + + /// Whether `key` is this node or under it. + pub(crate) fn holds(&self, key: &SharedString) -> bool { + self.key == *key + || matches!(&self.children, Children::Known(children) if children.iter().any(|child| child.holds(key))) + } +} + +/// What a row shows: a node, or the wait for a pending node's children. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Shown { + Node { + key: SharedString, + label: SharedString, + icon: Option, + opens: bool, + open: bool, + }, + Loading, +} + +/// One visible row: what it shows, how deep it sits, and its parent's row. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Row { + pub shown: Shown, + pub depth: usize, + pub parent: Option, +} + +impl Row { + pub(crate) fn key(&self) -> Option<&SharedString> { + match &self.shown { + Shown::Node { key, .. } => Some(key), + Shown::Loading => None, + } + } +} + +/// The rows in view, depth first, through the open nodes. +pub(crate) fn rows(nodes: &[TreeNode], open: &HashSet) -> Vec { + fn walk( + nodes: &[TreeNode], + open: &HashSet, + depth: usize, + parent: Option, + out: &mut Vec, + ) { + for node in nodes { + let here = out.len(); + let is_open = node.opens() && open.contains(&node.key); + out.push(Row { + shown: Shown::Node { + key: node.key.clone(), + label: node.label.clone(), + icon: node.icon, + opens: node.opens(), + open: is_open, + }, + depth, + parent, + }); + match (&node.children, is_open) { + (Children::Known(children), true) => { + walk(children, open, depth + 1, Some(here), out) + } + (Children::Pending, true) => out.push(Row { + shown: Shown::Loading, + depth: depth + 1, + parent: Some(here), + }), + _ => {} + } + } + } + let mut out = Vec::new(); + walk(nodes, open, 0, None, &mut out); + out +} + +/// What a key does on row `at`. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Move { + To(usize), + Open(SharedString), + Close(SharedString), +} + +/// Up, Down, Home and End walk the rows; Right opens a node or enters it, Left closes it or goes to its parent. +pub(crate) fn step(rows: &[Row], at: usize, key: &str) -> Option { + let last = rows.len().checked_sub(1)?; + let row = rows.get(at)?; + let (opens, open, node_key) = match &row.shown { + Shown::Node { + key, opens, open, .. + } => (*opens, *open, Some(key.clone())), + Shown::Loading => (false, false, None), + }; + match key { + "down" => (at < last).then(|| Move::To(at + 1)), + "up" => at.checked_sub(1).map(Move::To), + "home" => Some(Move::To(0)), + "end" => Some(Move::To(last)), + "right" if opens && !open => node_key.map(Move::Open), + "right" if open => (at < last).then(|| Move::To(at + 1)), + "left" if open => node_key.map(Move::Close), + "left" => row.parent.map(Move::To), + _ => None, + } +} + +/// A node's box: on when all its leaves are, mixed when some are. +pub(crate) fn check_state(node: &TreeNode, checked: &[SharedString]) -> CheckState { + let leaves = node.leaves(); + let on = leaves.iter().filter(|leaf| checked.contains(leaf)).count(); + match on { + 0 => CheckState::Off, + all if all == leaves.len() => CheckState::On, + _ => CheckState::Mixed, + } +} + +/// The checked leaves after pressing a node's box: a full node empties, any other fills. +pub(crate) fn toggled(node: &TreeNode, checked: &[SharedString]) -> Vec { + let leaves = node.leaves(); + let fill = check_state(node, checked) != CheckState::On; + let mut next: Vec = checked + .iter() + .filter(|key| !leaves.contains(key)) + .cloned() + .collect(); + if fill { + next.extend(leaves); + } + next +} + +/// Finds a node by key. +pub(crate) fn find<'a>(nodes: &'a [TreeNode], key: &SharedString) -> Option<&'a TreeNode> { + nodes.iter().find_map(|node| { + if node.key == *key { + return Some(node); + } + match &node.children { + Children::Known(children) => find(children, key), + _ => None, + } + }) +} + +/// Where a dragged node lands against a row. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DropAt { + Before, + Inside, + After, +} + +/// The place a pointer `share` of the way down a row picks: the edges for siblings, the middle of a node that opens for inside. +pub(crate) fn place_at(share: f32, opens: bool) -> DropAt { + match (share, opens) { + (share, true) if (0.25..0.75).contains(&share) => DropAt::Inside, + (share, _) if share < 0.5 => DropAt::Before, + _ => DropAt::After, + } +} + +/// Whether moving `dragged` against `target` keeps the tree whole: a node cannot land in itself or under itself. +pub(crate) fn can_drop(nodes: &[TreeNode], dragged: &SharedString, target: &SharedString) -> bool { + find(nodes, dragged).is_some_and(|node| !node.holds(target)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use gpui::SharedString; + + use super::*; + + fn tree() -> Vec { + vec![ + TreeNode::new("src", "src").children([ + TreeNode::new("lib", "lib.rs"), + TreeNode::new("ui", "ui").children([ + TreeNode::new("button", "button.rs"), + TreeNode::new("menu", "menu.rs"), + ]), + ]), + TreeNode::new("docs", "docs").pending(), + TreeNode::new("readme", "README.md"), + ] + } + + fn keys(list: &[&'static str]) -> Vec { + list.iter().map(|key| SharedString::from(*key)).collect() + } + + fn open(list: &[&'static str]) -> HashSet { + keys(list).into_iter().collect() + } + + #[test] + fn an_empty_folder_still_opens() { + assert!(TreeNode::new("empty", "empty").children([]).opens()); + assert!(!TreeNode::new("file", "file").opens()); + } + + #[test] + fn rows_walk_only_through_open_nodes() { + let nodes = tree(); + let shown: Vec<_> = rows(&nodes, &open(&["src", "docs"])) + .iter() + .map(|row| (row.key().cloned(), row.depth, row.parent)) + .collect(); + assert_eq!( + shown, + [ + (Some("src".into()), 0, None), + (Some("lib".into()), 1, Some(0)), + (Some("ui".into()), 1, Some(0)), + (Some("docs".into()), 0, None), + (None, 1, Some(3)), + (Some("readme".into()), 0, None), + ] + ); + } + + #[test] + fn keys_walk_open_close_and_climb() { + let nodes = tree(); + let shown = rows(&nodes, &open(&["src"])); + assert_eq!( + step(&shown, 0, "right"), + Some(Move::To(1)), + "an open node enters its first child" + ); + assert_eq!(step(&shown, 2, "right"), Some(Move::Open("ui".into()))); + assert_eq!(step(&shown, 0, "left"), Some(Move::Close("src".into()))); + assert_eq!( + step(&shown, 1, "left"), + Some(Move::To(0)), + "a child climbs to its parent" + ); + assert_eq!(step(&shown, 1, "right"), None, "a leaf has nowhere to go"); + assert_eq!(step(&shown, 4, "down"), None); + assert_eq!(step(&shown, 3, "end"), Some(Move::To(4))); + } + + #[test] + fn boxes_mix_and_fill_by_their_leaves() { + let nodes = tree(); + let src = &nodes[0]; + assert_eq!(check_state(src, &keys(&["lib"])), CheckState::Mixed); + assert_eq!( + check_state(src, &keys(&["lib", "button", "menu"])), + CheckState::On + ); + assert_eq!(check_state(src, &keys(&["readme"])), CheckState::Off); + assert_eq!( + toggled(src, &keys(&["lib", "readme"])), + keys(&["readme", "lib", "button", "menu"]) + ); + assert_eq!( + toggled(src, &keys(&["lib", "button", "menu", "readme"])), + keys(&["readme"]) + ); + } + + #[test] + fn a_drop_picks_its_place_and_refuses_its_own_subtree() { + assert_eq!(place_at(0.1, true), DropAt::Before); + assert_eq!(place_at(0.5, true), DropAt::Inside); + assert_eq!(place_at(0.9, true), DropAt::After); + assert_eq!(place_at(0.4, false), DropAt::Before); + assert_eq!(place_at(0.6, false), DropAt::After); + let nodes = tree(); + assert!(!can_drop(&nodes, &"src".into(), &"button".into())); + assert!(!can_drop(&nodes, &"ui".into(), &"ui".into())); + assert!(can_drop(&nodes, &"button".into(), &"readme".into())); + } +} diff --git a/src/lists/tree/row.rs b/src/lists/tree/row.rs new file mode 100644 index 0000000..154478d --- /dev/null +++ b/src/lists/tree/row.rs @@ -0,0 +1,285 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, DragMoveEvent, ElementId, Entity, EntityId, FocusHandle, InteractiveElement, + IntoElement, MouseButton, ParentElement, SharedString, StatefulInteractiveElement, Styled, + Window, div, prelude::*, +}; + +use super::{ + DropAt, Nav, OnKey, OnMove, OnPick, OnRow, OnToggle, TreeDrag, + model::{Row, Shown, TreeNode, can_drop, check_state, find, place_at}, +}; +use crate::{ + forms::{Editing, Input, check_mark}, + lists::select::Pick, + motion::{self, Spinner}, + primitives::{Disclosure, DragGhost, Icon}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +/// What drawing rows needs, shared by every range the list asks for. +pub(super) struct Rows { + pub id: ElementId, + pub owner: EntityId, + pub nodes: Rc>, + pub shown: Rc>, + pub selected: Rc>, + pub checked: Option>>, + pub current: Option, + pub nav: Entity