T13b Trees: Tree, TreeNode, TreeSelect, Outline, Disclosure (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 19:59:11 -04:00
1 parent 2d6404f688
commit db3b993cac
22 files changed
+1969 -94

No files matched your search

+3
View File
@@ -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()`.
+1
View File
@@ -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 |
+21
View File
@@ -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()
}
+271
View File
@@ -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<SharedString>,
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<Item> {
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<TreeNode> {
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<Item>, 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<Vec<Item>>, cx: &mut App, edit: impl FnOnce(&mut Vec<Item>)) {
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::<SharedString>::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)),
),
))
}
+5 -31
View File
@@ -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)
+33 -29
View File
@@ -12,17 +12,44 @@ use crate::{
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
type OnCommit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
pub(crate) type OnCommit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// 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<TextInput>, Subscription)>,
on_commit: Option<OnCommit>,
pub on_commit: Option<OnCommit>,
}
impl Editing {
/// Opens a field on `value`, all of it selected and focused; Enter or leaving keeps it.
pub(crate) fn begin(state: &Entity<Editing>, 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<Entity<TextInput>> {
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<Self>) {
pub(crate) fn finish(&mut self, cancel: bool, window: &mut Window, cx: &mut Context<Self>) {
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(
+3 -1
View File
@@ -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;
+15
View File
@@ -20,6 +20,7 @@ pub struct Choice {
pub icon: Option<IconName>,
pub note: Option<SharedString>,
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))
})
+9 -25
View File
@@ -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<bool>,
toggles: Vec<u64>,
}
/// 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();
})
+4
View File
@@ -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};
+150
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// 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<SharedString>,
on_select: Option<OnKey>,
}
impl Outline {
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>,
title: impl Into<SharedString>,
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<SharedString>) -> 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<SharedString> = 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)
}
}
+2 -2
View File
@@ -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,
+367
View File
@@ -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<dyn Fn(&[SharedString], &mut Window, &mut App)>;
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnRename = Rc<dyn Fn(&SharedString, &SharedString, &mut Window, &mut App)>;
type OnMove = Rc<dyn Fn(&SharedString, &SharedString, DropAt, &mut Window, &mut App)>;
type OnRow = Rc<dyn Fn(usize, &mut Window, &mut App)>;
type OnPick = Rc<dyn Fn(usize, Pick, &mut Window, &mut App)>;
type OnToggle = Rc<dyn Fn(&SharedString, bool, &mut App)>;
/// 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<SharedString>,
drop: Option<(SharedString, DropAt)>,
renaming: Option<SharedString>,
}
/// 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<TreeNode>,
open: Vec<SharedString>,
selected: Vec<SharedString>,
multiple: bool,
checked: Option<Vec<SharedString>>,
on_select: Option<OnKeys>,
on_check: Option<OnKeys>,
on_activate: Option<OnKey>,
on_load: Option<OnKey>,
on_rename: Option<OnRename>,
on_move: Option<OnMove>,
}
impl Tree {
pub fn new(id: impl Into<ElementId>, nodes: impl IntoIterator<Item = TreeNode>) -> 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<Item = impl Into<SharedString>>) -> Self {
self.open = keys.into_iter().map(Into::into).collect();
self
}
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> 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<Item = impl Into<SharedString>>) -> 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<SharedString> = 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<OnKey> = 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)
}
}
+357
View File
@@ -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<TreeNode>),
/// 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<IconName>,
pub(crate) children: Children,
}
impl TreeNode {
/// A leaf, until it gains children.
pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> 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<Item = TreeNode>) -> 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<SharedString> {
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<IconName>,
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<usize>,
}
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<SharedString>) -> Vec<Row> {
fn walk(
nodes: &[TreeNode],
open: &HashSet<SharedString>,
depth: usize,
parent: Option<usize>,
out: &mut Vec<Row>,
) {
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<Move> {
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<SharedString> {
let leaves = node.leaves();
let fill = check_state(node, checked) != CheckState::On;
let mut next: Vec<SharedString> = 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<TreeNode> {
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<SharedString> {
list.iter().map(|key| SharedString::from(*key)).collect()
}
fn open(list: &[&'static str]) -> HashSet<SharedString> {
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()));
}
}
+285
View File
@@ -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<Vec<TreeNode>>,
pub shown: Rc<Vec<Row>>,
pub selected: Rc<Vec<SharedString>>,
pub checked: Option<Rc<Vec<SharedString>>>,
pub current: Option<usize>,
pub nav: Entity<Nav>,
pub editing: Entity<Editing>,
pub focus: FocusHandle,
pub pick: OnPick,
pub toggle: OnToggle,
pub check: Option<OnKey>,
pub activate: OnRow,
pub on_move: Option<OnMove>,
}
pub(super) fn draw(
rows: &Rows,
range: Range<usize>,
window: &mut Window,
cx: &mut App,
) -> Vec<AnyElement> {
range.map(|ix| row(rows, ix, window, cx)).collect()
}
/// A faint line down each level a row sits under.
fn guides(depth: usize, cx: &App) -> impl IntoElement {
let theme = cx.theme();
div()
.absolute()
.inset_0()
.px_2()
.flex()
.children((0..depth).map(|_| {
div()
.flex_none()
.w(theme.tree_indent())
.h_full()
.flex()
.justify_center()
.child(
div()
.h_full()
.border_l_1()
.border_color(theme.colors.border),
)
}))
}
fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement {
let item = &rows.shown[ix];
let (key, label, icon, opens, open) = match &item.shown {
Shown::Node {
key,
label,
icon,
opens,
open,
} => (key.clone(), label.clone(), *icon, *opens, *open),
Shown::Loading => return waiting(rows, ix, item.depth, cx),
};
let checked = rows.checked.as_ref().map(|checked| {
let state = check_state(find(&rows.nodes, &key).expect("a shown node"), checked);
(
state,
motion::changes((rows.id.clone(), format!("check-{key}")), state, window, cx),
)
});
let nav = rows.nav.read(cx);
let renaming = nav.renaming.as_ref() == Some(&key);
let landing = nav
.drop
.as_ref()
.filter(|(target, _)| *target == key)
.map(|(_, at)| *at);
let field = renaming.then(|| rows.editing.read(cx).field()).flatten();
let theme = cx.theme();
let colors = &theme.colors;
let indent = theme.tree_indent();
let selected = rows.selected.contains(&key);
let (toggle, pick, activate, focus) = (
rows.toggle.clone(),
rows.pick.clone(),
rows.activate.clone(),
rows.focus.clone(),
);
let (owner, nodes, nav, on_move) = (
rows.owner,
rows.nodes.clone(),
rows.nav.clone(),
rows.on_move.clone(),
);
let (dropped_on, dragged_over, shut) = (key.clone(), key.clone(), key.clone());
let chevron = div()
.flex_none()
.w(indent)
.flex()
.justify_center()
.when(opens, |slot| {
slot.child(Disclosure::new(
(rows.id.clone(), format!("chevron-{key}")),
open,
))
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
toggle(&shut, !open, cx);
})
});
let body: AnyElement = match field {
Some(field) => {
let editing = rows.editing.clone();
div()
.flex_1()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
editing.update(cx, |editing, cx| editing.finish(true, window, cx));
}
})
.child(Input::new(&field).size(ControlSize::Sm))
.into_any_element()
}
None => div()
.flex_1()
.min_w_0()
.truncate()
.child(label.clone())
.into_any_element(),
};
let line = |at: DropAt| {
div()
.absolute()
.left_0()
.right_0()
.when(at == DropAt::Before, |line| line.top_0())
.when(at == DropAt::After, |line| line.bottom_0())
.border_t_2()
.border_color(colors.accent)
};
div()
.id((rows.id.clone(), format!("node-{key}")))
.relative()
.w_full()
.h(theme.control_height(ControlSize::Md))
.flex()
.items_center()
.gap_1()
.px_2()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(if rows.current == Some(ix) {
colors.focus
} else {
gpui::transparent_black()
})
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.when(selected, |row| row.bg(colors.active))
.when(!selected, |row| row.hover(|style| style.bg(colors.hover)))
.when(landing == Some(DropAt::Inside), |row| {
row.bg(colors.selection)
})
.child(guides(item.depth, cx))
.child(div().flex_none().w(indent * item.depth as f32))
.child(chevron)
.children(
checked
.zip(rows.check.clone())
.map(|((state, turns), check)| {
let key = key.clone();
div()
.flex_none()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
cx.stop_propagation();
check(&key, window, cx);
})
.child(check_mark(state, false, false, turns, cx))
}),
)
.children(icon.map(|icon| Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)))
.child(body)
.children(landing.filter(|at| *at != DropAt::Inside).map(line))
.on_click(move |event, window, cx| {
window.focus(&focus);
if event.click_count() == 2 {
activate(ix, window, cx);
return;
}
let held = event.modifiers();
let how = match (held.shift, held.platform) {
(true, _) => Pick::Range,
(false, true) => Pick::Toggle,
_ => Pick::One,
};
pick(ix, how, window, cx);
})
.when(!renaming, |row| {
row.on_drag(
TreeDrag {
owner,
key: key.clone(),
label: label.clone(),
},
move |drag, _, _, cx| DragGhost::new(drag.label.clone(), icon, cx),
)
})
.on_drag_move(move |event: &DragMoveEvent<TreeDrag>, _, cx| {
let drag = event.drag(cx);
if drag.owner != owner || !event.bounds.contains(&event.event.position) {
return;
}
let share = (event.event.position.y - event.bounds.top()) / event.bounds.size.height;
let fits = drag.key != dragged_over && can_drop(&nodes, &drag.key, &dragged_over);
let next = fits.then(|| (dragged_over.clone(), place_at(share, opens)));
nav.update(cx, |nav, cx| {
if nav.drop != next {
nav.drop = next;
cx.notify();
}
});
})
.on_drop({
let nav = rows.nav.clone();
move |drag: &TreeDrag, window, cx| {
if drag.owner != owner {
return;
}
let landed = nav.update(cx, |nav, cx| {
cx.notify();
nav.drop.take()
});
if let (Some((target, at)), Some(on_move)) = (landed, &on_move)
&& target == dropped_on
{
log::info!("tree: {} dropped {at:?} {target}", drag.key);
on_move(&drag.key, &target, at, window, cx);
}
}
})
.into_any_element()
}
/// The row under a pending node while its children come.
fn waiting(rows: &Rows, ix: usize, depth: usize, cx: &App) -> AnyElement {
let theme = cx.theme();
div()
.id((rows.id.clone(), format!("waiting-{ix}")))
.relative()
.w_full()
.h(theme.control_height(ControlSize::Md))
.flex()
.items_center()
.px_2()
.child(guides(depth, cx))
.child(
div()
.flex_none()
.w(theme.tree_indent() * (depth + 1) as f32),
)
.child(Spinner::new((rows.id.clone(), format!("spinner-{ix}"))).size(IconSize::Xs))
.into_any_element()
}
+158
View File
@@ -0,0 +1,158 @@
use std::rc::Rc;
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Window, prelude::*,
};
use super::{Shown, TreeNode, rows};
use crate::{
forms::{Choice, Listing, field_button, field_text, listing},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize},
};
type OnPick = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// Every node of a tree as a choice, depth first, each indented under its parent.
fn choices(nodes: &[TreeNode]) -> Vec<Choice> {
fn keys(nodes: &[TreeNode], out: &mut Vec<SharedString>) {
for node in nodes {
out.push(node.key.clone());
if let super::Children::Known(children) = &node.children {
keys(children, out);
}
}
}
let mut every = Vec::new();
keys(nodes, &mut every);
rows(nodes, &every.into_iter().collect())
.into_iter()
.filter_map(|row| match row.shown {
Shown::Node {
key, label, icon, ..
} => {
let choice = Choice::new(key, label).depth(row.depth);
Some(match icon {
Some(icon) => choice.icon(icon),
None => choice,
})
}
Shown::Loading => None,
})
.collect()
}
/// A select whose choices are a tree's nodes, indented under their parents; any node can be picked.
#[derive(IntoElement)]
pub struct TreeSelect {
id: ElementId,
nodes: Vec<TreeNode>,
selected: Option<SharedString>,
placeholder: SharedString,
on_change: Option<OnPick>,
}
impl TreeSelect {
pub fn new(id: impl Into<ElementId>, nodes: impl IntoIterator<Item = TreeNode>) -> Self {
Self {
id: id.into(),
nodes: nodes.into_iter().collect(),
selected: None,
placeholder: "Choose".into(),
on_change: None,
}
}
pub fn selected(mut self, key: impl Into<SharedString>) -> Self {
self.selected = Some(key.into());
self
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
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 TreeSelect {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let rows = Rc::new(choices(&self.nodes));
assert!(!rows.is_empty(), "tree select {:?} has no nodes", self.id);
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let shown = self
.selected
.as_ref()
.and_then(|key| rows.iter().find(|row| row.value == *key))
.cloned();
let colors = &cx.theme().colors;
let trigger = field_button(self.id.clone(), &focus, ControlSize::Md, false, window, cx)
.when_some(shown.as_ref().and_then(|row| row.icon), |trigger, icon| {
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
})
.child(field_text(
shown.map(|row| row.label),
self.placeholder,
false,
cx,
))
.child(
Icon::new(IconName::ChevronsUpDown)
.size(IconSize::Xs)
.color(colors.fg_subtle),
);
let (id, on_change) = (self.id.clone(), self.on_change);
let list = Listing {
id: &self.id,
rows,
selected: self.selected.as_ref(),
focused: focus.is_focused(window),
};
listing(
list,
trigger,
move |key, window, cx| {
log::info!("tree select {id:?}: {key}");
if let Some(on_change) = &on_change {
on_change(key, window, cx);
}
},
window,
cx,
)
}
}
#[cfg(test)]
mod tests {
use super::{TreeNode, choices};
#[test]
fn every_node_becomes_a_choice_at_its_depth() {
let nodes = [
TreeNode::new("design", "Design")
.children([TreeNode::new("brand", "Brand"), TreeNode::new("web", "Web")]),
TreeNode::new("ops", "Operations").pending(),
];
let shown: Vec<_> = choices(&nodes)
.into_iter()
.map(|choice| (choice.value.to_string(), choice.depth))
.collect();
assert_eq!(
shown,
[
("design".into(), 0),
("brand".into(), 1),
("web".into(), 1),
("ops".into(), 0)
]
);
}
}
+207
View File
@@ -0,0 +1,207 @@
use gpui::{
Context, Entity, IntoElement, Modifiers, MouseButton, Pixels, Point, Render, SharedString,
Styled, TestAppContext, VisualTestContext, Window, point, px,
};
use super::{DropAt, Tree, TreeNode};
use crate::theme::Theme;
/// A small project tree that keeps what its tree reports.
#[derive(Default)]
struct Explorer {
docs: Vec<TreeNode>,
selected: Vec<SharedString>,
checked: Option<Vec<SharedString>>,
heard: Vec<String>,
}
impl Render for Explorer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let heard = |view: &Entity<Explorer>| {
let view = view.clone();
move |line: String, cx: &mut gpui::App| {
view.update(cx, |explorer, _| explorer.heard.push(line))
}
};
let docs = match self.docs.is_empty() {
true => TreeNode::new("docs", "docs").pending(),
false => TreeNode::new("docs", "docs").children(self.docs.clone()),
};
let all = [
TreeNode::new("src", "src").children([
TreeNode::new("lib", "lib.rs"),
TreeNode::new("ui", "ui").child(TreeNode::new("button", "button.rs")),
]),
docs,
TreeNode::new("readme", "README.md"),
];
let (select, check, load, rename, moved) = (
view.clone(),
view,
heard(&cx.entity()),
heard(&cx.entity()),
heard(&cx.entity()),
);
let tree = Tree::new("tree", all)
.open(["src"])
.selected(self.selected.clone())
.on_select(move |keys, _, cx| {
select.update(cx, |explorer, _| explorer.selected = keys.to_vec())
})
.on_load(move |key, _, cx| load(format!("load {key}"), cx))
.on_rename(move |key, name, _, cx| rename(format!("rename {key} {name}"), cx))
.on_move(move |key, target, at, _, cx| moved(format!("move {key} {at:?} {target}"), cx))
.w(px(300.0))
.h(px(400.0));
match self.checked.clone() {
Some(checked) => tree.checked(checked).on_check(move |keys, _, cx| {
check.update(cx, |explorer, _| explorer.checked = Some(keys.to_vec()))
}),
None => tree,
}
}
}
fn explorer(checked: bool, cx: &mut TestAppContext) -> (Entity<Explorer>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
crate::forms::bind_keys(cx);
});
let (view, cx) = cx.add_window_view(move |_, _| Explorer {
checked: checked.then(Vec::new),
..Explorer::default()
});
settle(cx);
(view, cx)
}
/// Frames 2ms apart, past reduced motion's 1ms glides.
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
std::thread::sleep(std::time::Duration::from_millis(2));
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
/// Row `ix`'s left padding: rows are 28 tall.
fn row(ix: usize) -> Point<Pixels> {
point(px(4.0), px(14.0 + 28.0 * ix as f32))
}
fn read<T>(view: &Entity<Explorer>, cx: &mut VisualTestContext, f: impl Fn(&Explorer) -> T) -> T {
view.read_with(cx, |explorer, _| f(explorer))
}
#[gpui::test]
fn keys_walk_open_climb_and_close(cx: &mut TestAppContext) {
let (view, cx) = explorer(false, cx);
cx.simulate_click(row(0), Modifiers::none());
settle(cx);
let mut seen = Vec::new();
for keys in ["right", "down", "right", "right", "left", "left"] {
cx.simulate_keystrokes(keys);
settle(cx);
seen.push(read(&view, cx, |explorer| explorer.selected.join(",")));
}
assert_eq!(
seen,
["lib", "ui", "ui", "button", "ui", "ui"],
"right enters, left climbs, then closes"
);
cx.simulate_keystrokes("down");
settle(cx);
assert_eq!(
read(&view, cx, |explorer| explorer.selected.join(",")),
"docs",
"ui closed again"
);
}
#[gpui::test]
fn a_pending_node_asks_for_its_children_once(cx: &mut TestAppContext) {
let (view, cx) = explorer(false, cx);
cx.simulate_click(row(3), Modifiers::none());
settle(cx);
cx.simulate_keystrokes("right");
settle(cx);
settle(cx);
assert_eq!(
read(&view, cx, |explorer| explorer.heard.clone()),
["load docs"],
"asked once while it waits"
);
view.update(cx, |explorer, cx| {
explorer.docs = vec![TreeNode::new("guide", "guide.md")];
cx.notify();
});
settle(cx);
cx.simulate_keystrokes("left right down");
settle(cx);
assert_eq!(
read(&view, cx, |explorer| explorer.heard.clone()),
["load docs"]
);
assert_eq!(
read(&view, cx, |explorer| explorer.selected.join(",")),
"guide",
"the children came"
);
}
#[gpui::test]
fn space_checks_a_whole_branch_and_again_clears_it(cx: &mut TestAppContext) {
let (view, cx) = explorer(true, cx);
cx.simulate_click(row(0), Modifiers::none());
settle(cx);
cx.simulate_keystrokes("space");
settle(cx);
assert_eq!(
read(&view, cx, |explorer| explorer.checked.clone()),
Some(vec!["lib".into(), "button".into()])
);
cx.simulate_keystrokes("space");
settle(cx);
assert_eq!(
read(&view, cx, |explorer| explorer.checked.clone()),
Some(Vec::new())
);
}
#[gpui::test]
fn f2_renames_a_node(cx: &mut TestAppContext) {
let (view, cx) = explorer(false, cx);
cx.simulate_click(row(1), Modifiers::none());
settle(cx);
cx.simulate_keystrokes("f2");
settle(cx);
cx.simulate_input("main.rs");
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(
read(&view, cx, |explorer| explorer.heard.clone()),
["rename lib main.rs"]
);
}
#[gpui::test]
fn a_drop_in_a_folders_middle_moves_the_node_inside(cx: &mut TestAppContext) {
let (view, cx) = explorer(false, cx);
cx.simulate_mouse_down(row(4), MouseButton::Left, Modifiers::none());
for y in [120.0, 100.0, 80.0, 70.0] {
cx.simulate_mouse_move(point(px(40.0), px(y)), MouseButton::Left, Modifiers::none());
settle(cx);
}
cx.simulate_mouse_up(
point(px(40.0), px(70.0)),
MouseButton::Left,
Modifiers::none(),
);
settle(cx);
assert_eq!(
read(&view, cx, |explorer| explorer.heard.clone()),
[format!("move readme {:?} ui", DropAt::Inside)]
);
}
+65
View File
@@ -0,0 +1,65 @@
use std::f32::consts::FRAC_PI_2;
use gpui::{
Animation, AnimationExt, App, ElementId, Hsla, IntoElement, RenderOnce, Window, radians,
};
use super::{Icon, IconName};
use crate::{
motion,
theme::{ActiveTheme, IconSize},
};
/// A chevron that points right when shut and down when open, turning on each change and still on first paint.
#[derive(IntoElement)]
pub struct Disclosure {
id: ElementId,
open: bool,
size: IconSize,
color: Option<Hsla>,
}
impl Disclosure {
pub fn new(id: impl Into<ElementId>, open: bool) -> Self {
Self {
id: id.into(),
open,
size: IconSize::Xs,
color: None,
}
}
pub fn size(mut self, size: IconSize) -> Self {
self.size = size;
self
}
/// Its color; the subtle foreground unless set.
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl RenderOnce for Disclosure {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let open = self.open;
let turns = motion::changes((self.id.clone(), "turns"), open, window, cx);
let color = self.color.unwrap_or(cx.theme().colors.fg_subtle);
let turn = |share: f32| radians(share * FRAC_PI_2);
let icon = Icon::new(IconName::ChevronRight)
.size(self.size)
.color(color);
if turns == 0 {
return icon
.rotate(turn(if open { 1.0 } else { 0.0 }))
.into_any_element();
}
icon.with_animation(
(self.id, format!("turn-{turns}")),
Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic),
move |icon, t| icon.rotate(turn(if open { t } else { 1.0 - t })),
)
.into_any_element()
}
}
+2
View File
@@ -1,4 +1,5 @@
mod backdrop;
mod disclosure;
mod divider;
mod focus;
mod ghost;
@@ -10,6 +11,7 @@ mod severity;
mod tooltip;
pub use backdrop::{Backdrop, Place};
pub use disclosure::Disclosure;
pub use divider::Divider;
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
pub(crate) use focus::{Takeover, give_back, tab_stop, take_focus};
+5
View File
@@ -284,6 +284,11 @@ impl Theme {
px_to_rems(128.0)
}
/// How far each level of a tree steps in.
pub fn tree_indent(&self) -> Rems {
px_to_rems(16.0)
}
/// How far past the viewport a long list builds rows ahead.
pub fn list_overdraw(&self) -> Rems {
px_to_rems(240.0)
+1 -1
View File
@@ -210,7 +210,7 @@
- [x] Autocomplete → `forms::Combobox::free`
- [x] MultiSelect
- [x] CascadeSelect / Cascader
- [x] TreeSelect → lands with Lists & Trees (T13): its list is a Tree
- [x] TreeSelect → `lists::TreeSelect` (T13b): forms cannot import lists
- [x] TransferList
- [x] ListBox
- [x] ChoiceChips / FilterChips
+5 -5
View File
@@ -103,11 +103,11 @@
## T13b Lists & Trees · Trees
- [ ] Tree / TreeView — also T06b TreeSelect
- [ ] VirtualTree → `lists::Tree`
- [ ] CheckboxTree
- [ ] Outline
- [ ] NestedList → `lists::Outline`
- [x] Tree / TreeView → `lists::Tree`, `TreeNode`; also T06b TreeSelect → `lists::TreeSelect`
- [x] VirtualTree → `lists::Tree`, whose rows draw in a `uniform_list`
- [x] CheckboxTree → `lists::Tree` with `checked`
- [x] Outline → `lists::Outline`
- [x] NestedList → `lists::Outline`
## T13c Lists & Trees · Files