Skip to main content

blockworx/edit/
naming.rs

1//! The naming and flag family (`docs/op-emitter-playbook.md`, 10a/10b):
2//! one- or two-register updates on an existing entity.
3
4use blockworx_doc::{
5    block_model::{
6        AreaUpdate, BlockUpdate, Label, LabelUpdate, Pin, PinUpdate, RouteUpdate, TextUpdate,
7    },
8    commit::CommitBuilder,
9    document::Document,
10    geometry::{FracVal, GridRect, GridSize},
11    id::{AreaId, BlockId, PinId, RouteId, RouteLabelId, TextId},
12    opcode::{Crud, OpCodes},
13    values::{LabelSide, PinDir},
14};
15
16use blockworx_store::doc::Writability;
17
18use crate::edit::lock::MaterialPin;
19use crate::edit::lower::role_from_accent;
20
21/// What a title rename lands on — the two entities carrying a title label.
22#[derive(Clone, Copy, Debug)]
23pub enum TitleTarget {
24    Block(BlockId),
25    Area(AreaId),
26}
27
28/// Inventory row "Rename Title": a block's or area's title label gets a
29/// new name.
30pub fn rename_title(doc: &Document, target: TitleTarget, name: &str, builder: &mut CommitBuilder) {
31    match target {
32        TitleTarget::Block(id) => {
33            let Some(live) = doc.block(&id) else {
34                return;
35            };
36            if live.title.name == name {
37                return;
38            }
39            builder.push(OpCodes::Block(
40                id,
41                Crud::Update(BlockUpdate::Title(LabelUpdate::Name(name.into()))),
42            ));
43        }
44        TitleTarget::Area(id) => {
45            let Some(live) = doc.area(&id) else {
46                return;
47            };
48            if live.title.name == name {
49                return;
50            }
51            builder.push(OpCodes::Area(
52                id,
53                Crud::Update(AreaUpdate::Title(LabelUpdate::Name(name.into()))),
54            ));
55        }
56    }
57}
58
59/// What an accent pick lands on — the five accent-bearing entities. A
60/// port's accent is its own register (`PortAccent`); the stub and port-pin
61/// accents are derived state, never in the log.
62#[derive(Clone, Copy, Debug)]
63pub enum AccentTarget {
64    Block(BlockId),
65    Route(RouteId),
66    Area(AreaId),
67    Text(TextId),
68    Port(PinId),
69}
70
71/// Inventory row "Set Accent": recolor a block, wire, area, text, or
72/// port. `accent` is the picker's vocabulary (`None` = plain).
73pub fn set_accent(
74    doc: &Document,
75    target: AccentTarget,
76    accent: Option<u8>,
77    builder: &mut CommitBuilder,
78) {
79    let role = role_from_accent(accent);
80    match target {
81        AccentTarget::Block(id) => {
82            let Some(live) = doc.block(&id) else {
83                return;
84            };
85            if live.role == role {
86                return;
87            }
88            builder.push(OpCodes::Block(id, Crud::Update(BlockUpdate::Role(role))));
89        }
90        AccentTarget::Route(id) => {
91            let Some(live) = doc.route(&id) else {
92                return;
93            };
94            if live.role == role {
95                return;
96            }
97            builder.push(OpCodes::Route(id, Crud::Update(RouteUpdate::Role(role))));
98        }
99        AccentTarget::Area(id) => {
100            let Some(live) = doc.area(&id) else {
101                return;
102            };
103            if live.role == role {
104                return;
105            }
106            builder.push(OpCodes::Area(id, Crud::Update(AreaUpdate::Role(role))));
107        }
108        AccentTarget::Text(id) => {
109            let Some(live) = doc.text(&id) else {
110                return;
111            };
112            if live.role == role {
113                return;
114            }
115            builder.push(OpCodes::Text(id, Crud::Update(TextUpdate::Role(role))));
116        }
117        AccentTarget::Port(id) => {
118            let Some(live) = doc.pin(&id) else {
119                return;
120            };
121            if live.port_accent == role {
122                return;
123            }
124            builder.push(OpCodes::Pin(id, Crud::Update(PinUpdate::PortAccent(role))));
125        }
126    }
127}
128
129/// Inventory row "Rename Block Type": a block's type label gets a new name.
130pub fn rename_type(doc: &Document, block: BlockId, name: &str, builder: &mut CommitBuilder) {
131    let Some(live) = doc.block(&block) else {
132        return;
133    };
134    if live.type_label.name == name {
135        return;
136    }
137    builder.push(OpCodes::Block(
138        block,
139        Crud::Update(BlockUpdate::TypeLabel(LabelUpdate::Name(name.into()))),
140    ));
141}
142
143/// The width, in grid cells, that fits a pin's labels — the gesture's text
144/// measurement, which the emitter cannot make for itself.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub struct LabelFitWidth(u32);
147
148impl LabelFitWidth {
149    pub fn new(cells: u32) -> Self {
150        Self(cells)
151    }
152}
153
154impl From<LabelFitWidth> for u32 {
155    fn from(width: LabelFitWidth) -> u32 {
156        width.0
157    }
158}
159
160/// The rename/retype rider: the port body grows to fit its labels and never
161/// narrows, so a body the user widened keeps its width.
162fn widened(pin: &Pin, fit: LabelFitWidth) -> Option<GridRect> {
163    let rect = pin.rect;
164    let fit = u32::from(fit);
165    (fit > rect.size.w).then_some(GridRect {
166        size: GridSize {
167            w: fit,
168            ..rect.size
169        },
170        ..rect
171    })
172}
173
174/// Inventory row "Rename Pin": a pin's name, widening the port body to fit.
175pub fn rename_pin(
176    doc: &Document,
177    target: MaterialPin,
178    name: &str,
179    fit: LabelFitWidth,
180    builder: &mut CommitBuilder,
181) {
182    let pin = target.id();
183    let Some(live) = doc.pin(&pin) else {
184        return;
185    };
186    if live.name != name {
187        builder.push(OpCodes::Pin(
188            pin,
189            Crud::Update(PinUpdate::Name(name.into())),
190        ));
191    }
192    if let Some(rect) = widened(live, fit) {
193        builder.push(OpCodes::Pin(pin, Crud::Update(PinUpdate::Rect(rect))));
194    }
195}
196
197/// Inventory row "Set Pin Tag": a pin's location designator. Tags extend
198/// outward over the stub, so there is no widening rider.
199pub fn set_tag(doc: &Document, target: MaterialPin, tag: &str, builder: &mut CommitBuilder) {
200    let pin = target.id();
201    let Some(live) = doc.pin(&pin) else {
202        return;
203    };
204    if live.tag == tag {
205        return;
206    }
207    builder.push(OpCodes::Pin(pin, Crud::Update(PinUpdate::Tag(tag.into()))));
208}
209
210/// Inventory row "Retype Pin": a pin's type line, widening the port body to
211/// fit.
212pub fn retype_pin(
213    doc: &Document,
214    target: MaterialPin,
215    type_name: &str,
216    fit: LabelFitWidth,
217    builder: &mut CommitBuilder,
218) {
219    let pin = target.id();
220    let Some(live) = doc.pin(&pin) else {
221        return;
222    };
223    if live.type_name != type_name {
224        builder.push(OpCodes::Pin(
225            pin,
226            Crud::Update(PinUpdate::TypeName(type_name.into())),
227        ));
228    }
229    if let Some(rect) = widened(live, fit) {
230        builder.push(OpCodes::Pin(pin, Crud::Update(PinUpdate::Rect(rect))));
231    }
232}
233
234/// `InOut` → Input → Output → `InOut`.
235fn cycled(dir: PinDir) -> PinDir {
236    match dir {
237        PinDir::InOut => PinDir::Input,
238        PinDir::Input => PinDir::Output,
239        PinDir::Output => PinDir::InOut,
240    }
241}
242
243/// Inventory row "Cycle Pin Direction": clicking the selected pin's stub
244/// advances its signal direction.
245pub fn cycle_dir(doc: &Document, target: MaterialPin, builder: &mut CommitBuilder) {
246    let pin = target.id();
247    let Some(live) = doc.pin(&pin) else {
248        return;
249    };
250    builder.push(OpCodes::Pin(
251        pin,
252        Crud::Update(PinUpdate::Dir(cycled(live.dir))),
253    ));
254}
255
256/// Inventory row "Set Pin Direction (bulk)": one direction across a pin
257/// selection, leaving locked owners' interfaces frozen.
258pub fn set_dirs(doc: &Document, pins: &[MaterialPin], dir: PinDir, builder: &mut CommitBuilder) {
259    for target in pins {
260        let id = target.id();
261        let Some(live) = doc.pin(&id) else {
262            continue;
263        };
264        if live.dir == dir {
265            continue;
266        }
267        builder.push(OpCodes::Pin(id, Crud::Update(PinUpdate::Dir(dir))));
268    }
269}
270
271/// Whether a pin draws its location tag — the parameter form of the pin's
272/// `TagHidden` register.
273#[derive(Clone, Copy, Debug, PartialEq, Eq)]
274pub enum TagVisibility {
275    Shown,
276    Hidden,
277}
278
279impl TagVisibility {
280    pub fn is_hidden(self) -> bool {
281        self == TagVisibility::Hidden
282    }
283}
284
285impl From<bool> for TagVisibility {
286    fn from(hidden: bool) -> Self {
287        if hidden {
288            TagVisibility::Hidden
289        } else {
290            TagVisibility::Shown
291        }
292    }
293}
294
295/// Inventory row "Show/Hide Pin Tags": tag visibility across a pin
296/// selection.
297pub fn set_tag_visibility(
298    doc: &Document,
299    pins: &[PinId],
300    visibility: TagVisibility,
301    builder: &mut CommitBuilder,
302) {
303    let hidden = visibility.is_hidden();
304    for id in pins {
305        let Some(live) = doc.pin(id) else {
306            continue;
307        };
308        if live.tag_hidden == hidden {
309            continue;
310        }
311        builder.push(OpCodes::Pin(
312            *id,
313            Crud::Update(PinUpdate::TagHidden(hidden)),
314        ));
315    }
316}
317
318/// Whether a block's pin interface accepts edits — the parameter form of
319/// the block's `Locked` register.
320#[derive(Clone, Copy, Debug, PartialEq, Eq)]
321pub enum InterfaceLock {
322    Locked,
323    Unlocked,
324}
325
326impl InterfaceLock {
327    pub fn is_locked(self) -> bool {
328        self == InterfaceLock::Locked
329    }
330}
331
332impl From<bool> for InterfaceLock {
333    fn from(locked: bool) -> Self {
334        if locked {
335            InterfaceLock::Locked
336        } else {
337            InterfaceLock::Unlocked
338        }
339    }
340}
341
342/// Whether the affordances that *add* something are offered: the faint
343/// "Add name"/"Add type" prompts, the free-slot pin dots, the route-start
344/// targets, a wire's waypoint handles and text anchors — and, with them,
345/// the gestures those affordances begin.
346///
347/// One value for two questions that have always had the same answer. A
348/// locked block already withheld its prompts because its interface is
349/// frozen; a read-only session freezes everything, so it withholds them on
350/// the same terms. Folding both here is what keeps the paint layer and the
351/// gesture layer from disagreeing about whether a thing is editable.
352#[derive(Clone, Copy, PartialEq, Eq, Debug)]
353pub enum Authoring {
354    Offered,
355    Withheld,
356}
357
358impl Authoring {
359    /// What a session of this [`Writability`] offers on a shape under this
360    /// [`InterfaceLock`] — the whole rule, in one place.
361    pub fn of(writability: Writability, lock: InterfaceLock) -> Self {
362        match (writability, lock.is_locked()) {
363            (Writability::Writable, false) => Authoring::Offered,
364            _ => Authoring::Withheld,
365        }
366    }
367
368    pub fn is_withheld(self) -> bool {
369        self == Authoring::Withheld
370    }
371}
372
373impl From<Writability> for Authoring {
374    fn from(writability: Writability) -> Self {
375        Authoring::of(writability, InterfaceLock::Unlocked)
376    }
377}
378
379/// Inventory row "Lock/Unlock Block": freeze or thaw a block's pin
380/// interface.
381pub fn set_locked(
382    doc: &Document,
383    block: BlockId,
384    lock: InterfaceLock,
385    builder: &mut CommitBuilder,
386) {
387    let Some(live) = doc.block(&block) else {
388        return;
389    };
390    let locked = lock.is_locked();
391    if live.locked == locked {
392        return;
393    }
394    builder.push(OpCodes::Block(
395        block,
396        Crud::Update(BlockUpdate::Locked(locked)),
397    ));
398}
399
400/// Inventory row "Rename Route": name a wire. A blank name is the wire
401/// label's deletion instead ([`clear_wire_label`]), never a name write.
402pub fn rename_route(doc: &Document, route: RouteId, name: &str, builder: &mut CommitBuilder) {
403    if name.trim().is_empty() {
404        return;
405    }
406    let Some(live) = doc.route(&route) else {
407        return;
408    };
409    if live.name == name {
410        return;
411    }
412    builder.push(OpCodes::Route(
413        route,
414        Crud::Update(RouteUpdate::Name(name.into())),
415    ));
416}
417
418/// What a label drag lands on — the three placeable labels.
419#[derive(Clone, Copy, Debug)]
420pub enum LabelTarget {
421    BlockTitle(BlockId),
422    BlockType(BlockId),
423    AreaTitle(AreaId),
424}
425
426/// A committed label drag: the offset the gesture resolved, and the side
427/// the release height chose, when it chose one.
428#[derive(Clone, Copy, Debug)]
429pub struct LabelPlacement {
430    pub offset: FracVal,
431    pub side: Option<LabelSide>,
432}
433
434/// The two placement registers filter independently: a drag along one side
435/// writes no side, and a side change at the same offset writes no offset.
436fn placement_updates(label: &Label, placement: LabelPlacement) -> Vec<LabelUpdate> {
437    let mut updates = Vec::new();
438    if label.offset != placement.offset {
439        updates.push(LabelUpdate::Offset(placement.offset));
440    }
441    if let Some(side) = placement.side.filter(|side| *side != label.side) {
442        updates.push(LabelUpdate::Side(side));
443    }
444    updates
445}
446
447/// Inventory row "Move Title / Type Label": reposition a block's or
448/// area's title, or a block's type label.
449pub fn place_label(
450    doc: &Document,
451    target: LabelTarget,
452    placement: LabelPlacement,
453    builder: &mut CommitBuilder,
454) {
455    match target {
456        LabelTarget::BlockTitle(id) => {
457            let Some(live) = doc.block(&id) else {
458                return;
459            };
460            builder.extend(
461                placement_updates(&live.title, placement)
462                    .into_iter()
463                    .map(|update| OpCodes::Block(id, Crud::Update(BlockUpdate::Title(update)))),
464            );
465        }
466        LabelTarget::BlockType(id) => {
467            let Some(live) = doc.block(&id) else {
468                return;
469            };
470            builder.extend(
471                placement_updates(&live.type_label, placement)
472                    .into_iter()
473                    .map(|update| OpCodes::Block(id, Crud::Update(BlockUpdate::TypeLabel(update)))),
474            );
475        }
476        LabelTarget::AreaTitle(id) => {
477            let Some(live) = doc.area(&id) else {
478                return;
479            };
480            builder.extend(
481                placement_updates(&live.title, placement)
482                    .into_iter()
483                    .map(|update| OpCodes::Area(id, Crud::Update(AreaUpdate::Title(update)))),
484            );
485        }
486    }
487}
488
489/// Inventory rows "Edit Text Box" and "Delete Text Box (emptied)": a text
490/// box commits its content, or is discarded when that content is blank
491/// rather than left invisible.
492pub fn edit_text(doc: &Document, text: TextId, content: &str, builder: &mut CommitBuilder) {
493    let Some(live) = doc.text(&text) else {
494        return;
495    };
496    if content.trim().is_empty() {
497        builder.push(OpCodes::Text(text, Crud::Delete));
498        return;
499    }
500    if live.text == content {
501        return;
502    }
503    builder.push(OpCodes::Text(
504        text,
505        Crud::Update(TextUpdate::Text(content.into())),
506    ));
507}
508
509/// Inventory row "Delete Wire Label (empty rename)": clearing a wire
510/// label's text drops the label and unnames the wire it labelled.
511pub fn clear_wire_label(doc: &Document, label: RouteLabelId, builder: &mut CommitBuilder) {
512    let Some(live) = doc.route_label(&label) else {
513        return;
514    };
515    let route = live.owner;
516    builder.push(OpCodes::RouteLabel(label, Crud::Delete));
517    if doc.route(&route).is_some_and(|r| !r.name.is_empty()) {
518        builder.push(OpCodes::Route(
519            route,
520            Crud::Update(RouteUpdate::Name(String::new())),
521        ));
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    /// The capability a material edit takes, for a test whose pin is
530    /// unlocked. A locked one is `MaterialPin::of(...) == None`, which is
531    /// the point of the type and is asserted where it matters.
532    fn material(doc: &Document, n: u32) -> MaterialPin {
533        MaterialPin::of(doc, pin_id(n)).expect("the pin's owner is unlocked")
534    }
535    use crate::edit::harness::{fold, route_label_create, seals_to_nothing, wired};
536    use blockworx_doc::fixtures::{area_id, block_id, pin_id, route_id, route_label_id, text_id};
537    use blockworx_doc::values::Role;
538
539    #[test]
540    fn rename_title_lands_on_a_block_and_an_area() {
541        let doc = wired();
542        assert_eq!(
543            doc.block(&block_id(1)).unwrap().title.name,
544            "b1",
545            "precondition: the scene's block starts under its fixture name"
546        );
547
548        let mut builder = CommitBuilder::new("Renamed a block");
549        rename_title(&doc, TitleTarget::Block(block_id(1)), "ALU", &mut builder);
550        let doc = fold(builder, &doc);
551        assert_eq!(doc.block(&block_id(1)).unwrap().title.name, "ALU");
552
553        let mut builder = CommitBuilder::new("Renamed an area");
554        rename_title(&doc, TitleTarget::Area(area_id(8)), "notes", &mut builder);
555        let doc = fold(builder, &doc);
556        assert_eq!(doc.area(&area_id(8)).unwrap().title.name, "notes");
557    }
558
559    #[test]
560    fn renaming_to_the_same_name_seals_to_nothing() {
561        let doc = wired();
562        let mut builder = CommitBuilder::new("Renamed a block");
563        rename_title(&doc, TitleTarget::Block(block_id(1)), "b1", &mut builder);
564        seals_to_nothing(builder);
565    }
566
567    #[test]
568    fn an_absent_target_pushes_nothing() {
569        let doc = wired();
570        let mut builder = CommitBuilder::new("Renamed a ghost");
571        rename_title(&doc, TitleTarget::Block(block_id(99)), "x", &mut builder);
572        seals_to_nothing(builder);
573    }
574
575    #[test]
576    fn set_accent_recolors_every_target_kind() {
577        let doc = wired();
578        let mut builder = CommitBuilder::new("Recolored the scene");
579        set_accent(
580            &doc,
581            AccentTarget::Block(block_id(1)),
582            Some(0),
583            &mut builder,
584        );
585        set_accent(
586            &doc,
587            AccentTarget::Route(route_id(5)),
588            Some(3),
589            &mut builder,
590        );
591        set_accent(&doc, AccentTarget::Port(pin_id(3)), Some(7), &mut builder);
592        let doc = fold(builder, &doc);
593        assert_eq!(doc.block(&block_id(1)).unwrap().role, Role::Accent1);
594        assert_eq!(doc.route(&route_id(5)).unwrap().role, Role::Accent4);
595        assert_eq!(doc.pin(&pin_id(3)).unwrap().port_accent, Role::Accent8);
596
597        // Clearing back to plain is an edit; clearing an already-plain
598        // element is not.
599        let mut builder = CommitBuilder::new("Cleared an accent");
600        set_accent(&doc, AccentTarget::Block(block_id(1)), None, &mut builder);
601        let doc = fold(builder, &doc);
602        assert_eq!(doc.block(&block_id(1)).unwrap().role, Role::Accent0);
603        let mut builder = CommitBuilder::new("Cleared it again");
604        set_accent(&doc, AccentTarget::Block(block_id(1)), None, &mut builder);
605        seals_to_nothing(builder);
606    }
607
608    fn pin(doc: &Document, id: u32) -> &Pin {
609        doc.pin(&pin_id(id)).expect("the scene's pin exists")
610    }
611
612    fn body_width(doc: &Document, id: u32) -> u32 {
613        pin(doc, id).rect.size.w
614    }
615
616    /// The scene with its pins' owner frozen — the arm every material pin
617    /// edit must decline.
618    fn locked_scene() -> Document {
619        let doc = wired();
620        let mut builder = CommitBuilder::new("Locked a block");
621        set_locked(&doc, block_id(1), InterfaceLock::Locked, &mut builder);
622        let doc = fold(builder, &doc);
623        assert!(
624            doc.block(&block_id(1))
625                .expect("the scene's block exists")
626                .locked,
627            "precondition: the owner is locked"
628        );
629        assert_eq!(
630            pin(&doc, 3).owner,
631            block_id(1),
632            "precondition: pin 3 belongs to the locked block"
633        );
634        doc
635    }
636
637    #[test]
638    fn rename_type_writes_the_type_label_and_leaves_the_title() {
639        let doc = wired();
640        assert_eq!(
641            doc.block(&block_id(1))
642                .expect("the scene's block exists")
643                .type_label
644                .name,
645            "",
646            "precondition: the scene's block carries no type label"
647        );
648
649        let mut builder = CommitBuilder::new("Retyped a block");
650        rename_type(&doc, block_id(1), "ALU", &mut builder);
651        let doc = fold(builder, &doc);
652        let block = doc.block(&block_id(1)).expect("the scene's block exists");
653        assert_eq!(block.type_label.name, "ALU");
654        assert_eq!(block.title.name, "b1", "the twin label is untouched");
655
656        let mut builder = CommitBuilder::new("Retyped it the same");
657        rename_type(&doc, block_id(1), "ALU", &mut builder);
658        seals_to_nothing(builder);
659    }
660
661    #[test]
662    fn rename_pin_writes_the_name_and_widens_the_body() {
663        let doc = wired();
664        assert_eq!(
665            body_width(&doc, 3),
666            0,
667            "precondition: the fixture pin's body starts unsized"
668        );
669
670        let mut builder = CommitBuilder::new("Renamed a pin");
671        rename_pin(
672            &doc,
673            material(&doc, 3),
674            "clk",
675            LabelFitWidth::new(5),
676            &mut builder,
677        );
678        let doc = fold(builder, &doc);
679        assert_eq!(pin(&doc, 3).name, "clk");
680        assert_eq!(body_width(&doc, 3), 5);
681
682        let mut builder = CommitBuilder::new("Renamed it shorter");
683        rename_pin(
684            &doc,
685            material(&doc, 3),
686            "d0",
687            LabelFitWidth::new(3),
688            &mut builder,
689        );
690        let doc = fold(builder, &doc);
691        assert_eq!(pin(&doc, 3).name, "d0");
692        assert_eq!(body_width(&doc, 3), 5, "a widened body is never narrowed");
693
694        // The two registers filter independently: the same name still
695        // widens.
696        let mut builder = CommitBuilder::new("Remeasured the same name");
697        rename_pin(
698            &doc,
699            material(&doc, 3),
700            "d0",
701            LabelFitWidth::new(7),
702            &mut builder,
703        );
704        let doc = fold(builder, &doc);
705        assert_eq!(pin(&doc, 3).name, "d0");
706        assert_eq!(body_width(&doc, 3), 7);
707
708        let mut builder = CommitBuilder::new("Renamed it to itself");
709        rename_pin(
710            &doc,
711            material(&doc, 3),
712            "d0",
713            LabelFitWidth::new(7),
714            &mut builder,
715        );
716        seals_to_nothing(builder);
717    }
718
719    #[test]
720    fn retype_pin_writes_the_type_line_and_widens_the_body() {
721        let doc = wired();
722        assert_eq!(
723            pin(&doc, 3).type_name,
724            "",
725            "precondition: the fixture pin carries no type line"
726        );
727
728        let mut builder = CommitBuilder::new("Retyped a pin");
729        retype_pin(
730            &doc,
731            material(&doc, 3),
732            "clock",
733            LabelFitWidth::new(6),
734            &mut builder,
735        );
736        let doc = fold(builder, &doc);
737        assert_eq!(pin(&doc, 3).type_name, "clock");
738        assert_eq!(body_width(&doc, 3), 6);
739
740        let mut builder = CommitBuilder::new("Retyped it the same");
741        retype_pin(
742            &doc,
743            material(&doc, 3),
744            "clock",
745            LabelFitWidth::new(6),
746            &mut builder,
747        );
748        seals_to_nothing(builder);
749    }
750
751    #[test]
752    fn set_tag_writes_the_location_designator() {
753        let doc = wired();
754        assert_eq!(
755            pin(&doc, 3).tag,
756            "",
757            "precondition: the fixture pin is untagged"
758        );
759
760        let mut builder = CommitBuilder::new("Tagged a pin");
761        set_tag(&doc, material(&doc, 3), "U3", &mut builder);
762        let doc = fold(builder, &doc);
763        assert_eq!(pin(&doc, 3).tag, "U3");
764        assert_eq!(
765            body_width(&doc, 3),
766            0,
767            "tags extend over the stub, so the body is not widened"
768        );
769
770        let mut builder = CommitBuilder::new("Tagged it the same");
771        set_tag(&doc, material(&doc, 3), "U3", &mut builder);
772        seals_to_nothing(builder);
773    }
774
775    /// A locked owner freezes its interface by refusing the capability, so a
776    /// material emitter cannot be *called* against its pins rather than
777    /// declining inside: a new emitter that forgot the check does not compile.
778    #[test]
779    fn a_locked_owner_refuses_the_capability_material_edits_need() {
780        let doc = locked_scene();
781        for n in [3u32, 4] {
782            assert_eq!(
783                MaterialPin::of(&doc, pin_id(n)),
784                None,
785                "pin {n} belongs to a locked block, so no material edit can name it",
786            );
787        }
788        assert!(
789            MaterialPin::all(&doc, &[pin_id(3), pin_id(4)]).is_empty(),
790            "and a group of them is an empty set, not a refused edit",
791        );
792
793        // The bulk emitter, handed that empty set, writes nothing — the only
794        // shape in which a material emitter still sees a locked pin at all.
795        let mut builder = CommitBuilder::new("Redirected frozen pins");
796        set_dirs(
797            &doc,
798            &MaterialPin::all(&doc, &[pin_id(3), pin_id(4)]),
799            PinDir::Output,
800            &mut builder,
801        );
802        seals_to_nothing(builder);
803    }
804
805    /// The other half of the same rule: a lock says nothing about
806    /// presentation, so hiding a locked block's tags goes through. The
807    /// emitter takes plain ids and is offered no capability, which is how the
808    /// signature says so.
809    #[test]
810    fn a_locked_owner_still_takes_presentation_edits() {
811        let doc = locked_scene();
812        let mut builder = CommitBuilder::new("Hid a frozen block's tags");
813        set_tag_visibility(
814            &doc,
815            &[pin_id(3), pin_id(4)],
816            TagVisibility::Hidden,
817            &mut builder,
818        );
819        assert!(
820            builder.seal().is_some(),
821            "tag visibility is presentation, and a lock does not reach it",
822        );
823    }
824
825    #[test]
826    fn cycle_dir_visits_every_direction_and_returns() {
827        let mut doc = wired();
828        assert_eq!(
829            pin(&doc, 3).dir,
830            PinDir::Input,
831            "precondition: the fixture pin starts at the zero direction"
832        );
833
834        for expected in [PinDir::Output, PinDir::InOut, PinDir::Input] {
835            let mut builder = CommitBuilder::new("Cycled a pin");
836            cycle_dir(&doc, material(&doc, 3), &mut builder);
837            doc = fold(builder, &doc);
838            assert_eq!(pin(&doc, 3).dir, expected);
839        }
840    }
841
842    /// A pin the document does not hold has no capability to hand out, so
843    /// the emitter is unreachable rather than merely uneventful.
844    #[test]
845    fn an_absent_pin_has_no_capability_to_cycle() {
846        let doc = wired();
847        assert_eq!(MaterialPin::of(&doc, pin_id(99)), None);
848    }
849
850    #[test]
851    fn set_dirs_writes_the_group_and_skips_the_pins_already_facing_that_way() {
852        let doc = wired();
853        assert_eq!(
854            pin(&doc, 4).dir,
855            PinDir::Input,
856            "precondition: both scene pins start at the zero direction"
857        );
858
859        let mut builder = CommitBuilder::new("Redirected a group");
860        set_dirs(
861            &doc,
862            &MaterialPin::all(&doc, &[pin_id(3), pin_id(4)]),
863            PinDir::Output,
864            &mut builder,
865        );
866        let doc = fold(builder, &doc);
867        assert_eq!(pin(&doc, 3).dir, PinDir::Output);
868        assert_eq!(pin(&doc, 4).dir, PinDir::Output);
869
870        let mut builder = CommitBuilder::new("Redirected them again");
871        set_dirs(
872            &doc,
873            &MaterialPin::all(&doc, &[pin_id(3), pin_id(4)]),
874            PinDir::Output,
875            &mut builder,
876        );
877        seals_to_nothing(builder);
878    }
879
880    #[test]
881    fn set_tag_visibility_hides_a_group_and_a_locked_owner_does_not_stop_it() {
882        let doc = locked_scene();
883        assert!(
884            !pin(&doc, 3).tag_hidden,
885            "precondition: the fixture pins draw their tags"
886        );
887
888        let mut builder = CommitBuilder::new("Hid the tags");
889        set_tag_visibility(
890            &doc,
891            &[pin_id(3), pin_id(4)],
892            TagVisibility::Hidden,
893            &mut builder,
894        );
895        let doc = fold(builder, &doc);
896        assert!(pin(&doc, 3).tag_hidden);
897        assert!(pin(&doc, 4).tag_hidden);
898
899        let mut builder = CommitBuilder::new("Hid them again");
900        set_tag_visibility(
901            &doc,
902            &[pin_id(3), pin_id(4)],
903            TagVisibility::Hidden,
904            &mut builder,
905        );
906        seals_to_nothing(builder);
907
908        let mut builder = CommitBuilder::new("Showed the tags");
909        set_tag_visibility(&doc, &[pin_id(3)], TagVisibility::Shown, &mut builder);
910        let doc = fold(builder, &doc);
911        assert!(!pin(&doc, 3).tag_hidden);
912        assert!(
913            pin(&doc, 4).tag_hidden,
914            "the pin outside the group is untouched"
915        );
916    }
917
918    #[test]
919    fn set_locked_flips_the_flag_once() {
920        let doc = locked_scene();
921
922        let mut builder = CommitBuilder::new("Locked it again");
923        set_locked(&doc, block_id(1), InterfaceLock::Locked, &mut builder);
924        seals_to_nothing(builder);
925
926        let mut builder = CommitBuilder::new("Unlocked the block");
927        set_locked(&doc, block_id(1), InterfaceLock::Unlocked, &mut builder);
928        let doc = fold(builder, &doc);
929        assert!(
930            !doc.block(&block_id(1))
931                .expect("the scene's block exists")
932                .locked
933        );
934
935        let mut builder = CommitBuilder::new("Renamed a thawed pin");
936        rename_pin(
937            &doc,
938            material(&doc, 3),
939            "clk",
940            LabelFitWidth::new(5),
941            &mut builder,
942        );
943        let doc = fold(builder, &doc);
944        assert_eq!(
945            pin(&doc, 3).name,
946            "clk",
947            "thawing restores the pin interface"
948        );
949    }
950
951    #[test]
952    fn rename_route_writes_only_a_non_empty_name() {
953        let doc = wired();
954        assert_eq!(
955            doc.route(&route_id(5))
956                .expect("the scene's route exists")
957                .name,
958            "r5",
959            "precondition: the scene's wire starts under its fixture name"
960        );
961
962        let mut builder = CommitBuilder::new("Named a wire");
963        rename_route(&doc, route_id(5), "clk_net", &mut builder);
964        let doc = fold(builder, &doc);
965        assert_eq!(
966            doc.route(&route_id(5))
967                .expect("the scene's route exists")
968                .name,
969            "clk_net"
970        );
971
972        for blank in ["", "   "] {
973            let mut builder = CommitBuilder::new("Blanked a wire name");
974            rename_route(&doc, route_id(5), blank, &mut builder);
975            seals_to_nothing(builder);
976        }
977
978        let mut builder = CommitBuilder::new("Named it the same");
979        rename_route(&doc, route_id(5), "clk_net", &mut builder);
980        seals_to_nothing(builder);
981    }
982
983    #[test]
984    fn place_label_repositions_all_three_labels() {
985        let doc = wired();
986        let placement = LabelPlacement {
987            offset: FracVal::from(2.5),
988            side: Some(LabelSide::Bottom),
989        };
990        assert_eq!(
991            doc.block(&block_id(1))
992                .expect("the scene's block exists")
993                .title
994                .side,
995            LabelSide::Top,
996            "precondition: labels start at the zero placement"
997        );
998
999        let mut builder = CommitBuilder::new("Placed the labels");
1000        place_label(
1001            &doc,
1002            LabelTarget::BlockTitle(block_id(1)),
1003            placement,
1004            &mut builder,
1005        );
1006        place_label(
1007            &doc,
1008            LabelTarget::BlockType(block_id(1)),
1009            placement,
1010            &mut builder,
1011        );
1012        place_label(
1013            &doc,
1014            LabelTarget::AreaTitle(area_id(8)),
1015            placement,
1016            &mut builder,
1017        );
1018        let doc = fold(builder, &doc);
1019
1020        let block = doc.block(&block_id(1)).expect("the scene's block exists");
1021        for label in [&block.title, &block.type_label] {
1022            assert_eq!(label.offset, FracVal::from(2.5));
1023            assert_eq!(label.side, LabelSide::Bottom);
1024        }
1025        let area_title = &doc
1026            .area(&area_id(8))
1027            .expect("the scene's area exists")
1028            .title;
1029        assert_eq!(area_title.offset, FracVal::from(2.5));
1030        assert_eq!(area_title.side, LabelSide::Bottom);
1031
1032        let mut builder = CommitBuilder::new("Placed it where it already was");
1033        place_label(
1034            &doc,
1035            LabelTarget::BlockTitle(block_id(1)),
1036            placement,
1037            &mut builder,
1038        );
1039        seals_to_nothing(builder);
1040    }
1041
1042    #[test]
1043    fn a_placement_writes_only_the_register_that_moved() {
1044        let doc = wired();
1045
1046        let mut builder = CommitBuilder::new("Slid a title along its side");
1047        place_label(
1048            &doc,
1049            LabelTarget::BlockTitle(block_id(1)),
1050            LabelPlacement {
1051                offset: FracVal::from(1.0),
1052                side: None,
1053            },
1054            &mut builder,
1055        );
1056        let doc = fold(builder, &doc);
1057        let title = &doc
1058            .block(&block_id(1))
1059            .expect("the scene's block exists")
1060            .title;
1061        assert_eq!(title.offset, FracVal::from(1.0));
1062        assert_eq!(
1063            title.side,
1064            LabelSide::Top,
1065            "a drag that chose no side leaves the side alone"
1066        );
1067
1068        let mut builder = CommitBuilder::new("Dropped it on another side");
1069        place_label(
1070            &doc,
1071            LabelTarget::BlockTitle(block_id(1)),
1072            LabelPlacement {
1073                offset: FracVal::from(1.0),
1074                side: Some(LabelSide::Center),
1075            },
1076            &mut builder,
1077        );
1078        let commit = builder.seal().expect("the side moved");
1079        assert_eq!(
1080            commit.ops().len(),
1081            1,
1082            "an unmoved offset takes no write of its own"
1083        );
1084        let doc = doc
1085            .try_apply(&commit)
1086            .expect("the fold accepts the gesture");
1087        assert_eq!(
1088            doc.block(&block_id(1))
1089                .expect("the scene's block exists")
1090                .title
1091                .side,
1092            LabelSide::Center
1093        );
1094    }
1095
1096    #[test]
1097    fn edit_text_writes_content_and_discards_an_emptied_box() {
1098        let doc = wired();
1099        assert!(
1100            doc.text(&text_id(7)).is_some(),
1101            "precondition: the scene holds the text box"
1102        );
1103
1104        let mut builder = CommitBuilder::new("Edited a text box");
1105        edit_text(&doc, text_id(7), "two\nlines", &mut builder);
1106        let doc = fold(builder, &doc);
1107        assert_eq!(
1108            doc.text(&text_id(7)).expect("the scene's text exists").text,
1109            "two\nlines"
1110        );
1111
1112        let mut builder = CommitBuilder::new("Edited it to the same content");
1113        edit_text(&doc, text_id(7), "two\nlines", &mut builder);
1114        seals_to_nothing(builder);
1115
1116        let mut builder = CommitBuilder::new("Emptied a text box");
1117        edit_text(&doc, text_id(7), "  \n ", &mut builder);
1118        let doc = fold(builder, &doc);
1119        assert!(
1120            doc.text(&text_id(7)).is_none(),
1121            "an emptied box is discarded, not left invisible"
1122        );
1123
1124        let mut builder = CommitBuilder::new("Emptied it again");
1125        edit_text(&doc, text_id(7), "", &mut builder);
1126        seals_to_nothing(builder);
1127    }
1128
1129    #[test]
1130    fn clearing_a_wire_label_deletes_it_and_unnames_the_wire() {
1131        let doc = wired();
1132        let mut builder = CommitBuilder::new("Labelled a wire");
1133        builder.push(route_label_create(6, 5));
1134        builder.push(route_label_create(9, 5));
1135        let doc = fold(builder, &doc);
1136        assert_eq!(
1137            doc.route_label(&route_label_id(6))
1138                .expect("the label exists")
1139                .owner,
1140            route_id(5),
1141            "precondition: the label belongs to the scene's named wire"
1142        );
1143
1144        let mut builder = CommitBuilder::new("Cleared a wire label");
1145        clear_wire_label(&doc, route_label_id(6), &mut builder);
1146        let doc = fold(builder, &doc);
1147        assert!(doc.route_label(&route_label_id(6)).is_none());
1148        assert_eq!(
1149            doc.route(&route_id(5))
1150                .expect("the scene's route exists")
1151                .name,
1152            "",
1153            "the wire goes back to unlabelled rather than carrying a blank slot"
1154        );
1155
1156        let mut builder = CommitBuilder::new("Cleared the second label");
1157        clear_wire_label(&doc, route_label_id(9), &mut builder);
1158        let commit = builder.seal().expect("the label is deleted");
1159        assert_eq!(
1160            commit.ops().len(),
1161            1,
1162            "an already-unnamed wire takes no name write"
1163        );
1164
1165        let doc = doc
1166            .try_apply(&commit)
1167            .expect("the fold accepts the gesture");
1168        let mut builder = CommitBuilder::new("Cleared a ghost label");
1169        clear_wire_label(&doc, route_label_id(9), &mut builder);
1170        seals_to_nothing(builder);
1171    }
1172}