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#[derive(Debug, Clone)]
66pub struct FileTreeViewProps {
67 pub layout: LayoutStyle,
68 pub row_height: Px,
69 pub overscan: u32,
70 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 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 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 cx.cached_subtree_with(
327 CachedSubtreeProps::default().contained_layout(true),
328 |_cx| vec![list],
329 )
330}