Skip to main content

blockworx/tools/
address_bar.rs

1//! The address bar: the strip above the toolbar holding the path history
2//! arrows and the breadcrumb of the current block path — `top / Thing 1 /
3//! Core / …` — laid out like a browser's back/forward plus URL field.
4//!
5//! Every crumb but the trailing one is a jump: clicking `Thing 1` makes it the
6//! innermost level. The trailing `…` is a menu of the current block's children,
7//! so the path can be extended without leaving the bar. A path deeper than
8//! [`MAX_CRUMBS`] elides its head to an inert `…`, keeping the bar's width
9//! bounded.
10
11use crate::{
12    document::{BlockPath, Document},
13    store::RectId,
14    tools::{
15        nav_tree::{PathHistory, level_blocks},
16        tool::Action,
17        toolbar::icon_button,
18    },
19};
20
21const BACK_ICON: egui::ImageSource<'static> =
22    egui::include_image!("../../icons/icon-arrow-left.svg");
23const FORWARD_ICON: egui::ImageSource<'static> =
24    egui::include_image!("../../icons/icon-arrow-right.svg");
25
26/// How many trailing crumbs a long path keeps; the rest collapse into a leading
27/// `…`.
28const MAX_CRUMBS: usize = 5;
29
30/// The label of the document root's crumb — the level a path with no segments
31/// shows.
32const ROOT_CRUMB: &str = "top";
33
34/// The trailing crumb: a menu of blocks to descend into, and the ellipsis a
35/// truncated head is drawn with.
36const ELLIPSIS: &str = "…";
37
38/// What separates two crumbs.
39const SEPARATOR: &str = "/";
40
41/// The crumb field's inset look: rounded, padded, and tighter than the default
42/// widget spacing so the path reads as one string rather than a button row.
43const FIELD_ROUNDING: f32 = 6.0;
44const FIELD_PAD_X: i8 = 6;
45const FIELD_PAD_Y: i8 = 1;
46const CRUMB_GAP: f32 = 3.0;
47
48/// One rendered crumb: the block it jumps to (`None` for the document root) and
49/// the label it shows.
50struct Crumb {
51    target: Option<RectId>,
52    label: String,
53}
54
55/// The crumbs for `path`, root first, with the head elided when the path runs
56/// deeper than [`MAX_CRUMBS`]. Returns `(elided, crumbs)`; `elided` asks the
57/// caller for the inert leading `…`.
58fn crumbs(document: &Document, path: &BlockPath) -> (bool, Vec<Crumb>) {
59    let mut all = vec![Crumb {
60        target: None,
61        label: ROOT_CRUMB.to_owned(),
62    }];
63    all.extend(path.segments().iter().map(|&id| Crumb {
64        target: Some(id),
65        label: crate::tools::nav_tree::block_label(document, id),
66    }));
67    let elided = all.len() > MAX_CRUMBS;
68    if elided {
69        all.drain(..all.len() - MAX_CRUMBS);
70    }
71    (elided, all)
72}
73
74/// What the address bar renders against: the document, the canvas's current
75/// path, and what its arrows may offer.
76#[derive(Clone, Copy)]
77pub struct AddressScene<'a> {
78    pub document: &'a Document,
79    pub path: &'a BlockPath,
80    pub history: PathHistory,
81}
82
83/// Draw the history arrows into the toolbar's first row. The caller owns the
84/// surrounding frame and whatever sits between these and the breadcrumb.
85pub(crate) fn arrows(ui: &mut egui::Ui, history: PathHistory) -> Option<Action> {
86    let mut action = None;
87    if ui
88        .add_enabled(history.can_back, icon_button(ui, BACK_ICON))
89        .on_hover_text("Back")
90        .clicked()
91    {
92        action = Some(Action::PathBack);
93    }
94    if ui
95        .add_enabled(history.can_forward, icon_button(ui, FORWARD_ICON))
96        .on_hover_text("Forward")
97        .clicked()
98    {
99        action = Some(Action::PathForward);
100    }
101    action
102}
103
104/// The breadcrumb: the path as a row of buttons in an inset field, the way a
105/// browser's address box sits beside its arrows.
106pub(crate) fn breadcrumb(
107    ui: &mut egui::Ui,
108    document: &Document,
109    path: &BlockPath,
110) -> Option<Action> {
111    let mut action = None;
112    egui::Frame::new()
113        .fill(ui.visuals().extreme_bg_color)
114        .corner_radius(FIELD_ROUNDING)
115        .inner_margin(egui::Margin::symmetric(FIELD_PAD_X, FIELD_PAD_Y))
116        .show(ui, |ui| {
117            ui.horizontal(|ui| {
118                ui.spacing_mut().item_spacing.x = CRUMB_GAP;
119                let (elided, crumbs) = crumbs(document, path);
120                if elided {
121                    // The head is dropped, not reachable: what it stands for is
122                    // a click away in the navigator.
123                    ui.weak(ELLIPSIS);
124                    ui.weak(SEPARATOR);
125                }
126                for crumb in &crumbs {
127                    if ui.button(&crumb.label).clicked() {
128                        action = Some(Action::PathTo(crumb.target));
129                    }
130                    ui.weak(SEPARATOR);
131                }
132                if let Some(descend) = descend_menu(ui, document, path) {
133                    action = Some(descend);
134                }
135            });
136        });
137    action
138}
139
140/// The trailing `…`: a menu of the current level's blocks, each descending into
141/// it. Disabled when the current block has no children — there is nowhere to
142/// go, and an empty menu is a dead end.
143fn descend_menu(ui: &mut egui::Ui, document: &Document, path: &BlockPath) -> Option<Action> {
144    let children = level_blocks(document, path);
145    if children.is_empty() {
146        ui.add_enabled(false, egui::Button::new(ELLIPSIS))
147            .on_disabled_hover_text("Nothing to open here");
148        return None;
149    }
150    let mut picked = None;
151    ui.menu_button(ELLIPSIS, |ui| {
152        for (id, label) in &children {
153            if ui.button(label).clicked() {
154                picked = Some(Action::ExpandBlock(*id));
155                ui.close();
156            }
157        }
158    })
159    .response
160    .on_hover_text("Open a block on this level");
161    picked
162}
163
164#[cfg(all(test, feature = "kittest"))]
165mod kittest_visual {
166    use super::*;
167    use crate::canvas::palette::Luminance;
168    use crate::document::Block;
169    use crate::font::build_fonts;
170    use crate::preferences::{FontChoice, Theme};
171    use egui::{Rect, pos2, vec2};
172    use egui_kittest::Harness;
173
174    /// The address bar over a three-deep path: arrows, crumbs, trailing menu.
175    #[test]
176    fn address_bar_strip() {
177        let mut doc = Document::default();
178        let mut parent = doc.top_id;
179        let mut path = BlockPath::empty();
180        for name in ["Thing 1", "Core"] {
181            let id = doc.add_child(
182                parent,
183                Block::new(
184                    name.into(),
185                    Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
186                ),
187            );
188            path.push(id);
189            parent = id;
190        }
191        // A child of the innermost level, so the trailing menu has something.
192        doc.add_child(
193            parent,
194            Block::new(
195                "alu".into(),
196                Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
197            ),
198        );
199        let mut harness = Harness::builder()
200            .with_size(vec2(560.0, 90.0))
201            .build_ui(move |ui| {
202                let ctx = ui.ctx().clone();
203                egui_extras::install_image_loaders(&ctx);
204                ctx.set_fonts(build_fonts(FontChoice::Basic));
205                ctx.set_visuals(Theme::Catppuccin.palette(Luminance::Dark).egui_visuals());
206                // The row as the toolbar hosts it: one frame, arrows then
207                // crumbs (the toolbar's own hierarchy buttons sit between).
208                egui::Frame::popup(ui.style()).show(ui, |ui| {
209                    ui.horizontal(|ui| {
210                        let _ = arrows(
211                            ui,
212                            PathHistory {
213                                can_back: true,
214                                can_forward: false,
215                            },
216                        );
217                        let _ = breadcrumb(ui, &doc, &path);
218                    });
219                });
220            });
221        harness.run();
222        harness.snapshot("address_bar");
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::document::Block;
230    use egui::{Rect, pos2};
231
232    /// A document `top → a → b → …` deep enough to elide, returning the ids in
233    /// path order.
234    fn nested(depth: usize) -> (Document, Vec<RectId>) {
235        let mut doc = Document::default();
236        let mut parent = doc.top_id;
237        let mut ids = Vec::new();
238        for i in 0..depth {
239            let id = doc.add_child(
240                parent,
241                Block::new(
242                    format!("level{i}"),
243                    Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
244                ),
245            );
246            ids.push(id);
247            parent = id;
248        }
249        (doc, ids)
250    }
251
252    fn path_of(ids: &[RectId]) -> BlockPath {
253        let mut path = BlockPath::empty();
254        for &id in ids {
255            path.push(id);
256        }
257        path
258    }
259
260    #[test]
261    fn a_short_path_shows_the_root_and_every_segment() {
262        let (doc, ids) = nested(2);
263        let (elided, crumbs) = crumbs(&doc, &path_of(&ids));
264        assert!(!elided);
265        let labels: Vec<&str> = crumbs.iter().map(|c| c.label.as_str()).collect();
266        assert_eq!(labels, [ROOT_CRUMB, "level0", "level1"]);
267        assert_eq!(crumbs[0].target, None, "the root crumb clears the path");
268        assert_eq!(crumbs[2].target, Some(ids[1]));
269    }
270
271    /// A path deeper than the limit keeps its tail — where you are — and drops
272    /// the head behind an inert ellipsis.
273    #[test]
274    fn a_deep_path_elides_its_head() {
275        let (doc, ids) = nested(8);
276        let (elided, crumbs) = crumbs(&doc, &path_of(&ids));
277        assert!(elided);
278        assert_eq!(crumbs.len(), MAX_CRUMBS);
279        let labels: Vec<&str> = crumbs.iter().map(|c| c.label.as_str()).collect();
280        assert_eq!(labels, ["level3", "level4", "level5", "level6", "level7"]);
281        // The root itself is among what was dropped, so no crumb clears the path.
282        assert!(crumbs.iter().all(|c| c.target.is_some()));
283    }
284
285    #[test]
286    fn the_root_alone_is_one_crumb() {
287        let (doc, _) = nested(0);
288        let (elided, crumbs) = crumbs(&doc, &BlockPath::empty());
289        assert!(!elided);
290        assert_eq!(crumbs.len(), 1);
291        assert_eq!(crumbs[0].label, ROOT_CRUMB);
292    }
293}