Skip to main content

blockworx/shell/
tool_cluster.rs

1//! The tool cluster: the left edge, vertically centred, holding the creators
2//! and nothing else — "a tool creates or places something. Everything after
3//! that happens by selecting the result and acting on it".
4//!
5//! Select sits at the top with air under it, then the authoring tools, and a
6//! rule stands between the two groups: hairlines are forbidden as
7//! *incidental* decoration, and this one is categorical — what selects and
8//! what creates are two kinds.
9//!
10//! Each cell is its icon alone, the mockup's own treatment — the word lives
11//! in the tooltip with the digit: *"the icon should describe the function,
12//! and the tooltip can carry the words"*. Tapping the armed tool returns to
13//! Select, so the cluster is its own way out.
14//!
15//! Under the lens the whole cluster dims and goes inert — one of three
16//! redundant signals, with the viewing pill and the drained canvas.
17//!
18
19use blockworx_store::doc::Viewing;
20
21use crate::{
22    shell::glass::{self, Live},
23    tools::{
24        commands::{Act, CommandId, CommandSet},
25        names::{BAND_TOOLS, BandTool, ToolName},
26    },
27};
28
29/// Everything [`tool_cluster`] renders against.
30#[derive(Clone, Copy)]
31pub struct ToolCluster {
32    /// The cell shown armed (see
33    /// [`displayed_tool`](crate::tools::names::displayed_tool)).
34    pub selected: ToolName,
35    /// What the canvas is showing. A past rev has no tool to arm.
36    pub viewing: Viewing,
37}
38
39/// What one cluster frame reports: what a click fired, where a cell
40/// carried off the cluster was dropped, and — for the tests that click the
41/// real column — each cell's screen rect.
42pub struct ToolClusterFrame {
43    pub action: Option<Act>,
44    pub drag_out: Option<DragOut>,
45    #[cfg(test)]
46    pub tool_rects: Vec<(ToolName, egui::Rect)>,
47}
48
49/// A cell pressed and dragged off the cluster, released at `at` (screen
50/// space). The cluster reports where the drop landed and says nothing about
51/// what it means — the canvas transform and the chrome's own boxes are the
52/// app's, and a drop on the glass has to come to nothing.
53#[derive(Clone, Copy, Debug)]
54pub struct DragOut {
55    pub tool: ToolName,
56    pub at: egui::Pos2,
57}
58
59/// Draw the cluster in its berth.
60pub fn tool_cluster(
61    chrome: &mut super::Chrome,
62    commands: &mut CommandSet,
63    cluster: ToolCluster,
64) -> ToolClusterFrame {
65    chrome.piece(glass::Berth::ToolCluster, |ui| {
66        column(ui, commands, cluster)
67    })
68}
69
70/// The cells themselves, top to bottom. Split from [`tool_cluster`] so a
71/// caller with its own berth can draw the same column.
72fn column(ui: &mut egui::Ui, commands: &mut CommandSet, cluster: ToolCluster) -> ToolClusterFrame {
73    let ToolCluster { selected, viewing } = cluster;
74    let lens = matches!(viewing, Viewing::Past(_));
75    let mut clicked: Option<CommandId> = None;
76    let mut drag_out = None;
77    let mut rects = Vec::new();
78    // Under the lens the cluster dims and goes inert, rather than each cell
79    // forming its own opinion about a document that cannot be written.
80    ui.add_enabled_ui(!lens, |ui| {
81        if lens {
82            ui.multiply_opacity(UNDER_THE_LENS);
83        }
84        ui.vertical(|ui| {
85            ui.spacing_mut().item_spacing.y = CELL_GAP;
86            for (place, band) in BAND_TOOLS.iter().enumerate() {
87                // The mockup's `.toolsep`, between groups: what selects, what
88                // builds blocks, and what annotates the sheet. Hairlines are
89                // forbidden as *incidental* rules; these are structure.
90                if place > 0 && BAND_TOOLS[place - 1].group != band.group {
91                    glass::separator(ui, glass::Run::Column);
92                }
93                // The registry's own answer, so a cell and the command it
94                // fires cannot disagree: a locked block withholds Add Port,
95                // and a read-only session withholds every authoring tool.
96                let id = crate::tools::commands::band_command(band.tool);
97                let armed = Armed::of(selected, band.tool);
98                let response = cell(ui, band, armed, Live::from(commands.contains(id)))
99                    .on_hover_text(hover(ui.ctx(), band.tool, id))
100                    .on_disabled_hover_text(hover(ui.ctx(), band.tool, id));
101                rects.push((band.tool, response.rect));
102                // The cell's second gesture: carried off the cluster and
103                // dropped on the canvas, which stamps rather than arms
104                // (`crate::tools::stamp`). egui tells the two apart by how
105                // far the pointer travelled, so a cell answers both.
106                if response.dragged() {
107                    carried(ui.ctx(), band.tool);
108                }
109                if response.drag_stopped()
110                    && let Some(at) = response.interact_pointer_pos()
111                {
112                    drag_out = Some(DragOut {
113                        tool: band.tool,
114                        at,
115                    });
116                }
117                if response.clicked() {
118                    // Tapping the armed tool puts it down. Select armed is
119                    // already down, so it stays where it is.
120                    clicked = Some(match armed {
121                        Armed::Yes => CommandId::Arm(ToolName::Select),
122                        Armed::No => id,
123                    });
124                }
125            }
126        });
127    });
128    ToolClusterFrame {
129        action: clicked.and_then(|id| commands.take(id)),
130        drag_out,
131        #[cfg(test)]
132        tool_rects: rects,
133    }
134}
135
136/// The drag-out's preview: the cell's own icon riding under the pointer, so
137/// the gesture reads as carrying the tool onto the canvas rather than as a
138/// press that missed. Ghosted, in its own layer over everything, and
139/// uninteractable — it must not become the thing the drop lands on.
140fn carried(ctx: &egui::Context, tool: ToolName) {
141    let Some(at) = ctx.pointer_latest_pos() else {
142        return;
143    };
144    egui::Area::new(egui::Id::new("tool_carried"))
145        .order(egui::Order::Tooltip)
146        .fixed_pos(at - egui::Vec2::splat(glass::TOOL_ICON / 2.0))
147        .interactable(false)
148        .movable(false)
149        .constrain(false)
150        .fade_in(false)
151        .show(ctx, |ui| {
152            ui.multiply_opacity(CARRIED);
153            let icon = glass::image(ui, tool_icon(tool), glass::TOOL_ICON);
154            ui.add(icon);
155        });
156}
157
158/// How much of itself the carried icon keeps: enough to follow, not enough
159/// to be mistaken for something already placed.
160const CARRIED: f32 = 0.6;
161
162/// How much of itself the cluster keeps while a past rev is on the canvas —
163/// the mockup's `opacity:.35`, dim enough to read as unavailable at a glance.
164const UNDER_THE_LENS: f32 = 0.35;
165
166/// The air between cells — the mockup's `.tools{gap:2px}`. What sets Select
167/// apart is the rule above the creators, not a wider gap.
168const CELL_GAP: f32 = 2.0;
169
170/// Whether this cell's tool is the one the canvas is holding.
171#[derive(Clone, Copy, PartialEq, Eq)]
172enum Armed {
173    Yes,
174    No,
175}
176
177impl Armed {
178    fn of(selected: ToolName, tool: ToolName) -> Self {
179        if selected == tool {
180            Armed::Yes
181        } else {
182            Armed::No
183        }
184    }
185}
186
187/// One cell: the icon alone, centred; the tooltip carries the words.
188fn cell(ui: &mut egui::Ui, band: &BandTool, armed: Armed, live: Live) -> egui::Response {
189    ui.add_enabled_ui(live == Live::Yes, |ui| {
190        let (rect, response) = ui.allocate_exact_size(glass::TOOL, egui::Sense::click_and_drag());
191        if !ui.is_rect_visible(rect) {
192            return response;
193        }
194        let visuals = ui
195            .style()
196            .interact_selectable(&response, armed == Armed::Yes);
197        let held = glass::Pressed::from(response.is_pointer_button_down_on());
198        // The mockup's press: the plate ducks under the finger and springs
199        // back past its own size on release, arriving under the armed cell's
200        // blue. Only the plate moves — the cell keeps its box, so nothing
201        // below it in the column shifts while a press is in flight.
202        let scale = glass::press_scale(ui.ctx(), response.id, held);
203        // Unframed until pointed at or armed: seven boxed icons down the
204        // cluster read as a wall.
205        if armed == Armed::Yes || response.hovered() || held == glass::Pressed::Yes {
206            let plate = egui::Rect::from_center_size(rect.center(), rect.size() * scale);
207            ui.painter().rect(
208                plate.expand(visuals.expansion),
209                f32::from(glass::TOOL_RADIUS) * scale,
210                visuals.weak_bg_fill,
211                visuals.bg_stroke,
212                egui::StrokeKind::Inside,
213            );
214        }
215        glass::image(ui, tool_icon(band.tool), glass::TOOL_ICON).paint_at(
216            ui,
217            egui::Rect::from_center_size(rect.center(), egui::Vec2::splat(glass::TOOL_ICON)),
218        );
219        response
220    })
221    .inner
222}
223
224/// The tool's name, with every chord bound to it — its own digit beside the
225/// chord the editor already had. The cell prints one word; the tooltip prints
226/// the whole name.
227fn hover(ctx: &egui::Context, tool: ToolName, id: CommandId) -> String {
228    let chords: Vec<String> = crate::tools::commands::chords(id)
229        .map(|chord| crate::keys::spelled(ctx, *chord))
230        .collect();
231    if chords.is_empty() {
232        tool.to_string()
233    } else {
234        format!("{tool} ({})", chords.join(", "))
235    }
236}
237
238/// The cluster icon for a base tool. Only [`BAND_TOOLS`] reach this; any other
239/// `ToolName` falls back to the select cursor.
240fn tool_icon(tool: ToolName) -> egui::ImageSource<'static> {
241    match tool {
242        ToolName::NewBlock => NEW_BLOCK_ICON,
243        ToolName::NewArea => AREA_ICON,
244        ToolName::AddPin => ADD_PIN_ICON,
245        ToolName::AddPort => ADD_PORT_ICON,
246        ToolName::NewImage => IMAGE_ICON,
247        ToolName::AddText => ADD_TEXT_ICON,
248        ToolName::Route => ROUTE_ICON,
249        _ => SELECT_ICON,
250    }
251}
252
253const IMAGE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-image.svg");
254const SELECT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-select.svg");
255const NEW_BLOCK_ICON: egui::ImageSource<'static> =
256    egui::include_image!("../../icons/icon-new-block.svg");
257const AREA_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-area.svg");
258const ADD_PIN_ICON: egui::ImageSource<'static> =
259    egui::include_image!("../../icons/icon-add-pin.svg");
260const ADD_PORT_ICON: egui::ImageSource<'static> =
261    egui::include_image!("../../icons/icon-add-port.svg");
262const ADD_TEXT_ICON: egui::ImageSource<'static> =
263    egui::include_image!("../../icons/icon-add-text.svg");
264const ROUTE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-route.svg");
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::canvas::convert::{IntoEgui as _, IntoGeom as _};
270    use crate::edit::naming::InterfaceLock;
271    use crate::panels::painted::Chrome;
272    use crate::shell::tests::screen;
273    use crate::tools::commands::{CommandContext, History, band_command};
274    use crate::tools::tool::Action;
275    use crate::widget::test_fixtures::Scene;
276    use blockworx_geom::Pos2;
277    use blockworx_store::doc::Writability;
278
279    /// Every tool the cluster draws binds to its own digit, and the
280    /// digits run 1..n in the order the cluster lays them out.
281    #[test]
282    fn every_tool_binds_to_its_place_in_the_column() {
283        use blockworx_paint::{Key, Modifiers};
284        const DIGITS: [Key; 8] = [
285            Key::Num1,
286            Key::Num2,
287            Key::Num3,
288            Key::Num4,
289            Key::Num5,
290            Key::Num6,
291            Key::Num7,
292            Key::Num8,
293        ];
294        assert!(
295            BAND_TOOLS.len() <= DIGITS.len(),
296            "the cluster outgrew §5's digit row",
297        );
298        for (place, band) in BAND_TOOLS.iter().enumerate() {
299            let tool = band.tool;
300            let digits: Vec<Key> = crate::tools::commands::chords(band_command(tool))
301                .filter(|chord| chord.modifiers == Modifiers::None)
302                .map(|chord| chord.key)
303                .collect();
304            assert_eq!(
305                digits,
306                vec![DIGITS[place]],
307                "{tool:?} sits {} in the cluster but binds {digits:?}",
308                place + 1,
309            );
310        }
311    }
312
313    /// The session the cluster is a view of.
314    struct Session {
315        writability: Writability,
316        viewing: Viewing,
317        selected: ToolName,
318        scene: Scene,
319        rects: Vec<(ToolName, egui::Rect)>,
320        fired: Vec<Act>,
321        dropped: Vec<DragOut>,
322    }
323
324    impl Session {
325        fn new(writability: Writability) -> Self {
326            Session {
327                writability,
328                viewing: Viewing::Head,
329                selected: ToolName::Select,
330                scene: Scene::new(Vec::new()),
331                rects: Vec::new(),
332                fired: Vec::new(),
333                dropped: Vec::new(),
334            }
335        }
336
337        fn frame(&mut self, ui: &mut egui::Ui) {
338            let armed: crate::tools::tool::Tool = crate::tools::SelectTool.into();
339            let mut commands = {
340                let drawing = self.scene.drawing();
341                CommandSet::available(&CommandContext {
342                    tool: &armed,
343                    data: &drawing,
344                    history: History::empty(),
345                    current_lock: InterfaceLock::Unlocked,
346                    writability: self.writability,
347                    saving: blockworx_store::doc::Saving::Withheld,
348                    viewing: self.viewing,
349                })
350            };
351            let mut chrome = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
352            let frame = tool_cluster(
353                &mut chrome,
354                &mut commands,
355                ToolCluster {
356                    selected: self.selected,
357                    viewing: self.viewing,
358                },
359            );
360            self.rects = frame.tool_rects;
361            if let Some(act) = frame.action {
362                self.fired.push(act);
363            }
364            if let Some(carried) = frame.drag_out {
365                self.dropped.push(carried);
366            }
367        }
368    }
369
370    /// Click the cluster's `tool` cell and report which tool that armed, laid
371    /// out and hit-tested for real. `None` is a cell that dispatched nothing.
372    fn click_the_tool(session: &mut Session, tool: ToolName) -> Option<ToolName> {
373        let mut chrome = Chrome::new(screen().geom());
374        chrome.settle(|ui| session.frame(ui));
375        let at = session
376            .rects
377            .iter()
378            .find_map(|&(name, rect)| (name == tool).then_some(rect))
379            .expect("every tool reports its rect, live or dead");
380        assert!(at.is_positive(), "the {tool:?} cell never laid out");
381        let before = session.fired.len();
382        chrome.click_at(at.center().geom(), |ui| session.frame(ui));
383        match session.fired.get(before) {
384            None => None,
385            Some(Act::Edit(Action::Arm(armed))) => Some(*armed),
386            Some(other) => panic!(
387                "the {tool:?} cell dispatched {}",
388                crate::tools::commands::act_name(other),
389            ),
390        }
391    }
392
393    /// The cells run down the left edge, not across: a horizontal cluster
394    /// would be the band this replaced.
395    #[test]
396    fn the_cells_are_stacked_in_a_column() {
397        let mut session = Session::new(Writability::Writable);
398        let mut chrome = Chrome::new(screen().geom());
399        chrome.settle(|ui| session.frame(ui));
400        let rects = &session.rects;
401        assert_eq!(rects.len(), BAND_TOOLS.len(), "a tool never laid out");
402        for pair in rects.windows(2) {
403            let (above, below) = (pair[0].1, pair[1].1);
404            assert!(
405                below.top() > above.top(),
406                "{:?} is beside {:?}, not under it",
407                pair[1].0,
408                pair[0].0,
409            );
410            assert_eq!(
411                below.left(),
412                above.left(),
413                "the column is not aligned at {:?}",
414                pair[1].0,
415            );
416        }
417        assert_eq!(
418            rects[0].0,
419            ToolName::Select,
420            "Select is not at the top of the cluster (§5)",
421        );
422    }
423
424    /// The groups are set apart by the mockup's own rule: Select from the
425    /// block tools, the block tools from the sheet tools. A rule is a thing
426    /// drawn, so the test asks the geometry: the run at every boundary is
427    /// wider than the run between two tools of one group, and what fills the
428    /// one under Select is painted rather than empty.
429    #[test]
430    fn a_rule_stands_between_the_groups() {
431        let mut session = Session::new(Writability::Writable);
432        let mut chrome = Chrome::new(screen().geom());
433        chrome.settle(|ui| session.frame(ui));
434        let group_of = |tool: ToolName| {
435            BAND_TOOLS
436                .iter()
437                .find(|band| band.tool == tool)
438                .map(|band| band.group)
439        };
440        let gaps: Vec<(bool, f32)> = session
441            .rects
442            .windows(2)
443            .map(|pair| {
444                let boundary = group_of(pair[0].0) != group_of(pair[1].0);
445                (boundary, pair[1].1.top() - pair[0].1.bottom())
446            })
447            .collect();
448        let (boundaries, within): (Vec<_>, Vec<_>) = gaps.iter().partition(|(at, _)| *at);
449        assert_eq!(
450            boundaries.len(),
451            2,
452            "precondition: three groups on the band: {gaps:?}",
453        );
454        let narrowest = boundaries
455            .iter()
456            .map(|(_, gap)| *gap)
457            .fold(f32::INFINITY, f32::min);
458        assert!(
459            within.iter().all(|(_, gap)| narrowest > *gap),
460            "a group boundary carries no rule: {gaps:?}",
461        );
462        let under_select = &gaps[0].1;
463        let column = crate::shell::berth_rect(chrome.ctx(), glass::Berth::ToolCluster)
464            .expect("the cluster never laid out");
465        let select = session.rects[0].1;
466        assert!(
467            *under_select >= 1.0 + 2.0 * glass::separator_air(),
468            "the run under Select is too narrow to hold a rule and its air: \
469             {under_select}",
470        );
471        assert!(
472            column.width() > 0.0,
473            "precondition: the cluster laid out around {select:?}",
474        );
475    }
476
477    /// Item 2, the other half: *"The dividing line under the select tool is
478    /// not centered."* It is inset from both shoulders (the mockup's
479    /// `.toolsep{margin:5px 12px}`), and an inset taken off one side only is
480    /// a rule that hangs left of the column it divides.
481    #[test]
482    fn the_rule_under_select_is_centred_on_the_column_it_divides() {
483        let mut session = Session::new(Writability::Writable);
484        let mut chrome = Chrome::new(screen().geom());
485        chrome.settle(|ui| session.frame(ui));
486        let select = session.rects[0].1;
487        let next = session.rects[1].1;
488        assert!(
489            next.top() > select.bottom(),
490            "precondition: there is a run between Select and the creators",
491        );
492        let rule = chrome
493            .fills()
494            .iter()
495            .find(|(drawn, _)| {
496                drawn.height() <= 2.0
497                    && drawn.top() >= select.bottom()
498                    && drawn.bottom() <= next.top()
499            })
500            .map(|(drawn, _)| *drawn)
501            .expect("nothing is painted in the run under Select");
502        assert!(
503            (rule.center().x - select.center().x).abs() < 0.5,
504            "the rule is off centre: {rule:?} under a cell centred at {}",
505            select.center().x,
506        );
507        assert!(
508            rule.width() < select.width(),
509            "the rule runs shoulder to shoulder: {rule:?}",
510        );
511    }
512
513    /// A cell is its icon alone — the cluster paints no text of any
514    /// kind, and the words live in the tooltips (proved for the hover text
515    /// below).
516    #[test]
517    fn the_cluster_paints_no_words() {
518        let mut session = Session::new(Writability::Writable);
519        let mut chrome = Chrome::new(screen().geom());
520        chrome.settle(|ui| session.frame(ui));
521        let column = crate::shell::berth_rect(chrome.ctx(), glass::Berth::ToolCluster)
522            .expect("the cluster never laid out");
523        for text in chrome.texts() {
524            for rect in chrome.rects(text) {
525                assert!(
526                    !column.intersects(rect.egui()),
527                    "the cluster painted {text:?}; the tooltip carries the words (R24)",
528                );
529            }
530        }
531        for (tool, cell) in &session.rects {
532            assert_eq!(cell.size(), glass::TOOL, "{tool:?} is not cell-sized");
533        }
534    }
535
536    /// The digit is shown where the user looks for it, beside the chord the
537    /// tool already had.
538    #[test]
539    fn a_tools_hover_names_both_its_digit_and_its_chord() {
540        let ctx = egui::Context::default();
541        // `format_shortcut` reads the font set, which a context only has once
542        // it has run a frame.
543        let mut out = ctx.clone().run_ui(egui::RawInput::default(), |_| {});
544        out.textures_delta.clear();
545        let said = hover(&ctx, ToolName::NewBlock, CommandId::Arm(ToolName::NewBlock));
546        assert!(said.contains("New Block"), "{said}");
547        assert!(said.contains('2'), "the digit is missing: {said}");
548        assert!(said.contains('B'), "the existing chord is gone: {said}");
549    }
550
551    /// Tapping the armed tool puts it down, so the cluster is its own way
552    /// back to Select without reaching for Escape.
553    #[test]
554    fn tapping_the_armed_tool_returns_to_select() {
555        let mut session = Session::new(Writability::Writable);
556        session.selected = ToolName::NewBlock;
557        assert_eq!(
558            click_the_tool(&mut session, ToolName::NewBlock),
559            Some(ToolName::Select),
560            "tapping the armed tool re-armed it instead of putting it down",
561        );
562    }
563
564    /// Drag a cell off the cluster and release it at `to`, reporting the
565    /// drop the cluster made of it.
566    fn drag_the_tool_out(session: &mut Session, tool: ToolName, to: egui::Pos2) -> Option<DragOut> {
567        let mut chrome = Chrome::new(screen().geom());
568        chrome.settle(|ui| session.frame(ui));
569        let at = session
570            .rects
571            .iter()
572            .find_map(|&(name, rect)| (name == tool).then_some(rect))
573            .expect("every tool reports its rect, live or dead");
574        let before = session.dropped.len();
575        chrome.drag_between(at.center().geom(), to.geom(), |ui| session.frame(ui));
576        session.dropped.get(before).copied()
577    }
578
579    /// A cell answers two gestures, and the drag is the one that does
580    /// not arm. The cluster reports where the drop landed and nothing else
581    /// — what it means is the app's to say.
582    #[test]
583    fn carrying_a_cell_off_the_cluster_reports_the_drop_and_arms_nothing() {
584        let mut session = Session::new(Writability::Writable);
585        let onto = egui::pos2(600.0, 350.0);
586        let dropped = drag_the_tool_out(&mut session, ToolName::NewBlock, onto)
587            .expect("the cluster reported no drop");
588        assert_eq!(dropped.tool, ToolName::NewBlock);
589        assert_eq!(dropped.at, onto, "the drop point is where the release was");
590        assert!(
591            session.fired.is_empty(),
592            "the drag-out also armed something: {} actions",
593            session.fired.len(),
594        );
595    }
596
597    /// The registry gates the drag the way it gates the click: a cell drawn
598    /// dead cannot be carried out either.
599    #[test]
600    fn a_dead_cell_cannot_be_carried_out() {
601        let onto = egui::pos2(600.0, 350.0);
602        assert!(
603            drag_the_tool_out(
604                &mut Session::new(Writability::Writable),
605                ToolName::NewBlock,
606                onto,
607            )
608            .is_some(),
609            // Without this the refusal below could be a mis-aimed drag.
610            "a writable cluster reported no drop",
611        );
612        assert!(
613            drag_the_tool_out(
614                &mut Session::new(Writability::ReadOnly),
615                ToolName::NewBlock,
616                onto,
617            )
618            .is_none(),
619            "a read-only cluster let New Block be carried out",
620        );
621    }
622
623    /// The add-pin tool has a cell of its own, and the cell arms it.
624    #[test]
625    fn the_cluster_carries_the_add_pin_tool() {
626        assert_eq!(
627            click_the_tool(&mut Session::new(Writability::Writable), ToolName::AddPin),
628            Some(ToolName::AddPin),
629        );
630    }
631
632    /// A cell that is not armed arms its own tool, which is the other half of
633    /// the rule above.
634    #[test]
635    fn tapping_an_idle_tool_arms_it() {
636        let mut session = Session::new(Writability::Writable);
637        assert_eq!(
638            click_the_tool(&mut session, ToolName::NewBlock),
639            Some(ToolName::NewBlock),
640            "an idle cell did not arm its own tool",
641        );
642    }
643
644    /// A read-only session's cluster still shows every tool — it keeps its
645    /// shape — but the creators are dead, and the neutral Select is not.
646    #[test]
647    fn a_read_only_cluster_arms_select_but_no_creator() {
648        assert_eq!(
649            click_the_tool(&mut Session::new(Writability::Writable), ToolName::NewBlock),
650            Some(ToolName::NewBlock),
651            // Without this the refusal below could be a mis-aimed click.
652            "a writable cluster refused New Block",
653        );
654        assert!(
655            click_the_tool(&mut Session::new(Writability::ReadOnly), ToolName::NewBlock).is_none(),
656            "a read-only cluster armed the New Block tool",
657        );
658        assert_eq!(
659            click_the_tool(&mut Session::new(Writability::ReadOnly), ToolName::Select),
660            Some(ToolName::Select),
661            "a read-only cluster refused Select, which authors nothing",
662        );
663    }
664
665    /// Under the lens the whole cluster goes inert — Select included,
666    /// because there is no selection to make in a document nobody is
667    /// editing.
668    #[test]
669    fn the_lens_makes_the_whole_cluster_inert() {
670        let mut session = Session::new(Writability::Writable);
671        session.viewing = Viewing::Past(blockworx_doc::fixtures::rev(2));
672        for tool in [ToolName::Select, ToolName::NewBlock] {
673            assert!(
674                click_the_tool(&mut session, tool).is_none(),
675                "{tool:?} answered a click under the lens",
676            );
677        }
678    }
679
680    /// The cluster creates and nothing else: a navigation verb here would
681    /// be a second home for what the path pill already carries.
682    #[test]
683    fn the_cluster_carries_no_navigation() {
684        let mut session = Session::new(Writability::Writable);
685        let mut chrome = Chrome::new(screen().geom());
686        chrome.settle(|ui| session.frame(ui));
687        let column = crate::shell::berth_rect(chrome.ctx(), glass::Berth::ToolCluster)
688            .expect("the cluster never laid out");
689        let mut y = column.top();
690        while y <= column.bottom() {
691            chrome.click_at(Pos2::new(column.center().x, y), |ui| session.frame(ui));
692            y += 6.0;
693        }
694        assert!(
695            session
696                .fired
697                .iter()
698                .any(|a| matches!(a, Act::Edit(Action::Arm(_)))),
699            "precondition: the scan reaches the cluster's own cells",
700        );
701        assert!(
702            !session.fired.iter().any(|a| matches!(
703                a,
704                Act::Edit(Action::GoUp | Action::GoToPath(_) | Action::ExpandBlock(_))
705            )),
706            "a navigation verb is in the tool cluster: {} verbs",
707            session.fired.len(),
708        );
709    }
710}