1use crate::ext::ArmasContextExt;
6use egui::{Pos2, Response, Sense, Ui, Vec2};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11const ITEM_GAP: f32 = 0.0;
16const ITEM_PADDING_X: f32 = 8.0;
17const CORNER_RADIUS: f32 = 4.0;
18const INDENT_WIDTH: f32 = 16.0;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TreeItem {
27 pub name: String,
29 pub path: PathBuf,
31 pub is_directory: bool,
33}
34
35impl TreeItem {
36 pub fn leaf(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
38 Self {
39 name: name.into(),
40 path: path.into(),
41 is_directory: false,
42 }
43 }
44
45 pub fn branch(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
47 Self {
48 name: name.into(),
49 path: path.into(),
50 is_directory: true,
51 }
52 }
53
54 pub fn file(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
56 Self::leaf(name, path)
57 }
58
59 pub fn folder(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
61 Self::branch(name, path)
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct TreeViewResponse {
68 pub response: Response,
70 pub selected: Option<PathBuf>,
72 pub toggled: Option<PathBuf>,
74}
75
76struct ShowLevelParams<'a> {
82 parent: Option<&'a PathBuf>,
83 width: f32,
84 depth: usize,
85 levels_last: &'a mut Vec<bool>,
86 selected: &'a mut Option<PathBuf>,
87 toggled: &'a mut Option<PathBuf>,
88}
89
90struct ShowItemParams<'a> {
92 item: &'a TreeItem,
93 width: f32,
94 depth: usize,
95 is_last: bool,
96 levels_last: &'a [bool],
97 selected: &'a mut Option<PathBuf>,
98 toggled: &'a mut Option<PathBuf>,
99 theme: &'a crate::Theme,
100}
101
102#[derive(Clone, Default, Serialize, Deserialize)]
134pub struct TreeView {
135 selected: Option<PathBuf>,
137 #[serde(skip)]
138 expanded: HashSet<String>,
139
140 width: f32,
142 height: f32,
143 item_height: f32,
144 items: Vec<TreeItem>,
145 root_path: String,
146 show_lines: bool,
147}
148
149impl TreeView {
150 #[must_use]
152 pub fn new() -> Self {
153 Self {
154 root_path: "/".to_string(),
155 item_height: 24.0,
156 ..Default::default()
157 }
158 }
159
160 #[must_use]
162 pub const fn width(mut self, width: f32) -> Self {
163 self.width = width;
164 self
165 }
166
167 #[must_use]
169 pub const fn height(mut self, height: f32) -> Self {
170 self.height = height;
171 self
172 }
173
174 #[must_use]
176 pub const fn item_height(mut self, height: f32) -> Self {
177 self.item_height = height;
178 self
179 }
180
181 #[must_use]
183 pub fn items(mut self, items: Vec<TreeItem>) -> Self {
184 self.items = items;
185 self
186 }
187
188 #[must_use]
190 pub fn root_path(mut self, path: impl Into<String>) -> Self {
191 self.root_path = path.into();
192 self
193 }
194
195 #[must_use]
197 pub const fn show_lines(mut self, show: bool) -> Self {
198 self.show_lines = show;
199 self
200 }
201
202 #[must_use]
204 pub const fn selected(&self) -> Option<&PathBuf> {
205 self.selected.as_ref()
206 }
207
208 #[must_use]
210 pub fn is_expanded(&self, path: &Path) -> bool {
211 self.expanded.contains(&path.to_string_lossy().to_string())
212 }
213
214 fn toggle(&mut self, path: &Path) {
216 let key = path.to_string_lossy().to_string();
217 if !self.expanded.remove(&key) {
218 self.expanded.insert(key);
219 }
220 }
221
222 pub fn show(&mut self, ui: &mut Ui) -> TreeViewResponse {
224 let theme = ui.ctx().armas_theme();
225 let available = ui.available_size();
226 let width = if self.width > 0.0 {
227 self.width
228 } else {
229 available.x
230 };
231 let height = if self.height > 0.0 {
232 self.height
233 } else {
234 available.y
235 };
236
237 let mut selected_this_frame = None;
238 let mut toggled_this_frame = None;
239
240 let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
241
242 ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
243 egui::ScrollArea::vertical()
244 .id_salt("tree_view_scroll")
245 .show(ui, |ui| {
246 ui.add_space(theme.spacing.xs);
247 let mut levels_last = Vec::new();
248 let params = ShowLevelParams {
249 parent: None,
250 width,
251 depth: 0,
252 levels_last: &mut levels_last,
253 selected: &mut selected_this_frame,
254 toggled: &mut toggled_this_frame,
255 };
256 self.show_level(ui, params);
257 });
258 });
259
260 TreeViewResponse {
261 response,
262 selected: selected_this_frame,
263 toggled: toggled_this_frame,
264 }
265 }
266
267 #[allow(clippy::needless_pass_by_value)]
268 fn show_level(&mut self, ui: &mut Ui, params: ShowLevelParams) {
269 let theme = ui.ctx().armas_theme();
270 let items = self.get_children(params.parent);
271 let count = items.len();
272
273 for (i, item) in items.into_iter().enumerate() {
274 let is_expanded = item.is_directory && self.is_expanded(&item.path);
275 let is_last = i == count - 1;
276
277 let show_item_params = ShowItemParams {
278 item: &item,
279 width: params.width,
280 depth: params.depth,
281 is_last,
282 levels_last: params.levels_last,
283 selected: params.selected,
284 toggled: params.toggled,
285 theme: &theme,
286 };
287 self.show_item(ui, show_item_params);
288 ui.add_space(ITEM_GAP);
289
290 if is_expanded {
291 let path = item.path.clone();
292 params.levels_last.push(is_last);
293 let nested_params = ShowLevelParams {
294 parent: Some(&path),
295 width: params.width,
296 depth: params.depth + 1,
297 levels_last: params.levels_last,
298 selected: params.selected,
299 toggled: params.toggled,
300 };
301 self.show_level(ui, nested_params);
302 params.levels_last.pop();
303 }
304 }
305 }
306
307 #[allow(clippy::needless_pass_by_value)]
308 fn show_item(&mut self, ui: &mut Ui, params: ShowItemParams) {
309 let is_selected = self.selected.as_ref() == Some(¶ms.item.path);
310 let indent = params.depth as f32 * INDENT_WIDTH;
311 let show_lines = self.show_lines && params.depth > 0;
312
313 ui.horizontal(|ui| {
314 if show_lines {
316 let prefix_width = indent;
317 let (prefix_rect, _) = ui
318 .allocate_exact_size(Vec2::new(prefix_width, self.item_height), Sense::hover());
319
320 let line_color = params.theme.border();
321
322 for level in 0..params.depth {
324 let level_x =
325 prefix_rect.left() + (level as f32 * INDENT_WIDTH) + INDENT_WIDTH / 2.0;
326
327 let ancestor_is_last =
329 level < params.levels_last.len() && params.levels_last[level];
330
331 if !ancestor_is_last {
332 ui.painter().line_segment(
334 [
335 Pos2::new(level_x, prefix_rect.top()),
336 Pos2::new(level_x, prefix_rect.bottom()),
337 ],
338 egui::Stroke::new(1.0, line_color),
339 );
340 }
341 }
342
343 let line_x = prefix_rect.right() - INDENT_WIDTH / 2.0;
345 if !params.is_last {
346 ui.painter().line_segment(
347 [
348 Pos2::new(line_x, prefix_rect.top()),
349 Pos2::new(line_x, prefix_rect.bottom()),
350 ],
351 egui::Stroke::new(1.0, line_color),
352 );
353 }
354 } else if indent > 0.0 {
355 ui.add_space(indent);
356 }
357
358 let item_width = (params.width - indent - ITEM_PADDING_X).max(40.0);
359 let (rect, response) =
360 ui.allocate_exact_size(Vec2::new(item_width, self.item_height), Sense::click());
361 let hovered = response.hovered();
362
363 if is_selected || hovered {
365 let color = if is_selected {
366 params.theme.accent()
367 } else {
368 params.theme.accent().gamma_multiply(0.3)
369 };
370 ui.painter().rect_filled(rect, CORNER_RADIUS, color);
371 }
372
373 let text_color = if is_selected {
375 params.theme.accent_foreground()
376 } else {
377 params.theme.foreground()
378 };
379
380 let x = rect.left() + ITEM_PADDING_X;
381
382 let display_name = ¶ms.item.name;
383
384 ui.painter().text(
385 Pos2::new(x, rect.center().y),
386 egui::Align2::LEFT_CENTER,
387 display_name,
388 egui::FontId::proportional(13.0),
389 text_color,
390 );
391
392 if response.clicked() {
394 if params.item.is_directory {
395 self.toggle(¶ms.item.path);
396 *params.toggled = Some(params.item.path.clone());
397 } else {
398 self.selected = Some(params.item.path.clone());
399 *params.selected = Some(params.item.path.clone());
400 }
401 }
402 });
403 }
404
405 fn get_children(&self, parent: Option<&PathBuf>) -> Vec<TreeItem> {
406 let root = PathBuf::from(&self.root_path);
407
408 let mut items: Vec<_> = self
409 .items
410 .iter()
411 .filter(|item| {
412 let item_parent = item.path.parent();
413 match (parent, item_parent) {
414 (None, Some(p)) => p == root,
415 (Some(expected), Some(actual)) => actual == expected.as_path(),
416 _ => false,
417 }
418 })
419 .cloned()
420 .collect();
421
422 items.sort_by(|a, b| match (a.is_directory, b.is_directory) {
424 (true, false) => std::cmp::Ordering::Less,
425 (false, true) => std::cmp::Ordering::Greater,
426 _ => a.name.cmp(&b.name),
427 });
428
429 items
430 }
431}
432
433#[doc(hidden)]
435pub type Browser = TreeView;
436#[doc(hidden)]
437pub type BrowserItem = TreeItem;
438#[doc(hidden)]
439pub type BrowserResponse = TreeViewResponse;