1use std::collections::BTreeSet;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Point, Role, Toggled};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
13use crate::inputs::check_box_around;
14use crate::{IconName, View, util};
15
16const LEVEL_INDENT: f32 = 20.0;
17const OVERSCAN: usize = 6;
18
19type KeyHandler = Rc<dyn Fn(&str)>;
20
21#[derive(Clone, Debug, PartialEq)]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct TreeNode {
25 key: String,
26 label: String,
27 icon: Option<IconName>,
28 children: Vec<TreeNode>,
29 lazy: bool,
30 disabled: bool,
31}
32
33#[cfg_attr(feature = "live", ravenrs_live::expose)]
34impl TreeNode {
35 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
37 Self {
38 key: key.into(),
39 label: label.into(),
40 icon: None,
41 children: Vec::new(),
42 lazy: false,
43 disabled: false,
44 }
45 }
46
47 pub fn icon(mut self, icon: IconName) -> Self {
49 self.icon = Some(icon);
50 self
51 }
52
53 pub fn child(mut self, child: TreeNode) -> Self {
55 self.children.push(child);
56 self
57 }
58
59 pub fn children(mut self, children: impl IntoIterator<Item = TreeNode>) -> Self {
61 self.children.extend(children);
62 self
63 }
64
65 pub fn lazy(mut self) -> Self {
67 self.lazy = true;
68 self
69 }
70
71 pub fn disabled(mut self, disabled: bool) -> Self {
73 self.disabled = disabled;
74 self
75 }
76
77 fn can_open(&self) -> bool {
78 self.lazy || !self.children.is_empty()
79 }
80
81 fn matches(&self, query: &str) -> bool {
82 self.label.to_lowercase().contains(query) || self.children.iter().any(|child| child.matches(query))
83 }
84
85 fn collect_tips(&self, out: &mut Vec<String>) {
87 if self.children.is_empty() {
88 if !self.disabled {
89 out.push(self.key.clone());
90 }
91 } else {
92 for child in &self.children {
93 child.collect_tips(out);
94 }
95 }
96 }
97
98 fn check_state(&self, checked: &BTreeSet<String>) -> Toggled {
99 let mut tips = Vec::new();
100 self.collect_tips(&mut tips);
101 let on = tips.iter().filter(|tip| checked.contains(*tip)).count();
102 if on == 0 {
103 Toggled::False
104 } else if on == tips.len() {
105 Toggled::True
106 } else {
107 Toggled::Mixed
108 }
109 }
110}
111
112#[derive(Clone, Debug)]
114struct Line {
115 node: TreeNode,
116 level: u32,
117 parent: Option<usize>,
118 open: bool,
119}
120
121fn lines(nodes: &[TreeNode], expanded: &BTreeSet<String>, query: &str) -> Vec<Line> {
122 fn walk(
123 nodes: &[TreeNode],
124 level: u32,
125 parent: Option<usize>,
126 expanded: &BTreeSet<String>,
127 query: &str,
128 out: &mut Vec<Line>,
129 ) {
130 for node in nodes {
131 if !query.is_empty() && !node.matches(query) {
132 continue;
133 }
134 let open = node.can_open()
136 && (expanded.contains(&node.key)
137 || (!query.is_empty() && node.children.iter().any(|c| c.matches(query))));
138 let here = out.len();
139 out.push(Line { node: node.clone(), level, parent, open });
140 if open {
141 walk(&node.children, level + 1, Some(here), expanded, query, out);
142 }
143 }
144 }
145 let mut out = Vec::new();
146 walk(nodes, 1, None, expanded, query, &mut out);
147 out
148}
149
150#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
168pub struct TreeView {
169 nodes: Vec<TreeNode>,
170 selected: Option<Binding<Option<String>>>,
171 expanded: Option<Binding<BTreeSet<String>>>,
172 checked: Option<Binding<BTreeSet<String>>>,
173 query: String,
174 on_open: Option<KeyHandler>,
175 on_expand: Option<KeyHandler>,
176 height: Option<f32>,
177 row_height: f32,
178 label: Option<String>,
179}
180
181impl fmt::Debug for TreeView {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 f.debug_struct("TreeView").field("nodes", &self.nodes.len()).finish_non_exhaustive()
184 }
185}
186
187#[cfg_attr(feature = "live", ravenrs_live::expose)]
188impl TreeView {
189 pub fn new(nodes: Vec<TreeNode>) -> Self {
191 Self {
192 nodes,
193 selected: None,
194 expanded: None,
195 checked: None,
196 query: String::new(),
197 on_open: None,
198 on_expand: None,
199 height: None,
200 row_height: 32.0,
201 label: None,
202 }
203 }
204
205 pub fn selected(mut self, selected: Binding<Option<String>>) -> Self {
207 self.selected = Some(selected);
208 self
209 }
210
211 pub fn expanded(mut self, expanded: Binding<BTreeSet<String>>) -> Self {
213 self.expanded = Some(expanded);
214 self
215 }
216
217 pub fn checked(mut self, checked: Binding<BTreeSet<String>>) -> Self {
221 self.checked = Some(checked);
222 self
223 }
224
225 pub fn filter(mut self, text: impl Into<String>) -> Self {
227 self.query = text.into().trim().to_lowercase();
228 self
229 }
230
231 pub fn on_open(mut self, handler: impl Fn(&str) + 'static) -> Self {
233 self.on_open = Some(Rc::new(handler));
234 self
235 }
236
237 pub fn on_expand(mut self, handler: impl Fn(&str) + 'static) -> Self {
239 self.on_expand = Some(Rc::new(handler));
240 self
241 }
242
243 pub fn height(mut self, height: f32) -> Self {
245 self.height = Some(height);
246 self
247 }
248
249 pub fn row_height(mut self, height: f32) -> Self {
251 self.row_height = height.max(24.0);
252 self
253 }
254
255 pub fn label(mut self, label: impl Into<String>) -> Self {
257 self.label = Some(label.into());
258 self
259 }
260}
261
262#[derive(Clone)]
263struct TreeActions {
264 selected: Option<Binding<Option<String>>>,
265 expanded: Binding<BTreeSet<String>>,
266 checked: Option<Binding<BTreeSet<String>>>,
267 on_open: Option<KeyHandler>,
268 on_expand: Option<KeyHandler>,
269 active: State<Option<String>>,
270}
271
272#[cfg_attr(feature = "live", ravenrs_live::expose)]
273impl TreeActions {
274 fn set_open(&self, key: &str, open: bool) {
275 self.expanded.update(|set| {
276 if open {
277 set.insert(key.to_owned());
278 } else {
279 set.remove(key);
280 }
281 });
282 if open && let Some(on_expand) = &self.on_expand {
283 on_expand(key);
284 }
285 }
286
287 fn select(&self, node: &TreeNode) {
288 self.active.set(Some(node.key.clone()));
289 if !node.disabled
290 && let Some(selected) = &self.selected
291 {
292 selected.set(Some(node.key.clone()));
293 }
294 }
295
296 fn toggle_check(&self, node: &TreeNode) {
297 let Some(checked) = &self.checked else { return };
298 if node.disabled {
299 return;
300 }
301 let mut tips = Vec::new();
302 node.collect_tips(&mut tips);
303 let all_on = node.check_state(&checked.get()) == Toggled::True;
304 checked.update(|set| {
305 for tip in tips {
306 if all_on {
307 set.remove(&tip);
308 } else {
309 set.insert(tip);
310 }
311 }
312 });
313 }
314
315 fn open_item(&self, node: &TreeNode) {
316 self.select(node);
317 if !node.disabled
318 && let Some(on_open) = &self.on_open
319 {
320 on_open(&node.key);
321 }
322 }
323}
324
325struct TreeLine<'a> {
326 line: &'a Line,
327 actions: &'a TreeActions,
328 active: bool,
329 focused: bool,
330 selected: bool,
331 check: Option<Toggled>,
332 row_height: f32,
333}
334
335impl View for TreeLine<'_> {
336 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
337 let theme = *cx.theme();
338 let Line { node, level, open, .. } = self.line;
339 let ink = theme.color(Semantic::Ink);
340 let muted = theme.color(Semantic::InkMuted);
341 let tone = |color: Color| if node.disabled { util::faded(color) } else { color };
342 let mut parts = Vec::new();
343 if node.can_open() {
344 let chevron = if *open { IconName::ChevronDown } else { IconName::ChevronRight };
345 let glyph = util::icon(cx, chevron, 16.0, muted);
346 let button = cx.container(
347 LayoutStyle {
348 width: Some(20.0),
349 height: Some(self.row_height),
350 align: Align::Center,
351 justify: Justify::Center,
352 ..LayoutStyle::stack(Axis::Horizontal)
353 },
354 &[glyph],
355 Paint::None,
356 );
357 let (actions, key, open) = (self.actions.clone(), node.key.clone(), *open);
358 cx.on_click(button, Rc::new(move || actions.set_open(&key, !open)));
359 cx.semantics(button, Semantics::hidden());
360 parts.push(button);
361 } else {
362 parts.push(cx.container(
363 LayoutStyle { width: Some(20.0), ..LayoutStyle::leaf() },
364 &[],
365 Paint::None,
366 ));
367 }
368 if let Some(check) = self.check {
369 let state = util::EditorState { disabled: node.disabled, read_only: false };
370 let mark = match check {
371 Toggled::True => Some(IconName::Check),
372 Toggled::Mixed => Some(IconName::Minus),
373 _ => None,
374 }
375 .map(|icon| util::icon(cx, icon, 14.0, theme.color(Semantic::OnAccent)));
376 let square =
377 check_box_around(cx, &theme, !matches!(check, Toggled::False), false, state, mark.as_slice());
378 let (actions, item) = (self.actions.clone(), node.clone());
379 cx.on_click(square, Rc::new(move || actions.toggle_check(&item)));
380 cx.semantics(square, Semantics::hidden());
381 parts.push(square);
382 }
383 if let Some(icon) = node.icon {
384 parts.push(util::icon(cx, icon, 16.0, tone(muted)));
385 }
386 let text = cx.text_truncated(
387 &node.label,
388 &util::attrs(&theme, TextStyle::Body, None),
389 tone(ink),
390 LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
391 );
392 cx.semantics(text, Semantics::hidden());
393 parts.push(text);
394 let fill = if self.selected {
395 theme.color(Semantic::Accent).with_alpha(0.14)
396 } else if cx.is_hovered() {
397 ink.with_alpha(0.05)
398 } else {
399 Color::TRANSPARENT
400 };
401 let border = (self.active && self.focused)
402 .then(|| Border { width: 1.5, color: theme.color(Semantic::Accent) });
403 let row = cx.container(
404 LayoutStyle {
405 height: Some(self.row_height),
406 padding: Insets {
407 left: 4.0 + (*level as f32 - 1.0) * LEVEL_INDENT,
408 right: 8.0,
409 ..Insets::ZERO
410 },
411 spacing: 6.0,
412 align: Align::Center,
413 align_self: Some(Align::Stretch),
414 ..LayoutStyle::stack(Axis::Horizontal)
415 },
416 &parts,
417 Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, border, ..QuadPaint::new(fill) }),
418 );
419 let (select, item) = (self.actions.clone(), node.clone());
420 cx.on_click(row, Rc::new(move || select.select(&item)));
421 let (opener, item) = (self.actions.clone(), node.clone());
422 cx.on_double_click(row, Rc::new(move || opener.open_item(&item)));
423 cx.cursor(row, CursorIcon::Pointer);
424 let mut semantics = Semantics::new(Role::TreeItem)
425 .name(node.label.clone())
426 .level(*level)
427 .selected(self.selected)
428 .disabled(node.disabled)
429 .active(self.active);
430 if node.can_open() {
431 semantics = semantics.expanded(*open);
432 }
433 if let Some(check) = self.check {
434 semantics = semantics.toggled(check);
435 }
436 cx.semantics(row, semantics);
437 row
438 }
439}
440
441fn key_handler(
442 all: Rc<Vec<Line>>,
443 at: usize,
444 actions: TreeActions,
445 reveal: State<bool>,
446) -> crate::KeyHandler {
447 Rc::new(move |key, _| {
448 let Some(line) = all.get(at) else { return false };
449 let go = |index: usize| {
450 if let Some(line) = all.get(index) {
451 actions.active.set(Some(line.node.key.clone()));
452 reveal.set(true);
453 }
454 };
455 match key {
456 Key::ArrowDown => go((at + 1).min(all.len().saturating_sub(1))),
457 Key::ArrowUp => go(at.saturating_sub(1)),
458 Key::Home => go(0),
459 Key::End => go(all.len().saturating_sub(1)),
460 Key::ArrowRight if line.node.can_open() && !line.open => actions.set_open(&line.node.key, true),
461 Key::ArrowRight if line.open => go(at + 1),
462 Key::ArrowRight => {}
463 Key::ArrowLeft if line.open => actions.set_open(&line.node.key, false),
464 Key::ArrowLeft => {
465 if let Some(parent) = line.parent {
466 go(parent);
467 }
468 }
469 Key::Enter => actions.open_item(&line.node),
470 Key::Space if actions.checked.is_some() => actions.toggle_check(&line.node),
471 Key::Space => actions.select(&line.node),
472 _ => return false,
473 }
474 true
475 })
476}
477
478impl View for TreeView {
479 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
480 let local: State<BTreeSet<String>> = cx.use_state(BTreeSet::new);
481 let expanded = self.expanded.clone().unwrap_or_else(|| local.binding());
482 let active: State<Option<String>> = cx.use_state(|| None);
483 let reveal: State<bool> = cx.use_state(|| false);
484 let all = Rc::new(lines(&self.nodes, &expanded.get(), &self.query));
485 let selected = self.selected.as_ref().and_then(Binding::get);
486 let checked = self.checked.as_ref().map(Binding::get);
487 let wanted = active.get().or_else(|| selected.clone());
489 let at = wanted.and_then(|key| all.iter().position(|l| l.node.key == key)).unwrap_or(0);
490 let actions = TreeActions {
491 selected: self.selected.clone(),
492 expanded,
493 checked: self.checked.clone(),
494 on_open: self.on_open.clone(),
495 on_expand: self.on_expand.clone(),
496 active: active.clone(),
497 };
498 let rh = self.row_height;
499 let focused = cx.is_focused();
500 let offset = cx.scroll_offset();
501 let range = match self.height {
502 Some(height) => {
503 let viewport = cx.scroll_viewport().map_or(height, |size| size.height);
504 let top = at as f32 * rh;
505 if *reveal.peek() {
506 reveal.set_if_changed(false);
507 if top < offset.y {
508 cx.set_scroll_offset(Point::new(0.0, top));
509 } else if top + rh > offset.y + viewport {
510 cx.set_scroll_offset(Point::new(0.0, top + rh - viewport));
511 }
512 }
513 let first = ((offset.y / rh).floor() as usize).saturating_sub(OVERSCAN).min(all.len());
514 let last = (((offset.y + viewport) / rh).ceil() as usize + OVERSCAN).min(all.len());
515 first..last
516 }
517 None => 0..all.len(),
518 };
519 let mut rows = Vec::with_capacity(range.len() + 2);
520 rows.push(cx.container(
521 LayoutStyle { height: Some(range.start as f32 * rh), ..LayoutStyle::leaf() },
522 &[],
523 Paint::None,
524 ));
525 for i in range.clone() {
526 let line = &all[i];
527 let row = TreeLine {
528 line,
529 actions: &actions,
530 active: i == at,
531 focused,
532 selected: selected.as_deref() == Some(line.node.key.as_str()),
533 check: checked.as_ref().map(|set| line.node.check_state(set)),
534 row_height: rh,
535 };
536 rows.push(cx.keyed_child(util::hash_text(&line.node.key), &row));
537 }
538 rows.push(cx.container(
539 LayoutStyle {
540 height: Some(all.len().saturating_sub(range.end) as f32 * rh),
541 ..LayoutStyle::leaf()
542 },
543 &[],
544 Paint::None,
545 ));
546 let content = cx.container(
547 LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
548 &rows,
549 Paint::None,
550 );
551 let body = cx.container(
552 LayoutStyle {
553 height: self.height,
554 min_height: Some(0.0),
555 overflow_hidden: self.height.is_some(),
556 align: Align::Stretch,
557 align_self: Some(Align::Stretch),
558 ..LayoutStyle::stack(Axis::Vertical)
559 },
560 &[content],
561 Paint::None,
562 );
563 if self.height.is_some() {
564 cx.scrollable_virtual(body, false);
565 }
566 let name = self.label.clone().unwrap_or_else(|| cx.labels().tree_name.clone());
567 cx.semantics(body, Semantics::new(Role::Tree).name(name));
568 cx.focusable(body, 4.0);
569 cx.on_key(body, key_handler(all, at, actions, reveal));
570 body
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 fn sample() -> Vec<TreeNode> {
579 vec![
580 TreeNode::new("a", "Alpha")
581 .child(TreeNode::new("a1", "One").child(TreeNode::new("deep", "Deep")))
582 .child(TreeNode::new("a2", "Two")),
583 TreeNode::new("b", "Beta"),
584 ]
585 }
586
587 #[test]
588 fn closed_branches_are_not_followed() {
589 let keys = |open: &[&str]| -> Vec<String> {
590 let open = open.iter().map(|s| (*s).to_owned()).collect();
591 lines(&sample(), &open, "").into_iter().map(|l| l.node.key).collect()
592 };
593 assert_eq!(keys(&[]), ["a", "b"]);
594 assert_eq!(keys(&["a"]), ["a", "a1", "a2", "b"]);
595 assert_eq!(keys(&["a", "a1"]), ["a", "a1", "deep", "a2", "b"]);
596 assert_eq!(keys(&["a1"]), ["a", "b"], "a closed parent hides what is open inside");
597 }
598
599 #[test]
600 fn a_filter_opens_the_way_to_what_it_finds() {
601 let found = lines(&sample(), &BTreeSet::new(), "deep");
602 let keys: Vec<_> = found.iter().map(|l| l.node.key.as_str()).collect();
603 assert_eq!(keys, ["a", "a1", "deep"]);
604 assert_eq!(found[2].parent, Some(1));
605 assert_eq!(found[2].level, 3);
606 }
607
608 #[test]
609 fn a_branch_is_checked_by_what_is_under_it() {
610 let root = &sample()[0];
611 let set = |keys: &[&str]| keys.iter().map(|s| (*s).to_owned()).collect::<BTreeSet<_>>();
612 assert_eq!(root.check_state(&set(&[])), Toggled::False);
613 assert_eq!(root.check_state(&set(&["deep"])), Toggled::Mixed);
614 assert_eq!(root.check_state(&set(&["deep", "a2"])), Toggled::True);
615 }
616}