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(crate) 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]
410 fn a_label_names_the_entity_and_leaves_the_scope_to_the_row() {
411 let mut scene = Scene::new(vec![
412 fx::block(1, 0.0),
413 fx::titled(1, "Amplifier"),
414 fx::block_in(
415 2,
416 crate::path::Scope::Block(block_id(1)),
417 Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
418 ),
419 fx::titled(2, "Filter"),
420 ]);
421 let ops = vec![blockworx_store::fixture::block_move(2, 5)];
422
423 let said = Label::verb("Resize").describing(&scene.indexed(), &ops);
424 assert_eq!(said, "Resize block \u{201c}Filter\u{201d}");
425 }
426
427 #[test]
429 fn an_untitled_entity_still_reads_as_itself() {
430 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::titled(1, "")]);
431 let ops = vec![blockworx_store::fixture::block_move(1, 5)];
432 assert_eq!(
433 Label::verb("Move").describing(&scene.indexed(), &ops),
434 "Move untitled block",
435 );
436 }
437
438 #[test]
439 fn a_verbatim_label_is_left_exactly_as_it_was_minted() {
440 let mut scene = Scene::new(Vec::new());
441 assert_eq!(
442 Label::verbatim("Imported motor.svg").describing(
443 &scene.indexed(),
444 &[blockworx_store::fixture::block_create(1, "Adder")],
445 ),
446 "Imported motor.svg",
447 );
448 }
449
450 #[test]
453 fn one_entity_reads_as_itself_and_many_read_as_a_count() {
454 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::titled(1, "Filter")]);
455
456 let twice = vec![
457 blockworx_store::fixture::block_move(1, 5),
458 blockworx_store::fixture::block_rename(1, "Filter II"),
459 ];
460 assert_eq!(
461 Label::verb("edit").describing(&scene.indexed(), &twice),
462 "Edit block \u{201c}Filter\u{201d}",
463 "two ops on one block are one block",
464 );
465
466 let several = vec![
467 blockworx_store::fixture::block_create(2, "A"),
468 blockworx_store::fixture::block_create(3, "B"),
469 ];
470 assert_eq!(
471 Label::verb("delete").describing(&scene.indexed(), &several),
472 "Delete 2 blocks",
473 );
474 }
475
476 #[test]
480 fn an_image_reads_as_an_image_rather_than_as_its_payload() {
481 use blockworx_doc::block_model::Asset;
482 use blockworx_doc::opcode::Crud;
483
484 let mut scene = Scene::new(Vec::new());
485 let asset = Asset::Svg(b"<svg/>".to_vec().into());
486 let ops = vec![
487 OpCodes::Asset(asset.hash(), asset.clone()),
488 OpCodes::Image(
489 blockworx_doc::fixtures::image_id(1),
490 Crud::Update(blockworx_doc::block_model::ImageUpdate::Asset(asset.hash())),
491 ),
492 ];
493 assert_eq!(
494 Label::verb("Add").describing(&scene.indexed(), &ops),
495 "Add image",
496 );
497 }
498
499 fn scene_with_a_wire() -> Scene {
502 use blockworx_doc::values::PinSide;
503 Scene::new(vec![
504 fx::block(1, 0.0),
505 fx::titled(1, "Filter"),
506 fx::block(2, 200.0),
507 fx::pin_at(
508 1,
509 crate::path::Scope::Block(block_id(1)),
510 "out",
511 fx::slot(PinSide::East, 1),
512 Rect::ZERO,
513 ),
514 fx::pin_at(
515 2,
516 crate::path::Scope::Block(block_id(2)),
517 "in",
518 fx::slot(PinSide::West, 1),
519 Rect::ZERO,
520 ),
521 fx::route(1, crate::path::Scope::Root, 1, 2, &[]),
522 ])
523 }
524
525 #[test]
529 fn a_route_reads_by_the_ends_it_joins() {
530 use blockworx_doc::geometry::{GridPoint, Waypoint};
531 let mut scene = scene_with_a_wire();
532 scene.apply(vec![fx::route_named(1, "clk")]);
533 let bend = vec![OpCodes::Route(
534 blockworx_doc::fixtures::route_id(1),
535 Crud::Update(RouteUpdate::Waypoints(vec![Waypoint {
536 pos: GridPoint { x: 4, y: 4 },
537 locked: false,
538 }])),
539 )];
540
541 let said = Label::verb("Modify").describing(&scene.indexed(), &bend);
542 assert_eq!(
543 said,
544 "Modify route \u{201c}clk\u{201d} from Filter:out to c:in"
545 );
546 }
547
548 #[test]
551 fn creating_a_route_names_its_endpoints() {
552 use blockworx_doc::values::PinSide;
553 let mut scene = Scene::new(vec![
554 fx::block(1, 0.0),
555 fx::titled(1, "Filter"),
556 fx::block(2, 200.0),
557 fx::pin_at(
558 1,
559 crate::path::Scope::Block(block_id(1)),
560 "out",
561 fx::slot(PinSide::East, 1),
562 Rect::ZERO,
563 ),
564 fx::pin_at(
565 2,
566 crate::path::Scope::Block(block_id(2)),
567 "in",
568 fx::slot(PinSide::West, 1),
569 Rect::ZERO,
570 ),
571 ]);
572 assert!(
573 scene
574 .indexed()
575 .doc
576 .route(&blockworx_doc::fixtures::route_id(1))
577 .is_none(),
578 "precondition: the wire is created by the ops under test, not before them",
579 );
580 let drawn = vec![fx::route(1, crate::path::Scope::Root, 1, 2, &[])];
581 assert_eq!(
582 Label::verb(crate::tools::names::ToolName::Route.verb())
583 .describing(&scene.indexed(), &drawn),
584 "Create route from Filter:out to c:in",
585 );
586 }
587
588 #[test]
592 fn a_rename_carries_the_value_it_replaces() {
593 let mut scene = Scene::new(vec![
594 fx::block(1, 0.0),
595 fx::titled(1, "Amplifier"),
596 fx::block_in(
597 2,
598 crate::path::Scope::Block(block_id(1)),
599 Rect::from_min_max(pos2(4.0, 4.0), pos2(24.0, 24.0)),
600 ),
601 fx::titled(2, "Filter"),
602 ]);
603 assert_eq!(
604 Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(2, "Notch")]),
605 "Rename block \u{201c}Filter\u{201d} to \u{201c}Notch\u{201d}",
606 );
607 }
608
609 #[test]
612 fn naming_something_that_had_no_name_is_not_a_rename() {
613 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::titled(1, "")]);
614 assert_eq!(
615 Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(1, "Mixer")],),
616 "Name block \u{201c}Mixer\u{201d}",
617 );
618 assert_eq!(
621 Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(1, "")],),
622 "Rename untitled block",
623 );
624 }
625
626 #[test]
630 fn every_rename_shaped_op_reads_the_same_way() {
631 use blockworx_doc::values::PinSide;
632 let mut scene = Scene::new(vec![
633 fx::block(1, 0.0),
634 fx::titled(1, "Filter"),
635 fx::typed(1, "SVF"),
636 fx::pin_at(
637 1,
638 crate::path::Scope::Block(block_id(1)),
639 "out",
640 fx::slot(PinSide::East, 1),
641 Rect::ZERO,
642 ),
643 fx::pin_typed(1, "analog"),
644 fx::pin_tagged(1, "J1"),
645 ]);
646 let mut said = |op| Label::verb("Rename").describing(&scene.indexed(), &[op]);
647 assert_eq!(
648 said(fx::typed(1, "Ladder")),
649 "Rename block type \u{201c}SVF\u{201d} to \u{201c}Ladder\u{201d}",
650 );
651 assert_eq!(
652 said(OpCodes::Pin(
653 blockworx_doc::fixtures::pin_id(1),
654 Crud::Update(PinUpdate::Name("outp".into())),
655 )),
656 "Rename pin \u{201c}out\u{201d} to \u{201c}outp\u{201d}",
657 );
658 assert_eq!(
659 said(fx::pin_typed(1, "digital")),
660 "Rename pin type \u{201c}analog\u{201d} to \u{201c}digital\u{201d}",
661 );
662 assert_eq!(
663 said(fx::pin_tagged(1, "J2")),
664 "Rename pin tag \u{201c}J1\u{201d} to \u{201c}J2\u{201d}",
665 );
666 let mut wired = scene_with_a_wire();
667 wired.apply(vec![fx::route_named(1, "clk")]);
668 assert_eq!(
669 Label::verb("Rename").describing(&wired.indexed(), &[fx::route_named(1, "clock")],),
670 "Rename route \u{201c}clk\u{201d} to \u{201c}clock\u{201d}",
671 );
672 }
673
674 #[test]
678 fn a_text_edit_quotes_what_it_now_says() {
679 let mut scene = Scene::new(vec![fx::text(
680 1,
681 crate::path::Scope::Root,
682 "old note",
683 pos2(0.0, 0.0),
684 )]);
685 let mut edit = |content: &str| {
686 Label::verb("Edit").describing(&scene.indexed(), &[fx::text_content(1, content)])
687 };
688 assert_eq!(
689 edit("first line\nsecond line"),
690 "Edit text to \u{201c}first line second line\u{201d}",
691 );
692 let long = edit(&"wide ".repeat(20));
693 assert!(
694 long.ends_with('\u{201d}') && long.contains('\u{2026}'),
695 "a long run must be clipped with an ellipsis: {long}",
696 );
697 let quoted = long
698 .trim_start_matches("Edit text to \u{201c}")
699 .trim_end_matches('\u{201d}');
700 assert_eq!(
701 quoted.chars().count(),
702 EXCERPT,
703 "the excerpt is clipped to its width: {quoted:?}",
704 );
705 assert_eq!(edit(" "), "Edit text");
707 }
708}