Skip to main content

omp_tui/components/
tree.rs

1use omp_core::{Str, StrMut};
2use smallvec::SmallVec;
3
4use crate::{
5	component::{Component, EventCtx, Flow, Hit, HitTag, PaintCtx, Slot, next_slot},
6	context::UiContext,
7	frame::{Rect, Style},
8	input::{Key, Mouse},
9	props::{Prop, PropValue, Props},
10	rich::cell_width,
11};
12
13/// A labeled branch or leaf backing the `<node>` markup tag.
14pub struct TreeNode {
15	props:    Props,
16	slot:     Slot,
17	label:    Str,
18	children: Vec<Self>,
19}
20
21impl TreeNode {
22	/// Creates an empty tree node.
23	pub fn new() -> Self {
24		Self {
25			props:    Props::new(),
26			slot:     next_slot(),
27			label:    Str::default(),
28			children: Vec::new(),
29		}
30	}
31
32	/// Sets one node property.
33	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
34		self.props.set(prop, value);
35		self
36	}
37
38	/// Appends node label text.
39	pub fn label(mut self, label: impl Into<Str>) -> Self {
40		append(&mut self.label, label.into());
41		self
42	}
43
44	/// Appends a child node.
45	pub fn node(mut self, node: Self) -> Self {
46		self.children.push(node);
47		self
48	}
49
50	fn effective_label(&self) -> &str {
51		if self.label.is_empty() {
52			self.props.str_of(Prop::Label).map_or("", Str::as_str)
53		} else {
54			&self.label
55		}
56	}
57}
58
59impl Default for TreeNode {
60	fn default() -> Self {
61		Self::new()
62	}
63}
64
65#[derive(Clone, Debug, Default)]
66struct TreeState {
67	cursor: u16,
68	chosen: Option<Str>,
69	open:   SmallVec<Slot, 8>,
70}
71
72#[derive(Clone, Debug)]
73struct TreeRow {
74	node:         Slot,
75	depth:        u16,
76	path:         Str,
77	label:        Str,
78	has_children: bool,
79	/// Continuation bits for ancestor levels below the root: `true` when
80	/// that ancestor has further siblings, so its guide column keeps
81	/// running through this row.
82	gutters:      SmallVec<bool, 8>,
83	/// Whether this row closes its sibling run (`└─` instead of `├─`).
84	last:         bool,
85}
86
87/// An expandable hierarchy backing the `<tree>` markup tag.
88pub struct Tree {
89	props:      Props,
90	slot:       Slot,
91	nodes:      Vec<TreeNode>,
92	state:      TreeState,
93	rows:       Vec<TreeRow>,
94	rows_dirty: bool,
95}
96
97impl Tree {
98	/// Creates an empty tree.
99	pub fn new() -> Self {
100		Self {
101			props:      Props::new(),
102			slot:       next_slot(),
103			nodes:      Vec::new(),
104			state:      TreeState::default(),
105			rows:       Vec::new(),
106			rows_dirty: true,
107		}
108	}
109
110	#[allow(dead_code, reason = "acceptance-suite probe")]
111	pub(crate) const fn visible_rows_len(&self) -> usize {
112		self.rows.len()
113	}
114
115	/// Sets one tree property.
116	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
117		self.props.set(prop, value);
118		self
119	}
120
121	/// Sets one tree property from a string.
122	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
123		self.props.set(prop, value);
124		self
125	}
126
127	/// Appends a root node.
128	pub fn node(mut self, node: TreeNode) -> Self {
129		collect_open(std::slice::from_ref(&node), &mut self.state.open);
130		self.nodes.push(node);
131		self.rows_dirty = true;
132		self
133	}
134
135	fn rebuild_rows(&mut self) {
136		if !self.rows_dirty {
137			return;
138		}
139		self.rows.clear();
140		let mut trail = SmallVec::new();
141		walk_rows(&self.nodes, 0, "", &self.state.open, &mut trail, &mut self.rows);
142		if self.rows.is_empty() {
143			self.state.cursor = 0;
144		} else {
145			self.state.cursor = self.state.cursor.min(self.rows.len() as u16 - 1);
146		}
147		self.rows_dirty = false;
148	}
149
150	fn toggle(&mut self, slot: Slot) {
151		if self.state.open.contains(&slot) {
152			self.state.open.retain(|open| *open != slot);
153		} else {
154			self.state.open.push(slot);
155		}
156		self.rows_dirty = true;
157	}
158}
159
160impl Default for Tree {
161	fn default() -> Self {
162		Self::new()
163	}
164}
165
166impl Component for Tree {
167	fn props(&self) -> &Props {
168		&self.props
169	}
170
171	fn props_mut(&mut self) -> &mut Props {
172		&mut self.props
173	}
174
175	fn slot(&self) -> Slot {
176		self.slot
177	}
178
179	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
180		self.rebuild_rows();
181		let nat = self
182			.rows
183			.iter()
184			.map(|row| {
185				cell_width(&row.label)
186					.saturating_add(row.depth.saturating_mul(2))
187					.saturating_add(6)
188			})
189			.max()
190			.unwrap_or(16);
191		(16, nat)
192	}
193
194	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
195		self.rebuild_rows();
196		u16::try_from(self.rows.len()).unwrap_or(u16::MAX)
197	}
198
199	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
200		self.rebuild_rows();
201		let focused = pc.focus == Some(self.slot);
202		let hover_row = match pc.hover {
203			Some((slot, HitTag::Row(index))) if slot == self.slot => Some(index),
204			_ => None,
205		};
206		let bottom = rect.y.saturating_add(rect.height).min(pc.clip);
207		for (index, row) in self.rows.iter().enumerate() {
208			let index = index as u16;
209			let y = rect.y.saturating_add(index);
210			if y >= bottom {
211				break;
212			}
213			let hovered = hover_row == Some(index);
214			if hovered {
215				pc.frame
216					.fill(Rect::new(rect.x, y, rect.width, 1), Style::new().bg(pc.ctx.theme.hover));
217			}
218			let tint = |style: Style| {
219				if hovered {
220					style.bg(pc.ctx.theme.hover)
221				} else {
222					style
223				}
224			};
225			let here = focused && index == self.state.cursor;
226			let mut x = pc.frame.put(
227				rect.x,
228				y,
229				if here { pc.ctx.charset.cursor() } else { "  " },
230				tint(Style::new().fg(pc.ctx.theme.accent)),
231			);
232			if let Some(family) = self.props.guides() {
233				let (branch, last, cont) = pc.ctx.charset.guides(family);
234				let guide = tint(Style::new().fg(pc.ctx.theme.muted));
235				for &more in &row.gutters {
236					x = pc.frame.put(x, y, if more { cont } else { "  " }, guide);
237				}
238				if row.depth > 0 {
239					x = pc
240						.frame
241						.put(x, y, if row.last { last } else { branch }, guide);
242					x = pc.frame.put(x, y, " ", guide);
243				}
244			} else {
245				for _ in 0..row.depth {
246					x = pc
247						.frame
248						.put(x, y, "  ", tint(Style::new().fg(pc.ctx.theme.fg)));
249				}
250			}
251			let expander = if row.has_children {
252				pc.ctx.charset.expander(self.state.open.contains(&row.node))
253			} else if self.props.guides().is_some() {
254				// Guide trees indent leaves with the connector alone.
255				""
256			} else {
257				"  "
258			};
259			x = pc
260				.frame
261				.put(x, y, expander, tint(Style::new().fg(pc.ctx.theme.muted)));
262			let style = if here {
263				tint(Style::new().fg(pc.ctx.theme.accent).bold())
264			} else {
265				tint(Style::new().fg(pc.ctx.theme.fg))
266			};
267			x = pc.frame.put(x, y, &row.label, style);
268			if self.state.chosen.as_ref() == Some(&row.path) {
269				pc.frame.put(
270					x.saturating_add(1),
271					y,
272					pc.ctx.charset.check(),
273					tint(Style::new().fg(pc.ctx.theme.ok)),
274				);
275			}
276			pc.hits.push(Hit {
277				rect: Rect::new(rect.x, y, rect.width, 1),
278				slot: self.slot,
279				tag:  HitTag::Row(index),
280			});
281		}
282	}
283
284	fn focusable(&self) -> bool {
285		true
286	}
287
288	fn enter(&mut self, forward: bool) {
289		self.rebuild_rows();
290		if self.rows.is_empty() {
291			self.state.cursor = 0;
292		} else {
293			self.state.cursor = if forward {
294				0
295			} else {
296				self.rows.len() as u16 - 1
297			};
298		}
299	}
300
301	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
302		self.rebuild_rows();
303		if self.rows.is_empty() {
304			return Flow::Skip;
305		}
306		self.state.cursor = self.state.cursor.min(self.rows.len() as u16 - 1);
307		let current = usize::from(self.state.cursor);
308		let row = self.rows[current].clone();
309		let is_open = self.state.open.contains(&row.node);
310		match key {
311			Key::Up => {
312				if self.state.cursor == 0 {
313					return Flow::Skip;
314				}
315				self.state.cursor -= 1;
316			},
317			Key::Down => {
318				if self.state.cursor + 1 >= self.rows.len() as u16 {
319					return Flow::Skip;
320				}
321				self.state.cursor += 1;
322			},
323			Key::Right if row.has_children && !is_open => self.toggle(row.node),
324			Key::Left => {
325				if row.has_children && is_open {
326					self.toggle(row.node);
327				} else if let Some(parent) = self.rows[..current]
328					.iter()
329					.rposition(|candidate| candidate.depth + 1 == row.depth)
330				{
331					self.state.cursor = parent as u16;
332				} else {
333					return Flow::Skip;
334				}
335			},
336			Key::Enter | Key::Space => {
337				if row.has_children {
338					self.toggle(row.node);
339				} else {
340					self.state.chosen = Some(row.path);
341				}
342			},
343			_ => return Flow::Skip,
344		}
345		Flow::Consumed
346	}
347
348	fn mouse(
349		&mut self,
350		_ec: &mut EventCtx<'_>,
351		tag: HitTag,
352		_at: (u16, u16),
353		_rect: Rect,
354		mouse: Mouse,
355	) -> Flow {
356		self.rebuild_rows();
357		match mouse {
358			Mouse::WheelUp | Mouse::WheelDown => {
359				if self.rows.is_empty() {
360					return Flow::Skip;
361				}
362				let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
363				self.state.cursor =
364					(i64::from(self.state.cursor) + delta).clamp(0, self.rows.len() as i64 - 1) as u16;
365				Flow::Consumed
366			},
367			Mouse::Click => {
368				let HitTag::Row(index) = tag else {
369					return Flow::Skip;
370				};
371				let Some(row) = self.rows.get(usize::from(index)).cloned() else {
372					return Flow::Skip;
373				};
374				self.state.cursor = index;
375				if row.has_children {
376					self.toggle(row.node);
377				} else {
378					self.state.chosen = Some(row.path);
379				}
380				Flow::Consumed
381			},
382			Mouse::RightClick
383			| Mouse::MiddleClick
384			| Mouse::Move
385			| Mouse::Drag
386			| Mouse::Release
387			| Mouse::WheelLeft
388			| Mouse::WheelRight => Flow::Skip,
389		}
390	}
391
392	fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
393		let Some(id) = self.props.id() else {
394			return;
395		};
396		let value = self
397			.state
398			.chosen
399			.as_ref()
400			.map_or(serde_json::Value::Null, |path| serde_json::Value::String(path.to_string()));
401		out.insert(id.to_string(), value);
402	}
403}
404
405fn collect_open(nodes: &[TreeNode], open: &mut SmallVec<Slot, 8>) {
406	for node in nodes {
407		if node.props.flag(Prop::Open) {
408			open.push(node.slot);
409		}
410		collect_open(&node.children, open);
411	}
412}
413
414fn walk_rows(
415	nodes: &[TreeNode],
416	depth: u16,
417	prefix: &str,
418	open: &[Slot],
419	trail: &mut SmallVec<bool, 8>,
420	rows: &mut Vec<TreeRow>,
421) {
422	let count = nodes.len();
423	for (index, node) in nodes.iter().enumerate() {
424		let label = node.effective_label();
425		let path = if prefix.is_empty() {
426			Str::new(label)
427		} else {
428			let mut path =
429				StrMut::with_capacity(prefix.len().saturating_add(label.len()).saturating_add(1));
430			path.push_str(prefix);
431			path.push('/');
432			path.push_str(label);
433			path.freeze()
434		};
435		let has_children = !node.children.is_empty();
436		let last = index + 1 == count;
437		// Gutter columns exist only for ancestor levels below the root: the
438		// root level draws no connector column, so its continuation bit is
439		// never displayed.
440		let gutters = if depth > 1 {
441			trail[1..].into()
442		} else {
443			SmallVec::new()
444		};
445		rows.push(TreeRow {
446			node: node.slot,
447			depth,
448			path: path.clone(),
449			label: Str::new(label),
450			has_children,
451			gutters,
452			last,
453		});
454		if open.contains(&node.slot) {
455			trail.push(!last);
456			walk_rows(&node.children, depth.saturating_add(1), &path, open, trail, rows);
457			trail.pop();
458		}
459	}
460}
461
462fn append(target: &mut Str, suffix: Str) {
463	if target.is_empty() {
464		*target = suffix;
465		return;
466	}
467	let mut joined = StrMut::with_capacity(target.len().saturating_add(suffix.len()));
468	joined.push_str(target);
469	joined.push_str(&suffix);
470	*target = joined.freeze();
471}
472
473#[cfg(test)]
474mod tests {
475	use super::*;
476
477	#[test]
478	fn expand_collapse_rebuilds_rows_and_values_use_paths() {
479		let ctx = UiContext::default();
480		let mut tree = Tree::new().with(Prop::Id, "tree-id").node(
481			TreeNode::new()
482				.label("root")
483				.node(TreeNode::new().label("leaf")),
484		);
485		let mut ec = EventCtx::new(&ctx, 30, 4);
486		assert_eq!(tree.height(&ctx, 30), 1);
487		assert!(!tree.rows_dirty);
488		assert_eq!(tree.key(&mut ec, Key::Right), Flow::Consumed);
489		assert!(tree.rows_dirty);
490		assert_eq!(tree.height(&ctx, 30), 2);
491		assert_eq!(tree.rows[1].path, "root/leaf");
492		assert_eq!(tree.key(&mut ec, Key::Down), Flow::Consumed);
493		assert_eq!(tree.key(&mut ec, Key::Enter), Flow::Consumed);
494		let mut values = serde_json::Map::new();
495		tree.value(&mut values);
496		assert_eq!(values["tree-id"], serde_json::json!("root/leaf"));
497		assert_eq!(tree.key(&mut ec, Key::Left), Flow::Consumed);
498		assert_eq!(tree.state.cursor, 0);
499		assert_eq!(tree.key(&mut ec, Key::Left), Flow::Consumed);
500		assert_eq!(tree.height(&ctx, 30), 1);
501	}
502
503	#[test]
504	fn open_property_seeds_visible_rows() {
505		let ctx = UiContext::default();
506		let mut tree = Tree::new().node(
507			TreeNode::new()
508				.with(Prop::Label, "root")
509				.with(Prop::Open, true)
510				.node(TreeNode::new().label("leaf")),
511		);
512		assert_eq!(tree.height(&ctx, 20), 2);
513		assert_eq!(tree.rows[0].label, "root");
514	}
515}