1use 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#[derive(Clone, Copy)]
31pub struct ToolCluster {
32 pub selected: ToolName,
35 pub viewing: Viewing,
37}
38
39pub 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#[derive(Clone, Copy, Debug)]
54pub struct DragOut {
55 pub tool: ToolName,
56 pub at: egui::Pos2,
57}
58
59pub 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
70fn 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 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 if place > 0 && BAND_TOOLS[place - 1].group != band.group {
91 glass::separator(ui, glass::Run::Column);
92 }
93 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 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 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
136fn 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
158const CARRIED: f32 = 0.6;
161
162const UNDER_THE_LENS: f32 = 0.35;
165
166const CELL_GAP: f32 = 2.0;
169
170#[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
187fn 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 let scale = glass::press_scale(ui.ctx(), response.id, held);
203 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
224fn 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
238fn 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 #[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 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 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 #[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 #[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 #[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 #[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 #[test]
539 fn a_tools_hover_names_both_its_digit_and_its_chord() {
540 let ctx = egui::Context::default();
541 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 #[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 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 #[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 #[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 "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 #[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 #[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 #[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 "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 #[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 #[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}