Skip to main content

fresh/view/file_tree/
slots.rs

1use std::path::Path;
2
3use crate::primitives::display_width::str_width;
4use crate::view::theme::Theme;
5use fresh_core::api::OverlayColorSpec;
6use ratatui::style::Color;
7
8use super::{cache::insert_with_aliases, decorations::FileExplorerDecorationCache};
9
10pub const COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH: u16 = 2;
11pub const DEFAULT_LEADING_SLOT_MIN_WIDTH: usize = 2;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ExplorerTooltipSummary {
15    pub title: String,
16    pub lines: Vec<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExplorerLeadingSlotPayload {
21    pub text: String,
22    pub fg: Color,
23    pub min_width: usize,
24}
25
26impl ExplorerLeadingSlotPayload {
27    pub fn width(&self) -> usize {
28        str_width(&self.text).max(self.min_width)
29    }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ExplorerTrailingSlotPayload {
34    pub text: String,
35    pub fg: Color,
36    pub tooltip: Option<ExplorerTooltipSummary>,
37}
38
39impl ExplorerTrailingSlotPayload {
40    pub fn width(&self) -> usize {
41        str_width(&self.text)
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct ExplorerTrailingSlotResolution {
47    pub payload: Option<ExplorerTrailingSlotPayload>,
48    pub name_color_hint: Option<Color>,
49}
50
51#[derive(Debug, Clone)]
52pub struct ExplorerSlotResolution {
53    pub leading: Option<ExplorerLeadingSlotPayload>,
54    pub trailing: Option<ExplorerTrailingSlotPayload>,
55    pub name_color_hint: Option<Color>,
56}
57
58pub struct ExplorerSlotContext<'a> {
59    pub path: &'a Path,
60    pub is_dir: bool,
61    pub has_unsaved: bool,
62    pub is_symlink: bool,
63    pub is_hidden: bool,
64    pub decorations: &'a FileExplorerDecorationCache,
65    pub slot_overrides: &'a FileExplorerSlotOverrideCache,
66    pub theme: &'a Theme,
67    pub neutral_fg: Color,
68}
69
70pub trait ExplorerLeadingSlotProvider {
71    fn resolve(&self, context: &ExplorerSlotContext<'_>) -> Option<ExplorerLeadingSlotPayload>;
72}
73
74pub trait ExplorerTrailingSlotProvider {
75    fn resolve(&self, context: &ExplorerSlotContext<'_>) -> ExplorerTrailingSlotResolution;
76
77    fn hit_test_width(&self) -> u16 {
78        COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH
79    }
80}
81
82#[derive(Clone, Copy)]
83pub struct ExplorerSlotProviders {
84    pub leading: &'static dyn ExplorerLeadingSlotProvider,
85    pub trailing: &'static dyn ExplorerTrailingSlotProvider,
86}
87
88impl ExplorerSlotProviders {
89    pub fn resolver(self) -> ExplorerSlotResolver<'static> {
90        ExplorerSlotResolver::new(self.leading, self.trailing)
91    }
92}
93
94pub fn default_slot_providers() -> ExplorerSlotProviders {
95    ExplorerSlotProviders {
96        leading: &DEFAULT_LEADING_SLOT_PROVIDER,
97        trailing: &DEFAULT_TRAILING_SLOT_PROVIDER,
98    }
99}
100
101#[derive(Clone, Copy)]
102pub struct ExplorerSlotResolver<'a> {
103    leading: &'a dyn ExplorerLeadingSlotProvider,
104    trailing: &'a dyn ExplorerTrailingSlotProvider,
105}
106
107impl<'a> ExplorerSlotResolver<'a> {
108    pub fn new(
109        leading: &'a dyn ExplorerLeadingSlotProvider,
110        trailing: &'a dyn ExplorerTrailingSlotProvider,
111    ) -> Self {
112        Self { leading, trailing }
113    }
114
115    pub fn resolve(&self, context: &ExplorerSlotContext<'_>) -> ExplorerSlotResolution {
116        let trailing = self.trailing.resolve(context);
117        ExplorerSlotResolution {
118            leading: self.leading.resolve(context),
119            trailing: trailing.payload,
120            name_color_hint: trailing.name_color_hint,
121        }
122    }
123
124    pub fn trailing_hit_test_width(&self) -> u16 {
125        self.trailing.hit_test_width()
126    }
127}
128
129#[derive(Debug, Clone)]
130struct CachedLeadingSlot {
131    text: String,
132    color: OverlayColorSpec,
133    min_width: usize,
134}
135
136#[derive(Debug, Clone)]
137struct CachedTrailingSlot {
138    text: String,
139    color: OverlayColorSpec,
140    tooltip: Option<ExplorerTooltipSummary>,
141}
142
143#[derive(Debug, Clone)]
144struct CachedLeadingOverride {
145    slot: Option<CachedLeadingSlot>,
146    priority: i32,
147}
148
149#[derive(Debug, Clone)]
150struct CachedTrailingOverride {
151    slot: Option<CachedTrailingSlot>,
152    priority: i32,
153}
154
155#[derive(Debug, Clone)]
156struct CachedNameColorOverride {
157    color: Option<OverlayColorSpec>,
158    priority: i32,
159}
160
161#[derive(Debug, Default, Clone)]
162pub struct FileExplorerSlotOverrideCache {
163    direct_leading: std::collections::HashMap<std::path::PathBuf, CachedLeadingOverride>,
164    direct_trailing: std::collections::HashMap<std::path::PathBuf, CachedTrailingOverride>,
165    direct_name_color: std::collections::HashMap<std::path::PathBuf, CachedNameColorOverride>,
166}
167
168impl FileExplorerSlotOverrideCache {
169    pub fn rebuild<I>(
170        slots: I,
171        root: &Path,
172        symlink_mappings: &std::collections::HashMap<std::path::PathBuf, std::path::PathBuf>,
173    ) -> Self
174    where
175        I: IntoIterator<Item = fresh_core::file_explorer::FileExplorerSlotEntry>,
176    {
177        let mut direct_leading = std::collections::HashMap::new();
178        let mut direct_trailing = std::collections::HashMap::new();
179        let mut direct_name_color = std::collections::HashMap::new();
180
181        for slot in slots {
182            if !slot.path.starts_with(root) {
183                continue;
184            }
185
186            if slot.leading.is_some() || slot.suppress_leading {
187                let cached = CachedLeadingOverride {
188                    slot: slot.leading.as_ref().map(|leading| CachedLeadingSlot {
189                        text: leading.text.clone(),
190                        color: leading.color.clone(),
191                        min_width: leading.min_width,
192                    }),
193                    priority: slot.priority,
194                };
195                insert_with_aliases(
196                    &mut direct_leading,
197                    &slot.path,
198                    &cached,
199                    symlink_mappings,
200                    |map, path, value| insert_best_cached(map, path, value, |entry| entry.priority),
201                );
202            }
203
204            if slot.trailing.is_some() || slot.suppress_trailing {
205                let cached = CachedTrailingOverride {
206                    slot: slot.trailing.as_ref().map(|trailing| CachedTrailingSlot {
207                        text: trailing.text.clone(),
208                        color: trailing.color.clone(),
209                        tooltip: trailing
210                            .tooltip
211                            .as_ref()
212                            .map(|tooltip| ExplorerTooltipSummary {
213                                title: tooltip.title.clone(),
214                                lines: tooltip.lines.clone(),
215                            }),
216                    }),
217                    priority: slot.priority,
218                };
219                insert_with_aliases(
220                    &mut direct_trailing,
221                    &slot.path,
222                    &cached,
223                    symlink_mappings,
224                    |map, path, value| insert_best_cached(map, path, value, |entry| entry.priority),
225                );
226            }
227
228            if slot.name_color.is_some() || slot.suppress_name_color {
229                let cached = CachedNameColorOverride {
230                    color: slot.name_color.clone(),
231                    priority: slot.priority,
232                };
233                insert_with_aliases(
234                    &mut direct_name_color,
235                    &slot.path,
236                    &cached,
237                    symlink_mappings,
238                    |map, path, value| insert_best_cached(map, path, value, |entry| entry.priority),
239                );
240            }
241        }
242
243        Self {
244            direct_leading,
245            direct_trailing,
246            direct_name_color,
247        }
248    }
249
250    fn leading_override_for_path(&self, path: &Path) -> Option<&CachedLeadingOverride> {
251        self.direct_leading.get(path)
252    }
253
254    fn trailing_override_for_path(&self, path: &Path) -> Option<&CachedTrailingOverride> {
255        self.direct_trailing.get(path)
256    }
257
258    fn name_color_override_for_path(&self, path: &Path) -> Option<&CachedNameColorOverride> {
259        self.direct_name_color.get(path)
260    }
261
262    pub fn has_trailing_override_for_path(&self, path: &Path) -> bool {
263        self.direct_trailing.contains_key(path)
264    }
265}
266
267pub struct DefaultLeadingSlotProvider;
268
269pub static DEFAULT_LEADING_SLOT_PROVIDER: DefaultLeadingSlotProvider = DefaultLeadingSlotProvider;
270
271impl ExplorerLeadingSlotProvider for DefaultLeadingSlotProvider {
272    fn resolve(&self, context: &ExplorerSlotContext<'_>) -> Option<ExplorerLeadingSlotPayload> {
273        context
274            .slot_overrides
275            .leading_override_for_path(context.path)
276            .and_then(|override_entry| {
277                override_entry
278                    .slot
279                    .as_ref()
280                    .map(|slot| ExplorerLeadingSlotPayload {
281                        text: slot.text.clone(),
282                        fg: resolve_overlay_color(&slot.color, context.theme, context.neutral_fg),
283                        min_width: slot.min_width,
284                    })
285            })
286    }
287}
288
289pub struct DefaultTrailingSlotProvider;
290
291pub static DEFAULT_TRAILING_SLOT_PROVIDER: DefaultTrailingSlotProvider =
292    DefaultTrailingSlotProvider;
293
294impl ExplorerTrailingSlotProvider for DefaultTrailingSlotProvider {
295    fn resolve(&self, context: &ExplorerSlotContext<'_>) -> ExplorerTrailingSlotResolution {
296        let compatibility =
297            super::decorations::COMPATIBILITY_TRAILING_SLOT_PROVIDER.resolve(context);
298        let override_trailing = context
299            .slot_overrides
300            .trailing_override_for_path(context.path);
301        let override_name_color = context
302            .slot_overrides
303            .name_color_override_for_path(context.path);
304
305        ExplorerTrailingSlotResolution {
306            payload: match override_trailing {
307                Some(override_entry) => {
308                    override_entry
309                        .slot
310                        .as_ref()
311                        .map(|slot| ExplorerTrailingSlotPayload {
312                            text: slot.text.clone(),
313                            fg: resolve_overlay_color(
314                                &slot.color,
315                                context.theme,
316                                context.neutral_fg,
317                            ),
318                            tooltip: slot.tooltip.clone(),
319                        })
320                }
321                None => compatibility.payload,
322            },
323            name_color_hint: match override_name_color {
324                Some(override_entry) => override_entry
325                    .color
326                    .as_ref()
327                    .map(|color| resolve_overlay_color(color, context.theme, context.neutral_fg)),
328                None => compatibility.name_color_hint,
329            },
330        }
331    }
332
333    fn hit_test_width(&self) -> u16 {
334        COMPATIBILITY_TRAILING_SLOT_HIT_WIDTH
335    }
336}
337
338fn resolve_overlay_color(spec: &OverlayColorSpec, theme: &Theme, fallback: Color) -> Color {
339    match spec {
340        OverlayColorSpec::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
341        OverlayColorSpec::ThemeKey(key) => theme.resolve_theme_key(key).unwrap_or(fallback),
342    }
343}
344
345fn insert_best_cached<T, FPriority>(
346    map: &mut std::collections::HashMap<std::path::PathBuf, T>,
347    path: std::path::PathBuf,
348    value: T,
349    priority: FPriority,
350) where
351    FPriority: Fn(&T) -> i32,
352{
353    let replace = match map.get(&path) {
354        Some(existing) => priority(&value) >= priority(existing),
355        None => true,
356    };
357
358    if replace {
359        map.insert(path, value);
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn slot_overrides_do_not_bubble_to_ancestors() {
369        let cache = FileExplorerSlotOverrideCache::rebuild(
370            vec![fresh_core::file_explorer::FileExplorerSlotEntry {
371                path: std::path::PathBuf::from("/repo/src/file.ts"),
372                leading: None,
373                suppress_leading: false,
374                trailing: Some(fresh_core::file_explorer::FileExplorerTrailingSlot {
375                    text: "P".to_string(),
376                    color: OverlayColorSpec::ThemeKey("syntax.string".into()),
377                    tooltip: None,
378                }),
379                suppress_trailing: false,
380                name_color: Some(OverlayColorSpec::ThemeKey("syntax.type".into())),
381                suppress_name_color: false,
382                priority: 10,
383            }],
384            Path::new("/repo"),
385            &std::collections::HashMap::new(),
386        );
387
388        assert!(cache.has_trailing_override_for_path(Path::new("/repo/src/file.ts")));
389        assert!(!cache.has_trailing_override_for_path(Path::new("/repo/src")));
390        assert!(cache
391            .name_color_override_for_path(Path::new("/repo/src"))
392            .is_none());
393    }
394
395    #[test]
396    fn suppressed_trailing_and_name_color_block_compatibility_fallback() {
397        let theme = Theme::load_builtin("dark").unwrap();
398        let path = std::path::PathBuf::from("/repo/file.ts");
399        let decorations = FileExplorerDecorationCache::rebuild(
400            vec![fresh_core::file_explorer::FileExplorerDecoration {
401                path: path.clone(),
402                symbol: "M".to_string(),
403                color: OverlayColorSpec::ThemeKey("ui.file_status_modified_fg".into()),
404                priority: 50,
405            }],
406            Path::new("/repo"),
407            &std::collections::HashMap::new(),
408        );
409        let slot_overrides = FileExplorerSlotOverrideCache::rebuild(
410            vec![fresh_core::file_explorer::FileExplorerSlotEntry {
411                path: path.clone(),
412                leading: None,
413                suppress_leading: false,
414                trailing: None,
415                suppress_trailing: true,
416                name_color: None,
417                suppress_name_color: true,
418                priority: 10,
419            }],
420            Path::new("/repo"),
421            &std::collections::HashMap::new(),
422        );
423        let context = ExplorerSlotContext {
424            path: &path,
425            is_dir: false,
426            has_unsaved: false,
427            is_symlink: false,
428            is_hidden: false,
429            decorations: &decorations,
430            slot_overrides: &slot_overrides,
431            theme: &theme,
432            neutral_fg: theme.editor_fg,
433        };
434
435        let resolved = default_slot_providers().resolver().resolve(&context);
436        assert!(resolved.trailing.is_none());
437        assert!(resolved.name_color_hint.is_none());
438    }
439}