Skip to main content

blockworx/tools/
overlay.rs

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