Skip to main content

blockworx/tools/
toolbar.rs

1use crate::{
2    doc::{At, TimeStep, Viewing},
3    edit::{lower::accent_from_role, naming::InterfaceLock},
4    export::ExportScope,
5    grid::GRID_SIZE,
6    shape::{ShapeId, ShapeRef},
7    theme::{Role, Theme, accent_role},
8    tools::{
9        chrome::Panel,
10        commands::{CommandId, CommandSet},
11        names::{TOOLBAR_TOOLS, ToolName},
12        tool::{Action, RoleTarget},
13    },
14    widget::drawing::Drawing,
15};
16use blockworx_doc::{register::Register, values::Role as AccentRole};
17
18/// Whether the thing a toggle control opens — a picker, a window — is showing,
19/// which is what draws the control pressed.
20#[derive(Clone, Copy, PartialEq, Eq)]
21pub enum PanelState {
22    Open,
23    Closed,
24}
25
26/// Which selection popup is open, if any. They are mutually exclusive: opening
27/// one closes the other.
28#[derive(Clone, Copy, PartialEq, Eq)]
29pub enum OpenPicker {
30    Role,
31    PinType,
32}
33
34/// The egui temp-data key where the live toolbar stashes its frame rect each
35/// frame (read by [`selection_overlay`] to keep out from under it).
36pub fn toolbar_rect_id() -> egui::Id {
37    Panel::ModeToolbar.measurement("rect")
38}
39
40/// Where the live toolbar drew last frame, for the chrome that must keep out
41/// from under it. [`egui::Rect::NOTHING`] until it has drawn once, which
42/// unions away.
43pub fn drawn_rect(ctx: &egui::Context) -> egui::Rect {
44    ctx.data(|d| d.get_temp(toolbar_rect_id()))
45        .unwrap_or(egui::Rect::NOTHING)
46}
47
48/// The toolbar's view toggles, edited in place. Only the debug-marks checkbox
49/// remains.
50pub struct ViewToggles<'a> {
51    pub debug_marks: &'a mut bool,
52}
53
54/// Everything [`toolbar_contents`] renders against.
55pub struct ToolbarProps<'a> {
56    /// The toolbar button shown selected (see
57    /// [`displayed_tool`](crate::tools::names::displayed_tool)).
58    pub selected: ToolName,
59    pub toggles: ViewToggles<'a>,
60    /// Where the user stands in the hierarchy, and how they move through it.
61    pub nav: NavCluster<'a>,
62}
63
64/// What one toolbar frame reports: the action a button fired, the compass's
65/// rect, which the navigator popup hangs from, and — for the tests that click
66/// the real strip — each tool button's screen rect.
67pub struct ToolbarFrame {
68    pub action: Option<Action>,
69    pub compass: egui::Rect,
70    #[cfg(test)]
71    pub tool_rects: Vec<(ToolName, egui::Rect)>,
72}
73
74/// The live toolbar: plain widgets in a child of the canvas `Ui`, centered in
75/// a strip along the viewport top. Same layer as the canvas, painted after
76/// it, so the buttons draw on top and win hit-testing.
77pub fn toolbar(
78    commands: &mut CommandSet,
79    props: ToolbarProps<'_>,
80    viewport: egui::Rect,
81    ui: &mut egui::Ui,
82) -> ToolbarFrame {
83    let (frame, rect) = centered_strip(ui, Panel::ModeToolbar, viewport, |ui| {
84        toolbar_contents(ui, commands, props)
85    });
86    // Stash the frame's rect so the selection overlay can avoid covering it.
87    ui.ctx()
88        .data_mut(|d| d.insert_temp(toolbar_rect_id(), rect));
89    frame
90}
91
92/// Run `add` in a child `Ui` centered along `bounds`' top edge (below a
93/// one-cell margin) — the toolbar's `Area` anchoring, without the `Area`.
94/// Content-hugging widgets can't be centered before they're laid out, so the
95/// x comes from last frame's measured width (stashed per host `Ui`); the
96/// first frame draws once at the center-as-left-edge and snaps on the next.
97/// Returns `add`'s result and the content's rect.
98fn centered_strip<R>(
99    ui: &mut egui::Ui,
100    panel: Panel,
101    bounds: egui::Rect,
102    add: impl FnOnce(&mut egui::Ui) -> R,
103) -> (R, egui::Rect) {
104    let width_key = panel.measurement("width");
105    let left = if let Some(width) = ui.ctx().data(|d| d.get_temp::<f32>(width_key)) {
106        bounds.center().x - width / 2.0
107    } else {
108        ui.ctx().request_repaint();
109        bounds.center().x
110    };
111    let strip = egui::Rect::from_min_max(
112        egui::pos2(left, bounds.top() + GRID_SIZE),
113        bounds.right_bottom(),
114    );
115    let mut child = ui.new_child(
116        panel
117            .ui_builder()
118            .max_rect(strip)
119            .layout(egui::Layout::top_down(egui::Align::Min)),
120    );
121    let result = add(&mut child);
122    let rect = child.min_rect();
123    ui.ctx()
124        .data_mut(|d| d.insert_temp(width_key, rect.width()));
125    (result, rect)
126}
127
128/// The toolbar's widgets, independent of where they sit: the live app centers
129/// them over the canvas; the tutorial player embeds them in the video. Also
130/// returns each tool button's rect (in the host `Ui`'s coordinates), which
131/// the video feeds to the demo-cursor cues.
132fn toolbar_contents(
133    ui: &mut egui::Ui,
134    commands: &mut CommandSet,
135    props: ToolbarProps<'_>,
136) -> ToolbarFrame {
137    let ToolbarProps {
138        selected,
139        toggles: ViewToggles { debug_marks },
140        nav: NavCluster { history, nav_open },
141    } = props;
142    let mut action: Option<Action> = None;
143    let mut clicked: Option<CommandId> = None;
144    #[cfg(test)]
145    let mut rects = Vec::new();
146    let mut compass_rect = egui::Rect::NOTHING;
147    egui::Frame::popup(ui.style()).show(ui, |ui| {
148        ui.horizontal(|ui| {
149            debug_checkbox(ui, debug_marks);
150            for mode in TOOLBAR_TOOLS {
151                let button = egui::Button::image(icon_image(ui, tool_icon(*mode)))
152                    .selected(selected == *mode);
153                // The registry's own answer, so a button and the command it
154                // fires cannot disagree: a locked block withholds Add Port,
155                // and a read-only session withholds every authoring tool.
156                // Disabled rather than absent — the toolbar keeps its shape.
157                let id = CommandId::Arm(*mode);
158                let hover = match crate::tools::commands::binding(id) {
159                    Some(chord) => format!("{} ({})", mode, ui.ctx().format_shortcut(chord)),
160                    None => mode.to_string(),
161                };
162                let response = ui
163                    .add_enabled(commands.contains(id), button)
164                    .on_hover_text(hover);
165                #[cfg(test)]
166                rects.push((*mode, response.rect));
167                if response.clicked() {
168                    clicked = Some(id);
169                }
170            }
171            ui.separator();
172            // Where you are in the hierarchy and how you move through it —
173            // the navigator's compass, the path arrows, and enter/exit block.
174            // They sit beside the tools rather than in a corner of their own,
175            // in the space the file and preferences buttons left behind.
176            let compass = egui::Button::image(icon_image(ui, COMPASS_ICON)).selected(*nav_open);
177            let compass = ui.add(compass).on_hover_text("Navigator");
178            compass_rect = compass.rect;
179            if compass.clicked() {
180                *nav_open = !*nav_open;
181            }
182            for (enabled, icon, hover, step) in [
183                (history.can_back, BACK_ICON, "Back", Action::PathBack),
184                (
185                    history.can_forward,
186                    FORWARD_ICON,
187                    "Forward",
188                    Action::PathForward,
189                ),
190            ] {
191                if ui
192                    .add_enabled(enabled, icon_button(ui, icon))
193                    .on_hover_text(hover)
194                    .clicked()
195                {
196                    action = Some(step);
197                }
198            }
199            for (id, icon, hover) in [
200                (CommandId::ExpandBlock, EXPAND_ICON, "Enter block"),
201                (CommandId::GoUp, EXIT_ICON, "Go up a level"),
202            ] {
203                if ui
204                    .add_enabled(commands.contains(id), icon_button(ui, icon))
205                    .on_hover_text(hover)
206                    .clicked()
207                {
208                    clicked = Some(id);
209                }
210            }
211        });
212    });
213    ToolbarFrame {
214        action: action.or_else(|| clicked.and_then(|id| commands.take(id))),
215        compass: compass_rect,
216        #[cfg(test)]
217        tool_rects: rects,
218    }
219}
220
221/// A lower-left overlay for the document history: Undo and Redo as icon
222/// buttons with hover text, kept off the main toolbar so they sit near the
223/// canvas. Each is enabled while its command is in the frame's
224/// [`CommandSet`]. The hierarchy controls live elsewhere — on the toolbar's
225/// navigation group, and in the palette. In-`Ui` widgets like the toolbar (see
226/// [`toolbar`]): a bottom-up child of the canvas `Ui` hugs its content against
227/// the viewport's lower-left corner with no measuring pass.
228///
229/// While a past rev is on the canvas the pair beside the clock *is* the
230/// time machine: undo and redo do not apply to a document nobody is
231/// editing, so they become a tape player — step back, step forward, and
232/// seek to the latest rev. The clock never navigates; it only opens and
233/// closes the panel, at head and in the past alike, so the one control
234/// that toggles a window does not sometimes travel in time instead
235/// (docs/ui-issues-2.md, items 4 and 7).
236pub fn history_overlay(
237    commands: &mut CommandSet,
238    cluster: HistoryCluster<'_>,
239    viewport: egui::Rect,
240    ui: &mut egui::Ui,
241) -> HistoryFrame {
242    let HistoryCluster {
243        panel_open,
244        viewing,
245        head,
246        steps,
247    } = cluster;
248    let mut clicked: Option<CommandId> = None;
249    let mut action: Option<Action> = None;
250    let mut clock = egui::Rect::NOTHING;
251    let past = viewing != Viewing::Head;
252    let corner = egui::Rect::from_min_max(
253        viewport.left_top() + egui::vec2(GRID_SIZE, 0.0),
254        viewport.right_bottom() - egui::vec2(0.0, GRID_SIZE),
255    );
256    let mut child = ui.new_child(
257        Panel::History
258            .ui_builder()
259            .max_rect(corner)
260            .layout(egui::Layout::bottom_up(egui::Align::Min)),
261    );
262    egui::Frame::popup(child.style()).show(&mut child, |ui| {
263        ui.horizontal(|ui| {
264            // The trail toggle leads, so Undo and Redo keep the end of
265            // the cluster they have always sat at.
266            let toggle = egui::Button::image(icon_image(ui, TIMELINE_ICON)).selected(*panel_open);
267            let toggle = ui.add(toggle).on_hover_text("History");
268            clock = toggle.rect;
269            if toggle.clicked() {
270                *panel_open = !*panel_open;
271            }
272            ui.separator();
273            let mut step = |ui: &mut egui::Ui, icon, hover: &str, to: Option<At>| {
274                if ui
275                    .add_enabled(to.is_some(), icon_button(ui, icon))
276                    .on_hover_text(hover)
277                    .clicked()
278                {
279                    action = Some(match to {
280                        Some(At::Rev(rev)) => Action::ViewRev(rev),
281                        _ => Action::ViewHead,
282                    });
283                }
284            };
285            let mut button = |ui: &mut egui::Ui, id, icon, verb: &str, of: Option<&str>| {
286                let hover = match of {
287                    Some(label) => format!("{verb} {label}"),
288                    None => verb.to_owned(),
289                };
290                if ui
291                    .add_enabled(commands.contains(id), icon_button(ui, icon))
292                    .on_hover_text(hover)
293                    .clicked()
294                {
295                    clicked = Some(id);
296                }
297            };
298            if past {
299                step(
300                    ui,
301                    STEP_BACK_ICON,
302                    "Back one rev",
303                    viewing.stepped(head, TimeStep::Back),
304                );
305                step(
306                    ui,
307                    STEP_FORWARD_ICON,
308                    "Forward one rev",
309                    viewing.stepped(head, TimeStep::Forward),
310                );
311                // Always live while a past rev is showing: that is what
312                // there is to seek away from.
313                step(ui, SEEK_LATEST_ICON, "Return to latest", Some(At::Current));
314            } else {
315                button(ui, CommandId::Undo, UNDO_ICON, "Undo", steps.undo);
316                button(ui, CommandId::Redo, REDO_ICON, "Redo", steps.redo);
317            }
318        });
319    });
320    HistoryFrame {
321        action: action.or_else(|| clicked.and_then(|id| commands.take(id))),
322        clock,
323    }
324}
325
326/// The history cluster's own state: whether the history panel its clock
327/// button toggles is showing, and where in the log the canvas is standing.
328pub struct HistoryCluster<'a> {
329    pub panel_open: &'a mut bool,
330    pub viewing: Viewing,
331    /// The newest rev the log holds — the far end a step walks toward.
332    pub head: blockworx_doc::rev::Rev,
333    /// What the next undo and redo would take back or bring forward, by
334    /// the commit's own label — so a reader hovering the button is told
335    /// *which* edit, not merely that there is one.
336    pub steps: UndoSteps<'a>,
337}
338
339/// The commits the undo and redo buttons stand over.
340#[derive(Clone, Copy, Default)]
341pub struct UndoSteps<'a> {
342    pub undo: Option<&'a str>,
343    pub redo: Option<&'a str>,
344}
345
346/// What one history-cluster frame reports.
347pub struct HistoryFrame {
348    pub action: Option<Action>,
349    /// The clock button's rect, which the history panel hangs from.
350    pub clock: egui::Rect,
351}
352
353/// What the toolbar's navigation group shows and edits: the path-history
354/// arrows' availability and the navigator's open flag (which its compass
355/// toggles). Enter is enabled only while a block is selected (it is in the
356/// frame's [`CommandSet`] then); exit always is.
357pub struct NavCluster<'a> {
358    pub history: crate::tools::nav_tree::PathHistory,
359    pub nav_open: &'a mut bool,
360}
361
362/// The concrete color the accent swatch shows for `target`: its current accent
363/// role resolved against `theme`. Mirrors the role-picker's current-role lookup
364/// in [`crate::app`] — an unset accent falls back to the target's own
365/// un-accented stroke ([`Role::AreaStroke`], [`Role::TextBoxStroke`], or
366/// [`Role::AccentDefault`]).
367fn accent_swatch_color(data: &Drawing, target: RoleTarget, theme: &Theme) -> egui::Color32 {
368    let accent = |role: &Register<AccentRole>| accent_from_role(*role.as_ref());
369    let current = match target {
370        RoleTarget::Block(rid) => data.block(rid).and_then(|b| accent(&b.role)),
371        RoleTarget::Port(pid) => match data.shape(ShapeId::Port(pid)) {
372            Some(ShapeRef::Port(port)) => accent(&port.pin.port_accent),
373            _ => None,
374        },
375        RoleTarget::Route(rid) => data.auto_route(rid).and_then(|w| accent(&w.route.role)),
376        RoleTarget::Area(cid) => match data.shape(ShapeId::Area(cid)) {
377            Some(ShapeRef::Area(area)) => accent(&area.role),
378            _ => None,
379        },
380        RoleTarget::Text(tid) => match data.shape(ShapeId::Text(tid)) {
381            Some(ShapeRef::Text(text)) => accent(&text.text.role),
382            _ => None,
383        },
384    };
385    theme.resolve(accent_display_role(target, current))
386}
387
388/// The [`Role`] the accent swatch displays for `target` given its current accent
389/// index `current`: the mapped accent role, or — when unset — the target's own
390/// un-accented stroke, matching the role picker's "no accent" cell.
391fn accent_display_role(target: RoleTarget, current: Option<u8>) -> Role {
392    let default_role = match target {
393        RoleTarget::Area(_) => Role::AreaStroke,
394        RoleTarget::Text(_) => Role::TextBoxStroke,
395        _ => Role::AccentDefault,
396    };
397    accent_role(current).unwrap_or(default_role)
398}
399
400const COMPASS_ICON: egui::ImageSource<'static> =
401    egui::include_image!("../../icons/icon-compass.svg");
402const EXPAND_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-expand.svg");
403const EXIT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-exit.svg");
404const LOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-lock.svg");
405const UNLOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-unlock.svg");
406const DELETE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-trash.svg");
407const IMAGE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-image.svg");
408const SELECT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-select.svg");
409const NEW_BLOCK_ICON: egui::ImageSource<'static> =
410    egui::include_image!("../../icons/icon-new-block.svg");
411const AREA_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-area.svg");
412const ADD_PORT_ICON: egui::ImageSource<'static> =
413    egui::include_image!("../../icons/icon-add-port.svg");
414const ADD_TEXT_ICON: egui::ImageSource<'static> =
415    egui::include_image!("../../icons/icon-add-text.svg");
416const ROUTE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-route.svg");
417const ADD_ICON_ICON: egui::ImageSource<'static> =
418    egui::include_image!("../../icons/icon-add-icon.svg");
419const ROUTE_LABEL_ICON: egui::ImageSource<'static> =
420    egui::include_image!("../../icons/icon-route-label.svg");
421const UNDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-undo.svg");
422const REDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-redo.svg");
423const TIMELINE_ICON: egui::ImageSource<'static> =
424    egui::include_image!("../../icons/icon-timeline.svg");
425/// The tape-player trio the history cluster wears while a past rev is on
426/// the canvas: `|<`, `>|`, `>>|`.
427const STEP_BACK_ICON: egui::ImageSource<'static> =
428    egui::include_image!("../../icons/icon-step-back.svg");
429const STEP_FORWARD_ICON: egui::ImageSource<'static> =
430    egui::include_image!("../../icons/icon-step-forward.svg");
431pub(crate) const SEEK_LATEST_ICON: egui::ImageSource<'static> =
432    egui::include_image!("../../icons/icon-seek-latest.svg");
433const COPY_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-copy.svg");
434const CUT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-cut.svg");
435const FLIP_LR_ICON: egui::ImageSource<'static> =
436    egui::include_image!("../../icons/icon-flip-lr.svg");
437const FLIP_UD_ICON: egui::ImageSource<'static> =
438    egui::include_image!("../../icons/icon-flip-ud.svg");
439const BACK_ICON: egui::ImageSource<'static> =
440    egui::include_image!("../../icons/icon-arrow-left.svg");
441const FORWARD_ICON: egui::ImageSource<'static> =
442    egui::include_image!("../../icons/icon-arrow-right.svg");
443pub(crate) const EXPORT_ICON: egui::ImageSource<'static> =
444    egui::include_image!("../../icons/icon-export.svg");
445const REROUTE_ICON: egui::ImageSource<'static> =
446    egui::include_image!("../../icons/icon-reroute.svg");
447
448/// The router's debug-marks toggle, compiled in only under the `ui_debug`
449/// feature: it is a developer overlay, not shipped chrome.
450#[cfg(feature = "ui_debug")]
451fn debug_checkbox(ui: &mut egui::Ui, debug_marks: &mut bool) {
452    ui.checkbox(debug_marks, "Debug");
453    ui.separator();
454}
455
456#[cfg(not(feature = "ui_debug"))]
457fn debug_checkbox(_ui: &mut egui::Ui, _debug_marks: &mut bool) {}
458
459/// The toolbar icon for a base tool. Only [`TOOLBAR_TOOLS`] reach this; any other
460/// `ToolName` falls back to the select cursor (it is never shown on the toolbar).
461fn tool_icon(tool: ToolName) -> egui::ImageSource<'static> {
462    match tool {
463        ToolName::NewBlock => NEW_BLOCK_ICON,
464        ToolName::NewArea => AREA_ICON,
465        ToolName::AddPort => ADD_PORT_ICON,
466        ToolName::NewImage => IMAGE_ICON,
467        ToolName::AddText => ADD_TEXT_ICON,
468        ToolName::Route => ROUTE_ICON,
469        _ => SELECT_ICON,
470    }
471}
472
473/// The icon and hover text for the lock-toggle control. The padlock depicts the
474/// block's current state — closed when locked, open shackle when unlocked, like a
475/// physical padlock — while the hover text names the action a click performs.
476fn lock_toggle_icon(lock: InterfaceLock) -> (egui::ImageSource<'static>, &'static str) {
477    match lock {
478        InterfaceLock::Locked => (LOCK_ICON, "Unlock pins"),
479        InterfaceLock::Unlocked => (UNLOCK_ICON, "Lock pins"),
480    }
481}
482
483/// A 14×14 icon image tinted to the current text color, the shared building block
484/// for every icon button in the toolbar and overlays.
485pub(crate) fn icon_image(
486    ui: &egui::Ui,
487    source: egui::ImageSource<'static>,
488) -> egui::Image<'static> {
489    egui::Image::new(source)
490        .fit_to_exact_size(egui::vec2(14.0, 14.0))
491        .tint(ui.visuals().widgets.inactive.fg_stroke.color)
492}
493
494/// A small square icon button tinted to the current text color, matching the
495/// look of the navigation dialog's icon buttons.
496pub(crate) fn icon_button(
497    ui: &egui::Ui,
498    source: egui::ImageSource<'static>,
499) -> egui::Button<'static> {
500    egui::Button::image(icon_image(ui, source))
501}
502
503/// The accent control: a small filled square of the selection's current accent
504/// `color` that opens the role picker on click, sized to match [`icon_button`].
505/// When the picker is `open` the swatch carries an active-indicator ring.
506fn accent_swatch_button(
507    ui: &mut egui::Ui,
508    color: egui::Color32,
509    picker: PanelState,
510) -> egui::Response {
511    let (rect, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click());
512    let painter = ui.painter();
513    painter.rect_filled(rect, 2.0, color);
514    let (width, stroke) = if picker == PanelState::Open {
515        (2.0, ui.visuals().selection.stroke.color)
516    } else {
517        (1.0, ui.visuals().widgets.inactive.fg_stroke.color)
518    };
519    painter.rect_stroke(
520        rect,
521        2.0,
522        egui::Stroke::new(width, stroke),
523        egui::StrokeKind::Inside,
524    );
525    resp.on_hover_text("Accent")
526}
527
528/// Gap (screen px) between the selection's edge and the overlay placed just
529/// above or below it. Comfortably clears the selection frame, which is itself
530/// drawn slightly outside the selection's bounding box. Expressed in grid cells
531/// (world space) so the on-screen gap scales with zoom — a fixed pixel gap looks
532/// cramped when zoomed in and detached when zoomed out.
533const OVERLAY_BLOCK_GAP_CELLS: f32 = 1.5;
534/// Minimum gap (screen px) between the viewport top and an above-placed overlay —
535/// large enough to clear the mode toolbar. Tunable, since the toolbar may move or
536/// resize; it's screen space because zoom doesn't affect it.
537const OVERLAY_TOP_GAP: f32 = 96.0;
538/// Minimum gap (screen px) from the viewport's left/right/bottom edges.
539const OVERLAY_EDGE_GAP: f32 = 8.0;
540/// Fallback distance (screen px) up from the viewport bottom when the overlay fits
541/// neither above nor below the selection.
542const OVERLAY_BOTTOM_GAP: f32 = 16.0;
543
544/// The horizontal band an overlay of `size` can land in at vertical `y`, bounded
545/// by `x_min..=x_max` (the on-screen clamp).
546struct Strip {
547    y: f32,
548    size: egui::Vec2,
549    x_min: f32,
550    x_max: f32,
551}
552
553impl Strip {
554    /// An x in the strip that is clear of every obstacle, by sliding sideways
555    /// from `desired`: to the nearest landing past an obstacle's right edge
556    /// (preferred — e.g. just past the navigation dialog), else just left of an
557    /// obstacle. A small [`OVERLAY_EDGE_GAP`] is left between the overlay and the
558    /// obstacle, since touching rects still count as intersecting. `None` when no
559    /// clear x fits — e.g. a full-width obstacle. The caller has already tried
560    /// `desired` itself, so this only returns a shifted x.
561    fn shifted_clear_x(&self, desired: f32, obstacles: &[egui::Rect]) -> Option<f32> {
562        let (y, size, x_min, x_max) = (self.y, self.size, self.x_min, self.x_max);
563        let fits = |x: f32| {
564            let rect = egui::Rect::from_min_size(egui::pos2(x, y), size);
565            x >= x_min && x <= x_max && !obstacles.iter().any(|o| o.intersects(rect))
566        };
567        let right = obstacles
568            .iter()
569            .map(|o| o.right() + OVERLAY_EDGE_GAP)
570            .filter(|&x| x >= desired && fits(x))
571            .min_by(f32::total_cmp);
572        let left = obstacles
573            .iter()
574            .map(|o| o.left() - size.x - OVERLAY_EDGE_GAP)
575            .filter(|&x| x <= desired && fits(x))
576            .max_by(f32::total_cmp);
577        right.or(left)
578    }
579}
580
581/// Top-left screen position for an overlay of `size`, placed relative to the
582/// selection's screen-space bbox `sel` within `viewport`: prefer just above the
583/// selection, else just below — both horizontally centered on the selection — and
584/// only if neither centered placement clears the `obstacles` (the mode toolbar,
585/// the navigation dialog), slide sideways (see [`Strip::shifted_clear_x`]) at the above
586/// height then the below height as a last resort before hiding. Failing all that,
587/// pin a fixed distance up from the bottom and center on the viewport. Always
588/// clamped fully on screen. Returns `None` when nothing fits clear of the
589/// obstacles (the caller hides it). Assumes `viewport.intersects(sel)` already.
590fn place_overlay(
591    sel: egui::Rect,
592    viewport: egui::Rect,
593    size: egui::Vec2,
594    block_gap: f32,
595    obstacles: &[egui::Rect],
596) -> Option<egui::Pos2> {
597    let x_min = viewport.left() + OVERLAY_EDGE_GAP;
598    let x_max = viewport.right() - OVERLAY_EDGE_GAP - size.x;
599    if x_max < x_min {
600        return None; // wider than the viewport: can't fit on screen
601    }
602    let desired = (sel.center().x - size.x / 2.0).clamp(x_min, x_max);
603    let clear = |pos: egui::Pos2| {
604        let rect = egui::Rect::from_min_size(pos, size);
605        !obstacles.iter().any(|o| o.intersects(rect))
606    };
607
608    // Above clears the toolbar gap; below clears it and fits the bottom margin.
609    let above_y = sel.top() - block_gap - size.y;
610    let above_ok = above_y >= viewport.top() + OVERLAY_TOP_GAP;
611    let below_y = sel.bottom() + block_gap;
612    let below_fits_bottom = below_y + size.y <= viewport.bottom() - OVERLAY_EDGE_GAP;
613    let below_ok = below_y >= viewport.top() + OVERLAY_TOP_GAP && below_fits_bottom;
614
615    // Centered, preferring above then below — keeps the overlay on the selection.
616    if above_ok && clear(egui::pos2(desired, above_y)) {
617        return Some(egui::pos2(desired, above_y));
618    }
619    if below_ok && clear(egui::pos2(desired, below_y)) {
620        return Some(egui::pos2(desired, below_y));
621    }
622    // A centered placement is blocked: slide sideways to clear the obstacles,
623    // above first then below, before giving up on the selection's neighborhood.
624    let strip = |y: f32| Strip {
625        y,
626        size,
627        x_min,
628        x_max,
629    };
630    if above_ok && let Some(x) = strip(above_y).shifted_clear_x(desired, obstacles) {
631        return Some(egui::pos2(x, above_y));
632    }
633    if below_ok && let Some(x) = strip(below_y).shifted_clear_x(desired, obstacles) {
634        return Some(egui::pos2(x, below_y));
635    }
636    // There is room below the selection, but nothing there fit even shifted → hide
637    // rather than overlap the obstacles. The bottom-of-screen fallback is only for
638    // a selection that leaves no room below at all (e.g. it fills the viewport).
639    if below_fits_bottom {
640        return None;
641    }
642    let fallback_y = viewport.bottom() - OVERLAY_BOTTOM_GAP - size.y;
643    if fallback_y < viewport.top() + OVERLAY_TOP_GAP {
644        return None; // doesn't fit between the toolbar and the bottom
645    }
646    let fallback_desired = (viewport.center().x - size.x / 2.0).clamp(x_min, x_max);
647    if clear(egui::pos2(fallback_desired, fallback_y)) {
648        return Some(egui::pos2(fallback_desired, fallback_y));
649    }
650    strip(fallback_y)
651        .shifted_clear_x(fallback_desired, obstacles)
652        .map(|x| egui::pos2(x, fallback_y))
653}
654
655/// The export-format buttons, shared by the main menu's Export submenu and
656/// the block overlay's "Export as top level" menu, each offering the formats
657/// its `scope` carries. Returns the clicked format.
658pub(crate) fn export_format_menu(
659    ui: &mut egui::Ui,
660    scope: crate::export::ExportScope,
661) -> Option<crate::export::ExportFormat> {
662    scope
663        .formats()
664        .iter()
665        .copied()
666        .find(|format| ui.button(format.label()).clicked())
667}
668
669/// Action buttons shown next to the current selection: a view of the frame's
670/// [`CommandSet`], rendering each available selection command in registry
671/// order — icon buttons for most, an export submenu, the tag-visibility text
672/// toggle, the "I/O" picker button, and the accent swatch (a square of the
673/// current accent color). `open_picker` tells the matching button to show
674/// as active and to leave dismissal to the open popup. The overlay is placed in
675/// screen space relative to `sel_screen` (the selection's on-screen bounds) within
676/// `viewport` (see [`place_overlay`]); it is hidden when nothing is selected, the
677/// selection isn't visible, or it can't fit on screen. Returns the triggered
678/// `Action` and the overlay's top-right corner (used to anchor the popups above
679/// it; `None` when hidden, which dismisses them).
680#[allow(clippy::too_many_arguments)]
681pub fn selection_overlay(
682    commands: &mut CommandSet,
683    data: &Drawing,
684    theme: &Theme,
685    open_picker: Option<OpenPicker>,
686    sel_screen: Option<egui::Rect>,
687    viewport: egui::Rect,
688    zoom: crate::canvas::Zoom,
689    ui: &mut egui::Ui,
690) -> (Option<Action>, Option<egui::Pos2>) {
691    let ctx = ui.ctx().clone();
692    let ctx = &ctx;
693    let Some(sel_screen) = sel_screen else {
694        return (None, None);
695    };
696    if !viewport.intersects(sel_screen) {
697        return (None, None);
698    }
699    // Nothing to render is nothing to show. Withheld commands still count —
700    // a wire in a read-only session shows its controls disabled rather than
701    // no bar at all (docs/ui-issues.md follow-up: disabled beats hidden).
702    if overlay_controls(commands).next().is_none() {
703        return (None, None);
704    }
705    // The overlay must not cover the mode toolbar or the navigation dialog;
706    // read their last-frame rects so `place_overlay` can avoid them. The
707    // toolbar (in-`Ui` widgets since the `Area` migration) stashes its rect
708    // each frame; the navigator is still an `Area` — its rect persists in
709    // egui memory after it stops being shown (it's toggleable), so only a
710    // visible area counts as an obstacle.
711    let visible_area_rect = |name: &str| {
712        let id = egui::Id::new(name);
713        ctx.memory(|m| {
714            m.areas()
715                .visible_last_frame(&egui::LayerId::new(egui::Order::Middle, id))
716                .then(|| m.area_rect(id))
717                .flatten()
718        })
719    };
720    let obstacles: Vec<egui::Rect> = [
721        ctx.data(|d| d.get_temp(toolbar_rect_id())),
722        visible_area_rect("nav_tree"),
723        visible_area_rect("history_panel"),
724    ]
725    .into_iter()
726    .flatten()
727    .collect();
728    // World-space gap (grid cells → pixels) so the bar keeps a constant distance
729    // from its object at any zoom.
730    let block_gap = OVERLAY_BLOCK_GAP_CELLS * GRID_SIZE * zoom.get();
731    // The overlay places itself from last frame's measured rect — but only
732    // when that measurement is *of these controls*. A selection switch (a
733    // block's bar giving way to a wire's) changes the control list, and
734    // placing the new bar with the old bar's size flashed it one frame in
735    // the wrong place. So the measurement remembers what it measured, and a
736    // frame whose controls it does not match is an invisible sizing pass:
737    // laid out, measured, never painted, correctly placed next frame.
738    let drawn: Vec<CommandId> = overlay_controls(commands).map(|cmd| cmd.id).collect();
739    let size_key = Panel::SelectionButtons.measurement("rect");
740    let remembered = ctx
741        .data(|d| d.get_temp::<(Vec<CommandId>, egui::Rect)>(size_key))
742        .filter(|(of, _)| *of == drawn)
743        .map(|(_, rect)| rect.size());
744    let (builder, pos) = if let Some(size) = remembered {
745        match place_overlay(sel_screen, viewport, size, block_gap, &obstacles) {
746            Some(pos) => (Panel::SelectionButtons.ui_builder(), pos),
747            None => return (None, None),
748        }
749    } else {
750        ctx.request_repaint();
751        // Both flags, deliberately: `sizing_pass` alone only tightens the
752        // layout — egui's painter goes quiet on `invisible`, and its own
753        // Area/Grid sizing passes chain exactly this pair. Without it the
754        // measuring frame painted the bar at the provisional spot below.
755        (
756            Panel::SelectionButtons
757                .ui_builder()
758                .sizing_pass()
759                .invisible(),
760            sel_screen.center(),
761        )
762    };
763    let sizing = builder.sizing_pass;
764    let mut clicked: Option<CommandId> = None;
765    let mut child = ui.new_child(builder.max_rect(egui::Rect::from_min_size(pos, viewport.size())));
766    let rect = {
767        let ui = &mut child;
768        egui::Frame::popup(ui.style())
769            .show(ui, |ui| {
770                ui.horizontal(|ui| {
771                    for cmd in overlay_controls(commands) {
772                        let fired = ui
773                            .add_enabled_ui(!cmd.withheld(), |ui| {
774                                overlay_command(ui, cmd, data, theme, open_picker)
775                            })
776                            .inner;
777                        if let Some(id) = fired {
778                            clicked = Some(id);
779                        }
780                    }
781                });
782            })
783            .response
784            .rect
785    };
786    tracing::debug!(
787        target: "overlay",
788        pass = ctx.cumulative_pass_nr(),
789        sizing,
790        drawn = drawn.len(),
791        pos = ?pos,
792        rect = ?rect,
793        sel = ?sel_screen,
794        "overlay frame"
795    );
796    // Next frame's placement uses this frame's measurement of these controls.
797    ctx.data_mut(|d| d.insert_temp(size_key, (drawn, rect)));
798    if sizing {
799        return (None, None);
800    }
801    (
802        clicked.and_then(|id| commands.take(id)),
803        Some(rect.right_top()),
804    )
805}
806
807/// The controls this overlay lays out, in registry order, withheld ones
808/// included (they draw disabled). The one gate: "is this overlay empty",
809/// "what does it draw", and *how wide it is* all read this, so a command
810/// that renders nothing takes no room either — each control sits in a
811/// child `Ui` of its own, and an empty child still advances the row by
812/// one item spacing.
813fn overlay_controls(
814    commands: &CommandSet,
815) -> impl Iterator<Item = &crate::tools::commands::Command> {
816    commands.iter_drawn().filter(|cmd| drawn_in_overlay(cmd.id))
817}
818
819/// Whether `id` draws a control in the selection overlay. The one list, so
820/// that "is this overlay empty" and "what does this overlay draw" cannot
821/// disagree — an overlay with nothing to draw is not shown at all.
822fn drawn_in_overlay(id: CommandId) -> bool {
823    match id {
824        // One submenu covers every export format: the first format's command
825        // renders it, the rest are reached inside the menu.
826        CommandId::ExportSelection(format) => format == ExportScope::Selection.leading_format(),
827        // These belong to other surfaces: toolbar arming, the history
828        // cluster's undo/redo, whole-view export/import.
829        CommandId::Arm(_)
830        | CommandId::Undo
831        | CommandId::Redo
832        | CommandId::Export(_)
833        | CommandId::Import => false,
834        CommandId::HideTags | CommandId::ShowTags | CommandId::PinType | CommandId::Accent => true,
835        id => overlay_icon(id).is_some(),
836    }
837}
838
839/// Render `cmd`'s overlay control, returning the command a click fired.
840/// Only what [`overlay_controls`] yields reaches here.
841fn overlay_command(
842    ui: &mut egui::Ui,
843    cmd: &crate::tools::commands::Command,
844    data: &Drawing,
845    theme: &Theme,
846    open_picker: Option<OpenPicker>,
847) -> Option<CommandId> {
848    match cmd.id {
849        CommandId::ExportSelection(_) => {
850            let mut picked = None;
851            ui.menu_image_button(icon_image(ui, EXPORT_ICON), |ui| {
852                picked = export_format_menu(ui, ExportScope::Selection);
853            })
854            .response
855            .on_hover_text("Export");
856            picked.map(CommandId::ExportSelection)
857        }
858        CommandId::HideTags | CommandId::ShowTags => {
859            ui.button(cmd.label).clicked().then_some(cmd.id)
860        }
861        CommandId::PinType => {
862            let selected = open_picker == Some(OpenPicker::PinType);
863            (ui.add(egui::Button::new(cmd.label).selected(selected))
864                .clicked()
865                && !selected)
866                .then_some(cmd.id)
867        }
868        CommandId::Accent => {
869            let Action::OpenRolePicker { target } = &cmd.action else {
870                return None;
871            };
872            let swatch = accent_swatch_color(data, *target, theme);
873            let picker = if open_picker == Some(OpenPicker::Role) {
874                PanelState::Open
875            } else {
876                PanelState::Closed
877            };
878            (accent_swatch_button(ui, swatch, picker).clicked() && picker == PanelState::Closed)
879                .then_some(cmd.id)
880        }
881        id => {
882            let icon = overlay_icon(id)?;
883            ui.add(icon_button(ui, icon))
884                .on_hover_text(cmd.label)
885                .clicked()
886                .then_some(id)
887        }
888    }
889}
890
891/// The icon-button artwork for the overlay's plain commands. The padlock pair
892/// routes through [`lock_toggle_icon`] so the shackle depicts the block's
893/// current state while the command names the action a click performs.
894fn overlay_icon(id: CommandId) -> Option<egui::ImageSource<'static>> {
895    match id {
896        CommandId::Copy => Some(COPY_ICON),
897        CommandId::Cut => Some(CUT_ICON),
898        CommandId::FlipLr => Some(FLIP_LR_ICON),
899        CommandId::FlipUd => Some(FLIP_UD_ICON),
900        CommandId::Reroute | CommandId::RerouteBlock => Some(REROUTE_ICON),
901        CommandId::AddRouteLabel => Some(ROUTE_LABEL_ICON),
902        // Entering a block is a verb about the *selection*, so it is here as
903        // well as in the toolbar's navigation group.
904        CommandId::ExpandBlock => Some(EXPAND_ICON),
905        CommandId::Lock => Some(lock_toggle_icon(InterfaceLock::Unlocked).0),
906        CommandId::Unlock => Some(lock_toggle_icon(InterfaceLock::Locked).0),
907        CommandId::AddIcon => Some(ADD_ICON_ICON),
908        CommandId::Delete => Some(DELETE_ICON),
909        _ => None,
910    }
911}
912
913#[cfg(all(test, feature = "kittest"))]
914mod kittest_visual {
915    use super::*;
916    use crate::canvas::palette::Luminance;
917    use crate::font::build_fonts;
918    use crate::preferences::{FontChoice, Theme};
919    use egui::vec2;
920    use egui_kittest::Harness;
921
922    /// The toolbar as it now stands: the tools, then the navigation group the
923    /// lower-right cluster gave up. Enter draws dead — a picture has no
924    /// selection to enter — and so do the path arrows, which is the
925    /// disabled-not-hidden paradigm in one image.
926    #[test]
927    fn toolbar_strip() {
928        // Proportional[0] = Roboto (Basic), so the toolbar text renders in the
929        // installed font; visuals come from a base16 scheme.
930        let mut harness = Harness::builder()
931            .with_size(vec2(1240.0, 120.0))
932            .build_ui(move |ui| {
933                let ctx = ui.ctx().clone();
934                egui_extras::install_image_loaders(&ctx);
935                ctx.set_fonts(build_fonts(FontChoice::Basic));
936                ctx.set_visuals(Theme::Catppuccin.palette(Luminance::Dark).egui_visuals());
937
938                // The real toolbar centers itself in a strip along the host
939                // `Ui`'s top edge.
940                let mut debug = false;
941                let mut nav_open = false;
942                let viewport = ui.max_rect();
943                toolbar(
944                    &mut CommandSet::writable_toolbar(),
945                    ToolbarProps {
946                        selected: ToolName::Select,
947                        toggles: ViewToggles {
948                            debug_marks: &mut debug,
949                        },
950                        nav: NavCluster {
951                            history: crate::tools::nav_tree::PathHistory {
952                                can_back: true,
953                                can_forward: false,
954                            },
955                            nav_open: &mut nav_open,
956                        },
957                    },
958                    viewport,
959                    ui,
960                );
961            });
962        harness.run();
963        harness.snapshot("toolbar");
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::path::Scope;
971    use crate::preferences::Preferences;
972    use egui::{Rect, Vec2, pos2, vec2};
973
974    fn viewport() -> Rect {
975        Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
976    }
977    const SIZE: Vec2 = Vec2::new(200.0, 40.0);
978    // The world-space gap (in cells) at zoom 1, matching `selection_overlay`.
979    const GAP: f32 = OVERLAY_BLOCK_GAP_CELLS * GRID_SIZE;
980
981    /// The full chrome — main menu, toolbar, history cluster, title block,
982    /// selection overlay over a selected block — must settle once icons load
983    /// and each piece has measured itself (see [`crate::tools::settle`]).
984    #[test]
985    fn idle_chrome_settles() {
986        use crate::path::BlockPath;
987        use crate::tools::commands::{CommandContext, CommandSet, History};
988        use crate::tools::resize_block::ResizeBlock;
989        use crate::widget::test_fixtures::{self as fx, Scene};
990        use blockworx_doc::fixtures::block_id;
991        let b = block_id(1);
992        let mut scene = Scene::new(vec![
993            fx::block_in(
994                1,
995                Scope::Root,
996                Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 80.0)),
997            ),
998            fx::titled(1, "core"),
999        ]);
1000        let mut debug_marks = false;
1001        let mut nav = false;
1002        let mut prefs = Preferences::default();
1003        let mut rename_draft = String::new();
1004        let sel_screen = Some(Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0)));
1005        let settle = crate::tools::settle::probe(30, |ui| {
1006            let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0));
1007            let tool: crate::tools::tool::Tool = ResizeBlock::Selected {
1008                shape: ShapeId::Rect(b),
1009            }
1010            .into();
1011            // A path with names in it: the title block sizes itself to its
1012            // widest line, so an idle probe must include one.
1013            let mut path = BlockPath::empty();
1014            path.push(b);
1015            let mut commands = {
1016                let drawing = scene.drawing();
1017                CommandSet::available(&CommandContext {
1018                    tool: &tool,
1019                    data: &drawing,
1020                    history: History {
1021                        can_undo: true,
1022                        can_redo: false,
1023                    },
1024                    current_lock: InterfaceLock::Unlocked,
1025                    writability: crate::doc::Writability::Writable,
1026                    head: blockworx_doc::rev::Rev::ZERO,
1027                    saving: crate::doc::Saving::Withheld,
1028                    viewing: crate::doc::Viewing::Head,
1029                })
1030            };
1031            let (_, menu_rect) = crate::tools::main_menu::main_menu(
1032                &mut commands,
1033                crate::tools::main_menu::MainMenu {
1034                    prefs: &mut prefs,
1035                    recent: &[],
1036                    saving: crate::doc::Saving::Withheld,
1037                    document: crate::tools::file_menu::Document {
1038                        name: "engine",
1039                        draft: &mut rename_draft,
1040                        renaming: crate::doc::Renaming::Withheld,
1041                    },
1042                },
1043                viewport,
1044                ui,
1045            );
1046            let compass = toolbar(
1047                &mut commands,
1048                ToolbarProps {
1049                    selected: ToolName::Select,
1050                    toggles: ViewToggles {
1051                        debug_marks: &mut debug_marks,
1052                    },
1053                    nav: NavCluster {
1054                        history: crate::tools::nav_tree::PathHistory {
1055                            can_back: false,
1056                            can_forward: false,
1057                        },
1058                        nav_open: &mut nav,
1059                    },
1060                },
1061                viewport,
1062                ui,
1063            )
1064            .compass;
1065            assert!(
1066                compass.is_positive(),
1067                "the navigator popup needs the compass rect to hang from"
1068            );
1069            let mut panel_open = false;
1070            let _ = history_overlay(
1071                &mut commands,
1072                HistoryCluster {
1073                    panel_open: &mut panel_open,
1074                    viewing: Viewing::Head,
1075                    head: blockworx_doc::rev::Rev::ZERO,
1076                    steps: UndoSteps::default(),
1077                },
1078                viewport,
1079                ui,
1080            );
1081            {
1082                let indexed = scene.indexed();
1083                let block = crate::tools::title_block::TitleBlock {
1084                    name: "chrome".to_owned(),
1085                    author: "ada".to_owned(),
1086                    rev: blockworx_doc::rev::Rev::ZERO,
1087                    date: None,
1088                    from: None,
1089                };
1090                let theme = Theme::default();
1091                let _ = crate::tools::title_block::draw(
1092                    ui,
1093                    viewport,
1094                    &theme,
1095                    &block,
1096                    crate::doc::Writability::Writable,
1097                    &indexed,
1098                    &path,
1099                );
1100            }
1101            crate::tools::notices::draw(
1102                ui,
1103                viewport,
1104                menu_rect,
1105                &[crate::tools::notices::Notice::Standing(
1106                    "Read-only \u{2014} another blockworx has this container open".to_owned(),
1107                )],
1108            );
1109            let drawing = scene.drawing();
1110            let theme = Theme::default();
1111            let _ = selection_overlay(
1112                &mut commands,
1113                &drawing,
1114                &theme,
1115                None,
1116                sel_screen,
1117                viewport,
1118                crate::canvas::Zoom::unity(),
1119                ui,
1120            );
1121        });
1122        crate::tools::settle::assert_settles(&settle, 6);
1123    }
1124
1125    /// What the selection overlay drew for a wire: every control it laid
1126    /// out, the subset a click could actually fire, and the bar it
1127    /// measured itself as.
1128    #[derive(Clone, Copy)]
1129    struct Controls {
1130        drawn: usize,
1131        invocable: usize,
1132        bar: Rect,
1133    }
1134
1135    impl Default for Controls {
1136        fn default() -> Self {
1137            Controls {
1138                drawn: 0,
1139                invocable: 0,
1140                bar: Rect::NOTHING,
1141            }
1142        }
1143    }
1144
1145    /// Whether the selection overlay shows for a selected wire in a session
1146    /// of this `writability`, and what it drew.
1147    fn route_overlay(writability: crate::doc::Writability) -> (bool, Controls) {
1148        route_overlay_with(
1149            writability,
1150            crate::tools::commands::History {
1151                can_undo: false,
1152                can_redo: false,
1153            },
1154        )
1155    }
1156
1157    /// [`route_overlay`], with the frame's undo/redo availability spelled
1158    /// out — those commands draw controls elsewhere, so the overlay must
1159    /// be indifferent to them.
1160    fn route_overlay_with(
1161        writability: crate::doc::Writability,
1162        history: crate::tools::commands::History,
1163    ) -> (bool, Controls) {
1164        use crate::tools::commands::{CommandContext, CommandSet};
1165        use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
1166        let ctx = egui::Context::default();
1167        egui_extras::install_image_loaders(&ctx);
1168        let mut scene = two_blocks_with_a_routed_waypoint();
1169        let route = {
1170            let drawing = scene.drawing();
1171            let ids: Vec<_> = drawing.auto_routes().map(|(id, _)| id).collect();
1172            assert_eq!(ids.len(), 1, "the fixture carries exactly one wire");
1173            ids[0]
1174        };
1175        let tool: crate::tools::tool::Tool = crate::tools::EditRoute::Selected {
1176            id: route,
1177            anchor: pos2(0.0, 0.0),
1178        }
1179        .into();
1180        let screen = viewport();
1181        let mut shown = false;
1182        let mut controls = Controls::default();
1183        // Two frames: the overlay measures itself before it can place itself.
1184        for _ in 0..2 {
1185            ctx.clone()
1186                .run_ui(
1187                    egui::RawInput {
1188                        screen_rect: Some(screen),
1189                        ..Default::default()
1190                    },
1191                    |ui| {
1192                        let drawing = scene.drawing();
1193                        let mut commands = CommandSet::available(&CommandContext {
1194                            tool: &tool,
1195                            data: &drawing,
1196                            history,
1197                            current_lock: InterfaceLock::Unlocked,
1198                            writability,
1199                            head: blockworx_doc::rev::Rev::ZERO,
1200                            saving: crate::doc::Saving::Withheld,
1201                            viewing: crate::doc::Viewing::Head,
1202                        });
1203                        controls = Controls {
1204                            invocable: commands
1205                                .iter()
1206                                .filter(|cmd| drawn_in_overlay(cmd.id))
1207                                .count(),
1208                            drawn: overlay_controls(&commands).count(),
1209                            bar: Rect::NOTHING,
1210                        };
1211                        let theme = Theme::default();
1212                        let (_, corner) = selection_overlay(
1213                            &mut commands,
1214                            &drawing,
1215                            &theme,
1216                            None,
1217                            Some(Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0))),
1218                            screen,
1219                            crate::canvas::Zoom::unity(),
1220                            ui,
1221                        );
1222                        shown = corner.is_some();
1223                    },
1224                )
1225                .drop_without_applying_deltas();
1226        }
1227        controls.bar = ctx
1228            .data(|d| {
1229                d.get_temp::<(Vec<CommandId>, Rect)>(Panel::SelectionButtons.measurement("rect"))
1230            })
1231            .map_or(Rect::NOTHING, |(_, rect)| rect);
1232        (shown, controls)
1233    }
1234
1235    /// One frame of the overlay for `tool` against `scene`, on a persistent
1236    /// context: where the bar's corner landed (`None` while it hides), and
1237    /// how many shapes the frame actually painted — the overlay is the only
1238    /// thing this harness draws, so a hidden frame must paint zero.
1239    fn overlay_frame(
1240        ctx: &egui::Context,
1241        tool: &crate::tools::tool::Tool,
1242        scene: &mut crate::widget::test_fixtures::Scene,
1243    ) -> (Option<egui::Pos2>, usize) {
1244        use crate::tools::commands::{CommandContext, CommandSet, History};
1245        let screen = viewport();
1246        let mut corner = None;
1247        let mut out = ctx.clone().run_ui(
1248            egui::RawInput {
1249                screen_rect: Some(screen),
1250                ..Default::default()
1251            },
1252            |ui| {
1253                let drawing = scene.drawing();
1254                let mut commands = CommandSet::available(&CommandContext {
1255                    tool,
1256                    data: &drawing,
1257                    history: History {
1258                        can_undo: false,
1259                        can_redo: false,
1260                    },
1261                    current_lock: InterfaceLock::Unlocked,
1262                    writability: crate::doc::Writability::Writable,
1263                    head: blockworx_doc::rev::Rev::ZERO,
1264                    saving: crate::doc::Saving::Withheld,
1265                    viewing: crate::doc::Viewing::Head,
1266                });
1267                let theme = Theme::default();
1268                let (_, at) = selection_overlay(
1269                    &mut commands,
1270                    &drawing,
1271                    &theme,
1272                    None,
1273                    Some(Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0))),
1274                    screen,
1275                    crate::canvas::Zoom::unity(),
1276                    ui,
1277                );
1278                corner = at;
1279            },
1280        );
1281        out.textures_delta.clear();
1282        (corner, painted(&out.shapes))
1283    }
1284
1285    /// Leaf shapes in a frame's output, `Noop`s excluded.
1286    fn painted(shapes: &[egui::epaint::ClippedShape]) -> usize {
1287        fn leaves(shape: &egui::Shape) -> usize {
1288            match shape {
1289                egui::Shape::Noop => 0,
1290                egui::Shape::Vec(shapes) => shapes.iter().map(leaves).sum(),
1291                _ => 1,
1292            }
1293        }
1294        shapes.iter().map(|clipped| leaves(&clipped.shape)).sum()
1295    }
1296
1297    /// Switching what is selected must never paint the new bar with the old
1298    /// bar's measurement: the frame after a switch is an invisible sizing
1299    /// pass, and the next frame shows the bar measured for its own controls.
1300    /// Steady-state frames stay visible — the pass runs only on change.
1301    #[test]
1302    fn switching_selections_takes_a_hidden_sizing_frame_instead_of_flashing() {
1303        use crate::tools::resize_block::ResizeBlock;
1304        use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
1305        let ctx = egui::Context::default();
1306        egui_extras::install_image_loaders(&ctx);
1307        let mut scene = two_blocks_with_a_routed_waypoint();
1308        let route = {
1309            let drawing = scene.drawing();
1310            let ids: Vec<_> = drawing.auto_routes().map(|(id, _)| id).collect();
1311            assert_eq!(ids.len(), 1, "the fixture carries exactly one wire");
1312            ids[0]
1313        };
1314        let wire: crate::tools::tool::Tool = crate::tools::EditRoute::Selected {
1315            id: route,
1316            anchor: pos2(0.0, 0.0),
1317        }
1318        .into();
1319        let block: crate::tools::tool::Tool = ResizeBlock::Selected {
1320            shape: crate::shape::ShapeId::Rect(blockworx_doc::fixtures::block_id(1)),
1321        }
1322        .into();
1323
1324        let (corner, painted) = overlay_frame(&ctx, &wire, &mut scene);
1325        assert!(
1326            corner.is_none(),
1327            "the first frame ever should be a hidden sizing pass",
1328        );
1329        assert_eq!(
1330            painted, 0,
1331            "the sizing pass painted {painted} shape(s) — the flash the user \
1332             sees at the click point before the bar snaps into place",
1333        );
1334        let (corner, painted) = overlay_frame(&ctx, &wire, &mut scene);
1335        let wire_bar = corner.expect("the wire's bar shows once measured");
1336        assert!(painted > 0, "a shown bar paints");
1337        assert!(
1338            overlay_frame(&ctx, &wire, &mut scene).0.is_some(),
1339            "an unchanged selection must not flicker",
1340        );
1341
1342        let (corner, painted) = overlay_frame(&ctx, &block, &mut scene);
1343        assert!(
1344            corner.is_none(),
1345            "the switch frame drew the block's bar with the wire's measurement",
1346        );
1347        assert_eq!(painted, 0, "the switch frame painted {painted} shape(s)");
1348        let block_bar = overlay_frame(&ctx, &block, &mut scene)
1349            .0
1350            .expect("the block's bar shows once measured");
1351        assert_ne!(
1352            wire_bar, block_bar,
1353            "precondition: the two bars measure apart, or a stale placement would be invisible",
1354        );
1355
1356        assert!(
1357            overlay_frame(&ctx, &wire, &mut scene).0.is_none(),
1358            "switching back re-measures too — the memory holds one bar, not a history",
1359        );
1360        assert_eq!(
1361            overlay_frame(&ctx, &wire, &mut scene).0,
1362            Some(wire_bar),
1363            "the wire's bar returns exactly where it was",
1364        );
1365    }
1366
1367    /// A selected wire in a read-only session shows the same overlay it
1368    /// shows writable — every control drawn, none invocable — because a
1369    /// bar that vanishes reads as a bug while a bar drawn disabled reads
1370    /// as "not now" (docs/ui-issues.md follow-up). The writable half is
1371    /// the precondition: the same selection carries live controls when it
1372    /// can be edited.
1373    #[test]
1374    fn the_selection_overlay_draws_its_withheld_controls_disabled() {
1375        let (shown, writable) = route_overlay(crate::doc::Writability::Writable);
1376        assert!(
1377            writable.invocable > 0 && shown,
1378            "a writable wire lost its overlay",
1379        );
1380        assert_eq!(writable.invocable, writable.drawn);
1381        let (shown, read_only) = route_overlay(crate::doc::Writability::ReadOnly);
1382        assert!(shown, "the read-only overlay vanished instead of disabling");
1383        assert_eq!(
1384            read_only.drawn, writable.drawn,
1385            "read-only dropped controls instead of disabling them",
1386        );
1387        assert_eq!(
1388            read_only.invocable, 0,
1389            "a read-only wire kept {} invocable verb(s)",
1390            read_only.invocable,
1391        );
1392    }
1393
1394    /// A selection's control list is fixed — only enablement varies — so
1395    /// the bar it measures itself as is fixed too: a read-only wire's
1396    /// overlay is exactly the writable one's, to the pixel.
1397    #[test]
1398    fn a_read_only_overlay_measures_the_same_bar_as_a_writable_one() {
1399        let (_, writable) = route_overlay(crate::doc::Writability::Writable);
1400        let (_, read_only) = route_overlay(crate::doc::Writability::ReadOnly);
1401        assert!(
1402            writable.bar.is_positive(),
1403            "precondition: the overlay measured itself ({:?})",
1404            writable.bar,
1405        );
1406        assert_eq!(read_only.bar.size(), writable.bar.size());
1407    }
1408
1409    /// The bar hugs its controls: a command that draws nothing here — undo
1410    /// and redo belong to the history cluster — must take no room either.
1411    /// Each control sits in a child `Ui` of its own, and a child laid out
1412    /// for a command that renders nothing still advances the row by one
1413    /// item spacing, which is how the bar came adrift from its buttons.
1414    #[test]
1415    fn commands_the_overlay_does_not_draw_do_not_widen_it() {
1416        use crate::tools::commands::History;
1417        let (_, quiet) = route_overlay_with(
1418            crate::doc::Writability::Writable,
1419            History {
1420                can_undo: false,
1421                can_redo: false,
1422            },
1423        );
1424        let (_, busy) = route_overlay_with(
1425            crate::doc::Writability::Writable,
1426            History {
1427                can_undo: true,
1428                can_redo: true,
1429            },
1430        );
1431        assert!(quiet.bar.is_positive(), "precondition: the bar measured");
1432        assert_eq!(
1433            busy.drawn, quiet.drawn,
1434            "precondition: undo and redo draw no control in the overlay",
1435        );
1436        assert_eq!(
1437            busy.bar.size(),
1438            quiet.bar.size(),
1439            "two commands the overlay never draws widened it anyway",
1440        );
1441    }
1442
1443    /// Click the toolbar's `mode` button in a session of this `writability`
1444    /// and report what it dispatched. The toolbar is laid out for real and
1445    /// clicked through egui's own hit-testing against the frame's real
1446    /// [`CommandSet`], so a button that draws enabled when the registry
1447    /// withholds its command fails here.
1448    fn click_the_toolbar_tool(
1449        mode: ToolName,
1450        writability: crate::doc::Writability,
1451    ) -> Option<Action> {
1452        use crate::tools::commands::{CommandContext, CommandSet, History};
1453        use crate::widget::test_fixtures::Scene;
1454        const FRAMES: usize = 10;
1455        let ctx = egui::Context::default();
1456        egui_extras::install_image_loaders(&ctx);
1457        let mut scene = Scene::new(Vec::new());
1458        let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
1459        let screen = viewport();
1460        let mut debug_marks = false;
1461        let mut nav_open = false;
1462
1463        let mut button = Rect::NOTHING;
1464        let mut fired = None;
1465        for frame in 0..FRAMES {
1466            let clicking = frame == FRAMES - 1;
1467            let mut input = egui::RawInput {
1468                screen_rect: Some(screen),
1469                #[expect(
1470                    clippy::cast_precision_loss,
1471                    reason = "a frame count, not a measurement"
1472                )]
1473                time: Some(frame as f64 * 0.016),
1474                ..Default::default()
1475            };
1476            if clicking {
1477                assert!(
1478                    button.is_positive(),
1479                    "the {mode:?} button never laid out: {button:?}",
1480                );
1481                let pos = button.center();
1482                input.events = vec![
1483                    egui::Event::PointerMoved(pos),
1484                    egui::Event::PointerButton {
1485                        pos,
1486                        button: egui::PointerButton::Primary,
1487                        pressed: true,
1488                        modifiers: egui::Modifiers::NONE,
1489                    },
1490                    egui::Event::PointerButton {
1491                        pos,
1492                        button: egui::PointerButton::Primary,
1493                        pressed: false,
1494                        modifiers: egui::Modifiers::NONE,
1495                    },
1496                ];
1497            }
1498            ctx.clone()
1499                .run_ui(input, |ui| {
1500                    let mut commands = {
1501                        let drawing = scene.drawing();
1502                        CommandSet::available(&CommandContext {
1503                            tool: &tool,
1504                            data: &drawing,
1505                            history: History {
1506                                can_undo: false,
1507                                can_redo: false,
1508                            },
1509                            current_lock: InterfaceLock::Unlocked,
1510                            writability,
1511                            head: blockworx_doc::rev::Rev::ZERO,
1512                            saving: crate::doc::Saving::Withheld,
1513                            viewing: crate::doc::Viewing::Head,
1514                        })
1515                    };
1516                    let ToolbarFrame {
1517                        action, tool_rects, ..
1518                    } = toolbar(
1519                        &mut commands,
1520                        ToolbarProps {
1521                            selected: ToolName::Select,
1522                            toggles: ViewToggles {
1523                                debug_marks: &mut debug_marks,
1524                            },
1525                            nav: NavCluster {
1526                                history: crate::tools::nav_tree::PathHistory {
1527                                    can_back: false,
1528                                    can_forward: false,
1529                                },
1530                                nav_open: &mut nav_open,
1531                            },
1532                        },
1533                        screen,
1534                        ui,
1535                    );
1536                    button = tool_rects
1537                        .iter()
1538                        .find_map(|&(name, rect)| (name == mode).then_some(rect))
1539                        .expect("every toolbar tool reports its rect, enabled or not");
1540                    if clicking {
1541                        fired = action;
1542                    }
1543                })
1544                .drop_without_applying_deltas();
1545        }
1546        fired
1547    }
1548
1549    /// A read-only session's toolbar still shows every tool — the strip keeps
1550    /// its shape — but the authoring ones are dead, and the neutral Select
1551    /// beside them is not. Clicked through the real layout, so a button that
1552    /// merely *looks* disabled would fail here.
1553    #[test]
1554    fn a_read_only_toolbar_arms_select_but_no_authoring_tool() {
1555        use crate::doc::Writability;
1556        assert!(matches!(
1557            click_the_toolbar_tool(ToolName::NewBlock, Writability::Writable),
1558            Some(Action::SwitchTool(_)),
1559            // Without this the refusal below could be a mis-aimed click.
1560        ));
1561        assert!(
1562            click_the_toolbar_tool(ToolName::NewBlock, Writability::ReadOnly).is_none(),
1563            "a read-only toolbar armed the New Block tool",
1564        );
1565        assert!(
1566            matches!(
1567                click_the_toolbar_tool(ToolName::Select, Writability::ReadOnly),
1568                Some(Action::SwitchTool(_)),
1569            ),
1570            "a read-only toolbar refused Select, which authors nothing",
1571        );
1572    }
1573
1574    /// Every action a click along `row` dispatched, at [`STEP`] intervals
1575    /// across it. How a test asks which verbs a strip of identical icon
1576    /// buttons can reach, without hard-coding where in the strip each one
1577    /// landed: the strip is laid out for real and clicked through egui's own
1578    /// hit-testing, so a control that moved, shrank, went dead, or stopped
1579    /// taking clicks shows up as a missing verb. Clicks past the strip's end
1580    /// land on nothing and dispatch nothing.
1581    fn actions_along(
1582        row: egui::Rect,
1583        mut show: impl FnMut(&mut egui::Ui) -> Option<Action>,
1584    ) -> Vec<Action> {
1585        /// Comfortably finer than a 14px icon button, so no button is stepped
1586        /// over.
1587        const STEP: f32 = 6.0;
1588        let mut chrome = crate::tools::painted::Chrome::new(viewport());
1589        let mut fired = Vec::new();
1590        chrome.settle(|ui| {
1591            let _ = show(ui);
1592        });
1593        let mut x = row.left();
1594        while x <= row.right() {
1595            chrome.click_at(pos2(x, row.center().y), |ui| {
1596                if let Some(action) = show(ui) {
1597                    fired.push(action);
1598                }
1599            });
1600            x += STEP;
1601        }
1602        fired
1603    }
1604
1605    /// One history cluster, laid out for real, with `drive` given the
1606    /// clock button's rect and a frame closure to click through. Reports
1607    /// what the clicks dispatched and where the panel toggle ended up.
1608    fn history_cluster<R>(
1609        history: crate::tools::commands::History,
1610        viewing: Viewing,
1611        head: blockworx_doc::rev::Rev,
1612        drive: impl FnOnce(Rect, &mut dyn FnMut(&mut egui::Ui) -> Option<Action>) -> R,
1613    ) -> (R, bool) {
1614        use crate::tools::commands::{CommandContext, CommandSet};
1615        use crate::widget::test_fixtures::Scene;
1616        let mut scene = Scene::new(Vec::new());
1617        let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
1618        let screen = viewport();
1619        let mut panel_open = false;
1620        // Written by every frame and read after them, so the closure holds
1621        // only a shared borrow of it.
1622        let clock = std::cell::Cell::new(Rect::NOTHING);
1623        let mut frame = |ui: &mut egui::Ui| {
1624            let mut commands = {
1625                let drawing = scene.drawing();
1626                CommandSet::available(&CommandContext {
1627                    tool: &tool,
1628                    data: &drawing,
1629                    history,
1630                    current_lock: InterfaceLock::Unlocked,
1631                    writability: viewing.writability(),
1632                    head,
1633                    saving: crate::doc::Saving::Withheld,
1634                    viewing,
1635                })
1636            };
1637            let frame = history_overlay(
1638                &mut commands,
1639                HistoryCluster {
1640                    panel_open: &mut panel_open,
1641                    viewing,
1642                    head,
1643                    steps: UndoSteps::default(),
1644                },
1645                screen,
1646                ui,
1647            );
1648            clock.set(frame.clock);
1649            frame.action
1650        };
1651        let mut chrome = crate::tools::painted::Chrome::new(screen);
1652        chrome.settle(|ui| {
1653            let _ = frame(ui);
1654        });
1655        let clock = clock.get();
1656        assert!(clock.is_positive(), "the history cluster never laid out");
1657        let out = drive(clock, &mut frame);
1658        (out, panel_open)
1659    }
1660
1661    /// Every verb a scan across the cluster *past the clock* dispatched.
1662    fn history_cluster_run(
1663        history: crate::tools::commands::History,
1664        viewing: Viewing,
1665        head: blockworx_doc::rev::Rev,
1666    ) -> (Vec<Action>, bool) {
1667        history_cluster(history, viewing, head, |clock, frame| {
1668            let row =
1669                Rect::from_min_max(clock.right_top(), clock.right_bottom() + vec2(200.0, 0.0));
1670            actions_along(row, frame)
1671        })
1672    }
1673
1674    /// One click on the clock button itself.
1675    fn click_the_clock(viewing: Viewing, head: blockworx_doc::rev::Rev) -> (Option<Action>, bool) {
1676        history_cluster(no_undo(), viewing, head, |clock, frame| {
1677            let mut chrome = crate::tools::painted::Chrome::new(viewport());
1678            let mut fired = None;
1679            chrome.settle(|ui| {
1680                let _ = frame(ui);
1681            });
1682            chrome.click_at(clock.center(), |ui| {
1683                if let Some(action) = frame(ui) {
1684                    fired = Some(action);
1685                }
1686            });
1687            fired
1688        })
1689    }
1690
1691    fn no_undo() -> crate::tools::commands::History {
1692        crate::tools::commands::History {
1693            can_undo: false,
1694            can_redo: false,
1695        }
1696    }
1697
1698    /// The cluster's verbs past the clock, at head.
1699    fn history_cluster_verbs(history: crate::tools::commands::History) -> Vec<Action> {
1700        history_cluster_run(history, Viewing::Head, blockworx_doc::rev::Rev::ZERO).0
1701    }
1702
1703    /// Undo and Redo are the history cluster's, and a click on each reaches
1704    /// the registry.
1705    #[test]
1706    fn the_history_cluster_undoes_and_redoes() {
1707        let verbs = history_cluster_verbs(crate::tools::commands::History {
1708            can_undo: true,
1709            can_redo: true,
1710        });
1711        assert!(
1712            verbs.iter().any(|a| matches!(a, Action::Undo)),
1713            "the history cluster dispatched no Undo ({} verbs in all)",
1714            verbs.len(),
1715        );
1716        assert!(
1717            verbs.iter().any(|a| matches!(a, Action::Redo)),
1718            "the history cluster dispatched no Redo ({} verbs in all)",
1719            verbs.len(),
1720        );
1721    }
1722
1723    /// With nothing to redo the control is there to be seen — but dead. The
1724    /// sibling test above is what proves this one is not merely missing the
1725    /// button.
1726    #[test]
1727    fn the_redo_button_is_dead_with_nothing_to_redo() {
1728        let verbs = history_cluster_verbs(crate::tools::commands::History {
1729            can_undo: true,
1730            can_redo: false,
1731        });
1732        assert!(
1733            verbs.iter().any(|a| matches!(a, Action::Undo)),
1734            "precondition: the live half of the cluster still dispatches",
1735        );
1736        assert!(
1737            !verbs.iter().any(|a| matches!(a, Action::Redo)),
1738            "a dead Redo dispatched anyway",
1739        );
1740    }
1741
1742    /// While a past rev is on the canvas the pair becomes a tape player:
1743    /// a step back, a step forward, and a seek to the latest rev. Driven
1744    /// through the cluster's own layout, so an icon that moved or went
1745    /// dead shows up as a missing verb.
1746    #[test]
1747    fn viewing_the_past_turns_the_cluster_into_a_tape_player() {
1748        use blockworx_doc::fixtures::rev;
1749        let (verbs, _) = history_cluster_run(no_undo(), Viewing::Past(rev(2)), rev(4));
1750        let dispatched = |wanted: fn(&Action) -> bool| verbs.iter().any(wanted);
1751        assert!(
1752            dispatched(|a| matches!(a, Action::ViewRev(r) if *r == rev(1))),
1753            "the tape player will not step back ({} verbs)",
1754            verbs.len(),
1755        );
1756        assert!(
1757            dispatched(|a| matches!(a, Action::ViewRev(r) if *r == rev(3))),
1758            "the tape player will not step forward ({} verbs)",
1759            verbs.len(),
1760        );
1761        assert!(
1762            dispatched(|a| matches!(a, Action::ViewHead)),
1763            "the tape player will not seek to latest ({} verbs)",
1764            verbs.len(),
1765        );
1766        assert!(
1767            !dispatched(|a| matches!(a, Action::Undo | Action::Redo)),
1768            "undo and redo act on a head nobody is looking at",
1769        );
1770    }
1771
1772    /// A step back off the oldest rev has nowhere to go, so `|<` is drawn
1773    /// dead — the sibling above is what proves this is not merely a
1774    /// mis-aimed scan.
1775    #[test]
1776    fn the_step_back_button_is_dead_at_the_oldest_rev() {
1777        use blockworx_doc::fixtures::rev;
1778        let (verbs, _) = history_cluster_run(no_undo(), Viewing::Past(rev(1)), rev(4));
1779        assert!(
1780            verbs
1781                .iter()
1782                .any(|a| matches!(a, Action::ViewRev(r) if *r == rev(2))),
1783            "precondition: the live half of the tape still steps",
1784        );
1785        assert!(
1786            !verbs
1787                .iter()
1788                .any(|a| matches!(a, Action::ViewRev(r) if *r < rev(1))),
1789            "a dead step-back dispatched anyway",
1790        );
1791    }
1792
1793    /// The clock opens and closes the panel, and does nothing else — at
1794    /// head and in the past alike. It used to double as "return to
1795    /// current" while a rev was showing, so one control did two unrelated
1796    /// things depending on state, and the panel could not be opened from
1797    /// the past at all (docs/ui-issues-2.md, item 4).
1798    #[test]
1799    fn the_clock_only_ever_toggles_the_panel() {
1800        use blockworx_doc::fixtures::rev;
1801        for viewing in [Viewing::Head, Viewing::Past(rev(2))] {
1802            let (fired, open) = click_the_clock(viewing, rev(4));
1803            assert!(
1804                fired.is_none(),
1805                "{viewing:?}: the clock dispatched a verb instead of opening the panel",
1806            );
1807            assert!(open, "{viewing:?}: the clock did not open the panel");
1808        }
1809    }
1810
1811    /// The navigation group's verbs, clicked out of the toolbar itself: the
1812    /// row runs from the compass to the toolbar's right edge.
1813    fn toolbar_nav_verbs(history: crate::tools::nav_tree::PathHistory) -> Vec<Action> {
1814        use crate::tools::commands::{CommandContext, CommandSet};
1815        use crate::tools::resize_block::ResizeBlock;
1816        use crate::widget::test_fixtures::{self as fx, Scene};
1817        use blockworx_doc::fixtures::block_id;
1818        // A child inside a parent, viewed from inside the parent: Enter
1819        // wants a block selected and Up wants a level above the one on
1820        // the canvas, which the document root does not have.
1821        let mut scene = Scene::new(vec![
1822            fx::block_in(
1823                1,
1824                Scope::Root,
1825                Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 200.0)),
1826            ),
1827            fx::titled(1, "core"),
1828            fx::block_in(
1829                2,
1830                Scope::Block(block_id(1)),
1831                Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 80.0)),
1832            ),
1833            fx::titled(2, "inner"),
1834        ])
1835        .inside(block_id(1));
1836        // Entering a block is a verb about the selection, so one is selected.
1837        let tool: crate::tools::tool::Tool = ResizeBlock::Selected {
1838            shape: ShapeId::Rect(block_id(2)),
1839        }
1840        .into();
1841        let screen = viewport();
1842        let mut debug_marks = false;
1843        let mut nav_open = false;
1844        let compass = std::cell::Cell::new(Rect::NOTHING);
1845        let mut frame = |ui: &mut egui::Ui| {
1846            let mut commands = {
1847                let drawing = scene.drawing();
1848                CommandSet::available(&CommandContext {
1849                    tool: &tool,
1850                    data: &drawing,
1851                    history: crate::tools::commands::History {
1852                        can_undo: false,
1853                        can_redo: false,
1854                    },
1855                    current_lock: InterfaceLock::Unlocked,
1856                    writability: crate::doc::Writability::Writable,
1857                    head: blockworx_doc::rev::Rev::ZERO,
1858                    saving: crate::doc::Saving::Withheld,
1859                    viewing: crate::doc::Viewing::Head,
1860                })
1861            };
1862            let frame = toolbar(
1863                &mut commands,
1864                ToolbarProps {
1865                    selected: ToolName::Select,
1866                    toggles: ViewToggles {
1867                        debug_marks: &mut debug_marks,
1868                    },
1869                    nav: NavCluster {
1870                        history,
1871                        nav_open: &mut nav_open,
1872                    },
1873                },
1874                screen,
1875                ui,
1876            );
1877            compass.set(frame.compass);
1878            frame.action
1879        };
1880        let mut chrome = crate::tools::painted::Chrome::new(screen);
1881        chrome.settle(|ui| {
1882            let _ = frame(ui);
1883        });
1884        let compass = compass.get();
1885        assert!(
1886            compass.is_positive(),
1887            "the toolbar drew no compass to scan from",
1888        );
1889        let row = Rect::from_min_max(
1890            compass.right_top(),
1891            compass.right_bottom() + vec2(200.0, 0.0),
1892        );
1893        actions_along(row, frame)
1894    }
1895
1896    /// The navigation functions moved onto the toolbar, and they are the same
1897    /// four: back and forward through the path history, into a block and up a
1898    /// level. Driven through the toolbar's own layout and the frame's real
1899    /// [`CommandSet`], so a button that draws but dispatches nothing fails.
1900    #[test]
1901    fn the_toolbar_navigates_the_hierarchy_and_the_path_history() {
1902        let verbs = toolbar_nav_verbs(crate::tools::nav_tree::PathHistory {
1903            can_back: true,
1904            can_forward: true,
1905        });
1906        for wanted in ["back", "forward", "into the block", "up a level"] {
1907            let found = verbs.iter().any(|action| {
1908                matches!(
1909                    (wanted, action),
1910                    ("back", Action::PathBack)
1911                        | ("forward", Action::PathForward)
1912                        | ("into the block", Action::ExpandBlock(_))
1913                        | ("up a level", Action::GoUp)
1914                )
1915            });
1916            assert!(
1917                found,
1918                "the toolbar will not go {wanted} ({} verbs in all)",
1919                verbs.len(),
1920            );
1921        }
1922    }
1923
1924    /// With nowhere to go back to, the arrow is drawn and dead — the paradigm
1925    /// the whole toolbar keeps.
1926    #[test]
1927    fn the_path_arrows_are_dead_with_no_history_to_walk() {
1928        let verbs = toolbar_nav_verbs(crate::tools::nav_tree::PathHistory {
1929            can_back: false,
1930            can_forward: false,
1931        });
1932        assert!(
1933            verbs.iter().any(|a| matches!(a, Action::GoUp)),
1934            "precondition: the live half of the group still dispatches",
1935        );
1936        assert!(
1937            !verbs
1938                .iter()
1939                .any(|a| matches!(a, Action::PathBack | Action::PathForward)),
1940            "a dead path arrow dispatched anyway",
1941        );
1942    }
1943
1944    #[test]
1945    fn accent_swatch_maps_index_to_its_accent_role() {
1946        use blockworx_doc::id::BlockId;
1947        let block = RoleTarget::Block(BlockId::NULL);
1948        assert_eq!(accent_display_role(block, Some(0)), Role::Accent0);
1949        assert_eq!(accent_display_role(block, Some(7)), Role::Accent7);
1950        // An unset (or out-of-range) accent falls back to the plain default.
1951        assert_eq!(accent_display_role(block, None), Role::AccentDefault);
1952        assert_eq!(accent_display_role(block, Some(9)), Role::AccentDefault);
1953    }
1954
1955    #[test]
1956    fn accent_swatch_uses_each_targets_own_unaccented_stroke() {
1957        use blockworx_doc::id::{AreaId, TextId};
1958        // With no accent set, areas/text boxes preview their own stroke role
1959        // rather than the generic `AccentDefault`, matching the picker's cell.
1960        let area = RoleTarget::Area(AreaId::NULL);
1961        let text = RoleTarget::Text(TextId::NULL);
1962        assert_eq!(accent_display_role(area, None), Role::AreaStroke);
1963        assert_eq!(accent_display_role(text, None), Role::TextBoxStroke);
1964        // A set accent still wins over the target-specific default.
1965        assert_eq!(accent_display_role(area, Some(2)), Role::Accent2);
1966    }
1967
1968    #[test]
1969    fn lock_toggle_icon_depicts_the_blocks_current_state() {
1970        let (locked_icon, locked_hover) = lock_toggle_icon(InterfaceLock::Locked);
1971        let (unlocked_icon, unlocked_hover) = lock_toggle_icon(InterfaceLock::Unlocked);
1972        // Like a physical padlock: closed shackle when locked, open when not
1973        // (icons compare by URI, since `ImageSource` is not `PartialEq`).
1974        assert_eq!(locked_icon.uri(), LOCK_ICON.uri());
1975        assert_eq!(unlocked_icon.uri(), UNLOCK_ICON.uri());
1976        assert_ne!(locked_icon.uri(), unlocked_icon.uri());
1977        // The hover text names the action a click performs, not the state.
1978        assert_eq!(locked_hover, "Unlock pins");
1979        assert_eq!(unlocked_hover, "Lock pins");
1980    }
1981
1982    /// Guards the padlock artwork itself: `lock_toggle_icon` picking the right
1983    /// *file* only helps if that file actually draws the right shackle. Both
1984    /// padlocks share a body rect; only the shackle path differs, and the closed
1985    /// one returns to the body on both sides (`V4` down the right leg).
1986    #[test]
1987    fn padlock_svgs_draw_a_closed_and_an_open_shackle() {
1988        let closed = include_str!("../../icons/icon-lock.svg");
1989        let open = include_str!("../../icons/icon-unlock.svg");
1990        assert!(
1991            closed.contains(r#"d="M8 11V7a4 4 0 0 1 8 0v4""#),
1992            "{closed}"
1993        );
1994        assert!(open.contains(r#"d="M8 11V7a4 4 0 0 1 7.5-2""#), "{open}");
1995    }
1996
1997    /// Rising a level is the toolbar's job now, so the selection overlay draws
1998    /// no control for it; entering a block — a verb about the selection — keeps
1999    /// its button.
2000    #[test]
2001    fn the_selection_overlay_offers_enter_but_not_go_up() {
2002        assert!(overlay_icon(CommandId::GoUp).is_none());
2003        assert!(overlay_icon(CommandId::ExpandBlock).is_some());
2004    }
2005
2006    /// The import icon is the export icon's conjugate: the same tray, with the
2007    /// arrow coming *into* it. Guards the artwork, not just the file names.
2008    /// Enter and exit are a pair, after Bootstrap's box-arrow-in-down /
2009    /// box-arrow-up: one box, one arrow, pointing opposite ways.
2010    #[test]
2011    fn the_hierarchy_icons_share_a_box_and_oppose_their_arrows() {
2012        let enter = include_str!("../../icons/icon-expand.svg");
2013        let exit = include_str!("../../icons/icon-exit.svg");
2014        let box_path =
2015            r#"d="M9 9H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-4""#;
2016        assert!(enter.contains(box_path), "{enter}");
2017        assert!(exit.contains(box_path), "{exit}");
2018        // Into the box, and out of it.
2019        assert!(enter.contains(r#"points="8 11 12 15 16 11""#), "{enter}");
2020        assert!(exit.contains(r#"points="8 6 12 2 16 6""#), "{exit}");
2021    }
2022
2023    /// The tape trio, after a transport panel: a stop bar on the side the
2024    /// tape runs *to*, and one chevron per step — two for the seek, which
2025    /// runs past every rev between here and the latest. Guards the
2026    /// artwork, not just the file names.
2027    #[test]
2028    fn the_tape_icons_bar_the_end_they_travel_to() {
2029        let back = include_str!("../../icons/icon-step-back.svg");
2030        let forward = include_str!("../../icons/icon-step-forward.svg");
2031        let latest = include_str!("../../icons/icon-seek-latest.svg");
2032        assert!(
2033            back.contains(r#"<line x1="7" y1="5" x2="7" y2="19"/>"#),
2034            "{back}",
2035        );
2036        assert!(
2037            forward.contains(r#"<line x1="17" y1="5" x2="17" y2="19"/>"#),
2038            "{forward}",
2039        );
2040        assert!(
2041            latest.contains(r#"<line x1="21" y1="5" x2="21" y2="19"/>"#),
2042            "{latest}",
2043        );
2044        assert_eq!(back.matches("<polyline").count(), 1);
2045        assert_eq!(forward.matches("<polyline").count(), 1);
2046        assert_eq!(
2047            latest.matches("<polyline").count(),
2048            2,
2049            "the seek icon is the double chevron",
2050        );
2051        // The house style: 24×24, stroke-2, no fill.
2052        for icon in [back, forward, latest] {
2053            assert!(icon.contains(r#"viewBox="0 0 24 24""#), "{icon}");
2054            assert!(icon.contains(r#"stroke-width="2""#), "{icon}");
2055            assert!(icon.contains(r#"fill="none""#), "{icon}");
2056        }
2057    }
2058
2059    #[test]
2060    fn the_import_icon_reverses_the_export_arrow_over_the_same_tray() {
2061        let export = include_str!("../../icons/icon-export.svg");
2062        let import = include_str!("../../icons/icon-import.svg");
2063        let tray = r#"d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4""#;
2064        assert!(export.contains(tray), "{export}");
2065        assert!(import.contains(tray), "{import}");
2066        // Export's chevron points up and away; import's points down and in.
2067        assert!(export.contains(r#"points="17 8 12 3 7 8""#), "{export}");
2068        assert!(import.contains(r#"points="7 10 12 15 17 10""#), "{import}");
2069    }
2070
2071    #[test]
2072    fn places_above_a_mid_canvas_selection() {
2073        let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2074        let p = place_overlay(sel, viewport(), SIZE, GAP, &[]).unwrap();
2075        // Just above the top edge, centered on the selection.
2076        assert_eq!(p.y, 400.0 - GAP - SIZE.y);
2077        assert_eq!(p.x, 450.0 - SIZE.x / 2.0);
2078    }
2079
2080    #[test]
2081    fn flips_below_when_the_selection_is_too_near_the_top() {
2082        let sel = Rect::from_min_size(pos2(400.0, 100.0), vec2(100.0, 100.0));
2083        let p = place_overlay(sel, viewport(), SIZE, GAP, &[]).unwrap();
2084        // Above would breach the top gap, so it lands just below the bottom edge.
2085        assert_eq!(p.y, 200.0 + GAP);
2086    }
2087
2088    #[test]
2089    fn pins_to_bottom_center_when_the_selection_fills_the_screen() {
2090        let sel = Rect::from_min_size(pos2(-100.0, -100.0), vec2(1200.0, 1000.0));
2091        let vp = viewport();
2092        let p = place_overlay(sel, vp, SIZE, GAP, &[]).unwrap();
2093        assert_eq!(p.y, vp.bottom() - OVERLAY_BOTTOM_GAP - SIZE.y);
2094        assert_eq!(p.x, vp.center().x - SIZE.x / 2.0); // centered on the viewport
2095    }
2096
2097    #[test]
2098    fn clamps_horizontally_within_the_viewport() {
2099        let sel = Rect::from_min_size(pos2(950.0, 400.0), vec2(40.0, 40.0));
2100        let vp = viewport();
2101        let p = place_overlay(sel, vp, SIZE, GAP, &[]).unwrap();
2102        assert_eq!(p.x, vp.right() - OVERLAY_EDGE_GAP - SIZE.x);
2103    }
2104
2105    #[test]
2106    fn hides_when_the_overlay_is_wider_than_the_viewport() {
2107        let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2108        assert!(place_overlay(sel, viewport(), vec2(1100.0, 40.0), GAP, &[]).is_none());
2109    }
2110
2111    #[test]
2112    fn hides_when_below_would_sit_over_the_toolbar() {
2113        // Selection mostly scrolled off the top, only its bottom sliver visible
2114        // near the viewport top: above doesn't fit, and below — though there's
2115        // room beneath it — would land in the toolbar zone, so hide.
2116        let sel = Rect::from_min_size(pos2(400.0, -40.0), vec2(100.0, 80.0));
2117        assert!(place_overlay(sel, viewport(), SIZE, GAP, &[]).is_none());
2118    }
2119
2120    #[test]
2121    fn flips_below_when_above_would_overlap_an_obstacle() {
2122        // A mid-canvas selection would normally place above, but a toolbar-shaped
2123        // obstacle covers that spot, so it lands below instead.
2124        let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2125        let above = Rect::from_min_size(pos2(300.0, 300.0), vec2(400.0, 80.0));
2126        let p = place_overlay(sel, viewport(), SIZE, GAP, &[above]).unwrap();
2127        assert_eq!(p.y, 500.0 + GAP);
2128    }
2129
2130    #[test]
2131    fn shifts_right_past_the_nav_dialog_instead_of_hiding() {
2132        // A tall left-edge nav dialog spanning the full height: a left-side
2133        // selection's centered overlay overlaps it both above and below, so rather
2134        // than hide, slide right to just past the dialog (at the above height).
2135        let sel = Rect::from_min_size(pos2(20.0, 400.0), vec2(60.0, 60.0));
2136        let nav = Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 800.0));
2137        let p = place_overlay(sel, viewport(), SIZE, GAP, &[nav]).unwrap();
2138        assert_eq!(p.x, 260.0 + OVERLAY_EDGE_GAP); // just past the nav's right edge
2139        assert_eq!(p.y, 400.0 - GAP - SIZE.y); // still at the above height
2140    }
2141
2142    #[test]
2143    fn hides_when_a_full_width_obstacle_blocks_every_landing() {
2144        // An obstacle covering the whole viewport leaves no clear x at any height,
2145        // so even with sideways sliding the overlay hides.
2146        let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2147        let everything = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0));
2148        assert!(place_overlay(sel, viewport(), SIZE, GAP, &[everything]).is_none());
2149    }
2150}