Skip to main content

blockworx/shell/
top_bar.rs

1//! The top bar: docked, flat, translucent, 54 tall, and the only persistent
2//! chrome along the top of the window.
3//!
4//! Left is navigation, right is actions, centre is mode. That grammar is the
5//! whole of the bar's layout rule, and it is why the document chip, the action
6//! cluster, the viewing pill and the status chip's breadcrumb are one
7//! component rather than four.
8//!
9//! **Nothing says "saved".** Every edit is already durable, so a save
10//! indicator would be a lie about a step the user never takes. The dot beside
11//! the menu is a *liveness* signal instead: green when everything is recorded,
12//! yellow while the file catches up, red where nothing may be written, grey
13//! for a session with no file at all — and each of the four says which it is
14//! when the pointer rests on it.
15//!
16//! **Viewing is a state of this bar, not a second object.** While the lens is
17//! open the bar tints amber and its centre fills with the rev, the stepper and
18//! Return; nothing new arrives and nothing below it moves. Two of the three
19//! signals live here — the tint and the words — with the dimmed tool rail and
20//! the drained canvas ([`Saturation`](blockworx_paint::Saturation)) making up
21//! the rest.
22
23use crate::canvas::convert::IntoEgui as _;
24use blockworx_doc::rev::Rev;
25use blockworx_store::doc::{At, TimeStep, Viewing};
26
27use crate::{
28    history::{Direction, consequence},
29    kernel::{Consequences, Crumb, Lens, Liveness, Locked, TopBar},
30    panels::overlay::{EXPORT_ICON, export_format_menu, icon_image},
31    preferences::Preferences,
32    shell::glass::{self, Live, Opened, Tint},
33    theme::{Role, Theme},
34    tools::{
35        commands::{Act, CommandId, CommandSet},
36        tool::Action,
37    },
38};
39// The one effect the bar raises is the rename, and only a container has a name
40// to change.
41#[cfg(not(target_arch = "wasm32"))]
42use crate::tools::commands::Effect;
43
44/// What the bar knows about the document it names, beyond the name itself.
45#[cfg(not(target_arch = "wasm32"))]
46pub struct Document<'a> {
47    /// The rename box's text, which has to outlive the frame the user is
48    /// typing in. Reset to the name whenever the box opens, so it always
49    /// starts from the document's real name.
50    pub draft: &'a mut String,
51    pub renaming: blockworx_store::doc::Renaming,
52}
53
54/// Everything the bar renders against: what the session says, and what
55/// the shell adds — which panel is up, the appearance menu, the recent
56/// list and the rename box.
57pub struct Docked<'a> {
58    pub model: &'a TopBar,
59    /// Whether the navigator is on screen, which draws Browse pressed.
60    pub navigator: Opened,
61    pub theme: &'a Theme,
62    pub prefs: &'a mut Preferences,
63    /// The containers the menu offers to reopen, most recent first.
64    #[cfg(not(target_arch = "wasm32"))]
65    pub recent: &'a [std::path::PathBuf],
66    /// The document the breadcrumb's root segment can rename.
67    #[cfg(not(target_arch = "wasm32"))]
68    pub document: Document<'a>,
69}
70
71/// What one bar frame asked for: an action to dispatch, or the navigator
72/// toggled — which is chrome state, not a document action.
73pub struct Clicked {
74    pub action: Option<Act>,
75    pub browse: bool,
76}
77
78/// Draw the bar in its berth.
79pub fn top_bar(
80    chrome: &mut super::Chrome,
81    commands: &mut CommandSet,
82    mut bar: Docked<'_>,
83) -> Clicked {
84    let viewing = bar.model.lens.viewing;
85    let width = chrome.safe().viewport().width();
86    let tint = tint(chrome.ctx(), viewing, bar.theme);
87    chrome.shaped(glass::Berth::TopBar, egui::Vec2::ZERO, tint, |ui| {
88        ui.set_width(width - 2.0 * f32::from(glass::Shape::TopBar.margin().left));
89        let mut clicked = Clicked {
90            action: None,
91            browse: false,
92        };
93        let take = |clicked: &mut Clicked, act: Option<Act>| {
94            if act.is_some() {
95                clicked.action = act;
96            }
97        };
98        let picked = left(ui, commands, &mut bar);
99        take(&mut clicked, picked.action);
100        // The right run is laid out from the right edge inward, and what is
101        // left between the two runs is the centre — the mockup's own
102        // `.left{flex:none} .center{flex:1} .right{flex:none}`.
103        ui.allocate_ui_with_layout(
104            ui.available_size(),
105            egui::Layout::right_to_left(egui::Align::Center),
106            |ui| {
107                let right = right(ui, commands, &bar.model.steps, bar.navigator, viewing);
108                take(&mut clicked, right.action);
109                clicked.browse |= right.browse;
110                take(
111                    &mut clicked,
112                    centre(ui, &bar.model.lens, bar.theme).map(Act::from),
113                );
114            },
115        );
116        clicked
117    })
118}
119
120/// How amber the bar is this frame. The mockup transitions the *background*
121/// where it swaps the centre outright, so the colour is what animates and the
122/// words simply appear — the bar you already look at changes state.
123fn tint(ctx: &egui::Context, viewing: Viewing, theme: &Theme) -> Tint {
124    let through = glass::Progress::new(ctx.animate_bool_with_time(
125        egui::Id::new("top_bar_tint"),
126        matches!(viewing, Viewing::Past(_)),
127        glass::TINT_MOTION.as_secs_f32(),
128    ));
129    if f32::from(through) <= 0.0 {
130        return Tint::None;
131    }
132    Tint::Over(
133        theme
134            .resolve(Role::ViewingTint)
135            .egui()
136            .gamma_multiply(f32::from(through)),
137    )
138}
139
140/// Left: the document menu, the liveness dot, and the edit-context breadcrumb
141/// rooted at the document's own name.
142fn left(ui: &mut egui::Ui, commands: &mut CommandSet, bar: &mut Docked<'_>) -> Picked {
143    let mut picked = menu(ui, commands, bar);
144    live_dot(ui, bar.model.liveness, bar.theme);
145    if let Some(crumb) = breadcrumb(ui, bar) {
146        picked.action = Some(crumb);
147    }
148    picked
149}
150
151/// What the bar's left run asked for.
152#[derive(Default)]
153struct Picked {
154    action: Option<Act>,
155}
156
157/// The mockup's `.dot`. It is a mark rather than a control, so it is sized
158/// to the words beside it — and it always carries them, since a colour alone
159/// is a signal only to whoever already knows the code.
160fn live_dot(ui: &mut egui::Ui, liveness: Liveness, theme: &Theme) {
161    let (role, says) = dot(liveness);
162    ui.add_space(DOT_LEAD);
163    let (rect, response) =
164        ui.allocate_exact_size(egui::Vec2::splat(DOT_SIZE), egui::Sense::hover());
165    ui.painter()
166        .circle_filled(rect.center(), DOT_SIZE * 0.5, theme.resolve(role).egui());
167    response.on_hover_text(says);
168    ui.add_space(DOT_LEAD);
169}
170
171/// What the dot says, in colour and in words. Every state says both: the
172/// user could read three of the four colours and not the fourth, and a mark
173/// that means something only to whoever wrote it means nothing.
174fn dot(liveness: Liveness) -> (Role, &'static str) {
175    match liveness {
176        Liveness::Recorded => (Role::LiveDot, "All changes recorded"),
177        Liveness::ReadOnly(Locked::Lens) => (
178            Role::DotReadOnly,
179            "Read-only \u{2014} an earlier rev is on the canvas",
180        ),
181        Liveness::ReadOnly(Locked::Container) => (
182            Role::DotReadOnly,
183            "Read-only \u{2014} this document was opened without a write lock",
184        ),
185        Liveness::Scratch => (
186            Role::DotScratch,
187            "Scratch session \u{2014} not yet saved to disk",
188        ),
189    }
190}
191
192/// The edit-context breadcrumb, rooted at the document.
193///
194/// It collapses from the middle: the root and the level the canvas is
195/// standing on are never hidden, and what falls out between them goes behind
196/// one ellipsis that lists it. Segments truncate rather than wrap, so a long
197/// block name cannot push the centre off the bar.
198fn breadcrumb(ui: &mut egui::Ui, bar: &mut Docked<'_>) -> Option<Act> {
199    let mut action = None;
200    ui.spacing_mut().item_spacing.x = SEGMENT_GAP;
201    let here = bar.model.scope.here();
202    for shown in bar.model.scope.collapsed() {
203        match shown {
204            Crumb::Root => {
205                if let Some(picked) = root_segment(ui, bar, here) {
206                    action = Some(picked);
207                }
208            }
209            Crumb::Level(depth) => {
210                separator(ui);
211                let name = &bar.model.scope.names[depth - 1];
212                if segment(ui, name, Standing::from(depth == here)) {
213                    action = Some(bar.model.scope.up_to(depth).into());
214                }
215            }
216            Crumb::Elided(hidden) => {
217                separator(ui);
218                if let Some(depth) = elision(ui, &bar.model.scope.names, hidden) {
219                    action = Some(bar.model.scope.up_to(depth).into());
220                }
221            }
222        }
223    }
224    action
225}
226
227/// The document's own segment: the way back to the root, and — on a double
228/// click — the box that types over the name.
229///
230/// Both gestures on one word is the cost of the grammar: the document sits at
231/// the head of the breadcrumb, and it is renamed where it stands. The two are
232/// told apart in time rather than by giving one of them somewhere else
233/// to live.
234fn root_segment(ui: &mut egui::Ui, bar: &mut Docked<'_>, here: usize) -> Option<Act> {
235    #[cfg(not(target_arch = "wasm32"))]
236    {
237        let renaming = bar.document.renaming;
238        let name = bar.model.name.as_str();
239        let draft = &mut *bar.document.draft;
240        if let Some(typed) = name_in_place(ui, name, draft, renaming, Standing::from(here == 0)) {
241            return Some(match typed {
242                Typed::Renamed(to) => Effect::RenameDocument(to).into(),
243                Typed::Rose => bar.model.scope.up_to(0).into(),
244            });
245        }
246        None
247    }
248    #[cfg(target_arch = "wasm32")]
249    {
250        // The web build has no container to rename, so the name is a segment
251        // like any other there — full ink all the same (`.crumb.root`).
252        let standing = Standing::from(here == 0);
253        let response = crumb(ui, &bar.model.name, Voice::Full, Listens::from(standing));
254        (standing == Standing::Ancestor && response.clicked())
255            .then(|| bar.model.scope.up_to(0).into())
256    }
257}
258
259/// What a click on the document's segment came to.
260#[cfg(not(target_arch = "wasm32"))]
261enum Typed {
262    Renamed(String),
263    Rose,
264}
265
266/// The document's name, and — once it has been double-clicked — the box that
267/// types over it.
268///
269/// Whether the box is open is egui's to remember, not the document's: it is
270/// chrome state that dies with the session, and threading it through `App`
271/// would make a transient of the same rank as the path.
272#[cfg(not(target_arch = "wasm32"))]
273fn name_in_place(
274    ui: &mut egui::Ui,
275    name: &str,
276    draft: &mut String,
277    renaming: blockworx_store::doc::Renaming,
278    standing: Standing,
279) -> Option<Typed> {
280    let editing = ui
281        .ctx()
282        .data(|d| d.get_temp(renaming_id()).unwrap_or(false));
283    let close = |ui: &egui::Ui| {
284        ui.ctx().data_mut(|d| d.insert_temp(renaming_id(), false));
285    };
286    if !editing {
287        // The mockup gives the document full ink wherever it stands
288        // (`.crumb.root`), and it answers a gesture wherever it stands too,
289        // since the rename lives on it even at the root.
290        let response = crumb(ui, name, Voice::Full, Listens::Yes);
291        let renames =
292            response.double_clicked() && renaming == blockworx_store::doc::Renaming::Offered;
293        if renames {
294            draft.clear();
295            draft.push_str(name);
296            ui.ctx().data_mut(|d| d.insert_temp(renaming_id(), true));
297            ui.ctx().memory_mut(|m| m.request_focus(name_field_id()));
298        }
299        // egui reports the first half of a double click as a click of its
300        // own, so the rise is armed and waited out rather than taken: a
301        // rename would otherwise leave the root on the canvas and a view
302        // entry on the undo stack on its way to the box.
303        if renames || response.double_clicked() {
304            disarm_rise(ui.ctx());
305        } else if response.clicked() && standing == Standing::Ancestor {
306            arm_rise(ui.ctx());
307        }
308        let hint = match renaming {
309            blockworx_store::doc::Renaming::Offered => RENAME_HINT,
310            blockworx_store::doc::Renaming::Withheld => RENAME_WITHHELD,
311        };
312        response.on_hover_text(hint);
313        return rise_is_due(ui.ctx()).then_some(Typed::Rose);
314    }
315    let field = ui.add(
316        egui::TextEdit::singleline(draft)
317            .id(name_field_id())
318            .desired_width(NAME_WIDTH),
319    );
320    if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
321        close(ui);
322        return None;
323    }
324    // Clicking away is a commit rather than a cancel: the box opened on the
325    // document's own name, so the worst it can settle on is the name it
326    // started with.
327    if field.lost_focus() {
328        close(ui);
329        return (draft.as_str() != name).then(|| Typed::Renamed(draft.clone()));
330    }
331    None
332}
333
334/// The mockup's `···`, which lists what it hid rather than merely saying
335/// that it hid something.
336///
337/// Its box is fixed rather than hugged: egui reads a button's margins out of
338/// the state it is in, so a box left to its contents is a box that changes
339/// width under the pointer — the shifting item 2 is about.
340fn elision(ui: &mut egui::Ui, names: &[String], hidden: Vec<usize>) -> Option<usize> {
341    let mut picked = None;
342    let button = egui::Button::new(egui::RichText::new(ELLIPSIS).weak())
343        .min_size(egui::vec2(ELLIPSIS_WIDTH, CRUMB_HEIGHT))
344        .corner_radius(CRUMB_RADIUS)
345        .frame_when_inactive(false);
346    egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| {
347        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
348        for depth in hidden {
349            if let Some(name) = names.get(depth - 1)
350                && ui.button(name).clicked()
351            {
352                picked = Some(depth);
353            }
354        }
355    });
356    picked
357}
358
359/// Whether a segment is where the canvas is standing or somewhere it could go.
360#[derive(Clone, Copy, PartialEq, Eq)]
361enum Standing {
362    Here,
363    Ancestor,
364}
365
366impl From<bool> for Standing {
367    fn from(here: bool) -> Self {
368        if here {
369            Standing::Here
370        } else {
371            Standing::Ancestor
372        }
373    }
374}
375
376/// One segment. The level on the canvas reads at full strength and is a
377/// label — *"the last segment in the breadcrumb is not clickable. It's a
378/// label that indicates where we are in the hierarchy."* — and its ancestors
379/// are quieted, pressable words.
380fn segment(ui: &mut egui::Ui, text: &str, standing: Standing) -> bool {
381    let response = crumb(ui, text, Voice::from(standing), Listens::from(standing));
382    match standing {
383        Standing::Here => false,
384        Standing::Ancestor => response.on_hover_text(format!("Go to {text}")).clicked(),
385    }
386}
387
388/// How loudly a segment reads. The mockup gives two of them full ink — the
389/// document at the head of the trail (`.crumb.root`) and the level the canvas
390/// is standing on (`.crumb.here`) — and mutes what lies between.
391#[derive(Clone, Copy, PartialEq, Eq)]
392enum Voice {
393    Full,
394    Muted,
395}
396
397impl From<Standing> for Voice {
398    fn from(standing: Standing) -> Self {
399        match standing {
400            Standing::Here => Voice::Full,
401            Standing::Ancestor => Voice::Muted,
402        }
403    }
404}
405
406/// Whether a segment answers the pointer at all. Where the canvas is already
407/// standing it does not: `.crumb.here` is `cursor:default` with its hover
408/// treatment struck out, because there is nowhere for a click to go.
409#[derive(Clone, Copy, PartialEq, Eq)]
410enum Listens {
411    Yes,
412    No,
413}
414
415impl From<Standing> for Listens {
416    fn from(standing: Standing) -> Self {
417        match standing {
418            Standing::Here => Listens::No,
419            Standing::Ancestor => Listens::Yes,
420        }
421    }
422}
423
424/// The mockup's `.crumb`: 32 tall, 9 of shoulder either side, and capped at
425/// [`CRUMB_WIDTH`] with an ellipsis so one long name cannot push the bar's
426/// centre aside.
427///
428/// Painted rather than pressed, because egui's button is not a fixed box:
429/// its margins come out of the visuals for the state it is in
430/// (`button_padding + expansion - stroke width`), so a segment changes size
431/// the moment the pointer crosses it. The user: *"The shifting of text layout
432/// as you hover over the breadcrumb elements is disturbing."* Here the box is
433/// the same in every state and the hover is a fill inside it.
434fn crumb(ui: &mut egui::Ui, text: &str, voice: Voice, listens: Listens) -> egui::Response {
435    let font = egui::TextStyle::Body.resolve(ui.style());
436    let mut job = egui::text::LayoutJob::simple_singleline(
437        text.to_owned(),
438        font,
439        // Chosen once the response says whether the pointer is on it, which
440        // is after the galley has to exist to be measured.
441        egui::Color32::PLACEHOLDER,
442    );
443    job.wrap = egui::text::TextWrapping::truncate_at_width(CRUMB_WIDTH - 2.0 * CRUMB_PAD);
444    let galley = ui.painter().layout_job(job);
445    let size = egui::vec2(galley.size().x + 2.0 * CRUMB_PAD, CRUMB_HEIGHT);
446    let sense = match listens {
447        Listens::Yes => egui::Sense::click(),
448        Listens::No => egui::Sense::hover(),
449    };
450    let (rect, response) = ui.allocate_exact_size(size, sense);
451    if !ui.is_rect_visible(rect) {
452        return response;
453    }
454    let visuals = ui.visuals();
455    let ink = match (voice, listens == Listens::Yes && response.hovered()) {
456        (Voice::Full, _) | (Voice::Muted, true) => glass::full_ink(visuals),
457        (Voice::Muted, false) => visuals.weak_text_color(),
458    };
459    if listens == Listens::Yes && (response.hovered() || response.is_pointer_button_down_on()) {
460        let fill = ui.style().interact(&response).weak_bg_fill;
461        ui.painter().rect_filled(rect, CRUMB_RADIUS, fill);
462    }
463    let at = egui::pos2(
464        rect.left() + CRUMB_PAD,
465        rect.center().y - galley.size().y * 0.5,
466    );
467    ui.painter().galley(at, galley, ink);
468    response
469}
470
471/// Arm the rise the document's segment was asked for, to be taken once the
472/// second click of a double cannot arrive any more.
473#[cfg(not(target_arch = "wasm32"))]
474fn arm_rise(ctx: &egui::Context) {
475    let at = ctx.input(|i| i.time);
476    ctx.data_mut(|data| data.insert_temp(rising_id(), Rising { at }));
477    ctx.request_repaint_after(double_click_window(ctx));
478}
479
480#[cfg(not(target_arch = "wasm32"))]
481fn disarm_rise(ctx: &egui::Context) {
482    ctx.data_mut(|data| data.remove::<Rising>(rising_id()));
483}
484
485/// Whether an armed rise has outlived egui's own double-click window, and so
486/// is a single click after all. It asks for exactly the frame it will fire
487/// on, so an idle bar still settles.
488#[cfg(not(target_arch = "wasm32"))]
489fn rise_is_due(ctx: &egui::Context) -> bool {
490    let Some(armed) = ctx.data(|data| data.get_temp::<Rising>(rising_id())) else {
491        return false;
492    };
493    let waited = core::time::Duration::from_secs_f64((ctx.input(|i| i.time) - armed.at).max(0.0));
494    match double_click_window(ctx).checked_sub(waited) {
495        None | Some(core::time::Duration::ZERO) => {
496            disarm_rise(ctx);
497            true
498        }
499        Some(left) => {
500            ctx.request_repaint_after(left);
501            false
502        }
503    }
504}
505
506/// How long egui itself waits before it stops calling two clicks one — read
507/// from egui rather than restated, so the bar and the toolkit cannot
508/// disagree about what a double click is.
509#[cfg(not(target_arch = "wasm32"))]
510fn double_click_window(ctx: &egui::Context) -> core::time::Duration {
511    core::time::Duration::from_secs_f64(
512        ctx.options(|options| options.input_options.max_double_click_delay),
513    )
514}
515
516/// A click on the document's segment that has not yet proved to be a single
517/// one.
518#[cfg(not(target_arch = "wasm32"))]
519#[derive(Clone, Copy)]
520struct Rising {
521    at: f64,
522}
523
524fn separator(ui: &mut egui::Ui) {
525    ui.label(egui::RichText::new(SEPARATOR).weak());
526}
527
528/// The centre: empty unless a mode is active, and centred in the room the
529/// two runs left rather than in the window — the mockup's own
530/// `.center{flex:1;justify-content:center}`.
531///
532/// egui lays a row out from where the cursor is, so the row is measured on a
533/// pass of its own before it is placed. The alternative — adding up the
534/// widths of what the row holds — is the same layout written twice, and the
535/// copy drifts the first time the mode gains a control.
536fn centre(ui: &mut egui::Ui, lens: &Lens, theme: &Theme) -> Option<Action> {
537    if !matches!(lens.viewing, Viewing::Past(_)) {
538        return None;
539    }
540    let room = ui.available_rect_before_wrap();
541    let row = |rect: egui::Rect| {
542        egui::UiBuilder::new()
543            .max_rect(rect)
544            .layout(egui::Layout::left_to_right(egui::Align::Center))
545    };
546    let wanted = {
547        let mut probe = ui.new_child(row(room).sizing_pass().invisible());
548        let _ = mode(&mut probe, lens, theme);
549        probe.min_rect().width().min(room.width())
550    };
551    let at = egui::Rect::from_min_size(
552        egui::pos2(room.center().x - wanted * 0.5, room.top()),
553        egui::vec2(wanted, room.height()),
554    );
555    let mut child = ui.new_child(row(at));
556    let action = mode(&mut child, lens, theme);
557    ui.advance_cursor_after_rect(room);
558    action
559}
560
561/// What the centre holds while the lens is open: the rev, the stepper that
562/// walks it, and the one way out.
563fn mode(ui: &mut egui::Ui, lens: &Lens, theme: &Theme) -> Option<Action> {
564    let Viewing::Past(at) = lens.viewing else {
565        return None;
566    };
567    let ink = theme.resolve(Role::ViewingInk).egui();
568    let mut action = None;
569    // The centre is inverse video against the amber, so the icons on it have
570    // to be too: egui tints them from the enclosing style's foreground, which
571    // is the one meant for the untinted bar around it.
572    ui.visuals_mut().widgets.inactive.fg_stroke.color = ink;
573    ui.label(
574        egui::RichText::new(headline(at, &lens.age))
575            .color(ink)
576            .strong(),
577    );
578    if let Some(stepped) = stepper(ui, lens) {
579        action = Some(stepped);
580    }
581    if glass::tap_filled(
582        ui,
583        RETURN,
584        theme.resolve(Role::ViewingInk).egui(),
585        theme.resolve(Role::ViewingReturnInk).egui(),
586        RETURN_HINT,
587    )
588    .clicked()
589    {
590        action = Some(Action::ViewHead);
591    }
592    action
593}
594
595/// What the centre leads with: the rev, and how long ago it was written where
596/// there is a clock to say. Lower-case "rev" throughout the chrome.
597///
598/// The rev's *name* is not here: the history row is where a tag is read —
599/// one surface for the name rather than two that can word it differently.
600fn headline(at: Rev, age: &str) -> String {
601    let rev = format!("Rev {}", at.get());
602    match age.trim() {
603        "" => rev,
604        age => format!("{rev} \u{00b7} {age}"),
605    }
606}
607
608/// The stepper: adjacent revs differ by one operation, so walking them is how
609/// a reader finds the change — row selection is the fallback, not the primary
610/// interaction. Stepping off the newest rev closes the loop back into
611/// the writable present.
612fn stepper(ui: &mut egui::Ui, lens: &Lens) -> Option<Action> {
613    let mut action = None;
614    for (icon, hover, to) in [
615        (
616            STEP_OLDER_ICON,
617            "Older rev",
618            lens.viewing.stepped(lens.head, TimeStep::Back),
619        ),
620        (
621            STEP_NEWER_ICON,
622            "Newer rev",
623            lens.viewing.stepped(lens.head, TimeStep::Forward),
624        ),
625    ] {
626        if glass::tap_button(ui, icon, Live::from(to.is_some()), hover).clicked() {
627            action = Some(match to {
628                Some(At::Rev(rev)) => Action::ViewRev(rev),
629                _ => Action::ViewHead,
630            });
631        }
632    }
633    action
634}
635
636/// Right: undo, redo, then the two that move the view and the one that opens
637/// the navigator. Laid out from the right edge inward, so the list below
638/// reads backwards and the bar reads `undo redo | up fit browse`.
639fn right(
640    ui: &mut egui::Ui,
641    commands: &mut CommandSet,
642    steps: &Consequences,
643    navigator: Opened,
644    viewing: Viewing,
645) -> Clicked {
646    let mut clicked = None;
647    let browse = glass::tap_toggle(
648        ui,
649        BROWSE_ICON,
650        Live::Yes,
651        navigator,
652        browse_hover(ui.ctx()),
653    )
654    .clicked();
655    let fit = commands.contains(CommandId::FitView);
656    if glass::tap_button(ui, FIT_ICON, Live::from(fit), fit_hover(ui.ctx())).clicked() {
657        clicked = Some(CommandId::FitView);
658    }
659    // The user's own order: *"After Undo/Redo | <Go Up> <Fit> <Panel>, where
660    // <Go Up> is enabled if we can pop something off of our scope."* The
661    // registry is what knows whether there is a scope to pop, so the button
662    // reads it rather than forming a second opinion.
663    let can_rise = Live::from(commands.contains(CommandId::GoUp));
664    if glass::tap_button(ui, UP_ICON, can_rise, rise_hover(can_rise)).clicked() {
665        clicked = Some(CommandId::GoUp);
666    }
667    // What takes back an edit and what moves the camera are two different
668    // kinds of thing, so the mockup's separator stands between them.
669    glass::separator(ui, glass::Run::Row);
670    if let Some(id) = tape(ui, commands, steps, viewing) {
671        clicked = Some(id);
672    }
673    Clicked {
674        action: clicked.and_then(|id| commands.take(id)),
675        browse,
676    }
677}
678
679/// Undo and redo, in the order a right-to-left run wants them.
680fn tape(
681    ui: &mut egui::Ui,
682    commands: &mut CommandSet,
683    steps: &Consequences,
684    viewing: Viewing,
685) -> Option<CommandId> {
686    let mut clicked = None;
687    for (id, icon, step, of) in [
688        (CommandId::Redo, REDO_ICON, Direction::Forward, &steps.redo),
689        (CommandId::Undo, UNDO_ICON, Direction::Back, &steps.undo),
690    ] {
691        let live = Live::from(commands.contains(id));
692        let hover = consequence(step, of.as_ref(), (live == Live::Yes).into(), viewing);
693        if glass::tap_button(ui, icon, live, hover).clicked() {
694            clicked = Some(id);
695        }
696    }
697    clicked
698}
699
700/// Fit names its chord, since it is the one button whose keyboard equivalent
701/// nothing else shows.
702fn fit_hover(ctx: &egui::Context) -> String {
703    let chords: Vec<String> = crate::tools::commands::chords(CommandId::FitView)
704        .map(|chord| crate::keys::spelled(ctx, *chord))
705        .collect();
706    if chords.is_empty() {
707        FIT.to_owned()
708    } else {
709        format!("{FIT} ({})", chords.join(", "))
710    }
711}
712
713/// The word for the button is *Browse*, not Panels — it is not workspace
714/// furniture being shown or hidden.
715fn browse_hover(ctx: &egui::Context) -> String {
716    let chord = ctx.format_shortcut(&egui::KeyboardShortcut::new(
717        egui::Modifiers::COMMAND,
718        egui::Key::Backslash,
719    ));
720    format!("{BROWSE} ({chord})")
721}
722
723/// Rising a level names where it would land, and where it cannot: the
724/// button's consequence varies with where the canvas is standing.
725fn rise_hover(can_rise: Live) -> &'static str {
726    if can_rise == Live::Yes {
727        "Go up a level \u{2014} out to the scope that holds this one"
728    } else {
729        "Go up a level \u{2014} the canvas is at the document root"
730    }
731}
732
733/// The project itself, offered under Help.
734const GITHUB_URL: &str = "https://github.com/samitbasu/blockworx";
735
736/// The bar's one menu. The bar has a single document affordance, so a second
737/// icon beside it would be a second document menu.
738fn menu(ui: &mut egui::Ui, commands: &mut CommandSet, bar: &mut Docked<'_>) -> Picked {
739    let mut picked = Picked::default();
740    let button = egui::Button::image(icon_image(ui, MENU_ICON))
741        .min_size(egui::Vec2::splat(glass::TAP))
742        .frame_when_inactive(false);
743    egui::containers::menu::MenuButton::from_button(button)
744        .ui(ui, |ui| {
745            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
746            #[cfg(not(target_arch = "wasm32"))]
747            if let Some(chosen) =
748                crate::panels::file_menu::menu(ui, bar.recent, bar.model.lens.viewing)
749            {
750                picked.action = Some(chosen.into());
751            }
752            #[cfg(not(target_arch = "wasm32"))]
753            ui.separator();
754            if let Some(chosen) = import_export(ui, commands) {
755                picked.action = Some(chosen);
756            }
757            ui.separator();
758            ui.menu_button("Help", |ui| {
759                // See `preferences_menu::no_wrap`: a menu popup shrinks to
760                // fit, and a proportional font can round a label just past
761                // that width, wrapping the last glyph.
762                ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
763                if ui.button("GitHub").clicked() {
764                    ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
765                }
766            });
767            ui.menu_button("Preferences", |ui| {
768                crate::preferences_menu::menu(ui, bar.prefs);
769            });
770            ui.separator();
771            palette_hint(ui);
772        })
773        .0
774        .on_hover_text("Document menu");
775    picked
776}
777
778/// The command palette's chord, told rather than offered: the palette is a
779/// keyboard surface, and no persistent search field belongs in the chrome.
780fn palette_hint(ui: &mut egui::Ui) {
781    let chord = ui.ctx().format_shortcut(&egui::KeyboardShortcut::new(
782        egui::Modifiers::COMMAND,
783        egui::Key::K,
784    ));
785    ui.add_enabled(
786        false,
787        egui::Button::new(format!("Search tools, blocks and revs\u{2003}{chord}")),
788    );
789}
790
791/// Diagrams in and out. The format is picked up front rather than in a save
792/// dialog: the web dialog can offer no format chooser, so a dialog-driven
793/// choice would always yield SVG there.
794fn import_export(ui: &mut egui::Ui, commands: &mut CommandSet) -> Option<Act> {
795    let mut action = None;
796    // Import lands a document as a block in the current level, so it is a
797    // write like any other and the registry gates it.
798    let import = ui.add_enabled(
799        commands.contains(CommandId::Import),
800        egui::Button::image_and_text(icon_image(ui, IMPORT_ICON), "Import\u{2026}"),
801    );
802    if import.clicked() {
803        action = commands.take(CommandId::Import);
804    }
805    ui.menu_image_text_button(icon_image(ui, EXPORT_ICON), "Export", |ui| {
806        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
807        if let Some(format) = export_format_menu(ui, crate::export::ExportScope::View) {
808            action = Some(
809                Action::Export {
810                    format,
811                    selection: None,
812                }
813                .into(),
814            );
815        }
816    });
817    action
818}
819
820/// Escape returns to the present from anywhere.
821///
822/// Claimed here rather than in the editor's keyboard handler because the bar
823/// is in the mode exactly when the key means this, and claimed *before* the
824/// canvas pass so a tool still armed from before the lens opened cannot answer
825/// the key first. A focused text field — the palette's box, the rename box —
826/// keeps its own Escape.
827pub fn escape_exits(ctx: &egui::Context) -> Option<Action> {
828    let pressed = !ctx.egui_wants_keyboard_input()
829        && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
830    pressed.then_some(Action::ViewHead)
831}
832
833/// Where the armed rise is kept: chrome state that dies with the session.
834#[cfg(not(target_arch = "wasm32"))]
835fn rising_id() -> egui::Id {
836    egui::Id::new("top_bar_rising")
837}
838
839#[cfg(not(target_arch = "wasm32"))]
840fn renaming_id() -> egui::Id {
841    egui::Id::new("top_bar_renaming")
842}
843
844#[cfg(not(target_arch = "wasm32"))]
845fn name_field_id() -> egui::Id {
846    egui::Id::new("top_bar_name_field")
847}
848
849/// Where a document is renamed.
850#[cfg(not(target_arch = "wasm32"))]
851const RENAME_HINT: &str = "Double-click to rename";
852/// The same name, on a session that cannot rename it — disabled, not silent.
853#[cfg(not(target_arch = "wasm32"))]
854const RENAME_WITHHELD: &str = "This session has no diagram on disk to rename";
855/// How wide the box that types over the name stands.
856#[cfg(not(target_arch = "wasm32"))]
857const NAME_WIDTH: f32 = 160.0;
858
859const FIT: &str = "Zoom to fit";
860const BROWSE: &str = "Browse";
861const RETURN: &str = "Return";
862const RETURN_HINT: &str = "Return to current (Escape)";
863
864/// The mockup's `.dot{width:8px;margin:0 6px 0 4px}`.
865const DOT_SIZE: f32 = 8.0;
866const DOT_LEAD: f32 = 4.0;
867
868/// The mockup's `.crumb{height:32px;padding:0 9px;max-width:200px;
869/// border-radius:9px}` and `.crumbs{gap:1px}`.
870const CRUMB_HEIGHT: f32 = 32.0;
871const CRUMB_WIDTH: f32 = 200.0;
872const CRUMB_PAD: f32 = 9.0;
873const CRUMB_RADIUS: u8 = 9;
874/// The mockup's `.crumb.ell{padding:0 7px}` around three dots — a fixed box,
875/// so the collapse's own segment does not shift under the pointer either.
876const ELLIPSIS_WIDTH: f32 = 30.0;
877const SEGMENT_GAP: f32 = 1.0;
878const SEPARATOR: &str = "/";
879const ELLIPSIS: &str = "\u{00b7}\u{00b7}\u{00b7}";
880
881/// The mockup's own document-menu glyph, three lines with a short last one
882/// — *"The ellipses icon is hard to see. The icon from the mockup was
883/// better."* The selection bar's overflow keeps the horizontal ellipsis, so
884/// no two surfaces wear one mark.
885const MENU_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-menu.svg");
886const IMPORT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-import.svg");
887const UNDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-undo.svg");
888const REDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-redo.svg");
889const FIT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-fit.svg");
890const UP_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-level-up.svg");
891const BROWSE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-panel.svg");
892/// The mockup's own stepper: an arrow down into the log for the older rev
893/// and one up out of it for the newer — *"instead of left and right index
894/// controls in the rev view bar, use the up and down arrows from the mockup
895/// concept. They take up less space and feel cleaner."* The mapping is the
896/// mockup's (`#stepold` points down), and the tooltips still say which.
897const STEP_OLDER_ICON: egui::ImageSource<'static> =
898    egui::include_image!("../../icons/icon-step-older.svg");
899const STEP_NEWER_ICON: egui::ImageSource<'static> =
900    egui::include_image!("../../icons/icon-step-newer.svg");
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905    use crate::canvas::convert::IntoGeom as _;
906    use crate::history::{Consequence, Kind};
907    use crate::panels::painted::Chrome;
908    use crate::path::BlockPath;
909    use crate::path::Scope as PathScope;
910    use crate::shell::tests::screen;
911    use crate::tools::commands::{CommandContext, History};
912    use crate::widget::test_fixtures::{self as fx, Scene};
913    use blockworx_doc::fixtures::{block_id, rev};
914    use blockworx_geom::{Pos2, Rect};
915    use blockworx_store::doc::{Attachment, Saving, Viewing, Writability};
916
917    fn of(target: &str, kind: Kind) -> Consequence {
918        Consequence {
919            target: target.to_owned(),
920            kind,
921        }
922    }
923
924    const DOCUMENT: &str = "engine";
925    /// How far past a segment's own height its plate may stand — egui
926    /// expands a pressed control by a point, and the plate is still that
927    /// control's.
928    const PLATE_SLACK: f32 = 4.0;
929    /// The commit one undo would take back — what the button's tooltip names.
930    const NEWEST: &str = "Add block Filter";
931    /// The log the mode is a view of: rev 2 of four.
932    const AT: u64 = 2;
933    const HEAD: u64 = 4;
934
935    /// The session the bar is a view of.
936    struct Session {
937        history: History,
938        steps: Consequences,
939        /// What both tape buttons stand over, so a session can be put under
940        /// the lens with a view entry on top and asked whether undo works.
941        kind: Kind,
942        writability: Writability,
943        attachment: Attachment,
944        viewing: Viewing,
945        navigator: Opened,
946        prefs: Preferences,
947        theme: Theme,
948        scene: Scene,
949        path: BlockPath,
950        names: Vec<String>,
951        #[cfg(not(target_arch = "wasm32"))]
952        draft: String,
953        #[cfg(not(target_arch = "wasm32"))]
954        renaming: blockworx_store::doc::Renaming,
955        fired: Vec<Act>,
956        browses: usize,
957    }
958
959    impl Session {
960        fn new() -> Self {
961            Session {
962                history: History::doc(),
963                steps: Consequences {
964                    undo: Some(of(NEWEST, Kind::Doc)),
965                    redo: Some(of("Delete block Filter", Kind::Doc)),
966                },
967                kind: Kind::Doc,
968                writability: Writability::Writable,
969                attachment: Attachment::Attached,
970                viewing: Viewing::Head,
971                navigator: Opened::No,
972                prefs: Preferences::default(),
973                theme: Theme::default(),
974                scene: Scene::new(Vec::new()),
975                path: BlockPath::empty(),
976                names: Vec::new(),
977                #[cfg(not(target_arch = "wasm32"))]
978                draft: String::new(),
979                #[cfg(not(target_arch = "wasm32"))]
980                renaming: blockworx_store::doc::Renaming::Offered,
981                fired: Vec::new(),
982                browses: 0,
983            }
984        }
985
986        /// A session standing `depth` levels inside the document, with a name
987        /// for every level — what the breadcrumb is a picture of.
988        fn nested(depth: usize) -> Self {
989            let body = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(60.0, 60.0));
990            let mut ops = Vec::new();
991            let mut path = BlockPath::empty();
992            let mut names = Vec::new();
993            for level in 1..=depth {
994                let id = block_id(level as u32);
995                let scope = match level {
996                    1 => PathScope::Root,
997                    _ => PathScope::Block(block_id(level as u32 - 1)),
998                };
999                ops.push(fx::block_in(level as u32, scope, body.geom()));
1000                ops.push(fx::titled(level as u32, &format!("Level {level}")));
1001                path.push(id);
1002                names.push(format!("Level {level}"));
1003            }
1004            let mut session = Session::new();
1005            // The registry reads the *drawing's* scope, not the path beside
1006            // it, so a breadcrumb without one would draw Go Up dead.
1007            session.scene = Scene::new(ops).inside(block_id(depth as u32));
1008            session.path = path;
1009            session.names = names;
1010            session
1011        }
1012
1013        fn under_the_lens(mut self) -> Self {
1014            self.viewing = Viewing::Past(rev(AT));
1015            self
1016        }
1017
1018        fn frame(&mut self, ui: &mut egui::Ui) {
1019            let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
1020            let mut commands = {
1021                let drawing = self.scene.drawing();
1022                CommandSet::available(&CommandContext {
1023                    tool: &tool,
1024                    data: &drawing,
1025                    history: self.history,
1026                    current_lock: crate::edit::naming::InterfaceLock::Unlocked,
1027                    writability: self.writability,
1028                    saving: Saving::Withheld,
1029                    viewing: self.viewing,
1030                })
1031            };
1032            let mut chrome = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
1033            let steps = Consequences {
1034                undo: self.steps.undo.as_ref().map(|of| Consequence {
1035                    target: of.target.clone(),
1036                    kind: self.kind,
1037                }),
1038                redo: self.steps.redo.as_ref().map(|of| Consequence {
1039                    target: of.target.clone(),
1040                    kind: self.kind,
1041                }),
1042            };
1043            let model = TopBar {
1044                name: DOCUMENT.to_owned(),
1045                scope: crate::kernel::ScopePath {
1046                    path: self.path.clone(),
1047                    names: self.names.clone(),
1048                },
1049                steps,
1050                lens: Lens {
1051                    viewing: self.viewing,
1052                    head: rev(HEAD),
1053                    age: "3 hours ago".to_owned(),
1054                },
1055                liveness: Liveness::of(self.viewing, self.writability, self.attachment),
1056                #[cfg(not(target_arch = "wasm32"))]
1057                renaming: self.renaming,
1058            };
1059            let clicked = top_bar(
1060                &mut chrome,
1061                &mut commands,
1062                Docked {
1063                    model: &model,
1064                    navigator: self.navigator,
1065                    theme: &self.theme,
1066                    prefs: &mut self.prefs,
1067                    #[cfg(not(target_arch = "wasm32"))]
1068                    recent: &[],
1069                    #[cfg(not(target_arch = "wasm32"))]
1070                    document: Document {
1071                        draft: &mut self.draft,
1072                        renaming: self.renaming,
1073                    },
1074                },
1075            );
1076            if let Some(act) = clicked.action {
1077                self.fired.push(act);
1078            }
1079            if clicked.browse {
1080                self.browses += 1;
1081            }
1082        }
1083    }
1084
1085    /// The bar, driven through real frames and clicked through egui's own
1086    /// hit-testing.
1087    struct Bar {
1088        chrome: Chrome,
1089        session: Session,
1090    }
1091
1092    impl Bar {
1093        fn over(session: Session) -> Self {
1094            let mut bar = Bar {
1095                chrome: Chrome::new(screen().geom()),
1096                session,
1097            };
1098            // In the app's own colours, always: the bar is drawn on the
1099            // theme's surfaces, and a question about ink asked against
1100            // egui's stock palette is a question about the wrong app. The
1101            // document name was invisible for a whole pass because of it.
1102            bar.chrome.ctx().set_visuals(app_visuals());
1103            bar.settle();
1104            bar
1105        }
1106
1107        fn new() -> Self {
1108            Bar::over(Session::new())
1109        }
1110
1111        fn settle(&mut self) {
1112            let Self { chrome, session } = self;
1113            chrome.settle(|ui| session.frame(ui));
1114        }
1115
1116        fn rect(&self) -> Rect {
1117            crate::shell::berth_rect(self.chrome.ctx(), glass::Berth::TopBar)
1118                .expect("the top bar never laid out")
1119                .geom()
1120        }
1121
1122        fn click_at(&mut self, at: Pos2) {
1123            let Self { chrome, session } = self;
1124            chrome.click_at(at, |ui| session.frame(ui));
1125        }
1126
1127        fn click_on(&mut self, label: &str) {
1128            let Self { chrome, session } = self;
1129            chrome.click_on(label, |ui| session.frame(ui));
1130        }
1131
1132        fn type_text(&mut self, text: &str) {
1133            let Self { chrome, session } = self;
1134            chrome.type_text(text, |ui| session.frame(ui));
1135        }
1136
1137        fn press(&mut self, key: egui::Key) {
1138            let Self { chrome, session } = self;
1139            chrome.press(key, |ui| session.frame(ui));
1140        }
1141
1142        /// Every verb a click at each 5px step across the bar dispatched —
1143        /// finer than the narrowest control on it.
1144        fn scan(&mut self) -> Vec<&'static str> {
1145            let bar = self.rect();
1146            let mut x = bar.left();
1147            while x <= bar.right() {
1148                self.click_at(Pos2::new(x, bar.center().y));
1149                x += 5.0;
1150            }
1151            self.session
1152                .fired
1153                .iter()
1154                .map(crate::tools::commands::act_name)
1155                .collect()
1156        }
1157
1158        /// Every word the bar says with the pointer resting on each 5px step
1159        /// across it — which is where a tooltip lives.
1160        fn hovers(&mut self) -> Vec<String> {
1161            let bar = self.rect();
1162            let mut said = Vec::new();
1163            let mut x = bar.left();
1164            while x <= bar.right() {
1165                let Self { chrome, session } = self;
1166                chrome.hover_at(Pos2::new(x, bar.center().y), |ui| session.frame(ui));
1167                said.extend(self.chrome.texts().iter().map(|t| (*t).to_owned()));
1168                x += 5.0;
1169            }
1170            said
1171        }
1172
1173        /// The document menu is the bar's left end.
1174        fn open_the_menu(&mut self) {
1175            let at = self.rect();
1176            self.click_at(Pos2::new(at.left() + glass::TAP * 0.5, at.center().y));
1177        }
1178
1179        /// Two clicks on the document's own segment, which follows the dot.
1180        fn double_click_the_name(&mut self) {
1181            let at = self.name_rect();
1182            let Self { chrome, session } = self;
1183            chrome.double_click_at(at.center(), |ui| session.frame(ui));
1184        }
1185
1186        /// One click on it, and the frames after it: the second click's
1187        /// window has to run out before a single click is one.
1188        fn click_the_name(&mut self) {
1189            let at = self.name_rect();
1190            self.click_at(at.center());
1191        }
1192
1193        fn name_rect(&self) -> Rect {
1194            self.chrome
1195                .rect(DOCUMENT)
1196                .expect("the bar never drew the document's name")
1197        }
1198
1199        fn hover_at(&mut self, at: Pos2) {
1200            let Self { chrome, session } = self;
1201            chrome.hover_at(at, |ui| session.frame(ui));
1202        }
1203
1204        /// Where each of `names` landed on the last frame.
1205        fn trail(&self, names: &[&str]) -> Vec<Rect> {
1206            names
1207                .iter()
1208                .map(|name| {
1209                    self.chrome
1210                        .rect(name)
1211                        .unwrap_or_else(|| panic!("the breadcrumb never drew {name}"))
1212                })
1213                .collect()
1214        }
1215
1216        /// Whether the last frame painted a plate *of `at`'s own size*. The
1217        /// bar's own fill covers every segment on it, so a plate counts only
1218        /// when it is no bigger than the thing it sits under.
1219        fn lit(&self, at: Rect) -> bool {
1220            self.chrome.fills().iter().any(|(drawn, _)| {
1221                drawn.contains_rect(at) && drawn.height() <= CRUMB_HEIGHT + PLATE_SLACK
1222            })
1223        }
1224
1225        /// Every verb the bar has asked for so far, for a failure to print.
1226        fn verbs(&self) -> Vec<&'static str> {
1227            self.session
1228                .fired
1229                .iter()
1230                .map(crate::tools::commands::act_name)
1231                .collect()
1232        }
1233    }
1234
1235    /// Item 1: *"the last segment in the breadcrumb is not clickable. It's a
1236    /// label that indicates where we are in the hierarchy."* It answers no
1237    /// pointer, so it never lights up; an ancestor still does, which is what
1238    /// keeps the difference legible.
1239    #[test]
1240    fn the_level_the_canvas_stands_on_is_a_label_and_its_ancestors_are_not() {
1241        let mut bar = Bar::over(Session::nested(2));
1242        let trail = bar.trail(&["Level 1", "Level 2"]);
1243        let (ancestor, leaf) = (trail[0], trail[1]);
1244        assert!(
1245            !bar.lit(leaf) && !bar.lit(ancestor),
1246            "precondition: an untouched breadcrumb lights nothing up",
1247        );
1248
1249        bar.hover_at(leaf.center());
1250        assert!(
1251            !bar.lit(leaf),
1252            "the level the canvas is standing on lit up under the pointer",
1253        );
1254
1255        bar.hover_at(ancestor.center());
1256        assert!(
1257            bar.lit(ancestor),
1258            "an ancestor gives no sign that it can be pressed",
1259        );
1260    }
1261
1262    /// Item 2: *"The shifting of text layout as you hover over the breadcrumb
1263    /// elements is disturbing."* Every segment keeps its box in every state,
1264    /// so the trail is still where it was when the pointer arrives.
1265    #[test]
1266    fn nothing_on_the_breadcrumb_moves_when_the_pointer_crosses_it() {
1267        let names = [DOCUMENT, "Level 1", "Level 2"];
1268        let mut bar = Bar::over(Session::nested(2));
1269        let at_rest = bar.trail(&names);
1270        for hovered in &at_rest {
1271            bar.hover_at(hovered.center());
1272            assert_eq!(
1273                bar.trail(&names),
1274                at_rest,
1275                "the trail moved with the pointer over {hovered:?}",
1276            );
1277        }
1278    }
1279
1280    /// One click on the document is an ordinary breadcrumb click, landing
1281    /// once egui's own double-click window has run out without a second
1282    /// one arriving.
1283    #[cfg(not(target_arch = "wasm32"))]
1284    #[test]
1285    fn one_click_on_the_document_rises_to_the_root() {
1286        let mut bar = Bar::over(Session::nested(2));
1287        bar.click_the_name();
1288        let rose =
1289            bar.session.fired.iter().any(
1290                |act| matches!(act, Act::Edit(Action::GoToPath(to)) if to.segments().is_empty()),
1291            );
1292        assert!(
1293            rose,
1294            "the document's segment did not rise: {:?}",
1295            bar.verbs()
1296        );
1297    }
1298
1299    /// A double click renames, and the rise its first click armed is never
1300    /// taken — a scope pop is a view entry on the one undo stack, so a rise
1301    /// taken and absorbed would still be a rise the user has to take back
1302    /// by hand.
1303    #[cfg(not(target_arch = "wasm32"))]
1304    #[test]
1305    fn a_double_click_renames_the_document_without_moving_the_canvas() {
1306        let mut bar = Bar::over(Session::nested(2));
1307        bar.double_click_the_name();
1308        assert_eq!(
1309            bar.session.draft, DOCUMENT,
1310            "the box did not open on the document's own name",
1311        );
1312        assert!(
1313            bar.session.fired.is_empty(),
1314            "the rename navigated on its way to the box: {:?}",
1315            bar.verbs(),
1316        );
1317    }
1318
1319    /// The visuals the editor actually draws in.
1320    fn app_visuals() -> egui::Visuals {
1321        crate::canvas::convert::visuals(
1322            &blockworx_paint::Scheme::default().palette(blockworx_paint::Luminance::Dark),
1323        )
1324    }
1325
1326    /// How bright a colour reads, the way a reader's eye weights it.
1327    fn luminance(color: egui::Color32) -> f32 {
1328        0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b())
1329    }
1330
1331    /// The document's name has to be *readable*, which is not the same claim
1332    /// as "it is painted".
1333    ///
1334    /// `Visuals::strong_text_color` reads `widgets.active.fg_stroke`, and
1335    /// this palette sets that to its darkest base — the ink for a word on an
1336    /// accent-filled control. A name drawn with it sits at luminance 32 on a
1337    /// bar of 25, which is how a whole pass shipped with the document's name
1338    /// invisible on the real desktop and every test green: egui's stock dark
1339    /// visuals make the same call come out white.
1340    #[test]
1341    fn the_document_name_is_painted_in_ink_that_stands_off_the_bar() {
1342        let visuals = app_visuals();
1343        let bar = Bar::new();
1344        let ink = bar
1345            .chrome
1346            .format_of(DOCUMENT)
1347            .expect("the bar drew no document name")
1348            .color;
1349        assert_eq!(
1350            ink,
1351            glass::full_ink(&visuals),
1352            "the name is not painted in the shell's full-strength ink",
1353        );
1354        assert_ne!(
1355            ink,
1356            visuals.strong_text_color(),
1357            "the name went back to egui's strong text colour",
1358        );
1359        let against = luminance(visuals.window_fill);
1360        assert!(
1361            luminance(ink) - against > READABLE,
1362            "the name reads at {} against a bar at {against}",
1363            luminance(ink),
1364        );
1365    }
1366
1367    /// The muted segments have to clear the same bar, by less. An ancestor
1368    /// the eye cannot find is a control nobody presses.
1369    #[test]
1370    fn a_quieted_segment_still_stands_off_the_bar() {
1371        let visuals = app_visuals();
1372        let bar = Bar::over(Session::nested(2));
1373        let ink = bar
1374            .chrome
1375            .format_of("Level 1")
1376            .expect("the bar drew no ancestor segment")
1377            .color;
1378        let against = luminance(visuals.window_fill);
1379        assert!(
1380            luminance(ink) - against > QUIET,
1381            "an ancestor reads at {} against a bar at {against}",
1382            luminance(ink),
1383        );
1384        assert!(
1385            luminance(ink) < luminance(glass::full_ink(&visuals)),
1386            "an ancestor is as loud as where the canvas is standing",
1387        );
1388    }
1389
1390    /// How far the shell's ink must stand off its own surface, in the
1391    /// luminance the review measured this regression in: full-strength text
1392    /// clears it comfortably, and a quieted segment by enough to be found.
1393    const READABLE: f32 = 100.0;
1394    const QUIET: f32 = 40.0;
1395
1396    /// Everything the right run offers, clicked through egui's own
1397    /// hit-testing: a control that lays out but dispatches nothing fails here.
1398    #[test]
1399    fn the_bar_undoes_redoes_fits_and_opens_the_navigator() {
1400        let mut bar = Bar::new();
1401        let fired = bar.scan();
1402        for verb in ["Undo", "Redo", "ResetView"] {
1403            assert!(
1404                fired.contains(&verb),
1405                "the bar never dispatched {verb}: {fired:?}",
1406            );
1407        }
1408        assert!(bar.session.browses > 0, "Browse never toggled");
1409    }
1410
1411    /// The right run's order: *"After Undo/Redo | <Go Up> <Fit> <Panel>."*
1412    /// Dead at the document root, live one level in, and what it dispatches
1413    /// is the one verb the breadcrumb's ancestry serves.
1414    #[test]
1415    fn go_up_is_dead_at_the_root_and_rises_one_level_from_inside_a_block() {
1416        let from_root = Bar::new().scan();
1417        assert!(
1418            !from_root.contains(&"GoUp"),
1419            "the root offered somewhere to rise to: {from_root:?}",
1420        );
1421
1422        let mut inside = Bar::over(Session::nested(2));
1423        let fired = inside.scan();
1424        assert!(
1425            fired.contains(&"GoUp"),
1426            "a nested scope will not rise: {fired:?}",
1427        );
1428        // The breadcrumb's parent segment rises too, and it is scanned
1429        // first, so the *button* is the last Go Up on the sweep.
1430        let first = |verb: &str| fired.iter().position(|f| *f == verb);
1431        let button = fired.iter().rposition(|f| *f == "GoUp");
1432        assert!(
1433            first("Redo") < button && button < first("ResetView"),
1434            "the right run is not in the order the user gave it: {fired:?}",
1435        );
1436    }
1437
1438    /// Redo keeps its place and draws dead, exactly as undo does — and a dead
1439    /// button still says what it would have done.
1440    #[test]
1441    fn undo_and_redo_keep_their_place_and_their_words_when_their_stacks_empty() {
1442        let mut bar = Bar::over(Session {
1443            history: History::empty(),
1444            steps: Consequences::default(),
1445            ..Session::new()
1446        });
1447        let said = bar.hovers();
1448        for verb in ["Undo", "Redo"] {
1449            assert!(
1450                said.iter()
1451                    .any(|word| word.starts_with(verb) && word.contains("nothing to take back")),
1452                "an empty stack hid {verb} instead of disabling it: {said:?}",
1453            );
1454        }
1455        let fired = bar.scan();
1456        assert!(
1457            !fired.contains(&"Undo") && !fired.contains(&"Redo"),
1458            "an empty stack's buttons dispatched anyway: {fired:?}",
1459        );
1460        assert!(
1461            fired.contains(&"ResetView"),
1462            "precondition: the scan reaches the run's live half: {fired:?}",
1463        );
1464    }
1465
1466    /// The undo tooltip must name the target *and* what undoing it costs —
1467    /// and the two kinds of entry cost materially different things.
1468    #[test]
1469    fn the_undo_hover_names_the_target_and_which_of_the_two_kinds_it_is() {
1470        use crate::history::Offered;
1471        let edit = consequence(
1472            Direction::Back,
1473            Some(&of("Add block Filter", Kind::Doc)),
1474            Offered::Yes,
1475            Viewing::Head,
1476        );
1477        assert_eq!(edit, "Undo Add block Filter \u{2014} authors a rev");
1478
1479        let camera = consequence(
1480            Direction::Back,
1481            Some(&of("zoom to fit", Kind::View)),
1482            Offered::Yes,
1483            Viewing::Head,
1484        );
1485        assert_eq!(camera, "Undo zoom to fit \u{2014} view only, no rev");
1486
1487        assert!(
1488            consequence(Direction::Back, None, Offered::Yes, Viewing::Head)
1489                .contains("nothing to take back"),
1490            "an empty stack's button says so rather than promising a rev",
1491        );
1492        // The mockup's own words for the one case where the button is dead
1493        // for a reason the user can act on.
1494        assert_eq!(
1495            consequence(
1496                Direction::Back,
1497                Some(&of("Add block Filter", Kind::Doc)),
1498                Offered::No,
1499                Viewing::Past(rev(AT)),
1500            ),
1501            "Undo \u{2014} return to current first",
1502        );
1503    }
1504
1505    /// The hover is rendered, not merely computed: resting on the bar names
1506    /// the edit one press would take back.
1507    #[test]
1508    fn hovering_the_bar_names_the_edit_undo_would_take_back() {
1509        let mut bar = Bar::new();
1510        assert!(
1511            !bar.chrome.shows(NEWEST),
1512            "precondition: the bar names no commit until a button is hovered",
1513        );
1514        let said = bar.hovers();
1515        assert!(
1516            said.iter().any(|word| word.contains(NEWEST)),
1517            "no button on the bar names the edit undo would take back: {said:?}",
1518        );
1519    }
1520
1521    /// Under the lens undo still serves a *view* entry, which costs the log
1522    /// nothing — and the withheld half says why.
1523    #[test]
1524    fn under_the_lens_a_view_entry_is_still_undoable_and_a_doc_entry_says_why_not() {
1525        let mut view = Bar::over(Session {
1526            history: History::view(),
1527            kind: Kind::View,
1528            ..Session::new().under_the_lens()
1529        });
1530        let fired = view.scan();
1531        assert!(
1532            fired.contains(&"Undo") && fired.contains(&"Redo"),
1533            "the lens took the camera history with it: {fired:?}",
1534        );
1535
1536        let mut doc = Bar::over(Session::new().under_the_lens());
1537        let said = doc.hovers();
1538        assert!(
1539            said.iter()
1540                .any(|word| word.contains("return to current first")),
1541            "a withheld undo does not say what would free it: {said:?}",
1542        );
1543        let fired = doc.scan();
1544        assert!(
1545            !fired.contains(&"Undo") && !fired.contains(&"Redo"),
1546            "the bar wrote from a session that may not: {fired:?}",
1547        );
1548        assert!(
1549            fired.contains(&"ResetView"),
1550            "the lens took the view controls with it: {fired:?}",
1551        );
1552    }
1553
1554    /// The centre: the rev and its age, the stepper that walks either way,
1555    /// and Return. Clicked through egui's own hit-testing, so a control that
1556    /// lays out but dispatches nothing fails here.
1557    #[test]
1558    fn the_mode_names_the_rev_and_steps_and_returns() {
1559        assert_eq!(
1560            headline(rev(23), "3 hours ago"),
1561            "Rev 23 \u{00b7} 3 hours ago"
1562        );
1563        assert_eq!(
1564            headline(rev(23), "   "),
1565            "Rev 23",
1566            "a session with no clock is not made to guess at one",
1567        );
1568
1569        let mut bar = Bar::over(Session::new().under_the_lens());
1570        assert!(
1571            bar.chrome.shows("Rev 2 \u{00b7} 3 hours ago"),
1572            "the centre does not name the rev on the canvas: {:?}",
1573            bar.chrome.texts(),
1574        );
1575        let fired = bar.scan();
1576        for verb in ["ViewRev", "ViewHead"] {
1577            assert!(
1578                fired.contains(&verb),
1579                "the centre never dispatched {verb}: {fired:?}",
1580            );
1581        }
1582    }
1583
1584    /// The states, as the user reads them: green (all is good), red (no
1585    /// editing allowed), grey (nothing behind it). A colour and a set of
1586    /// words each — because the colours are palette slots whose hue is the
1587    /// scheme's to choose, a mark alone would say nothing.
1588    #[test]
1589    fn the_dot_tells_its_states_apart_in_colour_and_in_words() {
1590        let theme = Theme::default();
1591        let states = [
1592            Liveness::Recorded,
1593            Liveness::ReadOnly(Locked::Lens),
1594            Liveness::ReadOnly(Locked::Container),
1595            Liveness::Scratch,
1596        ];
1597        let colours: std::collections::HashSet<[u8; 4]> = states
1598            .iter()
1599            .map(|state| theme.resolve(dot(*state).0).to_array())
1600            .collect();
1601        assert_eq!(
1602            colours.len(),
1603            3,
1604            "the read-only pair share a colour; the other two must not",
1605        );
1606        let words: std::collections::HashSet<&str> =
1607            states.iter().map(|state| dot(*state).1).collect();
1608        assert_eq!(words.len(), states.len(), "two states say the same thing");
1609        assert!(
1610            words.iter().all(|said| !said.is_empty()),
1611            "a state says nothing at all: {words:?}",
1612        );
1613
1614        // The one resolver, over every input that reaches it.
1615        assert_eq!(
1616            Liveness::of(Viewing::Head, Writability::Writable, Attachment::Scratch,),
1617            Liveness::Scratch,
1618            "a session with nothing behind it is not the recorded state",
1619        );
1620        assert_eq!(
1621            Liveness::of(Viewing::Head, Writability::Writable, Attachment::Attached,),
1622            Liveness::Recorded,
1623        );
1624        assert_eq!(
1625            Liveness::of(Viewing::Head, Writability::ReadOnly, Attachment::Attached,),
1626            Liveness::ReadOnly(Locked::Container),
1627        );
1628        // The lens outranks the container: "no editing allowed" is what it
1629        // means, whatever may be written behind it.
1630        assert_eq!(
1631            Liveness::of(
1632                Viewing::Past(rev(AT)),
1633                Writability::Writable,
1634                Attachment::Attached,
1635            ),
1636            Liveness::ReadOnly(Locked::Lens),
1637        );
1638    }
1639
1640    /// The words are rendered, not merely computed: resting on the dot in
1641    /// any state names that state.
1642    #[test]
1643    fn the_dot_says_which_state_it_is_in_when_the_pointer_rests_on_it() {
1644        for (state, session) in [
1645            (Liveness::Recorded, Session::new()),
1646            (
1647                Liveness::Scratch,
1648                Session {
1649                    attachment: Attachment::Scratch,
1650                    ..Session::new()
1651                },
1652            ),
1653            (
1654                Liveness::ReadOnly(Locked::Lens),
1655                Session::new().under_the_lens(),
1656            ),
1657        ] {
1658            let mut bar = Bar::over(session);
1659            let said = bar.hovers();
1660            let wanted = dot(state).1;
1661            assert!(
1662                said.iter().any(|word| word == wanted),
1663                "the dot never said {wanted:?} in {state:?}: {said:?}",
1664            );
1665        }
1666    }
1667
1668    /// The breadcrumb is rooted at the document, ancestors navigate, and the
1669    /// level the canvas is standing on takes no click at all.
1670    #[test]
1671    fn the_breadcrumb_is_rooted_at_the_document_and_its_ancestors_navigate() {
1672        let mut bar = Bar::over(Session::nested(3));
1673        assert!(
1674            bar.chrome.shows(DOCUMENT),
1675            "the breadcrumb is not rooted at the document: {:?}",
1676            bar.chrome.texts(),
1677        );
1678        for level in ["Level 1", "Level 2", "Level 3"] {
1679            assert!(
1680                bar.chrome.shows(level),
1681                "the breadcrumb lost {level}: {:?}",
1682                bar.chrome.texts(),
1683            );
1684        }
1685        let fired = bar.scan();
1686        assert!(
1687            fired.contains(&"GoUp"),
1688            "the parent segment did not go up: {fired:?}",
1689        );
1690        let jumped: Vec<usize> = bar
1691            .session
1692            .fired
1693            .iter()
1694            .filter_map(|a| match a {
1695                Act::Edit(Action::GoToPath(to)) => Some(to.segments().len()),
1696                _ => None,
1697            })
1698            .collect();
1699        assert!(
1700            jumped.contains(&0) && jumped.contains(&1),
1701            "the document and the grandparent are not reachable: {jumped:?}",
1702        );
1703        assert!(
1704            !jumped.contains(&3),
1705            "the level the canvas is on answered a click: {jumped:?}",
1706        );
1707    }
1708
1709    /// The middle collapse, at the depth that forces it: the rule itself is
1710    /// the kernel's, and what is checked here is that the bar draws what it
1711    /// answers — both ends still on the strip.
1712    #[test]
1713    fn a_deep_path_collapses_from_the_middle_and_keeps_its_ends() {
1714        let bar = Bar::over(Session::nested(5));
1715        let said = bar.chrome.texts();
1716        assert!(
1717            said.contains(&DOCUMENT) && said.contains(&"Level 5"),
1718            "the collapse hid one of the ends: {said:?}",
1719        );
1720        assert!(
1721            !said.contains(&"Level 2"),
1722            "the middle did not collapse: {said:?}",
1723        );
1724        assert!(
1725            said.contains(&ELLIPSIS),
1726            "the collapse says nothing about what it hid: {said:?}",
1727        );
1728    }
1729
1730    /// A segment wider than the cap truncates rather than pushing the bar's
1731    /// centre aside.
1732    #[test]
1733    fn a_long_segment_is_capped_rather_than_allowed_to_push_the_bar_around() {
1734        let mut session = Session::nested(1);
1735        session.names = vec!["A block with a preposterously long name on it".to_owned()];
1736        let bar = Bar::over(session);
1737        let long = bar
1738            .chrome
1739            .rect("A block with a preposterously long name on it")
1740            .expect("the long segment never drew");
1741        let short = bar
1742            .chrome
1743            .rect(DOCUMENT)
1744            .expect("the document's own segment never drew");
1745        assert!(
1746            short.width() < CRUMB_WIDTH,
1747            "precondition: a short segment is not capped: {short:?}",
1748        );
1749        assert!(
1750            long.width() <= CRUMB_WIDTH,
1751            "a long segment ran to {} points, past the {CRUMB_WIDTH} cap",
1752            long.width(),
1753        );
1754    }
1755
1756    /// One menu carries what the document bar's File menu, Help menu and
1757    /// preferences gear carried — clicked through a real frame, so a section
1758    /// that lays out but opens onto nothing fails here.
1759    #[test]
1760    fn the_menu_reaches_every_door_the_document_chip_had() {
1761        let mut bar = Bar::new();
1762        bar.open_the_menu();
1763        #[cfg(not(target_arch = "wasm32"))]
1764        for entry in ["New diagram", "Open diagram\u{2026}"] {
1765            assert!(
1766                bar.chrome.shows(entry),
1767                "the bar's menu offers no {entry:?}: {:?}",
1768                bar.chrome.texts(),
1769            );
1770        }
1771        for entry in ["Import\u{2026}", "Export", "Help", "Preferences"] {
1772            assert!(
1773                bar.chrome.shows(entry),
1774                "the bar's menu lost {entry:?}: {:?}",
1775                bar.chrome.texts(),
1776            );
1777        }
1778        bar.click_on("Export");
1779        for format in crate::export::ExportScope::View.formats() {
1780            assert!(
1781                bar.chrome.shows(format.label()),
1782                "the view's Export menu is missing {}: {:?}",
1783                format.label(),
1784                bar.chrome.texts(),
1785            );
1786        }
1787    }
1788
1789    /// The user's own words: *"'open Json file' is unneeded detail for the
1790    /// end user... the JSON file is not a self contained archive of the
1791    /// document (it does not, for example, include the assets). So it can't
1792    /// really be 'loaded' as a document in an unsaved session."* One door
1793    /// into the menu, and it asks for a diagram.
1794    #[cfg(not(target_arch = "wasm32"))]
1795    #[test]
1796    fn the_menu_opens_one_thing_and_it_is_a_diagram() {
1797        let mut bar = Bar::new();
1798        bar.open_the_menu();
1799        assert!(
1800            bar.chrome.shows("Open diagram\u{2026}"),
1801            "the menu lost the one way in: {:?}",
1802            bar.chrome.texts(),
1803        );
1804        for gone in ["JSON", "Open document", "shared diagram", "Share\u{2026}"] {
1805            assert!(
1806                !bar.chrome.says(gone),
1807                "the menu still offers {gone:?}: {:?}",
1808                bar.chrome.texts(),
1809            );
1810        }
1811    }
1812
1813    /// Help and preferences are cascading submenus rather than icons of their
1814    /// own, and they still open onto their entries.
1815    #[test]
1816    fn help_and_preferences_keep_their_entries() {
1817        for (menu, entry) in [("Help", "GitHub"), ("Preferences", "Theme")] {
1818            let mut bar = Bar::new();
1819            bar.open_the_menu();
1820            bar.click_on(menu);
1821            assert!(
1822                bar.chrome.shows(entry),
1823                "the {menu} submenu does not open onto {entry:?}: {:?}",
1824                bar.chrome.texts(),
1825            );
1826        }
1827    }
1828
1829    /// A read-only session still lists Import — the menu keeps its shape —
1830    /// but the entry is dead, because the registry withheld the command
1831    /// rather than the bar forming a second opinion about writability.
1832    #[test]
1833    fn import_is_listed_in_a_read_only_session_and_does_nothing() {
1834        let mut writable = Bar::new();
1835        writable.open_the_menu();
1836        writable.click_on("Import\u{2026}");
1837        assert!(
1838            matches!(
1839                writable.session.fired.last(),
1840                Some(Act::Effect(Effect::Import))
1841            ),
1842            "a writable session's Import dispatched something else",
1843        );
1844
1845        let mut read_only = Bar::over(Session {
1846            writability: Writability::ReadOnly,
1847            ..Session::new()
1848        });
1849        read_only.open_the_menu();
1850        assert!(
1851            read_only.chrome.shows("Import\u{2026}"),
1852            "a read-only session hid Import instead of disabling it",
1853        );
1854        read_only.click_on("Import\u{2026}");
1855        assert!(
1856            read_only.session.fired.is_empty(),
1857            "a read-only session's Import dispatched",
1858        );
1859    }
1860
1861    /// Nothing on the bar implies a save — no word, and no dot promising one.
1862    /// The one "Save as…" the editor keeps lives inside the menu, which is
1863    /// closed here.
1864    #[cfg(not(target_arch = "wasm32"))]
1865    #[test]
1866    fn nothing_on_the_bar_says_saved() {
1867        let mut bar = Bar::new();
1868        for said in bar.chrome.texts() {
1869            assert!(
1870                !said.to_lowercase().contains("sav"),
1871                "the top bar says {said:?}",
1872            );
1873        }
1874        bar.open_the_menu();
1875        // Nothing here offers to refresh the projection either.
1876        assert!(
1877            !bar.chrome.says("document.json"),
1878            "the menu still offers to refresh the projection: {:?}",
1879            bar.chrome.texts(),
1880        );
1881    }
1882
1883    /// Save-as is contextual: the *same* entry, pressed under the lens, asks
1884    /// for the log through the rev on the canvas.
1885    #[cfg(not(target_arch = "wasm32"))]
1886    #[test]
1887    fn save_as_asks_for_the_rev_on_the_canvas() {
1888        use crate::file::{FileRequest, SaveScope};
1889        let mut bar = Bar::new();
1890        bar.open_the_menu();
1891        bar.click_on("Save as\u{2026}");
1892        assert!(
1893            matches!(
1894                bar.session.fired.last(),
1895                Some(Act::Effect(Effect::PickFile(FileRequest::SaveAsContainer(
1896                    SaveScope::Whole
1897                )))),
1898            ),
1899            "at the present, Save-as asked for something other than the whole document",
1900        );
1901
1902        let mut under = Bar::over(Session::new().under_the_lens());
1903        under.open_the_menu();
1904        under.click_on("Save as\u{2026}");
1905        assert!(
1906            matches!(
1907                under.session.fired.last(),
1908                Some(Act::Effect(Effect::PickFile(FileRequest::SaveAsContainer(
1909                    SaveScope::Through(at)
1910                )))) if *at == rev(AT),
1911            ),
1912            "under the lens, Save-as did not ask for the rev on the canvas",
1913        );
1914    }
1915
1916    /// On the segment the document owns: the box opens on the document's own
1917    /// name, takes what is typed, and dispatches exactly that.
1918    #[cfg(not(target_arch = "wasm32"))]
1919    #[test]
1920    fn double_clicking_the_name_renames_the_document_in_place() {
1921        let mut bar = Bar::new();
1922        assert!(
1923            bar.chrome.shows(DOCUMENT),
1924            "precondition: the bar draws the name: {:?}",
1925            bar.chrome.texts(),
1926        );
1927        bar.double_click_the_name();
1928        assert_eq!(
1929            bar.session.draft, DOCUMENT,
1930            "the box did not open on the document's own name",
1931        );
1932        bar.type_text("2");
1933        assert_ne!(bar.session.draft, DOCUMENT, "typing never reached the box");
1934        let typed = bar.session.draft.clone();
1935
1936        bar.press(egui::Key::Enter);
1937        let renamed = bar.session.fired.iter().find_map(|act| match act {
1938            Act::Effect(Effect::RenameDocument(to)) => Some(to.clone()),
1939            _ => None,
1940        });
1941        assert_eq!(
1942            renamed.as_deref(),
1943            Some(typed.as_str()),
1944            "what was typed is not what was sent",
1945        );
1946    }
1947
1948    /// One door, not two: the name is the only way to rename.
1949    #[cfg(not(target_arch = "wasm32"))]
1950    #[test]
1951    fn the_menu_no_longer_offers_a_second_way_to_rename() {
1952        let mut bar = Bar::new();
1953        bar.open_the_menu();
1954        assert!(
1955            !bar.chrome.says("Rename"),
1956            "the menu still offers its own rename: {:?}",
1957            bar.chrome.texts(),
1958        );
1959    }
1960
1961    /// Guarded as artwork rather than as file names: the document menu wears
1962    /// the mockup's own three lines with a short last one, and the stepper
1963    /// wears its up and down arrows, pointing the way the mockup points them.
1964    #[test]
1965    fn the_bar_wears_the_mockups_own_glyphs() {
1966        let menu = include_str!("../../icons/icon-menu.svg");
1967        for line in [r#"d="M4 7h16""#, r#"d="M4 12h16""#, r#"d="M4 17h10""#] {
1968            assert!(
1969                menu.contains(line),
1970                "the document menu is not the mockup's: {menu}",
1971            );
1972        }
1973        let older = include_str!("../../icons/icon-step-older.svg");
1974        let newer = include_str!("../../icons/icon-step-newer.svg");
1975        // `#stepold` draws down into the log and `#stepnew` up out of it.
1976        assert!(older.contains(r#"points="7 14 12 19 17 14""#), "{older}");
1977        assert!(newer.contains(r#"points="7 10 12 5 17 10""#), "{newer}");
1978        for icon in [menu, older, newer] {
1979            assert!(icon.contains(r#"viewBox="0 0 24 24""#), "{icon}");
1980            assert!(icon.contains(r#"stroke-width="2""#), "{icon}");
1981            assert!(icon.contains(r#"fill="none""#), "{icon}");
1982        }
1983    }
1984
1985    /// The stepper's words outlive its arrows: an arrow says *a* direction,
1986    /// and which end of the log that is stays in the tooltip.
1987    #[test]
1988    fn the_stepper_says_which_way_each_arrow_goes() {
1989        let mut bar = Bar::over(Session::new().under_the_lens());
1990        let said = bar.hovers();
1991        for way in ["Older rev", "Newer rev"] {
1992            assert!(
1993                said.iter().any(|word| word == way),
1994                "the stepper does not say {way}: {said:?}",
1995            );
1996        }
1997    }
1998
1999    /// Never tools: the rail carries them, and the bar carries none.
2000    #[test]
2001    fn the_bar_holds_no_tools() {
2002        let mut bar = Bar::new();
2003        bar.open_the_menu();
2004        for tool in crate::tools::names::band_tools() {
2005            assert!(
2006                !bar.chrome.shows(tool.label()),
2007                "{tool:?} strayed into the top bar",
2008            );
2009        }
2010    }
2011}