1use std::rc::Rc;
11
12use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
13use frus_layout::{Dimension, FlexDirection, Style};
14
15use crate::interaction::Status;
16use crate::theme::Theme;
17use crate::widget::Widget;
18
19const ROW_H: f32 = 32.0;
20const INDENT: f32 = 20.0;
21const CHEVRON_SIZE: f32 = 16.0;
24
25fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
33 over.or(theme.and_then(|t| t.widgets.tree.text_style))
34 .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
35 .resolved()
36}
37
38struct Row<Msg> {
43 depth: usize,
44 label: String,
45 expandable: bool,
46 expanded: bool,
47 selected: bool,
48 toggle: Option<Msg>,
50 select: Option<Msg>,
52 text_style: Option<TextStyle>,
53}
54
55impl<Msg: Clone> Row<Msg> {
56 fn chevron_start(&self) -> f32 {
58 self.depth as f32 * INDENT
59 }
60
61 fn sizing(&self, theme: Option<&Theme>) -> Style {
62 let style = label_style(self.text_style, theme);
67 let text_w = frus_text::measure_resolved(&self.label, &style).width;
68 let width = self.chevron_start() + INDENT + text_w + ROW_PAD_R;
69 Style {
70 width: Dimension::Length(width),
71 height: Dimension::Length(frus_text::line_box(ROW_H, &style, 0.0)),
72 ..Default::default()
73 }
74 }
75}
76
77const ROW_PAD_R: f32 = 12.0;
79
80impl<Msg: Clone> Widget<Msg> for Row<Msg> {
81 fn style(&self) -> Style {
82 self.sizing(None)
83 }
84
85 fn style_themed(&self, theme: &Theme) -> Style {
86 self.sizing(Some(theme))
87 }
88
89 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
90 &[]
91 }
92
93 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
94 let o = status.opacity;
95 let clickable = self.toggle.is_some() || self.select.is_some();
96 if self.selected {
98 let bg = theme.surface.lerp(theme.primary, 0.16);
99 scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
100 } else if clickable && status.hover_progress > 0.0 {
101 let bg = theme.state_layer(theme.surface, theme.on_surface, &status);
102 scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
103 }
104
105 for d in 0..self.depth {
107 let gx = bounds.x + d as f32 * INDENT + INDENT * 0.5;
108 scene.draw_rect(
109 Rect::new(gx, bounds.y, 1.0, bounds.height),
110 theme.border.fade(o * 0.6),
111 0.0,
112 0.0,
113 Color::TRANSPARENT,
114 );
115 }
116
117 let x = bounds.x + self.chevron_start();
118 let style = label_style(self.text_style, Some(theme));
119 let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
120 if self.expandable {
121 scene.text(
122 Point::new(x, ty),
123 if self.expanded { "▾" } else { "▸" }.to_string(),
124 &ResolvedTextStyle::exact(CHEVRON_SIZE),
127 theme.muted.fade(o),
128 );
129 }
130 scene.text(
131 Point::new(x + INDENT, ty),
132 self.label.clone(),
133 &style,
134 theme.on_surface.fade(o),
135 );
136 }
137
138 fn positional_click(
139 &self,
140 local_x: f32,
141 _local_y: f32,
142 _width: f32,
143 _height: f32,
144 ) -> Option<Msg> {
145 let start = self.chevron_start();
147 if self.expandable && local_x >= start && local_x < start + INDENT {
148 return self.toggle.clone();
149 }
150 self.select.clone().or_else(|| self.toggle.clone())
151 }
152
153 fn on_click(&self) -> Option<Msg> {
154 self.select.clone().or_else(|| self.toggle.clone())
156 }
157
158 fn focusable(&self) -> bool {
159 self.toggle.is_some() || self.select.is_some()
160 }
161}
162
163pub struct Tree<Msg = crate::callback::Callback> {
165 on_toggle: Box<dyn Fn(u64) -> Msg>,
166 on_select: Option<Rc<dyn Fn(u64) -> Msg>>,
167 selected: Option<u64>,
168 nodes: Vec<(u64, usize, String, bool, bool)>,
169 text_style: Option<TextStyle>,
170 children: Vec<Box<dyn Widget<Msg>>>,
171}
172
173impl<Msg: Clone + 'static> Tree<Msg> {
174 pub fn new<R: crate::callback::IntoMsg<Msg>>(on_toggle: impl Fn(u64) -> R + 'static) -> Self {
176 Self {
177 on_toggle: Box::new(crate::callback::handler1(on_toggle)),
178 on_select: None,
179 selected: None,
180 nodes: Vec::new(),
181 text_style: None,
182 children: Vec::new(),
183 }
184 }
185
186 #[must_use]
188 pub fn text_style(mut self, style: TextStyle) -> Self {
189 self.text_style = Some(style);
190 self.rebuild();
191 self
192 }
193
194 pub fn on_select<R: crate::callback::IntoMsg<Msg>>(
197 mut self,
198 on_select: impl Fn(u64) -> R + 'static,
199 ) -> Self {
200 self.on_select = Some(Rc::new(crate::callback::handler1(on_select)));
201 self.rebuild();
202 self
203 }
204
205 pub fn selected(mut self, id: Option<u64>) -> Self {
207 self.selected = id;
208 self.rebuild();
209 self
210 }
211
212 pub fn node(
215 mut self,
216 id: u64,
217 depth: usize,
218 label: impl Into<String>,
219 expandable: bool,
220 expanded: bool,
221 ) -> Self {
222 self.nodes
223 .push((id, depth, label.into(), expandable, expanded));
224 self.rebuild();
225 self
226 }
227
228 fn rebuild(&mut self) {
229 self.children = self
230 .nodes
231 .iter()
232 .map(|(id, depth, label, expandable, expanded)| {
233 Box::new(Row {
234 depth: *depth,
235 label: label.clone(),
236 expandable: *expandable,
237 expanded: *expanded,
238 selected: self.selected == Some(*id),
239 toggle: expandable.then(|| (self.on_toggle)(*id)),
240 select: self.on_select.as_ref().map(|f| f(*id)),
241 text_style: self.text_style,
242 }) as Box<dyn Widget<Msg>>
243 })
244 .collect();
245 }
246}
247
248impl<Msg: Clone> Widget<Msg> for Tree<Msg> {
249 fn style(&self) -> Style {
250 Style {
251 flex_direction: FlexDirection::Column,
252 gap: 1.0,
253 ..Default::default()
254 }
255 }
256
257 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
258 &self.children
259 }
260
261 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
262
263 fn on_click(&self) -> Option<Msg> {
264 None
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[derive(Clone, Debug, PartialEq)]
273 enum Msg {
274 Toggle(u64),
275 Select(u64),
276 }
277
278 #[test]
279 fn expandable_nodes_toggle_leaves_do_not() {
280 let tree = Tree::new(Msg::Toggle)
282 .node(1, 0, "Folder", true, true)
283 .node(2, 1, "file.txt", false, false);
284 let rows = Widget::<Msg>::children(&tree);
285 assert_eq!(rows.len(), 2);
286 assert_eq!(rows[0].on_click(), Some(Msg::Toggle(1))); assert_eq!(rows[1].on_click(), None); }
289
290 #[test]
291 fn chevron_toggles_body_selects() {
292 let tree = Tree::new(Msg::Toggle)
294 .on_select(Msg::Select)
295 .selected(Some(2))
296 .node(1, 0, "Folder", true, true)
297 .node(2, 1, "file.txt", false, false);
298 let rows = Widget::<Msg>::children(&tree);
299 assert_eq!(
301 rows[0].positional_click(INDENT * 0.5, 0.0, 200.0, ROW_H),
302 Some(Msg::Toggle(1))
303 );
304 assert_eq!(
305 rows[0].positional_click(INDENT * 3.0, 0.0, 200.0, ROW_H),
306 Some(Msg::Select(1))
307 );
308 assert_eq!(
310 rows[1].positional_click(INDENT * 0.5, 0.0, 200.0, ROW_H),
311 Some(Msg::Select(2))
312 );
313 assert_eq!(rows[0].on_click(), Some(Msg::Select(1)));
315 }
316}