Skip to main content

blockworx/panels/
overlay.rs

1//! The selection overlay, and the icon helpers the menus and the smaller
2//! chrome draw their buttons with.
3//!
4//! The frame took the rest — the tools are
5//! [`crate::shell::tool_cluster`], undo and redo are
6//! [`crate::shell::top_bar`] — so what is left here is the surface the
7//! whole shell is built around: controls scoped to what the user has
8//! already selected.
9//!
10//! One list feeds three surfaces — the row, the overflow menu it spills
11//! into, and the right-click menu that duplicates it — because a second list
12//! is a second answer to "what applies here", and there is only one.
13//! [`Bar`] is where the one list is ordered and cut: the selected thing's own
14//! verbs first, then the clerical ones, and the first
15//! [`INLINE_CONTROLS`](blockworx_kernel::bar::INLINE_CONTROLS) of
16//! that order in the row.
17//!
18//! The intersection is degenerate here, and this says so rather than
19//! computing one: every command a multi-shape selection offers is a verb over
20//! the whole set — copy, cut, delete, export the selection as one diagram —
21//! so the intersection is never partial. The per-member verbs (flip, accent,
22//! lock) are absent from a multi-selection because their actions carry one
23//! target each, not because an intersection dropped them; giving them
24//! multi-target actions is an emitter change, and not this frame's.
25
26use crate::canvas::convert::{IntoEgui as _, IntoGeom as _};
27use crate::{
28    canvas::Camera,
29    edit::naming::InterfaceLock,
30    export::ExportScope,
31    panels::chrome::Panel,
32    shell::{SafeArea, glass},
33    tools::commands::{Act, Command, CommandId, CommandSet},
34};
35use blockworx_doc::values::PinDir;
36use blockworx_kernel::bar::{self, Bar};
37use blockworx_tools::commands::in_overlay;
38
39/// Which selection popup is open, if any. They are mutually exclusive: opening
40/// one closes the other.
41#[derive(Clone, Copy, PartialEq, Eq)]
42pub enum OpenPicker {
43    Role,
44    PinType,
45}
46
47/// Whether this frame's input asked for the right-click menu. Where it
48/// opens is the pointer's own position, which egui remembers for the popup, so
49/// there is nothing here to carry.
50#[derive(Clone, Copy, PartialEq, Eq)]
51pub enum RightClick {
52    Asked,
53    No,
54}
55
56impl From<bool> for RightClick {
57    fn from(asked: bool) -> Self {
58        if asked {
59            RightClick::Asked
60        } else {
61            RightClick::No
62        }
63    }
64}
65
66const EXPAND_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-expand.svg");
67const LOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-lock.svg");
68const UNLOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-unlock.svg");
69const DELETE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-trash.svg");
70const ADD_ICON_ICON: egui::ImageSource<'static> =
71    egui::include_image!("../../icons/icon-add-icon.svg");
72const ROUTE_LABEL_ICON: egui::ImageSource<'static> =
73    egui::include_image!("../../icons/icon-route-label.svg");
74const COPY_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-copy.svg");
75const CUT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-cut.svg");
76const FLIP_LR_ICON: egui::ImageSource<'static> =
77    egui::include_image!("../../icons/icon-flip-lr.svg");
78const FLIP_UD_ICON: egui::ImageSource<'static> =
79    egui::include_image!("../../icons/icon-flip-ud.svg");
80pub(crate) const EXPORT_ICON: egui::ImageSource<'static> =
81    egui::include_image!("../../icons/icon-export.svg");
82const REROUTE_ICON: egui::ImageSource<'static> =
83    egui::include_image!("../../icons/icon-reroute.svg");
84/// The overflow button. An ellipsis rather than the hamburger, which is
85/// the document menu's glyph and would say the wrong thing here.
86const MORE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-more.svg");
87const EYE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-eye.svg");
88const EYE_OFF_ICON: egui::ImageSource<'static> =
89    egui::include_image!("../../icons/icon-eye-off.svg");
90/// The one glyph the I/O control wears, whatever the pins are doing: the
91/// arrow meeting a wall the user drew for it. The three direction glyphs
92/// themselves live with the picker that offers them.
93const PIN_TYPE_ICON: egui::ImageSource<'static> = crate::io_pin_picker::INPUT_ICON;
94
95/// The icon and hover text for the lock-toggle control. The padlock depicts the
96/// block's current state — closed when locked, open shackle when unlocked, like a
97/// physical padlock — while the hover text names the action a click performs.
98fn lock_toggle_icon(lock: InterfaceLock) -> (egui::ImageSource<'static>, &'static str) {
99    match lock {
100        InterfaceLock::Locked => (LOCK_ICON, "Unlock pins"),
101        InterfaceLock::Unlocked => (UNLOCK_ICON, "Lock pins"),
102    }
103}
104
105/// A 14×14 icon image tinted to the current text color, the shared building
106/// block for every icon button in the menus and the smaller chrome.
107pub(crate) fn icon_image(
108    ui: &egui::Ui,
109    source: egui::ImageSource<'static>,
110) -> egui::Image<'static> {
111    egui::Image::new(source)
112        .fit_to_exact_size(egui::vec2(14.0, 14.0))
113        .tint(ui.visuals().widgets.inactive.fg_stroke.color)
114}
115
116/// The line a run of list rows sits under — the history panel's day, the
117/// palette's source. Stated once so two lists cannot part company over what
118/// a group heading looks like: quiet, small, with air above it so the run
119/// below reads as one.
120pub(crate) fn group_heading(ui: &mut egui::Ui, text: &str) {
121    ui.add_space(6.0);
122    ui.label(egui::RichText::new(text).small().weak());
123}
124
125/// The bar wears the shell's own selection-bar shape, so its height is the
126/// pill tier's one constant rather than a second number beside it.
127const BAR: glass::Shape = glass::Shape::Bar;
128
129/// The gap between two cells in the row — the mockup's, which reads as one
130/// cluster rather than as a line of separate buttons.
131const CELL_GAP: f32 = 2.0;
132
133/// The accent square inside its tap-sized cell, and its corner.
134const SWATCH: f32 = 18.0;
135const SWATCH_RADIUS: u8 = 3;
136
137/// The icon beside a menu row's words.
138const MENU_ICON: f32 = 18.0;
139
140/// Everything one frame of the overlay resolves against: what the session
141/// says the bar holds, and what the shell adds — which picker is up, the
142/// room the chrome left, and the gestures over the canvas.
143pub struct Overlay<'a> {
144    pub commands: &'a mut CommandSet,
145    /// The bar's contents, while something is selected.
146    pub bar: Option<&'a crate::kernel::Overlay>,
147    pub open_picker: Option<OpenPicker>,
148    /// The room the floating chrome left, which the bar clamps into.
149    pub safe: SafeArea,
150    /// Whether the camera is being worked; the bar stands down while it is
151    /// and re-places on release.
152    pub camera: Camera,
153    pub right_click: RightClick,
154}
155
156/// The egui id the right-click menu is held open under.
157fn menu_id() -> egui::Id {
158    egui::Id::from(Panel::SelectionMenu)
159}
160
161/// The overlay stands down: no bar, and the menu and the pickers the app
162/// anchors to it go with it.
163fn dismissed(ctx: &egui::Context) -> (Option<Act>, Option<egui::Pos2>) {
164    egui::Popup::close_id(ctx, menu_id());
165    (None, None)
166}
167
168/// Where this frame's bar goes, or why it goes nowhere.
169enum Placed {
170    /// Measured, and there is a spot for it.
171    At(egui::Pos2),
172    /// Measured, and there is nowhere lawful: both bands fall outside the
173    /// region, and covering the selection is not an alternative.
174    Nowhere,
175    /// Not measured yet. This frame lays the bar out to find how wide it is
176    /// and paints nothing, so the next one can place it.
177    Measuring,
178}
179
180/// What the bar measured itself as, and what it was measuring. A selection
181/// switch changes the row, and placing the new row with the old row's size
182/// flashed it one frame in the wrong place — so the memory says what it is of,
183/// and a frame it does not match is a sizing pass instead.
184#[derive(Clone)]
185struct Measured {
186    controls: Vec<CommandId>,
187    count: usize,
188    rect: egui::Rect,
189    /// The pass this was drawn on, so a bar that has since gone does not
190    /// keep claiming the spot it stood at.
191    pass: u64,
192}
193
194/// Where the bar stood last frame, if it was drawn: what a press there is
195/// on, however egui's hit test — which sees the bar as part of the canvas's
196/// own layer — attributes it.
197pub(crate) fn bar_rect(ctx: &egui::Context) -> Option<egui::Rect> {
198    let measured =
199        ctx.data(|d| d.get_temp::<Measured>(Panel::SelectionButtons.measurement("rect")))?;
200    (measured.pass + 1 == ctx.cumulative_pass_nr()).then_some(measured.rect)
201}
202
203/// The export-format buttons, shared by the main menu's Export submenu and
204/// the block overlay's "Export as top level" menu, each offering the formats
205/// its `scope` carries. Returns the clicked format.
206pub(crate) fn export_format_menu(
207    ui: &mut egui::Ui,
208    scope: crate::export::ExportScope,
209) -> Option<crate::export::ExportFormat> {
210    scope
211        .formats()
212        .iter()
213        .copied()
214        .find(|format| ui.button(format.label()).clicked())
215}
216
217/// The commands scoped to the current selection, drawn beside it as a
218/// floating bar: the placement, the stand-down while the camera moves, the
219/// overflow, the count, and the right-click duplicate.
220///
221/// Returns the triggered `Action` and the bar's top-right corner (which the
222/// pickers anchor above; `None` when the bar is hidden, which dismisses them).
223pub fn selection_overlay(
224    ui: &mut egui::Ui,
225    overlay: Overlay<'_>,
226) -> (Option<Act>, Option<egui::Pos2>) {
227    let Overlay {
228        commands,
229        bar: model,
230        open_picker,
231        safe,
232        camera,
233        right_click,
234    } = overlay;
235    let ctx = ui.ctx().clone();
236    let ctx = &ctx;
237    let Some(model) = model.filter(|bar| safe.viewport().intersects(bar.selection.screen.egui()))
238    else {
239        return dismissed(ctx);
240    };
241    let selection = model.selection;
242    let screen = selection.screen.egui();
243    // Tracking the object through a pan is a jittering distraction. The
244    // measurement is left untouched, so the frame the gesture ends on places
245    // the bar again without another sizing pass.
246    if camera == Camera::Moving {
247        return dismissed(ctx);
248    }
249    let offered: Vec<&Command> = overlay_controls(commands).collect();
250    let drawn: Vec<CommandId> = offered.iter().map(|cmd| cmd.id).collect();
251    let count = selection.count;
252    let bar = Bar::of(offered, count, |cmd: &&Command| cmd.precedence);
253
254    let size_key = Panel::SelectionButtons.measurement("rect");
255    let remembered = ctx
256        .data(|d| d.get_temp::<Measured>(size_key))
257        .filter(|was| was.controls == drawn && was.count == count)
258        .map(|was| was.rect.size());
259    let placed = match remembered {
260        Some(size) => match bar::place(screen.geom(), size.geom(), safe.region().geom()) {
261            Some(bar) => Placed::At(bar.min.egui()),
262            None => Placed::Nowhere,
263        },
264        None => Placed::Measuring,
265    };
266    let (at, builder) = match placed {
267        Placed::At(at) => (at, Panel::SelectionButtons.ui_builder()),
268        Placed::Nowhere => return dismissed(ctx),
269        // Both flags, deliberately: `sizing_pass` alone only tightens the
270        // layout — egui's painter goes quiet on `invisible`, and its own
271        // Area/Grid sizing passes chain exactly this pair. Without it the
272        // measuring frame painted the bar at the provisional spot below.
273        Placed::Measuring => {
274            ctx.request_repaint();
275            (
276                screen.center(),
277                Panel::SelectionButtons
278                    .ui_builder()
279                    .sizing_pass()
280                    .invisible(),
281            )
282        }
283    };
284    let sizing = builder.sizing_pass;
285    let controls = Controls { model, open_picker };
286    let mut clicked: Option<CommandId> = None;
287    let mut child =
288        ui.new_child(builder.max_rect(egui::Rect::from_min_size(at, safe.viewport().size())));
289    let rect = {
290        let ui = &mut child;
291        glass::shell(ui, BAR, glass::Elevation::Floating, glass::Tint::None)
292            .show(ui, |ui| {
293                glass::type_scale(ui, BAR);
294                // The bar stands the pill tier's height whatever it holds, so
295                // it reads as one of the chip's kin rather than as a taller
296                // stranger over the drawing (the user: *"The selection
297                // overlay pill should also be smaller in height (same as the
298                // top-left pill)"*).
299                if let Some(height) = BAR.content_height() {
300                    ui.set_min_height(height);
301                }
302                clicked = draw_row(ui, &bar, count, controls);
303            })
304            .response
305            .rect
306    };
307    tracing::debug!(
308        target: "overlay",
309        pass = ctx.cumulative_pass_nr(),
310        sizing,
311        drawn = drawn.len(),
312        at = ?at,
313        rect = ?rect,
314        sel = ?screen,
315        "overlay frame"
316    );
317    let pass = ctx.cumulative_pass_nr();
318    ctx.data_mut(|d| {
319        d.insert_temp(
320            size_key,
321            Measured {
322                controls: drawn,
323                count,
324                rect,
325                pass,
326            },
327        );
328    });
329    if sizing {
330        return (None, None);
331    }
332    if let Some(id) = right_click_menu(ui, &bar, right_click, controls) {
333        clicked = Some(id);
334    }
335    drop(bar);
336    (
337        clicked.and_then(|id| commands.take(id)),
338        Some(rect.right_top()),
339    )
340}
341
342/// The bar's contents: the count, the row, and the overflow button.
343fn draw_row(
344    ui: &mut egui::Ui,
345    bar: &Bar<&Command>,
346    count: usize,
347    controls: Controls<'_>,
348) -> Option<CommandId> {
349    let mut clicked = None;
350    ui.horizontal(|ui| {
351        ui.spacing_mut().item_spacing.x = CELL_GAP;
352        if count > 1 {
353            ui.label(
354                egui::RichText::new(format!("{count} selected"))
355                    .small()
356                    .weak(),
357            );
358            glass::group_gap(ui);
359        }
360        if !matches!(bar, Bar::Row(_)) {
361            ui.label(
362                egui::RichText::new(bar::nothing_to_say(count))
363                    .small()
364                    .weak(),
365            );
366            return;
367        }
368        for cmd in bar.row() {
369            if let Some(id) = draw_command(ui, cmd, Shown::InTheBar, controls) {
370                clicked = Some(id);
371            }
372        }
373        let overflow = bar.overflow();
374        if overflow.is_empty() {
375            return;
376        }
377        let more = glass::tap_button(
378            ui,
379            MORE_ICON,
380            glass::Live::Yes,
381            format!("{} more", overflow.len()),
382        );
383        egui::Popup::menu(&more).show(|ui| {
384            for cmd in overflow {
385                if let Some(id) = draw_command(ui, cmd, Shown::InAMenu, controls) {
386                    clicked = Some(id);
387                }
388            }
389        });
390    });
391    clicked
392}
393
394/// On a pointer platform, right-click opens a menu with **exactly** the
395/// overlay's commands. It is handed the same [`Bar`] the row was drawn from,
396/// so it cannot carry one command more or one fewer.
397fn right_click_menu(
398    ui: &mut egui::Ui,
399    bar: &Bar<&Command>,
400    right_click: RightClick,
401    controls: Controls<'_>,
402) -> Option<CommandId> {
403    let mut clicked = None;
404    // Nothing to carry is no menu: a right-click that opened an empty box
405    // would be a surface promising commands it does not have.
406    let opened = match (right_click, bar.all().first()) {
407        (RightClick::Asked, Some(_)) => Some(egui::SetOpenCommand::Bool(true)),
408        (RightClick::Asked, None) => Some(egui::SetOpenCommand::Bool(false)),
409        (RightClick::No, _) => None,
410    };
411    egui::Popup::new(
412        menu_id(),
413        ui.ctx().clone(),
414        egui::PopupAnchor::PointerFixed,
415        ui.layer_id(),
416    )
417    .kind(egui::PopupKind::Menu)
418    .layout(egui::Layout::top_down_justified(egui::Align::Min))
419    .open_memory(opened)
420    .show(|ui| {
421        for cmd in bar.all() {
422            if let Some(id) = draw_command(ui, cmd, Shown::InAMenu, controls) {
423                clicked = Some(id);
424            }
425        }
426    });
427    clicked
428}
429
430/// The controls this overlay lays out, in registry order, withheld ones
431/// included (they draw disabled). The one gate: "is this overlay empty",
432/// "what does it draw", and *how wide it is* all read this, so a command
433/// that renders nothing takes no room either — each control sits in a
434/// child `Ui` of its own, and an empty child still advances the row by
435/// one item spacing.
436pub(crate) fn overlay_controls(commands: &CommandSet) -> impl Iterator<Item = &Command> {
437    commands.iter_drawn().filter(|cmd| in_overlay(cmd.id))
438}
439
440/// The two ways one command is drawn: as a cell in the bar's row, and as a row
441/// in a menu — the overflow's and the right-click's, which are the same menu.
442#[derive(Clone, Copy, PartialEq, Eq)]
443enum Shown {
444    InTheBar,
445    InAMenu,
446}
447
448/// What a control needs beyond its own command to draw itself: what the
449/// session says the bar holds, and which picker is currently up.
450#[derive(Clone, Copy)]
451struct Controls<'a> {
452    model: &'a crate::kernel::Overlay,
453    open_picker: Option<OpenPicker>,
454}
455
456/// Render `cmd` the way `shown` asks, returning the command a click fired.
457/// Only what [`overlay_controls`] yields reaches here. A withheld command is
458/// drawn dead rather than dropped, so the bar keeps its shape in a
459/// read-only session.
460fn draw_command(
461    ui: &mut egui::Ui,
462    cmd: &Command,
463    shown: Shown,
464    controls: Controls<'_>,
465) -> Option<CommandId> {
466    let Controls { model, open_picker } = controls;
467    ui.add_enabled_ui(!cmd.withheld(), |ui| match cmd.id {
468        CommandId::ExportSelection(_) => {
469            let mut picked = None;
470            match shown {
471                Shown::InTheBar => {
472                    let button =
473                        glass::tap_button(ui, EXPORT_ICON, glass::Live::Yes, cmd.label.as_ref());
474                    egui::Popup::menu(&button).show(|ui| {
475                        picked = export_format_menu(ui, ExportScope::Selection);
476                    });
477                }
478                Shown::InAMenu => {
479                    ui.menu_button(cmd.label.as_ref(), |ui| {
480                        picked = export_format_menu(ui, ExportScope::Selection);
481                    });
482                }
483            }
484            picked.map(CommandId::ExportSelection)
485        }
486        CommandId::Accent => {
487            let accent = model.swatch?;
488            let opened = glass::Opened::from(open_picker == Some(OpenPicker::Role));
489            let response = match shown {
490                Shown::InTheBar => accent_swatch(ui, accent.egui(), opened),
491                Shown::InAMenu => menu_row(ui, None, &cmd.label, opened),
492            };
493            (response.clicked() && opened == glass::Opened::No).then_some(cmd.id)
494        }
495        CommandId::PinType => {
496            let opened = glass::Opened::from(open_picker == Some(OpenPicker::PinType));
497            let says = pin_dir_hover(model.pin_dir);
498            let response = match shown {
499                Shown::InTheBar => {
500                    glass::tap_button(ui, PIN_TYPE_ICON, glass::Live::Yes, says.clone())
501                }
502                Shown::InAMenu => menu_row(ui, Some(PIN_TYPE_ICON), &cmd.label, opened),
503            };
504            (response.clicked() && opened == glass::Opened::No).then_some(cmd.id)
505        }
506        CommandId::HideTags | CommandId::ShowTags => {
507            let icon = tag_icon(cmd.id);
508            let response = match shown {
509                Shown::InTheBar => {
510                    glass::tap_button(ui, icon, glass::Live::Yes, cmd.label.as_ref())
511                }
512                Shown::InAMenu => menu_row(ui, Some(icon), &cmd.label, glass::Opened::No),
513            };
514            response.clicked().then_some(cmd.id)
515        }
516        id => {
517            let icon = overlay_icon(id)?;
518            let response = match shown {
519                Shown::InTheBar => {
520                    glass::tap_button(ui, icon, glass::Live::Yes, cmd.label.as_ref())
521                }
522                Shown::InAMenu => menu_row(ui, Some(icon), &cmd.label, glass::Opened::No),
523            };
524            response.clicked().then_some(id)
525        }
526    })
527    .inner
528}
529
530/// The eye the tag toggle wears, which shows the *state* the way the lock
531/// toggle beside it does — an open eye where the tags are showing, a struck
532/// one where they are hidden — while the words name the action. The user
533/// asked for the paradigm by name: *"Give me some kind of icon for the 'hide
534/// tag' 'show tag'. Maybe just a show/hide icon (the usual eye-based
535/// paradigm)."*
536///
537/// The registry offers `HideTags` exactly when the tags are visible, so the
538/// command's identity is the state.
539fn tag_icon(id: CommandId) -> egui::ImageSource<'static> {
540    match id {
541        CommandId::ShowTags => EYE_OFF_ICON,
542        _ => EYE_ICON,
543    }
544}
545
546/// The control names itself, and its words name the state.
547///
548/// The user's own rule: *"For the input/output configuration icon, do not
549/// change it based on the current state of the pin. That is confusing. Pick
550/// one icon (e.g., the |&lt;- icon) and use it always."* So the button wears
551/// [`PIN_TYPE_ICON`] in every state — a control is a place, and a place that
552/// changes its face is a new control every time you look — and the direction
553/// the pins are facing is told in the tooltip, where a varying thing
554/// belongs. A mixed selection has no one direction, and says so.
555fn pin_dir_hover(dir: Option<PinDir>) -> String {
556    let state = match dir {
557        Some(PinDir::Input) => "currently Input",
558        Some(PinDir::Output) => "currently Output",
559        Some(PinDir::InOut) => "currently Input Output",
560        None => "these pins face different ways",
561    };
562    format!("Direction \u{2014} {state}")
563}
564
565/// One row of a menu: the glyph the bar would have shown, and the words its
566/// tooltip would have carried. Tap-height like everything else.
567fn menu_row(
568    ui: &mut egui::Ui,
569    icon: Option<egui::ImageSource<'static>>,
570    label: &str,
571    opened: glass::Opened,
572) -> egui::Response {
573    let button = match icon {
574        Some(icon) => egui::Button::image_and_text(glass::image(ui, icon, MENU_ICON), label),
575        None => egui::Button::new(label),
576    };
577    ui.add(
578        button
579            .min_size(egui::vec2(0.0, glass::TAP))
580            .selected(opened == glass::Opened::Yes),
581    )
582}
583
584/// The accent control: the selection's current accent as a filled square in a
585/// tap-sized cell, opening the role picker on click. While the picker is up the
586/// swatch carries an active-indicator ring.
587fn accent_swatch(ui: &mut egui::Ui, color: egui::Color32, opened: glass::Opened) -> egui::Response {
588    let (cell, resp) = ui.allocate_exact_size(egui::Vec2::splat(glass::TAP), egui::Sense::click());
589    let rect = egui::Rect::from_center_size(cell.center(), egui::Vec2::splat(SWATCH));
590    let painter = ui.painter();
591    painter.rect_filled(rect, SWATCH_RADIUS, color);
592    let (width, stroke) = if opened == glass::Opened::Yes {
593        (2.0, ui.visuals().selection.stroke.color)
594    } else {
595        (1.0, ui.visuals().widgets.inactive.fg_stroke.color)
596    };
597    painter.rect_stroke(
598        rect,
599        SWATCH_RADIUS,
600        egui::Stroke::new(width, stroke),
601        egui::StrokeKind::Inside,
602    );
603    resp.on_hover_text("Accent")
604}
605
606/// The icon-button artwork for the overlay's plain commands. The padlock pair
607/// routes through [`lock_toggle_icon`] so the shackle depicts the block's
608/// current state while the command names the action a click performs.
609fn overlay_icon(id: CommandId) -> Option<egui::ImageSource<'static>> {
610    match id {
611        CommandId::Copy => Some(COPY_ICON),
612        CommandId::Cut => Some(CUT_ICON),
613        CommandId::FlipLr => Some(FLIP_LR_ICON),
614        CommandId::FlipUd => Some(FLIP_UD_ICON),
615        CommandId::Reroute | CommandId::RerouteBlock => Some(REROUTE_ICON),
616        CommandId::AddRouteLabel => Some(ROUTE_LABEL_ICON),
617        // Entering a block is a verb about the *selection*, so it is here as
618        // well as in the toolbar's navigation group.
619        CommandId::ExpandBlock => Some(EXPAND_ICON),
620        CommandId::Lock => Some(lock_toggle_icon(InterfaceLock::Unlocked).0),
621        CommandId::Unlock => Some(lock_toggle_icon(InterfaceLock::Locked).0),
622        CommandId::AddIcon => Some(ADD_ICON_ICON),
623        CommandId::Delete => Some(DELETE_ICON),
624        _ => None,
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::kernel::Selection;
632    use crate::kernel::chrome::accent_display_role;
633    use crate::shape::ShapeId;
634    use crate::theme::{Role, Theme};
635    use crate::tools::tool::RoleTarget;
636    use blockworx_kernel::bar::{CLEAR, INLINE_CONTROLS};
637    use blockworx_tools::commands::Precedence;
638
639    use blockworx_store::doc::Writability;
640
641    use crate::{
642        tools::{
643            commands::{CommandContext, History},
644            tool::Tool,
645        },
646        widget::test_fixtures::{Scene, two_blocks_with_a_routed_waypoint},
647    };
648    use egui::{Rect, Vec2, pos2, vec2};
649
650    fn viewport() -> Rect {
651        Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
652    }
653
654    /// A bar of a size the placement tests can reason about without drawing it.
655    const SIZE: Vec2 = Vec2::new(200.0, 40.0);
656
657    /// The glyph egui puts on a menu row that opens a submenu.
658    const SUBMENU_CHEVRON: &str = "\u{23f5}";
659
660    // ---- what the bar shows --------------------------------------------
661
662    /// The order is by what a command *is*: the user named the primaries —
663    /// *"For sure the primary actions should be 'Accent, Expand, Icon, Lock,
664    /// Rip, flip lr, and flip ud'. The 'copy/cut/delete' can all be put into
665    /// the overflow menu, as can the 'export to svg'."* — and a block is the
666    /// selection that offers all of them, more than the row holds.
667    #[test]
668    fn a_blocks_row_is_its_first_five_primaries_and_the_rest_go_behind_the_ellipsis() {
669        let all: Vec<CommandId> = Harness::block().laid_out().iter().map(|c| c.0).collect();
670        let bar = Bar::of(all.clone(), 1, |id: &CommandId| Precedence::of(*id));
671        assert!(
672            all.len() > INLINE_CONTROLS,
673            "precondition: a block offers more controls than the row holds: {all:?}",
674        );
675        assert_eq!(
676            bar.row().len(),
677            INLINE_CONTROLS,
678            "the row does not hold its fill: {:?}",
679            bar.row(),
680        );
681        assert_eq!(bar.all().len(), all.len(), "the cut lost a control");
682        let lock = if all.contains(&CommandId::Unlock) {
683            CommandId::Unlock
684        } else {
685            CommandId::Lock
686        };
687        assert_eq!(
688            bar.row(),
689            [
690                CommandId::Accent,
691                CommandId::ExpandBlock,
692                lock,
693                CommandId::AddIcon,
694                CommandId::RerouteBlock,
695            ],
696            "the row is not the user's primaries in the user's order",
697        );
698        for behind in [
699            CommandId::FlipLr,
700            CommandId::FlipUd,
701            CommandId::Copy,
702            CommandId::Cut,
703            CommandId::Delete,
704        ] {
705            assert!(
706                bar.overflow().contains(&behind),
707                "{behind:?} is not behind the ellipsis: {:?}",
708                bar.overflow(),
709            );
710        }
711        assert!(
712            bar.overflow()
713                .iter()
714                .any(|id| matches!(id, CommandId::ExportSelection(_))),
715            "the selection export is not behind the ellipsis: {:?}",
716            bar.overflow(),
717        );
718
719        // The order keeps the registry's within each rank.
720        for rank in [Precedence::Own, Precedence::Clerical] {
721            let in_registry_order: Vec<CommandId> = all
722                .iter()
723                .copied()
724                .filter(|&id| Precedence::of(id) == rank)
725                .collect();
726            let in_bar_order: Vec<CommandId> = bar
727                .all()
728                .iter()
729                .copied()
730                .filter(|&id| Precedence::of(id) == rank)
731                .collect();
732            assert_eq!(
733                in_bar_order, in_registry_order,
734                "{rank:?} came out reordered"
735            );
736        }
737    }
738
739    /// Item 27, which is item 22 asked of one more type: *"The selection
740    /// overlay for the port should also put the copy/cut/delete stuff behind
741    /// the extension, and use the toolbar for the other controls."* A port's
742    /// own verbs — its I/O direction, its tag, its accent, its flip — lead
743    /// the row, and the clerical ones follow them, exactly as a block's do,
744    /// because precedence lives on the command rather than on the type that
745    /// offered it.
746    #[test]
747    fn a_ports_own_controls_lead_the_row_and_its_clerical_verbs_follow() {
748        let mut port = Harness::port();
749        let ids: Vec<CommandId> = port.laid_out().iter().map(|c| c.0).collect();
750        let bar = Bar::of(ids.clone(), 1, |id: &CommandId| Precedence::of(*id));
751        assert!(matches!(bar, Bar::Row(_)), "a port raises no row: {ids:?}");
752        let own = [CommandId::PinType, CommandId::Accent, CommandId::FlipLr];
753        for id in own {
754            assert!(
755                bar.row().contains(&id),
756                "the port's own {id:?} is not in the row: {:?} / {:?}",
757                bar.row(),
758                bar.overflow(),
759            );
760        }
761        assert!(
762            bar.row()
763                .iter()
764                .any(|id| matches!(id, CommandId::HideTags | CommandId::ShowTags)),
765            "the port's tag toggle is not in the row: {:?}",
766            bar.row(),
767        );
768        assert!(
769            bar.overflow().contains(&CommandId::Delete),
770            "precondition: the port offers more than the row holds, so Delete is behind: {:?}",
771            bar.all(),
772        );
773    }
774
775    /// Item 26's glyphs, and which way round they read. The eye shows the
776    /// *state* — the same way the padlock beside it does — so a control
777    /// offering "Hide Tags" is one whose tags are currently open. The I/O
778    /// control does the opposite, on the user's own second thoughts (item
779    /// 14): one glyph always, and the state in words.
780    #[test]
781    fn the_tag_eye_shows_the_state_and_the_io_control_keeps_one_glyph() {
782        let source = |icon: egui::ImageSource<'static>| match icon {
783            egui::ImageSource::Bytes { uri, .. } => uri.to_string(),
784            _ => panic!("the overlay's icons are embedded bytes"),
785        };
786        assert!(source(tag_icon(CommandId::HideTags)).contains("icon-eye.svg"));
787        assert!(source(tag_icon(CommandId::ShowTags)).contains("icon-eye-off.svg"));
788        assert_ne!(
789            source(tag_icon(CommandId::HideTags)),
790            source(tag_icon(CommandId::ShowTags)),
791            "the two states share one glyph",
792        );
793
794        // Item 14 reverses item 26's other half: the I/O control keeps one
795        // glyph in every state — *"do not change it based on the current
796        // state of the pin. That is confusing."* — and says the state in
797        // words instead.
798        assert!(source(PIN_TYPE_ICON).contains("icon-pin-input.svg"));
799        let said: Vec<String> = [
800            Some(PinDir::Input),
801            Some(PinDir::Output),
802            Some(PinDir::InOut),
803            None,
804        ]
805        .into_iter()
806        .map(pin_dir_hover)
807        .collect();
808        assert_eq!(
809            said.iter().collect::<std::collections::HashSet<_>>().len(),
810            said.len(),
811            "two states of the control say the same thing: {said:?}",
812        );
813        for word in &said {
814            assert!(
815                word.starts_with("Direction"),
816                "the control does not name itself: {word}",
817            );
818        }
819        assert!(
820            said[1].contains("Output") && said[3].contains("different"),
821            "the words do not name the state: {said:?}",
822        );
823    }
824
825    /// The user's own sketch, checked against the paths: an arrow arriving at
826    /// a wall on the left is an input, one leaving for a wall on the right is
827    /// an output, and the two are mirror images rather than the same drawing
828    /// twice.
829    #[test]
830    fn the_io_icons_put_the_wall_on_opposite_sides() {
831        let input = include_str!("../../icons/icon-pin-input.svg");
832        let output = include_str!("../../icons/icon-pin-output.svg");
833        assert!(input.contains(r#"d="M5 4v16""#), "{input}");
834        assert!(output.contains(r#"d="M19 4v16""#), "{output}");
835        assert_ne!(input, output, "the two directions share one drawing");
836        let eye = include_str!("../../icons/icon-eye.svg");
837        let eye_off = include_str!("../../icons/icon-eye-off.svg");
838        assert!(
839            eye_off.contains(r#"d="M4 20L20 4""#) && !eye.contains(r#"d="M4 20L20 4""#),
840            "only the hidden state carries the slash",
841        );
842    }
843
844    /// Precedence is orthogonal to applicability: a route, a block and an
845    /// area share the clerical verbs and put every one of them after their
846    /// own verbs, though almost nothing else about their lists is the same.
847    #[test]
848    fn every_selection_type_puts_its_clerical_verbs_after_its_own() {
849        for (what, harness) in [
850            ("a block", Harness::block()),
851            ("a wire", Harness::wire()),
852            ("an area", Harness::area()),
853        ] {
854            let mut harness = harness;
855            let ids: Vec<CommandId> = harness.laid_out().iter().map(|c| c.0).collect();
856            let bar = Bar::of(ids.clone(), 1, |id: &CommandId| Precedence::of(*id));
857            let ranks: Vec<Precedence> = bar.all().iter().map(|&id| Precedence::of(id)).collect();
858            assert!(
859                ranks.contains(&Precedence::Own) && ranks.contains(&Precedence::Clerical),
860                "precondition: {what} offers verbs of both ranks: {ids:?}",
861            );
862            assert!(
863                ranks.is_sorted(),
864                "{what} puts a clerical verb before one of its own: {:?}",
865                bar.all(),
866            );
867            assert!(
868                bar.all().contains(&CommandId::Delete),
869                "{what} lost Delete altogether: {ids:?}",
870            );
871        }
872    }
873
874    /// Selecting an area raises a bar. Areas answer to fewer verbs than
875    /// blocks, but "fewer" is not "none" — the registry gives one an
876    /// accent, a copy, a cut and a delete.
877    #[test]
878    fn an_area_selection_raises_a_bar_with_its_own_commands() {
879        let mut area = Harness::area();
880        let drawn: Vec<CommandId> = area.laid_out().into_iter().map(|(id, _)| id).collect();
881        assert!(
882            !drawn.is_empty(),
883            "an area selection drew no controls at all",
884        );
885        for wanted in [
886            CommandId::Accent,
887            CommandId::Copy,
888            CommandId::Cut,
889            CommandId::Delete,
890        ] {
891            assert!(
892                drawn.contains(&wanted),
893                "the area's bar is missing {wanted:?}: {drawn:?}",
894            );
895        }
896        assert!(
897            area.corner.is_some(),
898            "the area's bar laid out no controls on screen",
899        );
900        assert!(area.painted > 0, "the area's bar painted nothing");
901    }
902
903    /// An area is the one selection routinely *bigger* than the room around
904    /// it: both outside candidates fall clear of the region, and the band
905    /// just inside its own leading edge is what keeps a bar on screen.
906    #[test]
907    fn a_selection_taller_than_the_region_still_gets_a_bar() {
908        let mut area = Harness::area();
909        let region = area.safe.region();
910        // An area drawn around the whole drawing: taller than the canvas has
911        // room for a bar above or below it.
912        area.selection.screen = Rect::from_min_max(
913            pos2(region.center().x - 200.0, region.top() - 40.0),
914            pos2(region.center().x + 200.0, region.bottom() + 40.0),
915        )
916        .geom();
917        let screen = area.selection.screen.egui();
918        assert!(
919            screen.top() - CLEAR - SIZE.y < region.top()
920                && screen.bottom() + CLEAR + SIZE.y > region.bottom(),
921            "precondition: neither band outside the selection fits the region",
922        );
923        let placed = bar::place(screen.geom(), SIZE.geom(), region.geom())
924            .expect("a selection bigger than its room still has somewhere lawful")
925            .egui();
926        assert!(
927            region.contains_rect(placed),
928            "the bar landed outside the region at {placed:?}",
929        );
930
931        let settled = settled(area);
932        assert!(
933            settled.corner.is_some() && settled.painted > 0,
934            "the oversized area's bar never drew",
935        );
936    }
937
938    /// The cut is by count: a list the row can hold is all row, and one
939    /// control more puts exactly that one behind the ellipsis.
940    #[test]
941    fn the_row_holds_five_and_only_the_sixth_goes_behind_the_ellipsis() {
942        let own = [
943            CommandId::Accent,
944            CommandId::ExpandBlock,
945            CommandId::Lock,
946            CommandId::AddIcon,
947            CommandId::RerouteBlock,
948        ];
949        assert_eq!(
950            own.len(),
951            INLINE_CONTROLS,
952            "precondition: the list is exactly the row's fill",
953        );
954        let full = Bar::of(own.to_vec(), 1, |id: &CommandId| Precedence::of(*id));
955        assert_eq!(full.row(), own, "a full row lost a control");
956        assert!(
957            full.overflow().is_empty(),
958            "five controls raised an ellipsis: {:?}",
959            full.overflow(),
960        );
961
962        let six: Vec<CommandId> = own.iter().copied().chain([CommandId::Delete]).collect();
963        assert_eq!(
964            six.len(),
965            INLINE_CONTROLS + 1,
966            "precondition: one control more"
967        );
968        let spilled = Bar::of(six, 1, |id: &CommandId| Precedence::of(*id));
969        assert_eq!(
970            spilled.row(),
971            own,
972            "the sixth control took a place in the row"
973        );
974        assert_eq!(spilled.overflow(), [CommandId::Delete]);
975    }
976
977    #[test]
978    fn a_multi_selection_sharing_nothing_says_so_rather_than_showing_an_empty_bar() {
979        assert!(matches!(
980            Bar::of(Vec::<CommandId>::new(), 3, |id: &CommandId| Precedence::of(
981                *id
982            )),
983            Bar::NothingShared
984        ));
985        assert!(matches!(
986            Bar::of(Vec::<CommandId>::new(), 1, |id: &CommandId| Precedence::of(
987                *id
988            )),
989            Bar::Nothing
990        ));
991    }
992
993    /// The count prefix, through a real frame. No blockworx multi-selection
994    /// has an empty intersection today — every command it offers is a verb
995    /// over the whole set — so the sentence above is proven at the split and
996    /// the count is proven here.
997    #[test]
998    fn a_multi_selection_wears_its_count_and_a_single_one_does_not() {
999        let mut chrome = crate::panels::painted::Chrome::new(viewport().geom());
1000        let mut many = Harness::two_blocks();
1001        chrome.settle(|ui| many.show(ui));
1002        assert!(
1003            many.corner.is_some(),
1004            "precondition: the bar drew for a two-block selection",
1005        );
1006        assert!(
1007            chrome.shows("2 selected"),
1008            "the multi-selection lost its count: {:?}",
1009            chrome.texts(),
1010        );
1011        let mut one = Harness::block();
1012        chrome.settle(|ui| one.show(ui));
1013        assert!(
1014            !chrome.texts().iter().any(|said| said.contains("selected")),
1015            "one block was counted: {:?}",
1016            chrome.texts(),
1017        );
1018    }
1019
1020    // ---- right-click ---------------------------------------------------
1021
1022    /// The right-click menu carries exactly the overlay's commands — the
1023    /// same list, so it can be neither longer (a command invisible on
1024    /// iPadOS) nor shorter. Read out of the menu's own area, so what the
1025    /// bar painted beside it cannot pad the answer.
1026    #[test]
1027    fn right_click_offers_exactly_the_overlays_commands() {
1028        let mut chrome = crate::panels::painted::Chrome::new(viewport().geom());
1029        let mut block = Harness::block();
1030        chrome.settle(|ui| block.show(ui));
1031        let laid = block.laid_out();
1032        let expected: Vec<&str> = laid.iter().map(|c| c.1.as_ref()).collect();
1033        assert!(
1034            expected.len() > 5,
1035            "precondition: some of these are only reachable through a menu",
1036        );
1037        chrome.hover_at(blockworx_geom::pos2(120.0, 700.0), |ui| block.show(ui));
1038        block.right_click = RightClick::Asked;
1039        chrome.frame(|ui| block.show(ui));
1040        block.right_click = RightClick::No;
1041        chrome.settle(|ui| block.show(ui));
1042
1043        let menu = egui::AreaState::load(chrome.ctx(), menu_id())
1044            .map(|state| state.rect())
1045            .filter(|rect| rect.is_positive())
1046            .expect("the right-click menu never opened");
1047        // egui draws its own chevron beside a row that opens a submenu; it is
1048        // decoration, not a command.
1049        let mut rows: Vec<&str> = chrome
1050            .texts_inside(menu.geom())
1051            .into_iter()
1052            .filter(|run| *run != SUBMENU_CHEVRON)
1053            .collect();
1054        let mut wanted = expected.clone();
1055        rows.sort_unstable();
1056        wanted.sort_unstable();
1057        assert_eq!(
1058            rows, wanted,
1059            "the menu and the bar disagree about what applies here",
1060        );
1061    }
1062
1063    // ---- camera ---------------------------------------------------------
1064
1065    /// The bar stands down while the camera is worked and comes back where it
1066    /// was on release — without a sizing pass, because the measurement it
1067    /// left behind still describes the same controls.
1068    #[test]
1069    fn the_bar_stands_down_while_the_camera_moves() {
1070        let ctx = egui::Context::default();
1071        egui_extras::install_image_loaders(&ctx);
1072        let mut wire = Harness::wire();
1073        run(&mut wire, &ctx, 2);
1074        let placed = wire.corner.expect("the bar shows once it has measured");
1075
1076        wire.camera = Camera::Moving;
1077        let painted = run(&mut wire, &ctx, 1);
1078        assert!(wire.corner.is_none(), "the bar tracked the pan");
1079        assert_eq!(painted, 0, "a hidden bar painted {painted} shape(s)");
1080
1081        wire.camera = Camera::Settled;
1082        run(&mut wire, &ctx, 1);
1083        assert_eq!(
1084            wire.corner,
1085            Some(placed),
1086            "the bar took a sizing pass to come back, or came back elsewhere",
1087        );
1088    }
1089
1090    // ---- the measuring pass and the bar's width -------------------------
1091
1092    /// Switching what is selected must never paint the new bar with the old
1093    /// bar's measurement: the frame after a switch is an invisible sizing
1094    /// pass, and the next frame shows the bar measured for its own controls.
1095    /// Steady-state frames stay visible — the pass runs only on change.
1096    #[test]
1097    fn switching_selections_takes_a_hidden_sizing_frame_instead_of_flashing() {
1098        let ctx = egui::Context::default();
1099        egui_extras::install_image_loaders(&ctx);
1100        let mut wire = Harness::wire();
1101        let mut block = Harness::block();
1102
1103        let painted = run(&mut wire, &ctx, 1);
1104        assert!(
1105            wire.corner.is_none(),
1106            "the first frame ever should be a hidden sizing pass",
1107        );
1108        assert_eq!(
1109            painted, 0,
1110            "the sizing pass painted {painted} shape(s) — the flash the user \
1111             sees at the click point before the bar snaps into place",
1112        );
1113        let painted = run(&mut wire, &ctx, 1);
1114        let wire_bar = wire.corner.expect("the wire's bar shows once measured");
1115        assert!(painted > 0, "a shown bar paints");
1116        run(&mut wire, &ctx, 1);
1117        assert!(
1118            wire.corner.is_some(),
1119            "an unchanged selection must not flicker"
1120        );
1121
1122        let painted = run(&mut block, &ctx, 1);
1123        assert!(
1124            block.corner.is_none(),
1125            "the switch frame drew the block's bar with the wire's measurement",
1126        );
1127        assert_eq!(painted, 0, "the switch frame painted {painted} shape(s)");
1128        run(&mut block, &ctx, 1);
1129        let block_bar = block.corner.expect("the block's bar shows once measured");
1130        assert_ne!(
1131            wire_bar, block_bar,
1132            "precondition: the two bars measure apart, or a stale placement would be invisible",
1133        );
1134
1135        run(&mut wire, &ctx, 1);
1136        assert!(
1137            wire.corner.is_none(),
1138            "switching back re-measures too — the memory holds one bar, not a history",
1139        );
1140        run(&mut wire, &ctx, 1);
1141        assert_eq!(
1142            wire.corner,
1143            Some(wire_bar),
1144            "the wire's bar returns exactly where it was",
1145        );
1146    }
1147
1148    /// A selected wire in a read-only session shows the same overlay it
1149    /// shows writable — every control drawn, none invocable — because a
1150    /// bar that vanishes reads as a bug while a bar drawn disabled reads
1151    /// as "not now". The writable half is the precondition: the same
1152    /// selection carries live controls when it can be edited.
1153    #[test]
1154    fn the_selection_overlay_draws_its_withheld_controls_disabled() {
1155        let writable = settled(Harness::wire());
1156        assert!(
1157            writable.invocable > 0 && writable.corner.is_some(),
1158            "a writable wire lost its overlay",
1159        );
1160        assert_eq!(writable.invocable, writable.drawn.len());
1161        let mut read_only = Harness::wire();
1162        read_only.writability = Writability::ReadOnly;
1163        let read_only = settled(read_only);
1164        assert!(
1165            read_only.corner.is_some(),
1166            "the read-only overlay vanished instead of disabling",
1167        );
1168        assert_eq!(
1169            read_only.drawn, writable.drawn,
1170            "read-only dropped controls instead of disabling them",
1171        );
1172        assert_eq!(
1173            read_only.invocable, 0,
1174            "a read-only wire kept {} invocable verb(s)",
1175            read_only.invocable,
1176        );
1177    }
1178
1179    /// A selection's control list is fixed — only enablement varies — so
1180    /// the bar it measures itself as is fixed too: a read-only wire's
1181    /// overlay is exactly the writable one's, to the pixel.
1182    #[test]
1183    fn a_read_only_overlay_measures_the_same_bar_as_a_writable_one() {
1184        let writable = settled(Harness::wire());
1185        let mut read_only = Harness::wire();
1186        read_only.writability = Writability::ReadOnly;
1187        let read_only = settled(read_only);
1188        assert!(
1189            writable.bar.is_positive(),
1190            "precondition: the overlay measured itself ({:?})",
1191            writable.bar,
1192        );
1193        assert_eq!(read_only.bar.size(), writable.bar.size());
1194    }
1195
1196    /// The bar hugs its controls: a command that draws nothing here — undo
1197    /// and redo belong to the action cluster — must take no room either.
1198    /// Each control sits in a child `Ui` of its own, and a child laid out
1199    /// for a command that renders nothing still advances the row by one
1200    /// item spacing, which is how the bar came adrift from its buttons.
1201    #[test]
1202    fn commands_the_overlay_does_not_draw_do_not_widen_it() {
1203        let quiet = settled(Harness::wire());
1204        let mut busy = Harness::wire();
1205        busy.history = History::doc();
1206        let busy = settled(busy);
1207        assert!(quiet.bar.is_positive(), "precondition: the bar measured");
1208        assert_eq!(
1209            busy.drawn, quiet.drawn,
1210            "precondition: undo and redo draw no control in the overlay",
1211        );
1212        assert_eq!(
1213            busy.bar.size(),
1214            quiet.bar.size(),
1215            "two commands the overlay never draws widened it anyway",
1216        );
1217    }
1218
1219    // ---- the artwork and the accent swatch ------------------------------
1220
1221    #[test]
1222    fn accent_swatch_maps_index_to_its_accent_role() {
1223        use blockworx_doc::id::BlockId;
1224        let block = RoleTarget::Block(BlockId::NULL);
1225        assert_eq!(accent_display_role(block, Some(0)), Role::Accent0);
1226        assert_eq!(accent_display_role(block, Some(7)), Role::Accent7);
1227        // An unset (or out-of-range) accent falls back to the plain default.
1228        assert_eq!(accent_display_role(block, None), Role::AccentDefault);
1229        assert_eq!(accent_display_role(block, Some(9)), Role::AccentDefault);
1230    }
1231
1232    #[test]
1233    fn accent_swatch_uses_each_targets_own_unaccented_stroke() {
1234        use blockworx_doc::id::{AreaId, TextId};
1235        // With no accent set, areas/text boxes preview their own stroke role
1236        // rather than the generic `AccentDefault`, matching the picker's cell.
1237        let area = RoleTarget::Area(AreaId::NULL);
1238        let text = RoleTarget::Text(TextId::NULL);
1239        assert_eq!(accent_display_role(area, None), Role::AreaStroke);
1240        assert_eq!(accent_display_role(text, None), Role::TextBoxStroke);
1241        // A set accent still wins over the target-specific default.
1242        assert_eq!(accent_display_role(area, Some(2)), Role::Accent2);
1243    }
1244
1245    #[test]
1246    fn lock_toggle_icon_depicts_the_blocks_current_state() {
1247        let (locked_icon, locked_hover) = lock_toggle_icon(InterfaceLock::Locked);
1248        let (unlocked_icon, unlocked_hover) = lock_toggle_icon(InterfaceLock::Unlocked);
1249        // Like a physical padlock: closed shackle when locked, open when not
1250        // (icons compare by URI, since `ImageSource` is not `PartialEq`).
1251        assert_eq!(locked_icon.uri(), LOCK_ICON.uri());
1252        assert_eq!(unlocked_icon.uri(), UNLOCK_ICON.uri());
1253        assert_ne!(locked_icon.uri(), unlocked_icon.uri());
1254        // The hover text names the action a click performs, not the state.
1255        assert_eq!(locked_hover, "Unlock pins");
1256        assert_eq!(unlocked_hover, "Lock pins");
1257    }
1258
1259    /// Guards the padlock artwork itself: `lock_toggle_icon` picking the right
1260    /// *file* only helps if that file actually draws the right shackle. Both
1261    /// padlocks share a body rect; only the shackle path differs, and the closed
1262    /// one returns to the body on both sides (`V4` down the right leg).
1263    #[test]
1264    fn padlock_svgs_draw_a_closed_and_an_open_shackle() {
1265        let closed = include_str!("../../icons/icon-lock.svg");
1266        let open = include_str!("../../icons/icon-unlock.svg");
1267        assert!(
1268            closed.contains(r#"d="M8 11V7a4 4 0 0 1 8 0v4""#),
1269            "{closed}"
1270        );
1271        assert!(open.contains(r#"d="M8 11V7a4 4 0 0 1 7.5-2""#), "{open}");
1272    }
1273
1274    /// Rising a level is the status strip's breadcrumb (playbook R4), so the
1275    /// selection overlay draws no control for it; entering a block — a verb
1276    /// about the selection — keeps its button.
1277    #[test]
1278    fn the_selection_overlay_offers_enter_but_not_go_up() {
1279        assert!(overlay_icon(CommandId::GoUp).is_none());
1280        assert!(overlay_icon(CommandId::ExpandBlock).is_some());
1281    }
1282
1283    /// Entering a scope and rising out of one are a pair, after phosphor's
1284    /// arrow-square-in / arrow-square-out, which the user named: *"the
1285    /// 'arrow-square-in' and 'arrow-square-out' icons are better
1286    /// representatives."* One box with a gap at its corner, one arrow through
1287    /// the gap, arriving or leaving. Guards the artwork, not the file names.
1288    #[test]
1289    fn the_hierarchy_icons_share_a_box_and_oppose_their_arrows() {
1290        let enter = include_str!("../../icons/icon-expand.svg");
1291        let rise = include_str!("../../icons/icon-level-up.svg");
1292        let box_path = r#"d="M10 5H5v14h14v-5""#;
1293        let shaft = r#"d="M20 4l-9 9""#;
1294        for icon in [enter, rise] {
1295            assert!(icon.contains(box_path), "{icon}");
1296            assert!(icon.contains(shaft), "{icon}");
1297        }
1298        // The head sits at the inner end of the shaft coming in, and at the
1299        // outer end of the one going out.
1300        assert!(enter.contains(r#"d="M11 7v6h6""#), "{enter}");
1301        assert!(rise.contains(r#"d="M14 4h6v6""#), "{rise}");
1302    }
1303
1304    #[test]
1305    fn the_import_icon_reverses_the_export_arrow_over_the_same_tray() {
1306        let export = include_str!("../../icons/icon-export.svg");
1307        let import = include_str!("../../icons/icon-import.svg");
1308        let tray = r#"d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4""#;
1309        assert!(export.contains(tray), "{export}");
1310        assert!(import.contains(tray), "{import}");
1311        // Export's chevron points up and away; import's points down and in.
1312        assert!(export.contains(r#"points="17 8 12 3 7 8""#), "{export}");
1313        assert!(import.contains(r#"points="7 10 12 15 17 10""#), "{import}");
1314    }
1315
1316    /// The overflow glyph is not the document menu's. Two surfaces wearing
1317    /// one icon is two meanings for one mark.
1318    #[test]
1319    fn the_overflow_glyph_is_an_ellipsis_of_its_own() {
1320        let more = include_str!("../../icons/icon-more.svg");
1321        assert!(
1322            MORE_ICON
1323                .uri()
1324                .is_some_and(|uri| uri.ends_with("icon-more.svg")),
1325            "the overflow button borrowed another surface's glyph: {:?}",
1326            MORE_ICON.uri(),
1327        );
1328        assert_eq!(more.matches("<circle").count(), 3);
1329        assert!(more.contains(r#"viewBox="0 0 24 24""#), "{more}");
1330        assert!(more.contains(r#"stroke-width="2""#), "{more}");
1331    }
1332
1333    // ---- the harness -----------------------------------------------------
1334
1335    /// One selection under test, and the frame conditions that vary: what is
1336    /// selected, where it sits, whether the camera is moving, whether the
1337    /// session may write.
1338    struct Harness {
1339        scene: Scene,
1340        tool: Tool,
1341        selection: Selection,
1342        camera: Camera,
1343        right_click: RightClick,
1344        writability: Writability,
1345        history: History,
1346        safe: SafeArea,
1347        /// What the last [`Self::show`] laid out — every control, withheld
1348        /// ones included — and the words each carries.
1349        drawn: Vec<(CommandId, std::borrow::Cow<'static, str>)>,
1350        /// How many of them a click could actually fire.
1351        invocable: usize,
1352        /// Where the last [`Self::show`] put the bar's top-right corner, or
1353        /// `None` while it is hidden.
1354        corner: Option<egui::Pos2>,
1355        /// Shapes the last [`Self::show`] painted. The overlay is all this
1356        /// harness draws, so a hidden frame must paint none.
1357        painted: usize,
1358        /// The bar the overlay measured itself as, once it has.
1359        bar: Rect,
1360        /// Everything a click fired.
1361        fired: Vec<Act>,
1362    }
1363
1364    impl Harness {
1365        fn over(scene: Scene, tool: Tool, count: usize) -> Self {
1366            Harness {
1367                scene,
1368                tool,
1369                selection: Selection {
1370                    screen: Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0)).geom(),
1371                    count,
1372                },
1373                camera: Camera::Settled,
1374                right_click: RightClick::No,
1375                writability: Writability::Writable,
1376                history: History::empty(),
1377                safe: SafeArea::over(viewport()),
1378                drawn: Vec::new(),
1379                invocable: 0,
1380                corner: None,
1381                painted: 0,
1382                bar: Rect::NOTHING,
1383                fired: Vec::new(),
1384            }
1385        }
1386
1387        /// The fixture's one wire, selected.
1388        fn wire() -> Self {
1389            let mut scene = two_blocks_with_a_routed_waypoint();
1390            let route = {
1391                let drawing = scene.drawing();
1392                let ids: Vec<_> = drawing.auto_routes().map(|(id, _)| id).collect();
1393                assert_eq!(ids.len(), 1, "the fixture carries exactly one wire");
1394                ids[0]
1395            };
1396            let tool = crate::tools::EditRoute::Selected {
1397                id: route,
1398                anchor: pos2(0.0, 0.0).geom(),
1399            }
1400            .into();
1401            Harness::over(scene, tool, 1)
1402        }
1403
1404        /// One block, which is the selection with the most commands.
1405        fn block() -> Self {
1406            let mut scene = two_blocks_with_a_routed_waypoint();
1407            let shape = ShapeId::Rect(blockworx_doc::fixtures::block_id(1));
1408            assert!(
1409                scene.drawing().shape(shape).is_some(),
1410                "precondition: the fixture holds the block this selects",
1411            );
1412            let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1413            Harness::over(scene, tool, 1)
1414        }
1415
1416        /// One area, selected the way a click on its border leaves it.
1417        fn area() -> Self {
1418            let mut scene = Scene::new(vec![crate::widget::test_fixtures::area(
1419                9,
1420                crate::path::Scope::Root,
1421                blockworx_geom::Rect::from_min_size(
1422                    blockworx_geom::pos2(0.0, 0.0),
1423                    blockworx_geom::vec2(200.0, 140.0),
1424                ),
1425            )]);
1426            let shape = ShapeId::Area(blockworx_doc::fixtures::area_id(9));
1427            assert!(
1428                scene.drawing().shape(shape).is_some(),
1429                "precondition: the fixture holds the area this selects",
1430            );
1431            let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1432            Harness::over(scene, tool, 1)
1433        }
1434
1435        /// One port of the current level, selected — the case item 27 is
1436        /// about.
1437        fn port() -> Self {
1438            let body = Rect::from_min_size(pos2(0.0, 0.0), vec2(60.0, 60.0));
1439            let mut scene = Scene::new(vec![crate::widget::test_fixtures::pin_at(
1440                7,
1441                crate::path::Scope::Root,
1442                "clk",
1443                crate::widget::test_fixtures::slot(crate::shape::pin::PinSide::West, 0),
1444                body.geom(),
1445            )]);
1446            let shape = ShapeId::Port(blockworx_doc::fixtures::pin_id(7));
1447            assert!(
1448                scene.drawing().shape(shape).is_some(),
1449                "precondition: the fixture holds the port this selects",
1450            );
1451            let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1452            Harness::over(scene, tool, 1)
1453        }
1454
1455        /// Both of the fixture's blocks, as a marquee leaves them.
1456        fn two_blocks() -> Self {
1457            let mut scene = two_blocks_with_a_routed_waypoint();
1458            let shapes: Vec<ShapeId> = [1, 2]
1459                .map(|n| ShapeId::Rect(blockworx_doc::fixtures::block_id(n)))
1460                .into();
1461            assert!(
1462                shapes.iter().all(|&id| scene.drawing().shape(id).is_some()),
1463                "precondition: the fixture holds both blocks",
1464            );
1465            let count = shapes.len();
1466            let tool = crate::tools::MultiSelect::Selected { shapes }.into();
1467            Harness::over(scene, tool, count)
1468        }
1469
1470        /// One real frame of the overlay, and nothing else, into `ui`.
1471        fn show(&mut self, ui: &mut egui::Ui) {
1472            let drawing = self.scene.drawing();
1473            let mut commands = CommandSet::available(&CommandContext {
1474                tool: &self.tool,
1475                data: &drawing,
1476                history: self.history,
1477                current_lock: InterfaceLock::Unlocked,
1478                writability: self.writability,
1479                saving: blockworx_store::doc::Saving::Withheld,
1480                viewing: blockworx_store::doc::Viewing::Head,
1481            });
1482            self.drawn = overlay_controls(&commands)
1483                .map(|cmd| (cmd.id, cmd.label.clone()))
1484                .collect();
1485            self.invocable = commands.iter().filter(|cmd| in_overlay(cmd.id)).count();
1486            let theme = Theme::default();
1487            let model = crate::kernel::Overlay::of(&drawing, &theme, self.selection, &commands);
1488            let (action, corner) = selection_overlay(
1489                ui,
1490                Overlay {
1491                    commands: &mut commands,
1492                    bar: Some(&model),
1493                    open_picker: None,
1494                    safe: self.safe,
1495                    camera: self.camera,
1496                    right_click: self.right_click,
1497                },
1498            );
1499            if let Some(fired) = action {
1500                self.fired.push(fired);
1501            }
1502            self.corner = corner;
1503        }
1504
1505        /// The controls this selection lays out, once one frame has resolved
1506        /// them.
1507        fn laid_out(&mut self) -> Vec<(CommandId, std::borrow::Cow<'static, str>)> {
1508            let ctx = egui::Context::default();
1509            egui_extras::install_image_loaders(&ctx);
1510            run(self, &ctx, 2);
1511            self.drawn.clone()
1512        }
1513    }
1514
1515    /// `frames` real frames on `ctx`, leaving the harness holding what the
1516    /// last of them did. Returns how many shapes it painted.
1517    fn run(harness: &mut Harness, ctx: &egui::Context, frames: usize) -> usize {
1518        for _ in 0..frames {
1519            let mut out = ctx.clone().run_ui(
1520                egui::RawInput {
1521                    screen_rect: Some(harness.safe.viewport()),
1522                    ..Default::default()
1523                },
1524                |ui| harness.show(ui),
1525            );
1526            out.textures_delta.clear();
1527            harness.painted = painted(&out.shapes);
1528        }
1529        harness.bar = ctx
1530            .data(|d| d.get_temp::<Measured>(Panel::SelectionButtons.measurement("rect")))
1531            .map_or(Rect::NOTHING, |was| was.rect);
1532        harness.painted
1533    }
1534
1535    /// A harness driven to rest on a context of its own — for the tests that
1536    /// ask what the bar came to rather than how it got there.
1537    fn settled(mut harness: Harness) -> Harness {
1538        let ctx = egui::Context::default();
1539        egui_extras::install_image_loaders(&ctx);
1540        run(&mut harness, &ctx, 2);
1541        harness
1542    }
1543
1544    /// Leaf shapes in a frame's output, `Noop`s excluded.
1545    fn painted(shapes: &[egui::epaint::ClippedShape]) -> usize {
1546        fn leaves(shape: &egui::Shape) -> usize {
1547            match shape {
1548                egui::Shape::Noop => 0,
1549                egui::Shape::Vec(shapes) => shapes.iter().map(leaves).sum(),
1550                _ => 1,
1551            }
1552        }
1553        shapes.iter().map(|clipped| leaves(&clipped.shape)).sum()
1554    }
1555}