1use blockworx_doc::{
20 block_model::{AreaUpdate, BlockUpdate, LabelUpdate, PinUpdate, RouteUpdate, TextUpdate},
21 document::{Document, IndexedDocument, TitleBlockUpdate},
22 id::{AreaId, BlockId, ImageId, PinId, RouteId, RouteLabelId, TextId},
23 opcode::{Crud, OpCodes},
24};
25
26#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum Label {
35 Verb(&'static str),
36 Verbatim(String),
37}
38
39impl Label {
40 pub fn verb(verb: &'static str) -> Self {
41 Label::Verb(verb)
42 }
43
44 pub fn verbatim(label: impl Into<String>) -> Self {
45 Label::Verbatim(label.into())
46 }
47
48 pub fn describing(&self, document: &IndexedDocument<'_>, ops: &[OpCodes]) -> String {
51 match self {
52 Label::Verbatim(label) => label.clone(),
53 Label::Verb(verb) => {
54 let Said {
55 verb: instead,
56 object,
57 } = subject(document, ops);
58 capitalized(&format!("{} {object}", instead.unwrap_or(verb)))
59 }
60 }
61 }
62}
63
64struct Said {
69 verb: Option<&'static str>,
70 object: String,
71}
72
73impl Said {
74 fn of(object: String) -> Self {
76 Said { verb: None, object }
77 }
78}
79
80fn nonblank(name: Option<String>) -> Option<String> {
83 name.map(|name| name.trim().to_owned())
84 .filter(|name| !name.is_empty())
85}
86
87fn called(noun: &str, name: Option<String>) -> String {
89 match nonblank(name) {
90 Some(name) => format!("{noun} \u{201c}{name}\u{201d}"),
91 None => format!("untitled {noun}"),
92 }
93}
94
95pub fn bare(noun: &str, name: Option<String>) -> String {
99 nonblank(name).unwrap_or_else(|| format!("untitled {noun}"))
100}
101
102const EXCERPT: usize = 40;
104
105fn excerpt(text: &str) -> Option<String> {
109 let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
110 if flat.is_empty() {
111 return None;
112 }
113 Some(if flat.chars().count() > EXCERPT {
114 flat.chars()
115 .take(EXCERPT - 1)
116 .collect::<String>()
117 .trim_end()
118 .to_owned()
119 + "\u{2026}"
120 } else {
121 flat
122 })
123}
124
125fn capitalized(text: &str) -> String {
126 let mut chars = text.chars();
127 match chars.next() {
128 None => String::new(),
129 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
130 }
131}
132
133#[derive(Clone, Copy, PartialEq, Eq, Hash)]
137enum Target {
138 Document,
139 Block(BlockId),
140 Pin(PinId),
141 Route(RouteId),
142 Text(TextId),
143 Area(AreaId),
144 Image(ImageId),
145 RouteLabel(RouteLabelId),
146}
147
148impl Target {
149 fn plural(self) -> &'static str {
151 match self {
152 Target::Document => "documents",
153 Target::Block(_) => "blocks",
154 Target::Pin(_) => "pins",
155 Target::Route(_) => "routes",
156 Target::Text(_) => "text boxes",
157 Target::Area(_) => "areas",
158 Target::Image(_) => "images",
159 Target::RouteLabel(_) => "route labels",
160 }
161 }
162
163 fn same_kind(self, other: Self) -> bool {
164 self.plural() == other.plural()
165 }
166}
167
168fn target(op: &OpCodes) -> Option<Target> {
172 Some(match op {
173 OpCodes::Document(_) => Target::Document,
174 OpCodes::Block(id, _) => Target::Block(*id),
175 OpCodes::Pin(id, _) => Target::Pin(*id),
176 OpCodes::Route(id, _) => Target::Route(*id),
177 OpCodes::Text(id, _) => Target::Text(*id),
178 OpCodes::Area(id, _) => Target::Area(*id),
179 OpCodes::Image(id, _) => Target::Image(*id),
180 OpCodes::RouteLabel(id, _) => Target::RouteLabel(*id),
181 OpCodes::Asset(..) => return None,
182 })
183}
184
185struct Renaming<'a> {
191 noun: &'static str,
192 was: Option<String>,
193 to: &'a str,
194}
195
196fn renaming<'a>(doc: &Document, op: &'a OpCodes) -> Option<Renaming<'a>> {
197 let label = |noun, was: Option<&String>, to| Renaming {
198 noun,
199 was: was.cloned(),
200 to,
201 };
202 Some(match op {
203 OpCodes::Document(TitleBlockUpdate::Name(to)) => {
204 label("diagram", Some(&doc.title_block().name), to)
205 }
206 OpCodes::Block(id, Crud::Update(BlockUpdate::Title(LabelUpdate::Name(to)))) => label(
207 "block",
208 doc.block(id).map(|block| block.title.name.clone()).as_ref(),
209 to,
210 ),
211 OpCodes::Block(id, Crud::Update(BlockUpdate::TypeLabel(LabelUpdate::Name(to)))) => label(
212 "block type",
213 doc.block(id)
214 .map(|block| block.type_label.name.clone())
215 .as_ref(),
216 to,
217 ),
218 OpCodes::Area(id, Crud::Update(AreaUpdate::Title(LabelUpdate::Name(to)))) => label(
219 "area",
220 doc.area(id).map(|area| area.title.name.clone()).as_ref(),
221 to,
222 ),
223 OpCodes::Pin(id, Crud::Update(PinUpdate::Name(to))) => {
224 label("pin", doc.pin(id).map(|pin| pin.name.clone()).as_ref(), to)
225 }
226 OpCodes::Pin(id, Crud::Update(PinUpdate::TypeName(to))) => label(
227 "pin type",
228 doc.pin(id).map(|pin| pin.type_name.clone()).as_ref(),
229 to,
230 ),
231 OpCodes::Pin(id, Crud::Update(PinUpdate::Tag(to))) => label(
232 "pin tag",
233 doc.pin(id).map(|pin| pin.tag.clone()).as_ref(),
234 to,
235 ),
236 OpCodes::Route(id, Crud::Update(RouteUpdate::Name(to))) => label(
237 "route",
238 doc.route(id).map(|route| route.name.clone()).as_ref(),
239 to,
240 ),
241 _ => return None,
242 })
243}
244
245fn renamed(doc: &Document, op: &OpCodes) -> Option<Said> {
249 let Renaming { noun, was, to } = renaming(doc, op)?;
250 let to = nonblank(Some(to.to_owned()))?;
251 Some(match nonblank(was) {
252 None => Said {
255 verb: Some("Name"),
256 object: called(noun, Some(to)),
257 },
258 Some(was) => Said::of(format!(
259 "{} to \u{201c}{to}\u{201d}",
260 called(noun, Some(was))
261 )),
262 })
263}
264
265fn subject(document: &IndexedDocument<'_>, ops: &[OpCodes]) -> Said {
268 let mut named: Vec<(Target, &OpCodes)> = Vec::new();
269 for op in ops {
270 let Some(target) = target(op) else { continue };
271 if !named.iter().any(|(seen, _)| *seen == target) {
272 named.push((target, op));
273 }
274 }
275 match named.as_slice() {
276 [] => Said::of("the drawing".to_owned()),
279 [(target, op)] => one(document, *target, op),
280 [(first, _), rest @ ..] => {
281 let count = rest.len() + 1;
282 let kind = if rest.iter().all(|(target, _)| target.same_kind(*first)) {
283 first.plural()
284 } else {
285 "shapes"
286 };
287 Said::of(format!("{count} {kind}"))
288 }
289 }
290}
291
292fn one(document: &IndexedDocument<'_>, target: Target, op: &OpCodes) -> Said {
293 if let Some(said) = renamed(document.doc, op) {
294 return said;
295 }
296 if let OpCodes::Text(_, Crud::Update(TextUpdate::Text(to))) = op {
299 return match excerpt(to) {
300 Some(shown) => Said::of(format!("text to \u{201c}{shown}\u{201d}")),
301 None => Said::of("text".to_owned()),
302 };
303 }
304 if let OpCodes::Route(_, Crud::Create(init)) = op {
307 return Said::of(format!("route {}", between(document, init.from, init.to)));
308 }
309 let name = named(document.doc, op);
310 match target {
311 Target::Document => Said::of("the diagram".to_owned()),
312 Target::Block(_) => Said::of(called("block", name)),
313 Target::Pin(_) => Said::of(called("pin", name)),
314 Target::Text(_) => Said::of(called("text", name)),
315 Target::Area(_) => Said::of(called("area", name)),
316 Target::Image(_) => Said::of("image".to_owned()),
319 Target::RouteLabel(_) => Said::of("route label".to_owned()),
320 Target::Route(id) => match document.doc.route(&id) {
321 None => Said::of(called("route", name)),
322 Some(ends) => Said::of(format!(
323 "{} {}",
324 called("route", name),
325 between(document, ends.from, ends.to)
326 )),
327 },
328 }
329}
330
331fn named(doc: &Document, op: &OpCodes) -> Option<String> {
337 match op {
338 OpCodes::Document(_) => non_empty(&doc.title_block().name),
339 OpCodes::Block(id, crud) => crud_name(
340 doc.block(id),
341 crud,
342 |init| &init.title.name,
343 |block| &block.title.name,
344 ),
345 OpCodes::Pin(id, crud) => crud_name(doc.pin(id), crud, |init| &init.name, |pin| &pin.name),
346 OpCodes::Route(id, crud) => {
347 crud_name(doc.route(id), crud, |init| &init.name, |route| &route.name)
348 }
349 OpCodes::Text(id, crud) => {
350 crud_name(doc.text(id), crud, |init| &init.text, |text| &text.text)
351 }
352 OpCodes::Area(id, crud) => crud_name(
353 doc.area(id),
354 crud,
355 |init| &init.title.name,
356 |area| &area.title.name,
357 ),
358 OpCodes::RouteLabel(..) | OpCodes::Image(..) | OpCodes::Asset(..) => None,
359 }
360}
361
362fn crud_name<E, I, U>(
363 entity: Option<&E>,
364 crud: &Crud<I, U>,
365 of_init: impl Fn(&I) -> &String,
366 of_entity: impl Fn(&E) -> &String,
367) -> Option<String> {
368 non_empty(match crud {
369 Crud::Create(init) => of_init(init),
370 _ => of_entity(entity?),
371 })
372}
373
374fn non_empty(name: &str) -> Option<String> {
375 (!name.is_empty()).then(|| name.to_owned())
376}
377
378fn between(document: &IndexedDocument<'_>, from: PinId, to: PinId) -> String {
382 let end = |id: PinId| {
383 let pin = document.doc.pin(&id);
384 let owner = pin
385 .map(|pin| pin.owner)
386 .and_then(|owner| document.doc.block(&owner));
387 format!(
388 "{}:{}",
389 bare("block", owner.map(|block| block.title.name.clone()),),
390 bare("pin", pin.map(|pin| pin.name.clone())),
391 )
392 };
393 format!("from {} to {}", end(from), end(to))
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::widget::test_fixtures::{self as fx, Scene};
400 use blockworx_doc::fixtures::block_id;
401 use blockworx_geom::{Rect, pos2};
402
403 #[test]
409 fn a_label_names_the_entity_and_leaves_the_scope_to_the_row() {
410 let mut scene = Scene::new(vec![
411 fx::block(1, 0.0),
412 fx::titled(1, "Amplifier"),
413 fx::block_in(
414 2,
415 crate::path::Scope::Block(block_id(1)),
416 Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
417 ),
418 fx::titled(2, "Filter"),
419 ]);
420 let ops = vec![blockworx_store::fixture::block_move(2, 5)];
421
422 let said = Label::verb("Resize").describing(&scene.indexed(), &ops);
423 assert_eq!(said, "Resize block \u{201c}Filter\u{201d}");
424 }
425
426 #[test]
428 fn an_untitled_entity_still_reads_as_itself() {
429 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::titled(1, "")]);
430 let ops = vec![blockworx_store::fixture::block_move(1, 5)];
431 assert_eq!(
432 Label::verb("Move").describing(&scene.indexed(), &ops),
433 "Move untitled block",
434 );
435 }
436
437 #[test]
438 fn a_verbatim_label_is_left_exactly_as_it_was_minted() {
439 let mut scene = Scene::new(Vec::new());
440 assert_eq!(
441 Label::verbatim("Imported motor.svg").describing(
442 &scene.indexed(),
443 &[blockworx_store::fixture::block_create(1, "Adder")],
444 ),
445 "Imported motor.svg",
446 );
447 }
448
449 #[test]
452 fn one_entity_reads_as_itself_and_many_read_as_a_count() {
453 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::titled(1, "Filter")]);
454
455 let twice = vec![
456 blockworx_store::fixture::block_move(1, 5),
457 blockworx_store::fixture::block_rename(1, "Filter II"),
458 ];
459 assert_eq!(
460 Label::verb("edit").describing(&scene.indexed(), &twice),
461 "Edit block \u{201c}Filter\u{201d}",
462 "two ops on one block are one block",
463 );
464
465 let several = vec![
466 blockworx_store::fixture::block_create(2, "A"),
467 blockworx_store::fixture::block_create(3, "B"),
468 ];
469 assert_eq!(
470 Label::verb("delete").describing(&scene.indexed(), &several),
471 "Delete 2 blocks",
472 );
473 }
474
475 #[test]
479 fn an_image_reads_as_an_image_rather_than_as_its_payload() {
480 use blockworx_doc::block_model::Asset;
481 use blockworx_doc::opcode::Crud;
482
483 let mut scene = Scene::new(Vec::new());
484 let asset = Asset::Svg(b"<svg/>".to_vec().into());
485 let ops = vec![
486 OpCodes::Asset(asset.hash(), asset.clone()),
487 OpCodes::Image(
488 blockworx_doc::fixtures::image_id(1),
489 Crud::Update(blockworx_doc::block_model::ImageUpdate::Asset(asset.hash())),
490 ),
491 ];
492 assert_eq!(
493 Label::verb("Add").describing(&scene.indexed(), &ops),
494 "Add image",
495 );
496 }
497
498 fn scene_with_a_wire() -> Scene {
501 use blockworx_doc::values::PinSide;
502 Scene::new(vec![
503 fx::block(1, 0.0),
504 fx::titled(1, "Filter"),
505 fx::block(2, 200.0),
506 fx::pin_at(
507 1,
508 crate::path::Scope::Block(block_id(1)),
509 "out",
510 fx::slot(PinSide::East, 1),
511 Rect::ZERO,
512 ),
513 fx::pin_at(
514 2,
515 crate::path::Scope::Block(block_id(2)),
516 "in",
517 fx::slot(PinSide::West, 1),
518 Rect::ZERO,
519 ),
520 fx::route(1, crate::path::Scope::Root, 1, 2, &[]),
521 ])
522 }
523
524 #[test]
528 fn a_route_reads_by_the_ends_it_joins() {
529 use blockworx_doc::geometry::{GridPoint, Waypoint};
530 let mut scene = scene_with_a_wire();
531 scene.apply(vec![fx::route_named(1, "clk")]);
532 let bend = vec![OpCodes::Route(
533 blockworx_doc::fixtures::route_id(1),
534 Crud::Update(RouteUpdate::Waypoints(vec![Waypoint {
535 pos: GridPoint { x: 4, y: 4 },
536 locked: false,
537 }])),
538 )];
539
540 let said = Label::verb("Modify").describing(&scene.indexed(), &bend);
541 assert_eq!(
542 said,
543 "Modify route \u{201c}clk\u{201d} from Filter:out to c:in"
544 );
545 }
546
547 #[test]
550 fn creating_a_route_names_its_endpoints() {
551 use blockworx_doc::values::PinSide;
552 let mut scene = Scene::new(vec![
553 fx::block(1, 0.0),
554 fx::titled(1, "Filter"),
555 fx::block(2, 200.0),
556 fx::pin_at(
557 1,
558 crate::path::Scope::Block(block_id(1)),
559 "out",
560 fx::slot(PinSide::East, 1),
561 Rect::ZERO,
562 ),
563 fx::pin_at(
564 2,
565 crate::path::Scope::Block(block_id(2)),
566 "in",
567 fx::slot(PinSide::West, 1),
568 Rect::ZERO,
569 ),
570 ]);
571 assert!(
572 scene
573 .indexed()
574 .doc
575 .route(&blockworx_doc::fixtures::route_id(1))
576 .is_none(),
577 "precondition: the wire is created by the ops under test, not before them",
578 );
579 let drawn = vec![fx::route(1, crate::path::Scope::Root, 1, 2, &[])];
580 assert_eq!(
581 Label::verb(crate::names::ToolName::Route.verb()).describing(&scene.indexed(), &drawn),
582 "Create route from Filter:out to c:in",
583 );
584 }
585
586 #[test]
590 fn a_rename_carries_the_value_it_replaces() {
591 let mut scene = Scene::new(vec![
592 fx::block(1, 0.0),
593 fx::titled(1, "Amplifier"),
594 fx::block_in(
595 2,
596 crate::path::Scope::Block(block_id(1)),
597 Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
598 ),
599 fx::titled(2, "Filter"),
600 ]);
601 assert_eq!(
602 Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(2, "Notch")]),
603 "Rename block \u{201c}Filter\u{201d} to \u{201c}Notch\u{201d}",
604 );
605 }
606
607 #[test]
610 fn naming_something_that_had_no_name_is_not_a_rename() {
611 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::titled(1, "")]);
612 assert_eq!(
613 Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(1, "Mixer")],),
614 "Name block \u{201c}Mixer\u{201d}",
615 );
616 assert_eq!(
619 Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(1, "")],),
620 "Rename untitled block",
621 );
622 }
623
624 #[test]
628 fn every_rename_shaped_op_reads_the_same_way() {
629 use blockworx_doc::values::PinSide;
630 let mut scene = Scene::new(vec![
631 fx::block(1, 0.0),
632 fx::titled(1, "Filter"),
633 fx::typed(1, "SVF"),
634 fx::pin_at(
635 1,
636 crate::path::Scope::Block(block_id(1)),
637 "out",
638 fx::slot(PinSide::East, 1),
639 Rect::ZERO,
640 ),
641 fx::pin_typed(1, "analog"),
642 fx::pin_tagged(1, "J1"),
643 ]);
644 let mut said = |op| Label::verb("Rename").describing(&scene.indexed(), &[op]);
645 assert_eq!(
646 said(fx::typed(1, "Ladder")),
647 "Rename block type \u{201c}SVF\u{201d} to \u{201c}Ladder\u{201d}",
648 );
649 assert_eq!(
650 said(OpCodes::Pin(
651 blockworx_doc::fixtures::pin_id(1),
652 Crud::Update(PinUpdate::Name("outp".into())),
653 )),
654 "Rename pin \u{201c}out\u{201d} to \u{201c}outp\u{201d}",
655 );
656 assert_eq!(
657 said(fx::pin_typed(1, "digital")),
658 "Rename pin type \u{201c}analog\u{201d} to \u{201c}digital\u{201d}",
659 );
660 assert_eq!(
661 said(fx::pin_tagged(1, "J2")),
662 "Rename pin tag \u{201c}J1\u{201d} to \u{201c}J2\u{201d}",
663 );
664 let mut wired = scene_with_a_wire();
665 wired.apply(vec![fx::route_named(1, "clk")]);
666 assert_eq!(
667 Label::verb("Rename").describing(&wired.indexed(), &[fx::route_named(1, "clock")],),
668 "Rename route \u{201c}clk\u{201d} to \u{201c}clock\u{201d}",
669 );
670 }
671
672 #[test]
676 fn a_text_edit_quotes_what_it_now_says() {
677 let mut scene = Scene::new(vec![fx::text(
678 1,
679 crate::path::Scope::Root,
680 "old note",
681 pos2(0.0, 0.0),
682 )]);
683 let mut edit = |content: &str| {
684 Label::verb("Edit").describing(&scene.indexed(), &[fx::text_content(1, content)])
685 };
686 assert_eq!(
687 edit("first line\nsecond line"),
688 "Edit text to \u{201c}first line second line\u{201d}",
689 );
690 let long = edit(&"wide ".repeat(20));
691 assert!(
692 long.ends_with('\u{201d}') && long.contains('\u{2026}'),
693 "a long run must be clipped with an ellipsis: {long}",
694 );
695 let quoted = long
696 .trim_start_matches("Edit text to \u{201c}")
697 .trim_end_matches('\u{201d}');
698 assert_eq!(
699 quoted.chars().count(),
700 EXCERPT,
701 "the excerpt is clipped to its width: {quoted:?}",
702 );
703 assert_eq!(edit(" "), "Edit text");
705 }
706}