1use std::collections::HashMap;
2use std::sync::Arc;
3
4use fret_core::{Color, Corners, Edges, Px, SemanticsRole};
5use fret_runtime::{CommandId, Model};
6use fret_ui::element::{
7 AnyElement, ContainerProps, Elements, LayoutStyle, Length, PressableA11y, PressableProps,
8 SpacerProps,
9};
10use fret_ui::scroll::{ScrollStrategy, VirtualListScrollHandle};
11use fret_ui::{ElementContext, Theme, UiHost};
12
13use crate::declarative::action_hooks::ActionHooksExt;
14use crate::declarative::model_watch::ModelWatchExt as _;
15use crate::ui;
16use crate::{
17 MetricRef, Size, Space, TreeEntry, TreeItemId, TreeRowRenderer, TreeRowState, TreeState,
18 flatten_tree,
19};
20
21type RetainedTreeRowFn<H> = dyn for<'a> Fn(&mut ElementContext<'a, H>, usize) -> AnyElement;
22
23fn resolve_list_colors(theme: &Theme) -> (Color, Color, Color, Color) {
24 let list_bg = theme
25 .color_by_key("list.background")
26 .or_else(|| theme.color_by_key("card"))
27 .unwrap_or_else(|| theme.color_token("card"));
28 let border = theme
29 .color_by_key("border")
30 .or_else(|| theme.color_by_key("list.border"))
31 .unwrap_or_else(|| theme.color_token("border"));
32 let row_hover = theme
33 .color_by_key("list.hover.background")
34 .or_else(|| theme.color_by_key("list.row.hover"))
35 .or_else(|| theme.color_by_key("accent"))
36 .unwrap_or_else(|| theme.color_token("accent"));
37 let row_active = theme
38 .color_by_key("list.active.background")
39 .or_else(|| theme.color_by_key("list.row.active"))
40 .or_else(|| theme.color_by_key("accent"))
41 .unwrap_or_else(|| theme.color_token("accent"));
42 (list_bg, border, row_hover, row_active)
43}
44
45fn resolve_row_height(theme: &Theme, size: Size) -> Px {
46 let base = theme
47 .metric_by_key("component.list.row_height")
48 .unwrap_or_else(|| size.list_row_h(theme));
49 Px(base.0.max(0.0))
50}
51
52fn resolve_row_padding_x(theme: &Theme) -> Px {
53 MetricRef::space(Space::N2p5).resolve(theme)
55}
56
57fn resolve_row_padding_y(theme: &Theme) -> Px {
58 MetricRef::space(Space::N1p5).resolve(theme)
59}
60
61fn resolve_indent(theme: &Theme) -> Px {
62 MetricRef::space(Space::N4).resolve(theme)
63}
64
65struct DefaultTreeRowRenderer;
66
67impl<H: UiHost> TreeRowRenderer<H> for DefaultTreeRowRenderer {
68 fn render_row(
69 &mut self,
70 cx: &mut ElementContext<'_, H>,
71 entry: &TreeEntry,
72 _state: TreeRowState,
73 ) -> Elements {
74 vec![
75 crate::ui::text(entry.label.as_ref())
76 .flex_shrink(1.0)
77 .min_w_0()
78 .truncate()
79 .into_element(cx),
80 ]
81 .into()
82 }
83}
84
85#[derive(Default)]
86struct TreeRowsState {
87 last_items_revision: Option<u64>,
88 last_state_revision: Option<u64>,
89 entries: Vec<TreeEntry>,
90 index_by_id: HashMap<TreeItemId, usize>,
91 scroll: VirtualListScrollHandle,
92}
93
94fn rebuild_entries(
95 items: Vec<crate::TreeItem>,
96 expanded: &std::collections::HashSet<TreeItemId>,
97) -> (Vec<TreeEntry>, HashMap<TreeItemId, usize>) {
98 let entries = flatten_tree(&items, expanded);
99 let index_by_id: HashMap<TreeItemId, usize> =
100 entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
101 (entries, index_by_id)
102}
103
104#[track_caller]
110pub fn tree_view<H: UiHost>(
111 cx: &mut ElementContext<'_, H>,
112 items: Model<Vec<crate::TreeItem>>,
113 state: Model<TreeState>,
114 size: Size,
115) -> AnyElement {
116 let mut renderer = DefaultTreeRowRenderer;
117 tree_view_with_renderer(cx, items, state, size, &mut renderer)
118}
119
120#[track_caller]
130pub fn tree_view_retained<H: UiHost + 'static>(
131 cx: &mut ElementContext<'_, H>,
132 items: Model<Vec<crate::TreeItem>>,
133 state: Model<TreeState>,
134 size: Size,
135 debug_row_test_id_prefix: Option<Arc<str>>,
136) -> AnyElement {
137 tree_view_retained_with_measure_mode(
138 cx,
139 items,
140 state,
141 size,
142 fret_ui::element::VirtualListMeasureMode::Fixed,
143 debug_row_test_id_prefix,
144 )
145}
146
147#[track_caller]
149pub fn tree_view_retained_with_measure_mode<H: UiHost + 'static>(
150 cx: &mut ElementContext<'_, H>,
151 items: Model<Vec<crate::TreeItem>>,
152 state: Model<TreeState>,
153 size: Size,
154 measure_mode: fret_ui::element::VirtualListMeasureMode,
155 debug_row_test_id_prefix: Option<Arc<str>>,
156) -> AnyElement {
157 tree_view_retained_impl(
158 cx,
159 items,
160 state,
161 size,
162 measure_mode,
163 debug_row_test_id_prefix,
164 )
165}
166
167#[track_caller]
168pub fn tree_view_with_renderer<H: UiHost>(
169 cx: &mut ElementContext<'_, H>,
170 items: Model<Vec<crate::TreeItem>>,
171 state: Model<TreeState>,
172 size: Size,
173 renderer: &mut impl TreeRowRenderer<H>,
174) -> AnyElement {
175 let items_revision = cx.app.models().revision(&items).unwrap_or(0);
176 let state_revision = cx.app.models().revision(&state).unwrap_or(0);
177
178 let TreeState { selected, expanded } = cx.watch_model(&state).paint().cloned_or_default();
179
180 let items_value = cx.watch_model(&items).layout().cloned_or_default();
181
182 let theme = Theme::global(&*cx.app);
183 let (list_bg, border, row_hover, row_active) = resolve_list_colors(theme);
184 let radius = theme.metric_token("metric.radius.md");
185
186 let row_h = resolve_row_height(theme, size);
187 let row_px = resolve_row_padding_x(theme);
188 let row_py = resolve_row_padding_y(theme);
189 let indent = resolve_indent(theme);
190
191 let (entries, index_by_id, scroll) = cx.slot_state(TreeRowsState::default, |rows_state| {
192 if rows_state.last_items_revision != Some(items_revision)
193 || rows_state.last_state_revision != Some(state_revision)
194 {
195 rows_state.last_items_revision = Some(items_revision);
196 rows_state.last_state_revision = Some(state_revision);
197
198 let (entries, index_by_id) = rebuild_entries(items_value, &expanded);
199 rows_state.entries = entries;
200 rows_state.index_by_id = index_by_id;
201 }
202
203 (
204 Arc::new(rows_state.entries.clone()),
205 rows_state.index_by_id.clone(),
206 rows_state.scroll.clone(),
207 )
208 });
209
210 if let Some(id) = selected
211 && let Some(idx) = index_by_id.get(&id).copied()
212 {
213 scroll.scroll_to_item(idx, ScrollStrategy::Nearest);
214 }
215
216 let len = entries.len();
217 let items_revision = items_revision ^ state_revision.rotate_left(17);
218
219 let mut options = fret_ui::element::VirtualListOptions::new(row_h, 2);
220 options.items_revision = items_revision;
221
222 let mut fill_layout = LayoutStyle::default();
223 fill_layout.size.width = Length::Fill;
224 fill_layout.size.height = Length::Fill;
225 fill_layout.flex.grow = 1.0;
226 fill_layout.flex.basis = Length::Px(Px(0.0));
227
228 cx.container(
229 ContainerProps {
230 layout: fill_layout,
231 background: Some(list_bg),
232 border: Edges::all(Px(1.0)),
233 border_color: Some(border),
234 corner_radii: Corners::all(radius),
235 ..Default::default()
236 },
237 |cx| {
238 let entries_for_key = Arc::clone(&entries);
239 let entries_for_row = Arc::clone(&entries);
240 let expanded = expanded.clone();
241
242 vec![cx.virtual_list_keyed_with_layout(
243 fill_layout,
244 len,
245 options,
246 &scroll,
247 move |i| entries_for_key.get(i).map(|e| e.id).unwrap_or_default(),
248 |cx, i| {
249 let Some(entry) = entries_for_row.get(i).cloned() else {
250 return cx.text("");
251 };
252
253 let is_selected = selected == Some(entry.id);
254 let is_expanded = entry.has_children && expanded.contains(&entry.id);
255 let row_state = TreeRowState {
256 selected: is_selected,
257 expanded: is_expanded,
258 disabled: entry.disabled,
259 depth: entry.depth,
260 has_children: entry.has_children,
261 };
262 let a11y_level = u32::try_from(entry.depth.saturating_add(1)).ok();
263
264 let bg = if is_selected { Some(row_active) } else { None };
265 let enabled = !entry.disabled;
266
267 let pad_left = Px(row_px.0 + indent.0 * (entry.depth as f32).max(0.0));
268 let toggle_cmd = entry
269 .has_children
270 .then(|| CommandId::new(format!("tree.toggle.{}", entry.id)));
271 let select_cmd =
272 enabled.then(|| CommandId::new(format!("tree.select.{}", entry.id)));
273
274 cx.pressable(
275 PressableProps {
276 enabled,
277 a11y: PressableA11y {
278 role: Some(SemanticsRole::TreeItem),
279 label: Some(entry.label.clone()),
280 level: a11y_level,
281 selected: is_selected,
282 ..Default::default()
283 },
284 ..Default::default()
285 },
286 |cx, st| {
287 cx.pressable_dispatch_command_if_enabled_opt(select_cmd);
288 let row_bg = if bg.is_some() {
289 bg
290 } else if enabled && st.pressed {
291 Some(row_active)
292 } else if enabled && st.hovered {
293 Some(row_hover)
294 } else {
295 None
296 };
297
298 vec![
299 cx.container(
300 ContainerProps {
301 padding: Edges {
302 top: row_py,
303 right: row_px,
304 bottom: row_py,
305 left: pad_left,
306 }
307 .into(),
308 background: row_bg,
309 ..Default::default()
310 },
311 |cx| {
312 vec![
313 ui::h_row(|cx| {
314 let mut out = Vec::new();
315
316 if entry.has_children {
317 let glyph: Arc<str> =
319 Arc::from(if is_expanded {
320 "v"
321 } else {
322 ">"
323 });
324 out.push(cx.pressable(
325 PressableProps {
326 enabled: toggle_cmd.is_some(),
327 a11y: PressableA11y {
328 role: Some(SemanticsRole::Button),
329 label: Some(Arc::from("Toggle")),
330 selected: false,
331 ..Default::default()
332 },
333 ..Default::default()
334 },
335 |cx, _st| {
336 cx.pressable_dispatch_command_if_enabled_opt(
337 toggle_cmd,
338 );
339 vec![cx.text(glyph.as_ref())]
340 },
341 ));
342 } else {
343 out.push(cx.spacer(SpacerProps {
344 min: Px(14.0),
345 ..Default::default()
346 }));
347 }
348
349 out.extend(
350 renderer.render_row(cx, &entry, row_state),
351 );
352 out.push(cx.spacer(SpacerProps::default()));
353 out.extend(
354 renderer.render_trailing(cx, &entry, row_state),
355 );
356 out
357 })
358 .gap(Space::N2)
359 .justify_start()
360 .items_center()
361 .into_element(cx),
362 ]
363 },
364 ),
365 ]
366 },
367 )
368 },
369 )]
370 },
371 )
372}
373
374#[track_caller]
375fn tree_view_retained_impl<H: UiHost + 'static>(
376 cx: &mut ElementContext<'_, H>,
377 items: Model<Vec<crate::TreeItem>>,
378 state: Model<TreeState>,
379 size: Size,
380 measure_mode: fret_ui::element::VirtualListMeasureMode,
381 debug_row_test_id_prefix: Option<Arc<str>>,
382) -> AnyElement {
383 let items_revision = cx.app.models().revision(&items).unwrap_or(0);
384 let state_revision = cx.app.models().revision(&state).unwrap_or(0);
385
386 let TreeState { selected, expanded } = cx.watch_model(&state).paint().cloned_or_default();
387
388 let items_value = cx.watch_model(&items).layout().cloned_or_default();
389
390 let theme = Theme::global(&*cx.app);
391 let (list_bg, border, row_hover, row_active) = resolve_list_colors(theme);
392 let radius = theme.metric_token("metric.radius.md");
393
394 let row_h = resolve_row_height(theme, size);
395 let row_px = resolve_row_padding_x(theme);
396 let row_py = resolve_row_padding_y(theme);
397 let indent = resolve_indent(theme);
398
399 let (entries, index_by_id, scroll) = cx.slot_state(TreeRowsState::default, |rows_state| {
400 if rows_state.last_items_revision != Some(items_revision)
401 || rows_state.last_state_revision != Some(state_revision)
402 {
403 rows_state.last_items_revision = Some(items_revision);
404 rows_state.last_state_revision = Some(state_revision);
405
406 let (entries, index_by_id) = rebuild_entries(items_value, &expanded);
407 rows_state.entries = entries;
408 rows_state.index_by_id = index_by_id;
409 }
410
411 (
412 Arc::new(rows_state.entries.clone()),
413 rows_state.index_by_id.clone(),
414 rows_state.scroll.clone(),
415 )
416 });
417
418 if let Some(id) = selected
419 && let Some(idx) = index_by_id.get(&id).copied()
420 {
421 scroll.scroll_to_item(idx, ScrollStrategy::Nearest);
422 }
423
424 let len = entries.len();
425 let items_revision = items_revision ^ state_revision.rotate_left(17);
426
427 let mut options = fret_ui::element::VirtualListOptions::new(row_h, 2);
428 options.items_revision = items_revision;
429 options.measure_mode = measure_mode;
430 options.key_cache = fret_ui::element::VirtualListKeyCacheMode::VisibleOnly;
431
432 let mut fill_layout = LayoutStyle::default();
433 fill_layout.size.width = Length::Fill;
434 fill_layout.size.height = Length::Fill;
435 fill_layout.flex.grow = 1.0;
436 fill_layout.flex.basis = Length::Px(Px(0.0));
437
438 cx.container(
439 ContainerProps {
440 layout: fill_layout,
441 background: Some(list_bg),
442 border: Edges::all(Px(1.0)),
443 border_color: Some(border),
444 corner_radii: Corners::all(radius),
445 ..Default::default()
446 },
447 |cx| {
448 let entries_for_key = Arc::clone(&entries);
449 let entries_for_row = Arc::clone(&entries);
450 let expanded = expanded.clone();
451
452 let key_at: Arc<dyn Fn(usize) -> fret_ui::ItemKey> = Arc::new(move |i| {
453 entries_for_key
454 .get(i)
455 .map(|e: &TreeEntry| e.id)
456 .unwrap_or_default()
457 });
458 let row_test_id_prefix = debug_row_test_id_prefix.clone();
459
460 let row: Arc<RetainedTreeRowFn<H>> =
461 Arc::new(move |cx: &mut ElementContext<'_, H>, i| {
462 let Some(entry) = entries_for_row.get(i).cloned() else {
463 return cx.text("");
464 };
465
466 let is_selected = selected == Some(entry.id);
467 let is_expanded = entry.has_children && expanded.contains(&entry.id);
468 let row_state = TreeRowState {
469 selected: is_selected,
470 expanded: is_expanded,
471 disabled: entry.disabled,
472 depth: entry.depth,
473 has_children: entry.has_children,
474 };
475 let a11y_level = u32::try_from(entry.depth.saturating_add(1)).ok();
476
477 let bg = if is_selected { Some(row_active) } else { None };
478 let enabled = !entry.disabled;
479
480 let pad_left = Px(row_px.0 + indent.0 * (entry.depth as f32).max(0.0));
481 let toggle_cmd = entry
482 .has_children
483 .then(|| CommandId::new(format!("tree.toggle.{}", entry.id)));
484 let select_cmd =
485 enabled.then(|| CommandId::new(format!("tree.select.{}", entry.id)));
486
487 let debug_test_id: Option<Arc<str>> = row_test_id_prefix
488 .as_ref()
489 .map(|prefix| Arc::from(format!("{prefix}-{}", entry.id)));
490 let debug_toggle_test_id: Option<Arc<str>> = debug_test_id
491 .as_ref()
492 .map(|id| Arc::from(format!("{id}-toggle")));
493
494 cx.pressable(
495 PressableProps {
496 enabled,
497 a11y: PressableA11y {
498 role: Some(SemanticsRole::TreeItem),
499 label: Some(entry.label.clone()),
500 level: a11y_level,
501 selected: is_selected,
502 test_id: debug_test_id.clone(),
503 ..Default::default()
504 },
505 ..Default::default()
506 },
507 |cx, st| {
508 cx.pressable_dispatch_command_if_enabled_opt(select_cmd);
509 let row_bg = if bg.is_some() {
510 bg
511 } else if enabled && st.pressed {
512 Some(row_active)
513 } else if enabled && st.hovered {
514 Some(row_hover)
515 } else {
516 None
517 };
518
519 let mut renderer = DefaultTreeRowRenderer;
520 vec![
521 cx.container(
522 ContainerProps {
523 padding: Edges {
524 top: row_py,
525 right: row_px,
526 bottom: row_py,
527 left: pad_left,
528 }
529 .into(),
530 background: row_bg,
531 ..Default::default()
532 },
533 |cx| {
534 vec![ui::h_row(|cx| {
535 let mut out = Vec::new();
536
537 if entry.has_children {
538 let glyph: Arc<str> = Arc::from(if is_expanded {
539 "v"
540 } else {
541 ">"
542 });
543 out.push(cx.pressable(
544 PressableProps {
545 enabled: toggle_cmd.is_some(),
546 a11y: PressableA11y {
547 role: Some(SemanticsRole::Button),
548 label: Some(Arc::from("Toggle")),
549 selected: false,
550 test_id: debug_toggle_test_id.clone(),
551 ..Default::default()
552 },
553 ..Default::default()
554 },
555 |cx, _st| {
556 cx.pressable_dispatch_command_if_enabled_opt(
557 toggle_cmd,
558 );
559 vec![cx.text(glyph.as_ref())]
560 },
561 ));
562 } else {
563 out.push(cx.spacer(SpacerProps {
564 min: Px(14.0),
565 ..Default::default()
566 }));
567 }
568
569 out.extend(renderer.render_row(cx, &entry, row_state));
570 out.push(cx.spacer(SpacerProps::default()));
571 out.extend(renderer.render_trailing(cx, &entry, row_state));
572 out
573 })
574 .gap(Space::N2)
575 .justify_start()
576 .items_center()
577 .into_element(cx)]
578 },
579 ),
580 ]
581 },
582 )
583 });
584
585 vec![cx.virtual_list_keyed_retained_with_layout(
586 fill_layout,
587 len,
588 options,
589 &scroll,
590 key_at,
591 row,
592 )]
593 },
594 )
595}