Skip to main content

fret_ui_kit/declarative/
tree.rs

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    // Prefer component-level Tailwind-like tokens; fall back to baseline metrics to avoid drift.
54    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/// Declarative tree view helper (virtualized, component-friendly).
105///
106/// This is intentionally minimal:
107/// - selection/expand policies live in the parent `TreeView` widget,
108/// - this function only renders rows and dispatches `tree.select.<id>` / `tree.toggle.<id>` commands.
109#[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/// A retained-host variant of [`tree_view`] that enables composable rows under cache-root reuse.
121///
122/// This uses the virt-003 retained VirtualList host path, so overscan boundary updates can attach/detach
123/// item subtrees without forcing a parent cache-root rerender.
124///
125/// Notes:
126/// - This defaults to fixed row height for predictable perf. If you need variable-height rows,
127///   use [`tree_view_retained_with_measure_mode`] with `VirtualListMeasureMode::Measured`.
128/// - `debug_row_test_id_prefix` is intended for scripted UI Gallery harnesses.
129#[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/// A variant of [`tree_view_retained`] that allows opting into measured (variable-height) rows.
148#[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                                                    // Toggle button (kept separate so click target is predictable).
318                                                    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}