Skip to main content

egui_macview/controls/
path_control.rs

1//! Authentic macOS AppKit Path Control (`NSPathControl` equivalent, SPEC-011).
2
3use crate::symbols::MacSymbol;
4use crate::theme::MacTheme;
5use egui::{
6    Align2, Color32, CornerRadius, FontId, Id, Painter, Pos2, Rect, Response, Sense, Stroke,
7    StrokeKind, Ui, Vec2,
8};
9use std::fmt::Debug;
10use std::hash::Hash;
11
12/// Single breadcrumb component inside [`MacPathControl`].
13#[derive(Debug, Clone)]
14pub struct MacPathComponent<'a> {
15    /// Display title of the path segment.
16    pub title: &'a str,
17    /// True if this segment represents a folder/directory; false for a leaf document/file.
18    pub is_folder: bool,
19    /// Optional explicit [`MacSymbol`] override.
20    pub symbol: Option<MacSymbol>,
21}
22
23impl<'a> MacPathComponent<'a> {
24    /// Creates a folder path component.
25    pub fn folder(title: &'a str) -> Self {
26        Self {
27            title,
28            is_folder: true,
29            symbol: None,
30        }
31    }
32
33    /// Creates a leaf file/document path component.
34    pub fn file(title: &'a str) -> Self {
35        Self {
36            title,
37            is_folder: false,
38            symbol: None,
39        }
40    }
41
42    /// Overrides the leading symbol with a [`MacSymbol`].
43    pub fn with_symbol(mut self, symbol: MacSymbol) -> Self {
44        self.symbol = Some(symbol);
45        self
46    }
47}
48
49/// Interaction output returned by [`MacPathControl::show`].
50#[derive(Debug, Clone)]
51pub struct MacPathControlResponse {
52    /// Overall widget interaction response.
53    pub response: Response,
54    /// Index of the path segment clicked during this frame, if any.
55    pub clicked_segment: Option<usize>,
56    /// True if the optional trailing `Choose...` button was clicked during this frame.
57    pub choose_clicked: bool,
58}
59
60/// Authentic macOS AppKit breadcrumb path bar (`NSPathControl` equivalent).
61pub struct MacPathControl<'a> {
62    id: Id,
63    components: std::borrow::Cow<'a, [MacPathComponent<'a>]>,
64    show_choose_button: bool,
65    choose_label: &'a str,
66    width: Option<f32>,
67    enabled: bool,
68    theme: Option<&'a MacTheme>,
69}
70
71impl<'a> MacPathControl<'a> {
72    /// Creates a new `MacPathControl` from a slice of string segment names (all folders except last).
73    pub fn from_segments(id_source: impl Hash + Debug, segments: &'a [&'a str]) -> Self {
74        #[allow(deprecated)]
75        let id = Id::new(id_source);
76        let n = segments.len();
77        let comps: Vec<_> = segments
78            .iter()
79            .enumerate()
80            .map(|(i, &title)| MacPathComponent {
81                title,
82                is_folder: i + 1 < n,
83                symbol: None,
84            })
85            .collect();
86        Self {
87            id,
88            components: std::borrow::Cow::Owned(comps),
89            show_choose_button: false,
90            choose_label: "Choose...",
91            width: None,
92            enabled: true,
93            theme: None,
94        }
95    }
96
97    /// Creates a new `MacPathControl` from explicit [`MacPathComponent`] items.
98    pub fn new(id_source: impl Hash + Debug, components: &'a [MacPathComponent<'a>]) -> Self {
99        #[allow(deprecated)]
100        let id = Id::new(id_source);
101        Self {
102            id,
103            components: std::borrow::Cow::Borrowed(components),
104            show_choose_button: false,
105            choose_label: "Choose...",
106            width: None,
107            enabled: true,
108            theme: None,
109        }
110    }
111
112    /// Shows or hides the trailing `Choose...` button.
113    pub fn show_choose_button(mut self, show: bool) -> Self {
114        self.show_choose_button = show;
115        self
116    }
117
118    /// Sets custom label for the trailing choose button.
119    pub fn choose_label(mut self, label: &'a str) -> Self {
120        self.choose_label = label;
121        self
122    }
123
124    /// Overrides total width in points.
125    pub fn width(mut self, width: f32) -> Self {
126        self.width = Some(width);
127        self
128    }
129
130    /// Enables or disables interaction.
131    pub fn enabled(mut self, enabled: bool) -> Self {
132        self.enabled = enabled;
133        self
134    }
135
136    /// Injects dynamic [`MacTheme`].
137    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
138        self.theme = Some(theme);
139        self
140    }
141
142    /// Renders the path control bar in `ui`.
143    pub fn show(self, ui: &mut Ui) -> MacPathControlResponse {
144        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
145        let theme: &MacTheme = &theme_cow;
146        let pcm = theme.path_control();
147
148        let width = self.width.unwrap_or_else(|| ui.available_width());
149        let height = pcm.height.points();
150        let sense = if self.enabled {
151            Sense::click()
152        } else {
153            Sense::hover()
154        };
155        let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), sense);
156
157        let painter = ui.painter_at(rect);
158        let corner_r = pcm.corner_radius.points();
159        let border_w = pcm.border_width.points();
160        let border_stroke = Stroke::new(border_w, theme.separator());
161        painter.rect(
162            rect,
163            CornerRadius::same(corner_r as u8),
164            theme.control_background(),
165            border_stroke,
166            StrokeKind::Inside,
167        );
168
169        let font_id = FontId::proportional(pcm.font_size.points());
170        let pad_h = pcm.padding_horizontal.points();
171        let seg_pad_h = pcm.segment_padding_horizontal.points();
172        let seg_spacing = pcm.segment_spacing.points();
173        let icon_sz = pcm.icon_size.points();
174        let icon_gap = pcm.icon_spacing.points();
175        let chev_sz = pcm.chevron_size.points();
176        let seg_r = CornerRadius::same(pcm.segment_corner_radius.points() as u8);
177
178        let mut choose_clicked = false;
179        let right_limit = if self.show_choose_button {
180            let choose_galley = painter.layout_no_wrap(
181                self.choose_label.to_owned(),
182                font_id.clone(),
183                Color32::PLACEHOLDER,
184            );
185            let choose_text_w = choose_galley.size().x;
186            let btn_w = choose_text_w + pcm.choose_button_padding.points() * 2.0;
187            let btn_h = pcm.choose_button_height.points();
188            let btn_pos = Pos2::new(
189                rect.right() - pad_h - btn_w,
190                rect.top() + (height - btn_h) * 0.5,
191            );
192            let btn_rect = Rect::from_min_size(btn_pos, Vec2::new(btn_w, btn_h));
193            let btn_resp = ui.interact(btn_rect, self.id.with("choose_btn"), sense);
194            if btn_resp.clicked() && self.enabled {
195                choose_clicked = true;
196            }
197            let down = btn_resp.is_pointer_button_down_on();
198            let btn_bg = if down {
199                theme.selected_control()
200            } else if btn_resp.hovered() {
201                theme.control()
202            } else {
203                theme.window_background()
204            };
205            let btn_fg = if down {
206                theme
207                    .selected_control_background_style()
208                    .primary_text_color(theme)
209            } else if self.enabled {
210                theme.label()
211            } else {
212                theme.placeholder_text()
213            };
214            painter.rect(btn_rect, seg_r, btn_bg, border_stroke, StrokeKind::Inside);
215            let choose_pos = Align2::CENTER_CENTER
216                .anchor_size(btn_rect.center(), choose_galley.size())
217                .min;
218            painter.galley(choose_pos, choose_galley, btn_fg);
219            btn_rect.left() - pad_h
220        } else {
221            rect.right() - pad_h
222        };
223
224        let mut cursor_x = rect.left() + pad_h;
225        let mut clicked_segment = None;
226        let count = self.components.len();
227        let text_color = if self.enabled {
228            theme.label()
229        } else {
230            theme.placeholder_text()
231        };
232
233        for (idx, comp) in self.components.iter().enumerate() {
234            if cursor_x >= right_limit {
235                break;
236            }
237            let title_galley = painter.layout_no_wrap(
238                comp.title.to_owned(),
239                font_id.clone(),
240                Color32::PLACEHOLDER,
241            );
242            let title_w = title_galley.size().x;
243            let seg_w = (seg_pad_h * 2.0 + icon_sz + icon_gap + title_w)
244                .min((right_limit - cursor_x).max(0.0));
245            let seg_rect = Rect::from_min_size(
246                Pos2::new(cursor_x, rect.top() + border_w),
247                Vec2::new(seg_w, height - border_w * 2.0),
248            );
249            let seg_resp = ui.interact(seg_rect, self.id.with(("seg", idx)), sense);
250            if seg_resp.clicked() && self.enabled {
251                clicked_segment = Some(idx);
252            }
253            if seg_resp.hovered() && self.enabled {
254                painter.rect_filled(seg_rect, seg_r, theme.control());
255            }
256
257            let seg_clip = seg_rect.intersect(Rect::from_min_max(
258                rect.min,
259                Pos2::new(right_limit, rect.max.y),
260            ));
261            let seg_painter = painter.with_clip_rect(seg_clip);
262            let icon_center =
263                Pos2::new(seg_rect.left() + seg_pad_h + icon_sz * 0.5, rect.center().y);
264            let icon_rect = Rect::from_center_size(icon_center, Vec2::splat(icon_sz));
265            paint_component_icon(&seg_painter, theme, comp, icon_rect);
266
267            let text_anchor = Pos2::new(icon_rect.right() + icon_gap, rect.center().y);
268            let text_pos = Align2::LEFT_CENTER
269                .anchor_size(text_anchor, title_galley.size())
270                .min;
271            seg_painter.galley(text_pos, title_galley, text_color);
272
273            cursor_x = seg_rect.right() + seg_spacing;
274            if idx + 1 < count && cursor_x + chev_sz < right_limit {
275                let chev_pos = Pos2::new(cursor_x + chev_sz * 0.5, rect.center().y);
276                let chev_font = FontId::proportional(chev_sz);
277                painter.text(
278                    chev_pos,
279                    Align2::CENTER_CENTER,
280                    MacSymbol::ChevronRight.as_str(),
281                    chev_font,
282                    theme.secondary_label(),
283                );
284                cursor_x += chev_sz + seg_spacing;
285            }
286        }
287
288        MacPathControlResponse {
289            response,
290            clicked_segment,
291            choose_clicked,
292        }
293    }
294}
295
296/// Paints the leading folder, file, or override symbol for a path segment.
297fn paint_component_icon(
298    painter: &Painter,
299    theme: &MacTheme,
300    comp: &MacPathComponent<'_>,
301    icon_rect: Rect,
302) {
303    let pcm = theme.path_control();
304    let border_w = pcm.border_width.points();
305    let r = CornerRadius::same((border_w * 2.0) as u8);
306    if let Some(sym) = comp.symbol {
307        let font = FontId::proportional(pcm.font_size.points());
308        painter.text(
309            icon_rect.center(),
310            Align2::CENTER_CENTER,
311            sym.as_str(),
312            font,
313            theme.control_accent(),
314        );
315    } else if comp.is_folder {
316        let tab_w = pcm.folder_tab_width.points();
317        let tab_h = pcm.folder_tab_height.points();
318        let body_min = Pos2::new(
319            icon_rect.left() + border_w,
320            icon_rect.top() + tab_h + border_w,
321        );
322        let body_max = Pos2::new(
323            icon_rect.right() - border_w,
324            icon_rect.bottom() - border_w * 2.0,
325        );
326        let body_rect = Rect::from_min_max(body_min, body_max);
327        let tab_rect = Rect::from_min_size(
328            Pos2::new(body_rect.left(), body_rect.top() - tab_h),
329            Vec2::new(tab_w, tab_h + border_w),
330        );
331        painter.rect_filled(
332            tab_rect,
333            CornerRadius::same(border_w as u8),
334            theme.control_accent(),
335        );
336        painter.rect_filled(body_rect, r, theme.control_accent());
337    } else {
338        let inset_x = border_w * 2.0 + border_w;
339        let inset_y = border_w * 2.0;
340        let doc_min = Pos2::new(icon_rect.left() + inset_x, icon_rect.top() + inset_y);
341        let doc_max = Pos2::new(icon_rect.right() - inset_x, icon_rect.bottom() - inset_y);
342        let doc_rect = Rect::from_min_max(doc_min, doc_max);
343        painter.rect(
344            doc_rect,
345            r,
346            Color32::TRANSPARENT,
347            Stroke::new(border_w, theme.secondary_label()),
348            StrokeKind::Inside,
349        );
350    }
351}