Skip to main content

blockworx/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.** §8.1 gives the scope a line of
14//! its own on every history row, recorded at seal beside the ids, so a
15//! label 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(crate) 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: §8.1 gives the scope
407    /// its own line on the row, so a label that appended it would say it
408    /// twice.
409    #[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    /// An entity with no name of its own still reads as itself.
428    #[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    /// A run of ops on one entity is that entity, not its op count; a run
451    /// across several is counted, and counted by kind where they agree.
452    #[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    /// The one gesture whose ops are mostly payload: an image places a
477    /// content-addressed asset beside the entity that carries it, and the
478    /// label must name the entity rather than the bytes.
479    #[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    /// Two wired blocks: `Filter`'s `out` to the default-titled `c`'s
500    /// `in`, with the wire itself unnamed until a test names it.
501    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    /// A wire is recognized by what it joins, so its endpoints are named
526    /// by their block *and pin* — one spelling, whether the wire is being
527    /// drawn or edited afterwards.
528    #[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    /// Drawing a wire says which two ends it joined — the wire is not in
549    /// the document yet, so the endpoints come out of the op itself.
550    #[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    /// A rename says what it replaced: at the seal the document is still
589    /// the pre-image, so the old value is read from it and the new one
590    /// from the op.
591    #[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    /// Nothing to rename *from* is not a rename, whichever tool opened
610    /// the gesture: the op's own verb wins over the gesture's.
611    #[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        // Clearing a name is neither: there is no new value to quote, so
619        // the gesture's own verb and the entity alone are what is left.
620        assert_eq!(
621            Label::verb("Rename").describing(&scene.indexed(), &[fx::titled(1, "")],),
622            "Rename untitled block",
623        );
624    }
625
626    /// One rule, every renamable label — a drifting second spelling for
627    /// pin tags or type labels is exactly what the single builder exists
628    /// to prevent.
629    #[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    /// A text box's content is what it is called, so an edit quotes what
675    /// it now says — flattened onto one line and clipped, since a log row
676    /// is not a place to reproduce a paragraph.
677    #[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        // Emptying a text box has nothing to quote.
706        assert_eq!(edit("   "), "Edit text");
707    }
708}