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).
61///
62/// # Example
63/// ```rust
64/// use egui_macview::{MacAppearance, MacPathComponent, MacPathControl, MacTheme};
65///
66/// # let ctx = egui::Context::default();
67/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
68/// let theme = MacTheme::new(MacAppearance::Aqua);
69/// let path = [
70///     MacPathComponent::folder("Users"),
71///     MacPathComponent::folder("Projects"),
72///     MacPathComponent::file("Cargo.toml"),
73/// ];
74///
75/// let res = MacPathControl::new("file_breadcrumbs", &path)
76///     .show_choose_button(true)
77///     .theme(&theme)
78///     .show(ui);
79/// if let Some(idx) = res.clicked_segment {
80///     // Navigated to segment `idx`
81/// }
82/// # });
83/// # out.textures_delta.clear();
84/// ```
85pub struct MacPathControl<'a> {
86    id: Id,
87    components: std::borrow::Cow<'a, [MacPathComponent<'a>]>,
88    show_choose_button: bool,
89    choose_label: &'a str,
90    width: Option<f32>,
91    enabled: bool,
92    theme: Option<&'a MacTheme>,
93}
94
95impl<'a> MacPathControl<'a> {
96    /// Creates a new `MacPathControl` from a slice of string segment names (all folders except last).
97    pub fn from_segments(id_source: impl Hash + Debug, segments: &'a [&'a str]) -> Self {
98        #[allow(deprecated)]
99        let id = Id::new(id_source);
100        let n = segments.len();
101        let comps: Vec<_> = segments
102            .iter()
103            .enumerate()
104            .map(|(i, &title)| MacPathComponent {
105                title,
106                is_folder: i + 1 < n,
107                symbol: None,
108            })
109            .collect();
110        Self {
111            id,
112            components: std::borrow::Cow::Owned(comps),
113            show_choose_button: false,
114            choose_label: "Choose...",
115            width: None,
116            enabled: true,
117            theme: None,
118        }
119    }
120
121    /// Creates a new `MacPathControl` from explicit [`MacPathComponent`] items.
122    pub fn new(id_source: impl Hash + Debug, components: &'a [MacPathComponent<'a>]) -> Self {
123        #[allow(deprecated)]
124        let id = Id::new(id_source);
125        Self {
126            id,
127            components: std::borrow::Cow::Borrowed(components),
128            show_choose_button: false,
129            choose_label: "Choose...",
130            width: None,
131            enabled: true,
132            theme: None,
133        }
134    }
135
136    /// Shows or hides the trailing `Choose...` button.
137    pub fn show_choose_button(mut self, show: bool) -> Self {
138        self.show_choose_button = show;
139        self
140    }
141
142    /// Sets custom label for the trailing choose button.
143    pub fn choose_label(mut self, label: &'a str) -> Self {
144        self.choose_label = label;
145        self
146    }
147
148    /// Overrides total width in points.
149    pub fn width(mut self, width: f32) -> Self {
150        self.width = Some(width);
151        self
152    }
153
154    /// Enables or disables interaction.
155    pub fn enabled(mut self, enabled: bool) -> Self {
156        self.enabled = enabled;
157        self
158    }
159
160    /// Injects dynamic [`MacTheme`].
161    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
162        self.theme = Some(theme);
163        self
164    }
165
166    /// Renders the path control bar in `ui`.
167    pub fn show(self, ui: &mut Ui) -> MacPathControlResponse {
168        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
169        let theme: &MacTheme = &theme_cow;
170        let pcm = theme.path_control();
171
172        let width = self.width.unwrap_or_else(|| ui.available_width());
173        let height = pcm.height.points();
174        let sense = if self.enabled {
175            Sense::click()
176        } else {
177            Sense::hover()
178        };
179        let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), sense);
180
181        let painter = ui.painter_at(rect);
182        let corner_r = pcm.corner_radius.points();
183        let border_w = pcm.border_width.points();
184        let border_stroke = Stroke::new(border_w, theme.separator());
185        painter.rect(
186            rect,
187            CornerRadius::same(corner_r as u8),
188            theme.control_background(),
189            border_stroke,
190            StrokeKind::Inside,
191        );
192
193        let font_id = FontId::proportional(pcm.font_size.points());
194        let pad_h = pcm.padding_horizontal.points();
195        let seg_pad_h = pcm.segment_padding_horizontal.points();
196        let seg_spacing = pcm.segment_spacing.points();
197        let icon_sz = pcm.icon_size.points();
198        let icon_gap = pcm.icon_spacing.points();
199        let chev_sz = pcm.chevron_size.points();
200        let seg_r = CornerRadius::same(pcm.segment_corner_radius.points() as u8);
201
202        let mut choose_clicked = false;
203        let right_limit = if self.show_choose_button {
204            let choose_galley = painter.layout_no_wrap(
205                self.choose_label.to_owned(),
206                font_id.clone(),
207                Color32::PLACEHOLDER,
208            );
209            let choose_text_w = choose_galley.size().x;
210            let btn_w = choose_text_w + pcm.choose_button_padding.points() * 2.0;
211            let btn_h = pcm.choose_button_height.points();
212            let btn_pos = Pos2::new(
213                rect.right() - pad_h - btn_w,
214                rect.top() + (height - btn_h) * 0.5,
215            );
216            let btn_rect = Rect::from_min_size(btn_pos, Vec2::new(btn_w, btn_h));
217            let btn_resp = ui.interact(btn_rect, self.id.with("choose_btn"), sense);
218            if btn_resp.clicked() && self.enabled {
219                choose_clicked = true;
220            }
221            let down = btn_resp.is_pointer_button_down_on();
222            let btn_bg = if down {
223                theme.selected_control()
224            } else if btn_resp.hovered() {
225                theme.control()
226            } else {
227                theme.window_background()
228            };
229            let btn_fg = if down {
230                theme
231                    .selected_control_background_style()
232                    .primary_text_color(theme)
233            } else if self.enabled {
234                theme.label()
235            } else {
236                theme.placeholder_text()
237            };
238            painter.rect(btn_rect, seg_r, btn_bg, border_stroke, StrokeKind::Inside);
239            let choose_pos = Align2::CENTER_CENTER
240                .anchor_size(btn_rect.center(), choose_galley.size())
241                .min;
242            painter.galley(choose_pos, choose_galley, btn_fg);
243            btn_rect.left() - pad_h
244        } else {
245            rect.right() - pad_h
246        };
247
248        let mut cursor_x = rect.left() + pad_h;
249        let mut clicked_segment = None;
250        let count = self.components.len();
251        let text_color = if self.enabled {
252            theme.label()
253        } else {
254            theme.placeholder_text()
255        };
256
257        for (idx, comp) in self.components.iter().enumerate() {
258            if cursor_x >= right_limit {
259                break;
260            }
261            let title_galley = painter.layout_no_wrap(
262                comp.title.to_owned(),
263                font_id.clone(),
264                Color32::PLACEHOLDER,
265            );
266            let title_w = title_galley.size().x;
267            let seg_w = (seg_pad_h * 2.0 + icon_sz + icon_gap + title_w)
268                .min((right_limit - cursor_x).max(0.0));
269            let seg_rect = Rect::from_min_size(
270                Pos2::new(cursor_x, rect.top() + border_w),
271                Vec2::new(seg_w, height - border_w * 2.0),
272            );
273            let seg_resp = ui.interact(seg_rect, self.id.with(("seg", idx)), sense);
274            if seg_resp.clicked() && self.enabled {
275                clicked_segment = Some(idx);
276            }
277            if seg_resp.hovered() && self.enabled {
278                painter.rect_filled(seg_rect, seg_r, theme.control());
279            }
280
281            let seg_clip = seg_rect.intersect(Rect::from_min_max(
282                rect.min,
283                Pos2::new(right_limit, rect.max.y),
284            ));
285            let seg_painter = painter.with_clip_rect(seg_clip);
286            let icon_center =
287                Pos2::new(seg_rect.left() + seg_pad_h + icon_sz * 0.5, rect.center().y);
288            let icon_rect = Rect::from_center_size(icon_center, Vec2::splat(icon_sz));
289            paint_component_icon(&seg_painter, theme, comp, icon_rect);
290
291            let text_anchor = Pos2::new(icon_rect.right() + icon_gap, rect.center().y);
292            let text_pos = Align2::LEFT_CENTER
293                .anchor_size(text_anchor, title_galley.size())
294                .min;
295            seg_painter.galley(text_pos, title_galley, text_color);
296
297            cursor_x = seg_rect.right() + seg_spacing;
298            if idx + 1 < count && cursor_x + chev_sz < right_limit {
299                let chev_pos = Pos2::new(cursor_x + chev_sz * 0.5, rect.center().y);
300                let chev_font = FontId::proportional(chev_sz);
301                painter.text(
302                    chev_pos,
303                    Align2::CENTER_CENTER,
304                    MacSymbol::ChevronRight.as_str(),
305                    chev_font,
306                    theme.secondary_label(),
307                );
308                cursor_x += chev_sz + seg_spacing;
309            }
310        }
311
312        MacPathControlResponse {
313            response,
314            clicked_segment,
315            choose_clicked,
316        }
317    }
318}
319
320/// Paints the leading folder, file, or override symbol for a path segment.
321fn paint_component_icon(
322    painter: &Painter,
323    theme: &MacTheme,
324    comp: &MacPathComponent<'_>,
325    icon_rect: Rect,
326) {
327    let pcm = theme.path_control();
328    let border_w = pcm.border_width.points();
329    let r = CornerRadius::same((border_w * 2.0) as u8);
330    if let Some(sym) = comp.symbol {
331        let font = FontId::proportional(pcm.font_size.points());
332        painter.text(
333            icon_rect.center(),
334            Align2::CENTER_CENTER,
335            sym.as_str(),
336            font,
337            theme.control_accent(),
338        );
339    } else if comp.is_folder {
340        let tab_w = pcm.folder_tab_width.points();
341        let tab_h = pcm.folder_tab_height.points();
342        let body_min = Pos2::new(
343            icon_rect.left() + border_w,
344            icon_rect.top() + tab_h + border_w,
345        );
346        let body_max = Pos2::new(
347            icon_rect.right() - border_w,
348            icon_rect.bottom() - border_w * 2.0,
349        );
350        let body_rect = Rect::from_min_max(body_min, body_max);
351        let tab_rect = Rect::from_min_size(
352            Pos2::new(body_rect.left(), body_rect.top() - tab_h),
353            Vec2::new(tab_w, tab_h + border_w),
354        );
355        painter.rect_filled(
356            tab_rect,
357            CornerRadius::same(border_w as u8),
358            theme.control_accent(),
359        );
360        painter.rect_filled(body_rect, r, theme.control_accent());
361    } else {
362        let inset_x = border_w * 2.0 + border_w;
363        let inset_y = border_w * 2.0;
364        let doc_min = Pos2::new(icon_rect.left() + inset_x, icon_rect.top() + inset_y);
365        let doc_max = Pos2::new(icon_rect.right() - inset_x, icon_rect.bottom() - inset_y);
366        let doc_rect = Rect::from_min_max(doc_min, doc_max);
367        painter.rect(
368            doc_rect,
369            r,
370            Color32::TRANSPARENT,
371            Stroke::new(border_w, theme.secondary_label()),
372            StrokeKind::Inside,
373        );
374    }
375}