T13b Trees: Tree, TreeNode, TreeSelect, Outline, Disclosure (review pending: codex 401)
This commit is contained in:
22 files changed
+1969
-94
No files matched your search
@@ -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()`.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user