Skip to main content

blockworx_editor/edit/
describe.rs

1//! What a commit is called: the verb the gesture opened under, the entity
2//! its ops touched, and the scope it happened in.
3//!
4//! One builder for every tool, because a history list whose rows are
5//! written by twenty call sites is a list whose rows disagree. A gesture
6//! knows its verb before it knows its object — the object is whatever the
7//! ops turn out to name — so the two meet here, at the seal.
8//!
9//! Names are read from the document the commit was written *against*
10//! (`named`), so a label says what a thing was called when it happened
11//! rather than chasing a later rename.
12//!
13//! **The label does not name the scope.** Every history row gives the
14//! scope a line of its own, recorded at seal beside the ids, so a label
15//! that appended `in <scope>` would say it twice — and say it worse,
16//! since the row's line elides from the left to keep the leaf while the
17//! label's suffix would be truncated leaf-first.
18
19use 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/// What a gesture is called, before its ops are known.
27///
28/// The two arms are the two kinds of label there are: a *verb*, which is
29/// only half a sentence and is finished here once the ops say what it
30/// acted on, and a label that already says everything — an import names
31/// the file it brought in, and appending the twenty entities that file
32/// carried would say less, not more.
33#[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    /// The finished label for a commit carrying `ops`, written against
49    /// `document`.
50    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
64/// What the ops turned out to say: the object half of the sentence, and —
65/// where the ops know their verb better than the gesture that opened —
66/// the verb to say it with. Naming a thing that had no name is not a
67/// rename, whatever the gesture called itself.
68struct Said {
69    verb: Option<&'static str>,
70    object: String,
71}
72
73impl Said {
74    /// The gesture's own verb stands.
75    fn of(object: String) -> Self {
76        Said { verb: None, object }
77    }
78}
79
80/// A name with nothing in it is no name — the one trim, so "untitled"
81/// means the same thing everywhere.
82fn nonblank(name: Option<String>) -> Option<String> {
83    name.map(|name| name.trim().to_owned())
84        .filter(|name| !name.is_empty())
85}
86
87/// The untitled fallback, spelled once: `"Filter"` or `untitled block`.
88fn 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
95/// A bare name, for the places a label points at a thing rather than
96/// introducing it (a route's endpoints, a PDF outline entry): `Filter`, or
97/// `untitled block`.
98pub fn bare(noun: &str, name: Option<String>) -> String {
99    nonblank(name).unwrap_or_else(|| format!("untitled {noun}"))
100}
101
102/// How wide a quoted excerpt of authored text may read in a label.
103const EXCERPT: usize = 40;
104
105/// `text` as a one-line label can carry it: whitespace flattened, clipped
106/// to [`EXCERPT`] characters with an ellipsis. `None` when nothing is
107/// left to quote.
108fn 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/// The entity a target names, as a reader would say it. `Target` rather
134/// than the op itself so a run of ops on one entity — the shape nearly
135/// every gesture takes — reads as that entity rather than as its op count.
136#[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    /// The plural a run of these reads as.
150    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
168/// What an op acts on. Only an artwork payload answers `None`: it is
169/// bytes rather than an entity, and always travels beside the op that
170/// places it.
171fn 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
185/// A rename-shaped op: the noun for the label it writes, the value the
186/// document still holds (the commit is sealed against its pre-image), and
187/// the value the op carries. One list, so every renamable label — a
188/// title, a type label, a pin's name or tag, a route's name, an area's
189/// title, the document's own — reads the same way.
190struct 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
245/// The sentence a rename-shaped op says, or `None` when it writes an
246/// empty name — clearing a label is not "renaming it to nothing", so it
247/// falls back to naming the entity alone.
248fn 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        // Nothing to rename *from*: the gesture's "Rename" is the wrong
253        // word for it, whichever tool opened.
254        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
265/// What `ops` acted on: the one entity where they agree on one, a count
266/// otherwise.
267fn 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        // Reachable only by a commit of pure artwork payload, which no
277        // gesture produces on its own — the placement travels with it.
278        [] => 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    // A text box's content *is* its name, so an edit quotes what it now
297    // says rather than what it used to.
298    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    // A wire drawn this very commit is not in the document yet, so its
305    // endpoints come out of the op that creates it.
306    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        // Neither carries a name of its own, so neither gets the
317        // "untitled" hedge: an image is an image.
318        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
331/// The target's display name in `doc`, the document the op was written
332/// against. A create names what it is about to call the entity — the
333/// document cannot, since the entity is not in it yet — and everything
334/// else reads the name the entity carried before the commit. Kinds with
335/// nothing a human would call a name have none.
336fn 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
378/// A route's endpoints as a reader points at them — the block each sits
379/// on and the pin's own name — which is how a wire is recognized, since
380/// the wire itself is usually unnamed.
381fn 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    /// A block gesture names the block and the scope it happened in — the
404    /// punch list's own example, built from real ops against a real
405    /// document.
406    /// The label names the entity and stops there: the scope has a line of
407    /// its own on the row, so a label that appended it would say it twice.
408    #[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    /// An entity with no name of its own still reads as itself.
427    #[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    /// A run of ops on one entity is that entity, not its op count; a run
450    /// across several is counted, and counted by kind where they agree.
451    #[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    /// The one gesture whose ops are mostly payload: an image places a
476    /// content-addressed asset beside the entity that carries it, and the
477    /// label must name the entity rather than the bytes.
478    #[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    /// Two wired blocks: `Filter`'s `out` to the default-titled `c`'s
499    /// `in`, with the wire itself unnamed until a test names it.
500    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    /// A wire is recognized by what it joins, so its endpoints are named
525    /// by their block *and pin* — one spelling, whether the wire is being
526    /// drawn or edited afterwards.
527    #[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    /// Drawing a wire says which two ends it joined — the wire is not in
548    /// the document yet, so the endpoints come out of the op itself.
549    #[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    /// A rename says what it replaced: at the seal the document is still
587    /// the pre-image, so the old value is read from it and the new one
588    /// from the op.
589    #[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    /// Nothing to rename *from* is not a rename, whichever tool opened
608    /// the gesture: the op's own verb wins over the gesture's.
609    #[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        // Clearing a name is neither: there is no new value to quote, so
617        // the gesture's own verb and the entity alone are what is left.
618        assert_eq!(
619            Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(1, "")],),
620            "Rename untitled block",
621        );
622    }
623
624    /// One rule, every renamable label — a drifting second spelling for
625    /// pin tags or type labels is exactly what the single builder exists
626    /// to prevent.
627    #[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    /// A text box's content is what it is called, so an edit quotes what
673    /// it now says — flattened onto one line and clipped, since a log row
674    /// is not a place to reproduce a paragraph.
675    #[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        // Emptying a text box has nothing to quote.
704        assert_eq!(edit("   "), "Edit text");
705    }
706}