Skip to main content

fret_ui_kit/declarative/
file_tree.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use fret_core::{Color, Edges, Px, SemanticsRole};
5use fret_runtime::Model;
6use fret_ui::element::{
7    AnyElement, LayoutStyle, Length, PressableA11y, PressableProps, SemanticsDecoration,
8};
9use fret_ui::scroll::{ScrollStrategy, VirtualListScrollHandle};
10use fret_ui::{ElementContext, Theme, UiHost};
11
12use crate::declarative::CachedSubtreeExt as _;
13use crate::declarative::CachedSubtreeProps;
14use crate::declarative::model_watch::ModelWatchExt as _;
15use crate::declarative::style as decl_style;
16use crate::style::{ChromeRefinement, LayoutRefinement};
17use crate::{ColorRef, MetricRef, Space, TreeEntry, TreeItem, TreeItemId, TreeState, flatten_tree};
18
19fn resolve_list_colors(theme: &Theme) -> (Color, Color, Color) {
20    let list_bg = theme
21        .color_by_key("list.background")
22        .or_else(|| theme.color_by_key("card"))
23        .unwrap_or_else(|| theme.color_token("card"));
24    let row_hover = theme
25        .color_by_key("list.hover.background")
26        .or_else(|| theme.color_by_key("list.row.hover"))
27        .or_else(|| theme.color_by_key("accent"))
28        .unwrap_or_else(|| theme.color_token("accent"));
29    let row_active = theme
30        .color_by_key("list.active.background")
31        .or_else(|| theme.color_by_key("list.row.active"))
32        .or_else(|| theme.color_by_key("accent"))
33        .unwrap_or_else(|| theme.color_token("accent"));
34    (list_bg, row_hover, row_active)
35}
36
37fn resolve_row_height(theme: &Theme, default: Px) -> Px {
38    let base = theme
39        .metric_by_key("component.list.row_height")
40        .unwrap_or(default);
41    Px(base.0.max(0.0))
42}
43
44fn resolve_row_padding_x(theme: &Theme) -> Px {
45    MetricRef::space(Space::N2p5).resolve(theme)
46}
47
48fn resolve_row_padding_y(theme: &Theme) -> Px {
49    MetricRef::space(Space::N1p5).resolve(theme)
50}
51
52fn resolve_indent(theme: &Theme) -> Px {
53    MetricRef::space(Space::N4).resolve(theme)
54}
55
56/// A retained-host, cache-root friendly file-tree list helper.
57///
58/// This is a pragmatic "workspace surface" building block:
59/// - row identity is `TreeItemId`,
60/// - click selects, and folders also toggle expansion on click,
61/// - virtualization uses the virt-003 retained host path (so overscan boundary updates can
62///   attach/detach without rerendering a parent cache root).
63///
64/// `debug_row_test_id_prefix` is intended for scripted harnesses (e.g. UI Gallery torture pages).
65#[derive(Debug, Clone)]
66pub struct FileTreeViewProps {
67    pub layout: LayoutStyle,
68    pub row_height: Px,
69    pub overscan: u32,
70    /// Optional retained-subtree budget for `VirtualList` window shifts.
71    ///
72    /// When set, this overrides the default heuristic (`overscan * 2`). Larger values reduce
73    /// remount/layout churn at window boundaries, at the cost of retaining more offscreen
74    /// subtrees.
75    pub keep_alive: Option<usize>,
76    pub debug_root_test_id: Option<Arc<str>>,
77    pub debug_row_test_id_prefix: Option<Arc<str>>,
78}
79
80impl Default for FileTreeViewProps {
81    fn default() -> Self {
82        Self {
83            layout: LayoutStyle {
84                size: fret_ui::element::SizeStyle {
85                    width: Length::Fill,
86                    height: Length::Px(Px(460.0)),
87                    ..Default::default()
88                },
89                overflow: fret_ui::element::Overflow::Clip,
90                ..Default::default()
91            },
92            row_height: Px(26.0),
93            overscan: 12,
94            keep_alive: None,
95            debug_root_test_id: None,
96            debug_row_test_id_prefix: None,
97        }
98    }
99}
100
101#[derive(Default)]
102struct FileTreeRowsState {
103    last_items_revision: Option<u64>,
104    last_state_revision: Option<u64>,
105    last_scrolled_selected: Option<TreeItemId>,
106    last_scrolled_index: Option<usize>,
107    last_scrolled_items_revision: Option<u64>,
108    last_scrolled_state_revision: Option<u64>,
109    entries: Vec<TreeEntry>,
110    index_by_id: HashMap<TreeItemId, usize>,
111}
112
113fn rebuild_entries(
114    items: Vec<TreeItem>,
115    expanded: &std::collections::HashSet<TreeItemId>,
116) -> (Vec<TreeEntry>, HashMap<TreeItemId, usize>) {
117    let entries = flatten_tree(&items, expanded);
118    let index_by_id: HashMap<TreeItemId, usize> =
119        entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
120    (entries, index_by_id)
121}
122
123#[track_caller]
124pub fn file_tree_view_retained_v0<H: UiHost + 'static>(
125    cx: &mut ElementContext<'_, H>,
126    items: Model<Vec<TreeItem>>,
127    state: Model<TreeState>,
128    scroll: &VirtualListScrollHandle,
129    props: FileTreeViewProps,
130) -> AnyElement {
131    let items_revision = cx.app.models().revision(&items).unwrap_or(0);
132    let state_revision = cx.app.models().revision(&state).unwrap_or(0);
133    let TreeState { selected, expanded } = cx.watch_model(&state).paint().cloned_or_default();
134    let items_value = cx.watch_model(&items).layout().cloned_or_default();
135
136    let (list_bg, row_hover, row_active, row_h, row_px, row_py, indent) = {
137        let theme = Theme::global(&*cx.app);
138        let (list_bg, row_hover, row_active) = resolve_list_colors(theme);
139        let row_h = resolve_row_height(theme, props.row_height);
140        let row_px = resolve_row_padding_x(theme);
141        let row_py = resolve_row_padding_y(theme);
142        let indent = resolve_indent(theme);
143        (
144            list_bg, row_hover, row_active, row_h, row_px, row_py, indent,
145        )
146    };
147
148    let entries: Arc<Vec<TreeEntry>> = cx.slot_state(FileTreeRowsState::default, |rows_state| {
149        if rows_state.last_items_revision != Some(items_revision)
150            || rows_state.last_state_revision != Some(state_revision)
151        {
152            rows_state.last_items_revision = Some(items_revision);
153            rows_state.last_state_revision = Some(state_revision);
154            let (entries, index_by_id) = rebuild_entries(items_value, &expanded);
155            rows_state.entries = entries;
156            rows_state.index_by_id = index_by_id;
157        }
158
159        let selected_idx = selected.and_then(|id| rows_state.index_by_id.get(&id).copied());
160        if let Some(selected_id) = selected
161            && let Some(idx) = selected_idx
162        {
163            let should_scroll = rows_state.last_scrolled_selected != Some(selected_id)
164                || rows_state.last_scrolled_index != Some(idx)
165                || rows_state.last_scrolled_items_revision != Some(items_revision)
166                || rows_state.last_scrolled_state_revision != Some(state_revision);
167            if should_scroll {
168                scroll.scroll_to_item(idx, ScrollStrategy::Nearest);
169                rows_state.last_scrolled_selected = Some(selected_id);
170                rows_state.last_scrolled_index = Some(idx);
171                rows_state.last_scrolled_items_revision = Some(items_revision);
172                rows_state.last_scrolled_state_revision = Some(state_revision);
173            }
174        } else {
175            rows_state.last_scrolled_selected = None;
176            rows_state.last_scrolled_index = None;
177            rows_state.last_scrolled_items_revision = Some(items_revision);
178            rows_state.last_scrolled_state_revision = Some(state_revision);
179        }
180
181        Arc::new(rows_state.entries.clone())
182    });
183
184    let state_for_row = state.clone();
185    let entries_for_row = Arc::clone(&entries);
186
187    let mut options =
188        fret_ui::element::VirtualListOptions::known(row_h, props.overscan as usize, move |_i| {
189            row_h
190        });
191    // VirtualList windowing should react to entry-list changes (expand/collapse + tree updates).
192    // We conservatively fold both model revisions into the virtualizer revision.
193    options.items_revision = items_revision ^ state_revision.rotate_left(1);
194    options.keep_alive = props
195        .keep_alive
196        .unwrap_or_else(|| (props.overscan as usize).saturating_mul(2));
197
198    let expanded_for_row = expanded.clone();
199    let selected_for_row = selected;
200    let row_test_id_prefix = props.debug_row_test_id_prefix.clone();
201    let row = move |cx: &mut ElementContext<'_, H>, i: usize| {
202        let Some(entry) = entries_for_row.get(i).cloned() else {
203            return cx.text("");
204        };
205
206        let is_selected = selected_for_row == Some(entry.id);
207        let is_expanded = entry.has_children && expanded_for_row.contains(&entry.id);
208        let a11y_level = u32::try_from(entry.depth.saturating_add(1)).ok();
209
210        let debug_test_id: Option<Arc<str>> = row_test_id_prefix
211            .as_ref()
212            .map(|prefix| Arc::from(format!("{prefix}-{}", entry.id)));
213
214        let enabled = !entry.disabled;
215        let pad_left = Px(row_px.0 + indent.0 * (entry.depth as f32).max(0.0));
216        let state_for_row = state_for_row.clone();
217
218        cx.pressable(
219            PressableProps {
220                enabled,
221                a11y: PressableA11y {
222                    role: Some(SemanticsRole::TreeItem),
223                    label: Some(entry.label.clone()),
224                    level: a11y_level,
225                    selected: is_selected,
226                    test_id: debug_test_id,
227                    ..Default::default()
228                },
229                ..Default::default()
230            },
231            move |cx, st| {
232                let row_id = entry.id;
233                let row_has_children = entry.has_children;
234                let state_for_activate = state_for_row.clone();
235                cx.pressable_add_on_activate(Arc::new(move |host, action_cx, _reason| {
236                    let _ = host.models_mut().update(&state_for_activate, |st| {
237                        st.selected = Some(row_id);
238                        if row_has_children && !st.expanded.insert(row_id) {
239                            st.expanded.remove(&row_id);
240                        }
241                    });
242
243                    // Ensure at least one frame is produced even under aggressive cache reuse.
244                    host.request_redraw(action_cx.window);
245                }));
246
247                let background = if is_selected {
248                    row_active
249                } else if enabled && (st.hovered || st.pressed) {
250                    row_hover
251                } else {
252                    list_bg
253                };
254
255                let icon = if entry.has_children {
256                    if is_expanded { "v" } else { ">" }
257                } else {
258                    "-"
259                };
260
261                let mut row_props = {
262                    let theme = Theme::global(&*cx.app);
263                    decl_style::container_props(
264                        theme,
265                        ChromeRefinement::default().bg(ColorRef::Color(background)),
266                        LayoutRefinement::default()
267                            .w_full()
268                            .h_px(MetricRef::Px(row_h)),
269                    )
270                };
271                row_props.layout.overflow = fret_ui::element::Overflow::Clip;
272                row_props.padding = Edges {
273                    top: row_py,
274                    right: row_px,
275                    bottom: row_py,
276                    left: pad_left,
277                }
278                .into();
279
280                vec![cx.container(row_props, |cx| {
281                    vec![
282                        crate::ui::h_row(|cx| {
283                            let icon = crate::ui::text(icon).flex_shrink_0().into_element(cx);
284                            let label = crate::ui::text(entry.label.as_ref())
285                                .flex_1()
286                                .min_w_0()
287                                .truncate()
288                                .into_element(cx);
289                            [icon, label]
290                        })
291                        .layout(LayoutRefinement::default().w_full().h_full())
292                        .gap(Space::N2)
293                        .items_center()
294                        .into_element(cx),
295                    ]
296                })]
297            },
298        )
299    };
300
301    let key_at = {
302        let entries: Arc<Vec<TreeEntry>> = Arc::clone(&entries);
303        move |i: usize| -> TreeItemId {
304            entries.get(i).map(|e: &TreeEntry| e.id).unwrap_or_default()
305        }
306    };
307
308    let layout = props.layout;
309    let list = cx.virtual_list_keyed_retained_with_layout_fn(
310        layout,
311        entries.len(),
312        options,
313        scroll,
314        key_at,
315        row,
316    );
317
318    let list = list.attach_semantics(SemanticsDecoration {
319        role: Some(fret_core::SemanticsRole::List),
320        test_id: props.debug_root_test_id.clone(),
321        ..Default::default()
322    });
323
324    // Keep a cache root boundary so the file-tree surface can be adopted as a panel-level unit.
325    // Consumers can still wrap this in their own cache roots if needed.
326    cx.cached_subtree_with(
327        CachedSubtreeProps::default().contained_layout(true),
328        |_cx| vec![list],
329    )
330}