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
@@ -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)]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user