Skip to main content

monospace_diagram/
shape.rs

1//! A diagram's own figures: a closed set of kinds, each holding every position and parameter the
2//! core shape it constructs takes. See [`docs/diagram-model.md`](../../../docs/diagram-model.md).
3//!
4//! The set is closed on purpose, because every question this layer exists to answer is a question
5//! about which kind a shape is and a closed set is the only one of the three ways of holding them
6//! where the compiler answers it. A diagram's shape is therefore a value of this crate's own type —
7//! an identity, a position that may be a reference, and the parameters of its figure — which is the
8//! opposite of a core shape in every one of those respects: a core shape is a value with no
9//! identity, no lifecycle and no mutable state, which draws itself and answers nothing else about
10//! itself. The two are not the same concept and are not made to share one, so the core learns
11//! nothing about diagrams and its public API is not widened for a layer it does not know about.
12
13use monospace_core::Shape as _;
14use monospace_core::{
15    BoxShape, Connector, Direction, Glyph, Line, Orientation, Pos, Size, Stroke, Surface, Terminal,
16    Text, TextShape,
17};
18
19use crate::position::{flat_size, side_centre};
20use crate::{Anchor, Delta, Diagram, Position, Reference, ShapeId};
21
22/// Which endpoint of a connector an operation targets.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum End {
25    /// The starting endpoint (`from`).
26    From,
27    /// The ending endpoint (`to`).
28    To,
29}
30
31/// Where a connector endpoint stands and which way it leaves.
32///
33/// An endpoint is either an absolute point on the canvas (which states its own leaving direction)
34/// or a reference to a side of another figure (where the leaving direction is derived from that
35/// side's anchor).
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum EndpointAt {
38    /// A free point on the canvas and the direction the connector leaves it in.
39    Absolute {
40        /// The point on the canvas where the endpoint stands.
41        at: Pos,
42        /// The direction the connector leaves this endpoint in.
43        leaving: Direction,
44    },
45    /// Another figure and one of its four sides; the leaving direction is `reference.anchor.leaving()`.
46    Reference(Reference),
47}
48
49impl EndpointAt {
50    /// The position this endpoint stands at.
51    #[must_use]
52    pub const fn position(self) -> Position {
53        match self {
54            Self::Absolute { at, .. } => Position::Absolute(at),
55            Self::Reference(reference) => Position::Reference(reference),
56        }
57    }
58
59    /// Which way a connector leaves when starting from this endpoint.
60    #[must_use]
61    pub const fn leaving(self) -> Direction {
62        match self {
63            Self::Absolute { leaving, .. } => leaving,
64            Self::Reference(reference) => reference.anchor.leaving(),
65        }
66    }
67
68    /// Where this endpoint stands now in `diagram` given its `terminal`, or `None` when it does not resolve.
69    #[must_use]
70    pub(crate) fn resolve(self, diagram: &Diagram, terminal: &Terminal) -> Option<Pos> {
71        match self {
72            Self::Absolute { at, .. } => Some(at),
73            Self::Reference(reference) => {
74                let shape = diagram.get(reference.id)?;
75                let point = shape.anchor(reference.anchor)?;
76                Some(
77                    reference
78                        .offset
79                        .grow(reference.anchor.outward(self.effective_out(terminal)))
80                        .apply(point),
81                )
82            }
83        }
84    }
85
86    /// The effective outward cell count for this endpoint position given its terminal.
87    ///
88    /// Non-arm terminals (`Symbol`, `Glyph`) attached to a reference stand one cell outward by default
89    /// (`reference.out + 1`), whereas `Arm` terminals stand directly on the border cell (`reference.out + 0`)
90    /// to compose into junctions. Absolute endpoints have no anchor to offset from and return 0.
91    #[must_use]
92    pub(crate) fn effective_out(self, terminal: &Terminal) -> i16 {
93        match self {
94            Self::Absolute { .. } => 0,
95            Self::Reference(reference) => {
96                let base = i16::from(*terminal != Terminal::Arm);
97                reference.out.saturating_add(base)
98            }
99        }
100    }
101
102    /// This endpoint, moved by `by`, changing nothing else.
103    #[must_use]
104    pub(crate) fn displaced_by(self, by: Delta) -> Self {
105        match self.position().displaced_by(by) {
106            Position::Absolute(at) => Self::Absolute {
107                at,
108                leaving: self.leaving(),
109            },
110            Position::Reference(reference) => Self::Reference(reference),
111        }
112    }
113}
114
115/// One endpoint of a connector: where it stands (and leaves) and its terminal.
116///
117/// Mirrors `monospace_core::Endpoint` rather than reusing it, so that a later change to how an
118/// endpoint is anchored stays inside this crate. The terminal is the core's own type and this crate
119/// re-exports nothing: a caller takes it from `monospace_core`, exactly as it already takes the
120/// `Pos`, `Direction` and `Glyph` the other two fields hold.
121///
122/// `at` is an [`EndpointAt`] rather than a `Pos`: an absolute point states its leaving direction,
123/// while an attached endpoint derives its leaving direction from the anchor it hangs from.
124///
125/// `PartialEq` and `Eq` are here for [`Shape`]'s sake rather than this struct's own: a derive does
126/// not reach through a field, and `Shape::Connector` holds one of these.
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub struct Endpoint {
129    /// The endpoint's placement and leaving direction. The terminal occupies this position itself.
130    pub at: EndpointAt,
131    /// What this endpoint contributes to the cell at `at`.
132    pub terminal: Terminal,
133}
134
135impl Endpoint {
136    /// An endpoint at an absolute point on the canvas, leaving in `leaving`.
137    #[must_use]
138    pub const fn absolute(at: Pos, leaving: Direction, terminal: Terminal) -> Self {
139        Self {
140            at: EndpointAt::Absolute { at, leaving },
141            terminal,
142        }
143    }
144
145    /// An endpoint hanging from `reference`, leaving in the direction of `reference.anchor`.
146    #[must_use]
147    pub const fn reference(reference: Reference, terminal: Terminal) -> Self {
148        Self {
149            at: EndpointAt::Reference(reference),
150            terminal,
151        }
152    }
153
154    /// The position this endpoint stands at.
155    #[must_use]
156    pub const fn position(&self) -> Position {
157        self.at.position()
158    }
159
160    /// The direction the connector leaves this endpoint in.
161    #[must_use]
162    pub const fn leaving(&self) -> Direction {
163        self.at.leaving()
164    }
165
166    /// The effective outward cell count for this endpoint.
167    #[must_use]
168    pub fn effective_out(&self) -> i16 {
169        self.at.effective_out(&self.terminal)
170    }
171
172    /// Where this endpoint stands now in `diagram`, taking its `terminal` into account,
173    /// or `None` when it does not resolve.
174    #[must_use]
175    pub fn resolve(&self, diagram: &Diagram) -> Option<Pos> {
176        self.at.resolve(diagram, &self.terminal)
177    }
178
179    /// An endpoint's position, frozen against a removal of the shape `id` names, or `None` when there
180    /// is nothing here to freeze.
181    pub(crate) fn frozen_position(&self, id: ShapeId, diagram: &Diagram) -> Option<EndpointAt> {
182        match self.at {
183            EndpointAt::Reference(reference) if reference.id == id => {
184                self.resolve(diagram).map(|at| EndpointAt::Absolute {
185                    at,
186                    leaving: reference.anchor.leaving(),
187                })
188            }
189            _ => None,
190        }
191    }
192}
193
194/// A figure a diagram can hold: one of a closed set of kinds, each carrying every position and
195/// parameter the core shape it constructs takes.
196///
197/// `Clone` and `PartialEq` are what let a caller compare what a diagram hands back with what it
198/// added, and what let a figure displaced by nothing at all come back equal to itself. Every leaf
199/// type this enum holds already supported them.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum Shape {
202    /// A box: a position, a size, a stroke, an optional fill and an optional text.
203    Box {
204        /// The box's top-left corner.
205        at: Pos,
206        /// The box's width and height, in cells.
207        size: Size,
208        /// The stroke every cell this box writes is drawn in.
209        stroke: Stroke,
210        /// The glyph that fills the interior, or `None` for no fill at all.
211        fill: Option<Glyph>,
212        /// The block centred in the interior, or `None` for no text at all.
213        text: Option<Text>,
214    },
215    /// A line: a position, a length, an orientation and a stroke.
216    Line {
217        /// The position of the line's first cell.
218        at: Pos,
219        /// How many cells the line occupies. Any value is accepted, including 0.
220        len: u16,
221        /// Whether the line runs along a row or a column.
222        orientation: Orientation,
223        /// The stroke every cell this line writes is drawn in.
224        stroke: Stroke,
225    },
226    /// A connector: two endpoints and a stroke.
227    Connector {
228        /// One endpoint of the connector.
229        from: Endpoint,
230        /// The other endpoint of the connector.
231        to: Endpoint,
232        /// The stroke the route between the two endpoints is drawn in.
233        stroke: Stroke,
234    },
235}
236
237impl Shape {
238    /// The middle of one of this figure's four sides, or `None` when its kind answers no anchor at
239    /// all.
240    ///
241    /// A `Box` answers all four from its own `at` and `size`, a `Line` answers all four as a box one
242    /// cell thick, and a `Connector` answers none of them. `None` is the ordinary answer rather
243    /// than a failure: a connector composes two positions and belongs to neither, so a reference to
244    /// one resolves to nothing, which is what keeps a chain of references one link long and makes
245    /// a cycle impossible to build.
246    ///
247    /// It is crate-private because the anchors exist to be resolved _through_, and
248    /// [`Endpoint::resolve`] is how. Drawing is the only consumer so far, and a caller holding a
249    /// position and the diagram can already reach the number.
250    pub(crate) fn anchor(&self, anchor: Anchor) -> Option<Pos> {
251        match self {
252            Self::Box { at, size, .. } => Some(side_centre(*at, *size, anchor)),
253            Self::Line {
254                at,
255                len,
256                orientation,
257                ..
258            } => Some(side_centre(*at, flat_size(*len, *orientation), anchor)),
259            Self::Connector { .. } => None,
260        }
261    }
262
263    /// Converts this shape into the `monospace_core` shape it describes and draws it into
264    /// `surface`, dropping no parameter.
265    ///
266    /// It takes the diagram it is drawn from because a connector's endpoints are positions and a
267    /// position may be a reference, which only a diagram can resolve. The `Box` and `Line` arms
268    /// ignore the argument: their `at` is still a point, so the model's restriction — only a
269    /// connector's endpoint may name another figure — is a signature here rather than a rule
270    /// enforced at run time.
271    ///
272    /// A connector whose endpoints do not both resolve is not drawn at all. Both are asked before
273    /// anything is written, so an end that cannot be placed never leaves the other half of a
274    /// connector on the picture, and every other shape draws exactly what it drew.
275    pub(crate) fn draw(&self, surface: &mut impl Surface, diagram: &Diagram) {
276        match self {
277            Self::Box {
278                at,
279                size,
280                stroke,
281                fill,
282                text,
283            } => {
284                BoxShape {
285                    at: *at,
286                    size: *size,
287                    stroke: stroke.clone(),
288                    fill: fill.clone(),
289                }
290                .draw(surface);
291
292                if let Some(text) = text {
293                    let (Some(x), Some(y)) = (at.x.checked_add(1), at.y.checked_add(1)) else {
294                        return;
295                    };
296                    TextShape {
297                        at: Pos { x, y },
298                        area: Size {
299                            width: size.width.saturating_sub(2),
300                            height: size.height.saturating_sub(2),
301                        },
302                        text: text.clone(),
303                    }
304                    .draw(surface);
305                }
306            }
307            Self::Line {
308                at,
309                len,
310                orientation,
311                stroke,
312            } => Line {
313                at: *at,
314                len: *len,
315                orientation: *orientation,
316                stroke: stroke.clone(),
317            }
318            .draw(surface),
319            Self::Connector { from, to, stroke } => {
320                let (Some(from_at), Some(to_at)) = (from.resolve(diagram), to.resolve(diagram))
321                else {
322                    return;
323                };
324                Connector {
325                    from: monospace_core::Endpoint {
326                        at: from_at,
327                        leaving: from.at.leaving(),
328                        terminal: from.terminal.clone(),
329                    },
330                    to: monospace_core::Endpoint {
331                        at: to_at,
332                        leaving: to.at.leaving(),
333                        terminal: to.terminal.clone(),
334                    },
335                    stroke: stroke.clone(),
336                }
337                .draw(surface);
338            }
339        }
340    }
341
342    /// Builds a new figure, this one moved by `by`, changing nothing.
343    ///
344    /// Every position this variant holds moves: a `Box` and a `Line` their own `at`, a `Connector`
345    /// `from.at` and `to.at` **together**. A connector is not the exception to displacement. Its
346    /// route is derived from its two endpoints and never described by the caller, so displacing
347    /// both is sufficient and displacing one would leave a value whose route means something the
348    /// caller never asked for. Everything else is copied through, because a displacement is about
349    /// where a figure stands and not about what it is.
350    ///
351    /// It takes `&self` and gives back a `Self` rather than consuming either, so it composes with
352    /// `Diagram::get`, which borrows: `diagram.get(&id).map(|shape| shape.displaced_by(by))` is
353    /// the whole read-and-displace step, and nothing is cloned at the call site. A delta of
354    /// nothing gives back the same figure, which is what the widened derives are for.
355    ///
356    /// An endpoint that holds a **reference** moves by the same delta, and **what it moves is its
357    /// gap rather than its place**: the reference's `id` and `anchor` come back as they went in —
358    /// they name which figure and which side, and a displacement does not move either — while its
359    /// `offset` grows on each screen axis. So displacing the connector slides that endpoint away
360    /// from the border the figure it hangs from still draws, and the connector draws as a
361    /// translation of itself rather than bending its route to reach a side that stayed put. The
362    /// model's §4 _Positions_ states where this lands, and §9 _Changing a diagram_ points there.
363    ///
364    /// That is the whole of the difference from displacing the figure a reference hangs from, and
365    /// the two are not the same rule: displacing **that** figure carries the endpoint with it and
366    /// leaves the gap alone, because the side it is measured from moved too.
367    #[must_use]
368    pub fn displaced_by(&self, by: Delta) -> Self {
369        match self {
370            Self::Box {
371                at,
372                size,
373                stroke,
374                fill,
375                text,
376            } => Self::Box {
377                at: by.apply(*at),
378                size: *size,
379                stroke: stroke.clone(),
380                fill: fill.clone(),
381                text: text.clone(),
382            },
383            Self::Line {
384                at,
385                len,
386                orientation,
387                stroke,
388            } => Self::Line {
389                at: by.apply(*at),
390                len: *len,
391                orientation: *orientation,
392                stroke: stroke.clone(),
393            },
394            Self::Connector { from, to, stroke } => Self::Connector {
395                from: Endpoint {
396                    at: from.at.displaced_by(by),
397                    terminal: from.terminal.clone(),
398                },
399                to: Endpoint {
400                    at: to.at.displaced_by(by),
401                    terminal: to.terminal.clone(),
402                },
403                stroke: stroke.clone(),
404            },
405        }
406    }
407
408    /// This figure resized by `by`, along each screen axis.
409    ///
410    /// It takes `&self` and gives back a `Self` rather than consuming either, composing with
411    /// `Diagram::get` in the same way [`Self::displaced_by`] does: `diagram.get(&id).map(|shape| shape.resized_by(by))`
412    /// is the whole read-and-resize step.
413    ///
414    /// **A [`Box`](Self::Box) is resized**: its width changes by `by.dx` and its height by
415    /// `by.dy`, clamped to `min(current, 2)..=u16::MAX`. A box does not shrink below **2×2**,
416    /// the size under which a box draws nothing, while a box already below 2 in a dimension is not
417    /// touched by shrinking in that dimension, and growing it by one is allowed. Its position,
418    /// stroke, fill and text are preserved.
419    ///
420    /// **A [`Line`](Self::Line) is resized along its orientation**: its length changes by `by.dx`
421    /// for a horizontal line and by `by.dy` for a vertical line, clamped to
422    /// `min(current, 1)..=u16::MAX`. A line does not shrink below **1**, while a line already below
423    /// 1 (length 0) is not touched by shrinking, and growing it by one is allowed. An orthogonal
424    /// delta changes nothing. Its position, orientation and stroke are preserved.
425    ///
426    /// **A [`Connector`](Self::Connector) comes back equal**: its resize is out of scope and leaves
427    /// it untouched.
428    #[must_use]
429    pub fn resized_by(&self, by: Delta) -> Self {
430        match self {
431            Self::Box {
432                at,
433                size,
434                stroke,
435                fill,
436                text,
437            } => Self::Box {
438                at: *at,
439                size: Size {
440                    width: clamp_dimension(size.width, by.dx, 2),
441                    height: clamp_dimension(size.height, by.dy, 2),
442                },
443                stroke: stroke.clone(),
444                fill: fill.clone(),
445                text: text.clone(),
446            },
447            Self::Line {
448                at,
449                len,
450                orientation,
451                stroke,
452            } => {
453                let delta = match orientation {
454                    Orientation::Horizontal => by.dx,
455                    Orientation::Vertical => by.dy,
456                };
457                Self::Line {
458                    at: *at,
459                    len: clamp_dimension(*len, delta, 1),
460                    orientation: *orientation,
461                    stroke: stroke.clone(),
462                }
463            }
464            Self::Connector { .. } => self.clone(),
465        }
466    }
467
468    /// Whether this figure can be resized.
469    ///
470    /// **A box and a line can be resized.** A connector has two endpoints and its resize is out of
471    /// scope; `resized_by` leaves it unchanged. The predicate is here, beside that method,
472    /// because the two are the same rule read from two sides.
473    #[must_use]
474    pub const fn can_resize(&self) -> bool {
475        matches!(self, Self::Box { .. } | Self::Line { .. })
476    }
477
478    /// This figure drawn with `stroke`.
479    ///
480    /// It takes `&self` and gives back a `Self` rather than consuming either, composing with
481    /// `Diagram::get` in the same way [`Self::displaced_by`] and [`Self::resized_by`] do:
482    /// `diagram.get(&id).map(|shape| shape.with_stroke(stroke))` is the whole read-and-re-stroke
483    /// step.
484    ///
485    /// **All three kinds of shape change their stroke**: a [`Box`](Self::Box), a
486    /// [`Line`](Self::Line), and a [`Connector`](Self::Connector) each replace their `stroke` with
487    /// `stroke`, leaving all other parameters unchanged.
488    #[must_use]
489    pub fn with_stroke(&self, stroke: Stroke) -> Self {
490        match self {
491            Self::Box {
492                at,
493                size,
494                fill,
495                text,
496                ..
497            } => Self::Box {
498                at: *at,
499                size: *size,
500                stroke,
501                fill: fill.clone(),
502                text: text.clone(),
503            },
504            Self::Line {
505                at,
506                len,
507                orientation,
508                ..
509            } => Self::Line {
510                at: *at,
511                len: *len,
512                orientation: *orientation,
513                stroke,
514            },
515            Self::Connector { from, to, .. } => Self::Connector {
516                from: from.clone(),
517                to: to.clone(),
518                stroke,
519            },
520        }
521    }
522
523    /// This figure with `text` in its interior, or itself unchanged when it is not a box.
524    ///
525    /// **Only a box holds text.** A line has a length and a connector has two endpoints, and neither
526    /// has an interior to put a block in; `with_text` leaves both unchanged, exactly as `resized_by`
527    /// leaves a connector unchanged. The predicate and the mutation are the same rule read from two
528    /// sides, which is why the two arms that cannot change are written out rather than filtered.
529    ///
530    /// **It takes `&self` and gives back a `Self`**, composing with `Diagram::get` the way
531    /// [`Self::with_stroke`] does: `diagram.get(&id).map(|shape| shape.with_text(text))` is the whole
532    /// read-and-re-text step.
533    #[must_use]
534    pub fn with_text(&self, text: Option<Text>) -> Self {
535        match self {
536            Self::Box {
537                at,
538                size,
539                stroke,
540                fill,
541                ..
542            } => Self::Box {
543                at: *at,
544                size: *size,
545                stroke: stroke.clone(),
546                fill: fill.clone(),
547                text,
548            },
549            Self::Line { .. } | Self::Connector { .. } => self.clone(),
550        }
551    }
552
553    /// This figure with its orientation toggled between horizontal and vertical, or itself
554    /// unchanged when it is not a line.
555    ///
556    /// **Only a line has an orientation.** A box and a connector have no orientation to toggle;
557    /// `with_rotated_orientation` leaves both unchanged, exactly as `with_text` leaves lines and
558    /// connectors unchanged.
559    ///
560    /// **It pivots around the line's origin `at`**, preserving `at`, `len`, and `stroke`
561    /// unchanged.
562    ///
563    /// **It takes `&self` and gives back a `Self`**, composing with `Diagram::get` the way
564    /// [`Self::with_stroke`] does:
565    /// `diagram.get(&id).map(Shape::with_rotated_orientation)` is the whole read-and-rotate step.
566    #[must_use]
567    pub fn with_rotated_orientation(&self) -> Self {
568        match self {
569            Self::Line {
570                at,
571                len,
572                orientation,
573                stroke,
574            } => {
575                let toggled = match orientation {
576                    Orientation::Horizontal => Orientation::Vertical,
577                    Orientation::Vertical => Orientation::Horizontal,
578                };
579                Self::Line {
580                    at: *at,
581                    len: *len,
582                    orientation: toggled,
583                    stroke: stroke.clone(),
584                }
585            }
586            Self::Box { .. } | Self::Connector { .. } => self.clone(),
587        }
588    }
589
590    /// This figure with `from.terminal` and `to.terminal` swapped, or itself unchanged when it is not
591    /// a connector.
592    ///
593    /// **Only a connector holds terminals.** A box and a line have no endpoints to swap terminals
594    /// between; `with_inverted_terminals` leaves both unchanged, exactly as `with_text` leaves lines
595    /// and connectors unchanged.
596    ///
597    /// **It leaves `from.at` and `to.at` unchanged**, preserving anchor references, offsets, outward
598    /// distances, and absolute positions while inverting semantic flow direction.
599    ///
600    /// **It takes `&self` and gives back a `Self`**, composing with `Diagram::get` the way
601    /// [`Self::with_stroke`] does.
602    #[must_use]
603    pub fn with_inverted_terminals(&self) -> Self {
604        match self {
605            Self::Connector { from, to, stroke } => Self::Connector {
606                from: Endpoint {
607                    at: from.at,
608                    terminal: to.terminal.clone(),
609                },
610                to: Endpoint {
611                    at: to.at,
612                    terminal: from.terminal.clone(),
613                },
614                stroke: stroke.clone(),
615            },
616            Self::Box { .. } | Self::Line { .. } => self.clone(),
617        }
618    }
619
620    /// Produces a copy of `self` with the targeted [`End`] moved by `by` cells and standoff
621    /// adjusted by `out`.
622    ///
623    /// Non-connector figures have no endpoints and come back unchanged.
624    ///
625    /// For a connector:
626    /// - If the targeted endpoint is [`EndpointAt::Absolute`], its `at` is displaced by `by`, `out`
627    ///   is ignored, and its leaving direction and terminal are preserved.
628    /// - If the targeted endpoint is [`EndpointAt::Reference`], its `offset` is grown by `by` on
629    ///   both axes and its standoff `out` is adjusted by `out` using saturating arithmetic.
630    #[must_use]
631    pub fn with_endpoint_moved(&self, end: End, by: Delta, out: i16) -> Self {
632        let Self::Connector { from, to, stroke } = self else {
633            return self.clone();
634        };
635
636        let move_endpoint = |ep: &Endpoint| -> Endpoint {
637            match ep.at {
638                EndpointAt::Absolute { at, leaving } => Endpoint {
639                    at: EndpointAt::Absolute {
640                        at: by.apply(at),
641                        leaving,
642                    },
643                    terminal: ep.terminal.clone(),
644                },
645                EndpointAt::Reference(reference) => Endpoint {
646                    at: EndpointAt::Reference(Reference {
647                        id: reference.id,
648                        anchor: reference.anchor,
649                        offset: reference.offset.grow(by),
650                        out: reference.out.saturating_add(out),
651                    }),
652                    terminal: ep.terminal.clone(),
653                },
654            }
655        };
656
657        match end {
658            End::From => Self::Connector {
659                from: move_endpoint(from),
660                to: to.clone(),
661                stroke: stroke.clone(),
662            },
663            End::To => Self::Connector {
664                from: from.clone(),
665                to: move_endpoint(to),
666                stroke: stroke.clone(),
667            },
668        }
669    }
670
671    /// This figure with the offset and standoff `out` of the targeted endpoint reset to zero.
672    ///
673    /// Non-connector figures and connectors with an absolute targeted endpoint come back unchanged.
674    /// When the targeted endpoint is an attached [`EndpointAt::Reference`], its `offset` is set to
675    /// `Delta::ZERO` and its `out` is set to `0`, leaving all other parameters and the other endpoint
676    /// untouched.
677    #[must_use]
678    pub fn with_endpoint_reset(&self, end: End) -> Self {
679        let Self::Connector { from, to, stroke } = self else {
680            return self.clone();
681        };
682
683        let reset_endpoint = |ep: &Endpoint| match ep.at {
684            EndpointAt::Reference(reference) => Endpoint {
685                at: EndpointAt::Reference(Reference {
686                    offset: Delta::ZERO,
687                    out: 0,
688                    ..reference
689                }),
690                terminal: ep.terminal.clone(),
691            },
692            EndpointAt::Absolute { .. } => ep.clone(),
693        };
694
695        match end {
696            End::From => Self::Connector {
697                from: reset_endpoint(from),
698                to: to.clone(),
699                stroke: stroke.clone(),
700            },
701            End::To => Self::Connector {
702                from: from.clone(),
703                to: reset_endpoint(to),
704                stroke: stroke.clone(),
705            },
706        }
707    }
708
709    /// This figure with the terminal style of the targeted endpoint replaced.
710    ///
711    /// Non-connector figures come back unchanged. For connectors, only the targeted endpoint's
712    /// `terminal` is updated to `terminal`, preserving its `at` position or reference (including
713    /// `offset` and `out`) and leaving the other endpoint untouched.
714    #[must_use]
715    pub fn with_endpoint_terminal(&self, end: End, terminal: Terminal) -> Self {
716        let Self::Connector { from, to, stroke } = self else {
717            return self.clone();
718        };
719
720        match end {
721            End::From => Self::Connector {
722                from: Endpoint {
723                    at: from.at,
724                    terminal,
725                },
726                to: to.clone(),
727                stroke: stroke.clone(),
728            },
729            End::To => Self::Connector {
730                from: from.clone(),
731                to: Endpoint {
732                    at: to.at,
733                    terminal,
734                },
735                stroke: stroke.clone(),
736            },
737        }
738    }
739
740    /// This figure with the targeted attached endpoint detached into an absolute canvas position.
741    ///
742    /// Non-connector figures, connectors with an already-detached targeted endpoint, and connectors
743    /// whose targeted reference does not resolve in `diagram` come back unchanged. When the targeted
744    /// endpoint is an attached [`EndpointAt::Reference`], it is converted into
745    /// [`EndpointAt::Absolute`] at its current resolved canvas position and anchor departure
746    /// direction (`reference.anchor.leaving()`), preserving its terminal style and leaving the other
747    /// endpoint untouched.
748    #[must_use]
749    pub fn with_endpoint_detached(&self, end: End, diagram: &Diagram) -> Self {
750        let Self::Connector { from, to, stroke } = self else {
751            return self.clone();
752        };
753
754        let detach_endpoint = |ep: &Endpoint| match ep.at {
755            EndpointAt::Reference(reference) => {
756                if let Some(at) = ep.resolve(diagram) {
757                    Endpoint {
758                        at: EndpointAt::Absolute {
759                            at,
760                            leaving: reference.anchor.leaving(),
761                        },
762                        terminal: ep.terminal.clone(),
763                    }
764                } else {
765                    ep.clone()
766                }
767            }
768            EndpointAt::Absolute { .. } => ep.clone(),
769        };
770
771        match end {
772            End::From => Self::Connector {
773                from: detach_endpoint(from),
774                to: to.clone(),
775                stroke: stroke.clone(),
776            },
777            End::To => Self::Connector {
778                from: from.clone(),
779                to: detach_endpoint(to),
780                stroke: stroke.clone(),
781            },
782        }
783    }
784
785    /// This figure with the targeted detached endpoint attached to `reference`.
786    ///
787    /// Non-connector figures, connectors whose targeted endpoint is already attached
788    /// ([`EndpointAt::Reference`]), and connectors whose `reference` names no figure or a figure
789    /// that answers no anchor come back unchanged. When the targeted endpoint is detached
790    /// ([`EndpointAt::Absolute`]), it becomes [`EndpointAt::Reference`] at `reference`,
791    /// preserving its terminal style and leaving the other endpoint untouched. The leaving
792    /// direction is `reference.anchor.leaving()` by construction.
793    #[must_use]
794    pub fn with_endpoint_attached(
795        &self,
796        end: End,
797        reference: Reference,
798        diagram: &Diagram,
799    ) -> Self {
800        let Self::Connector { from, to, stroke } = self else {
801            return self.clone();
802        };
803
804        if diagram
805            .get(reference.id)
806            .and_then(|shape| shape.anchor(reference.anchor))
807            .is_none()
808        {
809            return self.clone();
810        }
811
812        let attach_endpoint = |ep: &Endpoint| match ep.at {
813            EndpointAt::Absolute { .. } => Endpoint {
814                at: EndpointAt::Reference(reference),
815                terminal: ep.terminal.clone(),
816            },
817            EndpointAt::Reference(_) => ep.clone(),
818        };
819
820        match end {
821            End::From => Self::Connector {
822                from: attach_endpoint(from),
823                to: to.clone(),
824                stroke: stroke.clone(),
825            },
826            End::To => Self::Connector {
827                from: from.clone(),
828                to: attach_endpoint(to),
829                stroke: stroke.clone(),
830            },
831        }
832    }
833
834    /// This figure with the departure direction of the targeted detached endpoint replaced.
835    ///
836    /// Non-connector figures, connectors with an attached targeted endpoint ([`EndpointAt::Reference`]),
837    /// and connectors where the targeted endpoint already holds `leaving` come back unchanged.
838    /// When the targeted endpoint is detached ([`EndpointAt::Absolute`]), its `leaving` direction is
839    /// set to `leaving`, preserving its position and terminal style and leaving the other endpoint
840    /// untouched.
841    #[must_use]
842    pub fn with_endpoint_leaving(&self, end: End, leaving: Direction) -> Self {
843        let Self::Connector { from, to, stroke } = self else {
844            return self.clone();
845        };
846
847        let update_endpoint = |ep: &Endpoint| match ep.at {
848            EndpointAt::Absolute { at, .. } => Endpoint {
849                at: EndpointAt::Absolute { at, leaving },
850                terminal: ep.terminal.clone(),
851            },
852            EndpointAt::Reference(_) => ep.clone(),
853        };
854
855        match end {
856            End::From => Self::Connector {
857                from: update_endpoint(from),
858                to: to.clone(),
859                stroke: stroke.clone(),
860            },
861            End::To => Self::Connector {
862                from: from.clone(),
863                to: update_endpoint(to),
864                stroke: stroke.clone(),
865            },
866        }
867    }
868
869    /// This figure with every reference **naming `id`** replaced by the point it was resolving to,
870    /// or `None` when this figure holds no such reference and nothing changed.
871    ///
872    /// **All three kinds are matched, and the two that cannot hold a reference today are matched on
873    /// purpose.** A `Box` and a `Line` hold their own `at` as a `Pos` rather than a `Position`, so
874    /// the model's restriction — only a connector's endpoint may name another figure — is the type
875    /// system's rather than a rule someone remembers. Each of those two arms becomes a
876    /// `Position::Reference` arm the day [#89](https://github.com/andresmoschini/monospace/issues/89)
877    /// widens it, and **nothing above this match changes**. Leaving them out would mean that
878    /// widening rewrote the method rather than two lines of it.
879    ///
880    /// **It answers `Option<Self>` rather than `Self`,** for three reasons that are not all about
881    /// tidiness. `None` is the ordinary answer and the crate already has that idiom — `Shape::anchor`
882    /// answers `None` for a connector and `Endpoint::resolve` for a reference that does not resolve,
883    /// and neither is a failure. It is the shape that pays forward: the alternative pays a clone for
884    /// every figure on every removal, including the two that cannot change. And it **cannot report
885    /// a rewrite that changed nothing**, which a figure coming back equal to what went in cannot
886    /// distinguish from one that genuinely rewrote to the same value.
887    ///
888    /// **Both endpoints are rebuilt together, and that is what removes the need for anything that
889    /// remembers which end is which.** A connector may hang from the same figure at **both** ends,
890    /// at different anchors with different offsets, so the two frozen points differ: measured on a
891    /// four-by-three box at the origin, `from` on its right side at offset `(0, 0)` and `to` on its
892    /// bottom at offset `(1, 0)` freeze to `{3, 1}` and `{2, 2}`. An implementation that asked once
893    /// and wrote twice would put the first point into both ends. **Both answers are computed before
894    /// either endpoint is built**, the `(None, None)` arm is what says "this figure is not mine to
895    /// rewrite", and the two `unwrap_or` calls are what keep the endpoint that was not frozen
896    /// exactly as it was.
897    #[must_use]
898    pub(crate) fn with_frozen_references(&self, id: ShapeId, diagram: &Diagram) -> Option<Self> {
899        match self {
900            Self::Box { .. } | Self::Line { .. } => None,
901            Self::Connector { from, to, stroke } => match (
902                from.frozen_position(id, diagram),
903                to.frozen_position(id, diagram),
904            ) {
905                (None, None) => None,
906                (new_from, new_to) => Some(Self::Connector {
907                    from: Endpoint {
908                        at: new_from.unwrap_or(from.at),
909                        ..from.clone()
910                    },
911                    to: Endpoint {
912                        at: new_to.unwrap_or(to.at),
913                        ..to.clone()
914                    },
915                    stroke: stroke.clone(),
916                }),
917            },
918        }
919    }
920}
921
922/// Clamps `current + delta` to `min(current, floor)..=u16::MAX`.
923///
924/// A box dimension does not shrink below 2 (where `BoxShape` draws nothing, D2 of #284) and a line
925/// length does not shrink below 1 (where `Line` draws nothing, D2 of #301). Growth stops at
926/// `u16::MAX`. A dimension already below the floor is not touched by shrinking, and growing it by
927/// one is allowed.
928fn clamp_dimension(current: u16, delta: i16, floor: u16) -> u16 {
929    let min = i64::from(current.min(floor));
930    let max = i64::from(u16::MAX);
931    let target = i64::from(current) + i64::from(delta);
932    u16::try_from(target.clamp(min, max)).expect("clamped to u16 range")
933}
934
935#[cfg(test)]
936mod tests {
937    use std::num::NonZeroU16;
938
939    use super::{Delta, End, Endpoint, EndpointAt, Shape};
940    use crate::{Anchor, Diagram, Reference, ShapeId};
941    use monospace_core::{
942        Buffer, CellReader, Direction, FigureLayer, Glyph, GlyphCatalog, Orientation, Pos, Size,
943        StampMode, Stroke, Terminal, Text, Window, render,
944    };
945
946    /// The window the arrangement below is drawn in: two 3×3 boxes on an 11×3 canvas, which is the
947    /// one the spec measures.
948    const ORIGIN: Pos = Pos { x: 0, y: 0 };
949    const WINDOW: Size = Size {
950        width: 11,
951        height: 3,
952    };
953
954    fn light() -> Stroke {
955        Stroke::from("light")
956    }
957
958    fn box_at(x: i16) -> Shape {
959        Shape::Box {
960            at: Pos { x, y: 0 },
961            size: Size {
962                width: 3,
963                height: 3,
964            },
965            stroke: light(),
966            fill: None,
967            text: None,
968        }
969    }
970
971    /// The connector between the two boxes, each endpoint standing on the nearer box's border cell at
972    /// the given position.
973    fn connector(from: Terminal, to: Terminal) -> Shape {
974        Shape::Connector {
975            from: Endpoint::absolute(Pos { x: 2, y: 1 }, Direction::Right, from),
976            to: Endpoint::absolute(Pos { x: 8, y: 1 }, Direction::Left, to),
977            stroke: light(),
978        }
979    }
980
981    /// A line across the same window, so all three kinds are one call away.
982    fn line_at(x: i16) -> Shape {
983        Shape::Line {
984            at: Pos { x, y: 0 },
985            len: 3,
986            orientation: Orientation::Horizontal,
987            stroke: light(),
988        }
989    }
990
991    /// The connector of _an endpoint hangs from a side_ with its `from` named as a **reference** to
992    /// `box_id`'s right side rather than as the point that side resolves to.
993    fn hanging_from(box_id: ShapeId) -> Shape {
994        Shape::Connector {
995            from: Endpoint::reference(
996                Reference::new(box_id, Anchor::Right, Delta { dx: 0, dy: 0 }, 0),
997                Terminal::Arm,
998            ),
999            to: Endpoint::absolute(Pos { x: 7, y: 1 }, Direction::Left, Terminal::Arm),
1000            stroke: light(),
1001        }
1002    }
1003
1004    /// `Shape::with_frozen_references` answers **`None` for every figure holding no reference to
1005    /// the figure it is asked about** — over **all three kinds** — and `Some` for the one that
1006    /// does.
1007    ///
1008    /// **Every one of the four is `None` and not a copy**, which is the whole claim. The alternative
1009    /// signature returned the figure itself and let the caller assign unconditionally, and a figure
1010    /// that comes back equal to what went in is indistinguishable from one that genuinely rewrote to
1011    /// the same value — so a test pinning the rewrite could not tell a working method from a clone
1012    /// that paid for every figure on every removal.
1013    ///
1014    /// **The `Box` and `Line` arms are what this test exists for.** Neither can hold a reference
1015    /// today — their own `at` is a `Pos` — so the model's restriction is the type system's rather
1016    /// than a rule someone remembers, and a `Box` answering `Some` is a clone being made for
1017    /// nothing. Both are pinned here so that
1018    /// [#89](https://github.com/andresmoschini/monospace/issues/89) widening them to a `Position`
1019    /// is **two lines** rather than the method: the arms are already there to become
1020    /// `Position::Reference` arms.
1021    ///
1022    /// **Asked of the method rather than through a `Diagram`.** A removal test would pass on a body
1023    /// that never asked this method at all, and the figures are built as `Shape` values with a
1024    /// diagram beside them for `resolve` to ask — the same way this module's existing tests build
1025    /// theirs.
1026    #[test]
1027    fn a_figure_holding_no_reference_answers_nothing_and_one_holding_one_freezes() {
1028        let beside = Diagram::new();
1029        let (beside, named) = beside.add(box_at(0));
1030        let (beside, another) = beside.add(box_at(8));
1031
1032        // The two kinds that cannot hold a reference today, the connector holding none, and the
1033        // connector naming a figure that is not the one it is asked about.
1034        for holds_nothing in [
1035            box_at(0),
1036            line_at(0),
1037            connector(Terminal::Arm, Terminal::Arm),
1038            hanging_from(another),
1039        ] {
1040            assert_eq!(
1041                holds_nothing.with_frozen_references(named, &beside),
1042                None,
1043                "{holds_nothing:?} held nothing to freeze, so this is not that case"
1044            );
1045        }
1046
1047        let holds_a_reference = hanging_from(named);
1048        let freezes = holds_a_reference
1049            .with_frozen_references(named, &beside)
1050            .expect("a connector naming the figure it is asked about freezes");
1051        let Shape::Connector { from, .. } = &freezes else {
1052            unreachable!("the figure above is a connector")
1053        };
1054        assert_eq!(
1055            from.at,
1056            EndpointAt::Absolute {
1057                at: Pos { x: 2, y: 1 },
1058                leaving: Direction::Right,
1059            },
1060            "the end did not freeze at the point its reference was resolving to"
1061        );
1062        assert_ne!(
1063            freezes, holds_a_reference,
1064            "the frozen figure is the one that went in, so nothing changed"
1065        );
1066    }
1067
1068    /// A figure displaced by nothing at all comes back equal to itself.
1069    ///
1070    /// All three kinds rather than one, and a `Connector` among them because it moves through an
1071    /// `Endpoint`: a derive that stopped short of that struct would leave this case failing and
1072    /// every other one green. The widened derives are what make the rule sayable, and
1073    /// `displaced_by` taking `&self` is what makes it hold, since it builds a new value out of
1074    /// copies and cannot have touched the one it read.
1075    #[test]
1076    fn a_figure_displaced_by_nothing_comes_back_equal_to_itself() {
1077        let nothing = Delta { dx: 0, dy: 0 };
1078
1079        for shape in [
1080            box_at(0),
1081            line_at(0),
1082            connector(Terminal::Arm, Terminal::Arm),
1083        ] {
1084            assert_eq!(shape.displaced_by(nothing), shape);
1085        }
1086    }
1087
1088    fn draw(shapes: Vec<Shape>) -> Buffer {
1089        let mut diagram = Diagram::new();
1090        for shape in shapes {
1091            diagram = diagram.add(shape).0;
1092        }
1093        let mut buffer = Buffer::new(Window {
1094            origin: ORIGIN,
1095            size: WINDOW,
1096        });
1097        diagram.draw(&mut buffer);
1098        buffer
1099    }
1100
1101    /// The same three shapes added in the two orders the model distinguishes, which now that no
1102    /// shape carries a `mode` is the order of the `shapes` array and nothing else: the second box
1103    /// after the connector, and the second box before it. The two returned buffers differ only in what
1104    /// the connector's terminal did to the cell it shares with the left box's right-hand border.
1105    fn both_orders(from: Terminal, to: Terminal) -> (Buffer, Buffer) {
1106        (
1107            draw(vec![
1108                box_at(0),
1109                connector(from.clone(), to.clone()),
1110                box_at(8),
1111            ]),
1112            draw(vec![box_at(0), box_at(8), connector(from, to)]),
1113        )
1114    }
1115
1116    fn text_of(buffer: &Buffer) -> String {
1117        render(buffer, &GlyphCatalog::light(), buffer.window())
1118    }
1119
1120    fn glyph_terminals() -> (Terminal, Terminal) {
1121        (
1122            Terminal::Glyph {
1123                glyph: Glyph::new("◄").expect("one glyph"),
1124            },
1125            Terminal::Glyph {
1126                glyph: Glyph::new("►").expect("one glyph"),
1127            },
1128        )
1129    }
1130
1131    /// A glyph terminal is decided on every side, so nothing composes into the cell it shares
1132    /// with the left box's border and whichever figure is in front keeps it. Two orders, two
1133    /// pictures, and the one drawn between the boxes has lost that border cell.
1134    #[test]
1135    fn glyph_terminals_draw_two_different_pictures_and_the_border_cell_is_lost_in_one() {
1136        let (from, to) = glyph_terminals();
1137        let (second_box_after, second_box_before) = both_orders(from, to);
1138
1139        assert_ne!(text_of(&second_box_after), text_of(&second_box_before));
1140        assert_eq!(
1141            text_of(&second_box_after),
1142            concat!("┌─┐     ┌─┐\n", "│ ◄─────│ │\n", "└─┘     └─┘\n")
1143        );
1144        assert_eq!(
1145            text_of(&second_box_before),
1146            concat!("┌─┐     ┌─┐\n", "│ ◄─────► │\n", "└─┘     └─┘\n")
1147        );
1148
1149        // (2, 1) is the left box's own border cell, where its two terminals stand. With the connector
1150        // in front the cell holds a decided literal and the border is gone; with the box in front
1151        // the connector's two literals are what got overwritten, and what survives is the border's own
1152        // undecided cell. Either way the cell is decided, which is what distinguishes this from an
1153        // arm terminal and what the other test below measures.
1154        let at = Pos { x: 2, y: 1 };
1155        for buffer in [&second_box_after, &second_box_before] {
1156            let cell = buffer.cell(at).expect("the connector writes this cell");
1157            assert!(cell.is_decided(), "{cell:?} at (2, 1) should be decided");
1158        }
1159    }
1160
1161    /// An arm terminal leaves three sides undecided, so whatever reaches the cell afterwards still
1162    /// joins it. Both orders therefore draw the same picture, and the border cell is a junction in
1163    /// each — the equality the two rendered files measured, made permanent.
1164    #[test]
1165    fn arm_terminals_draw_one_identical_picture_with_a_junction_in_both_orders() {
1166        let (second_box_after, second_box_before) = both_orders(Terminal::Arm, Terminal::Arm);
1167
1168        assert_eq!(text_of(&second_box_after), text_of(&second_box_before));
1169        assert_eq!(
1170            text_of(&second_box_after),
1171            concat!("┌─┐     ┌─┐\n", "│ ├─────┤ │\n", "└─┘     └─┘\n")
1172        );
1173
1174        // The border cell each connector terminal stands on: a junction in both orders, and a cell
1175        // that is not decided in either, which is what leaves the other three sides open for the
1176        // box to join.
1177        for buffer in [&second_box_after, &second_box_before] {
1178            let cell = buffer
1179                .cell(Pos { x: 2, y: 1 })
1180                .expect("the connector writes this cell");
1181            assert!(!cell.is_decided(), "{cell:?} at (2, 1) should be undecided");
1182        }
1183    }
1184
1185    /// A box holding a text, drawn into a window wide and tall enough for it.
1186    fn box_with_text(size: Size, content: &str) -> Shape {
1187        Shape::Box {
1188            at: ORIGIN,
1189            size,
1190            stroke: light(),
1191            fill: None,
1192            text: Some(
1193                Text::new(content)
1194                    .unwrap_or_else(|| panic!("{content:?} has no control character")),
1195            ),
1196        }
1197    }
1198
1199    fn drawn_in(size: Size, shape: &Shape) -> String {
1200        let mut buffer = Buffer::new(Window {
1201            origin: ORIGIN,
1202            size,
1203        });
1204        shape.draw(
1205            &mut FigureLayer::new(&mut buffer, StampMode::Above),
1206            &Diagram::new(),
1207        );
1208        text_of(&buffer)
1209    }
1210
1211    /// Rule 6 - an 8x5 box centres `hi` in its 6x3 interior, which puts it at `(3, 2)`.
1212    #[test]
1213    fn a_box_centres_its_text_in_the_interior() {
1214        let size = Size {
1215            width: 8,
1216            height: 5,
1217        };
1218
1219        assert_eq!(
1220            drawn_in(size, &box_with_text(size, "hi")),
1221            "┌──────┐\n│      │\n│  hi  │\n│      │\n└──────┘\n"
1222        );
1223    }
1224
1225    /// Rule 6 - a 2x2 box has a 0x0 interior, and its text still draws: `x` lands on the
1226    /// bottom-right corner rather than being skipped.
1227    #[test]
1228    fn a_two_by_two_box_draws_its_text_on_the_corner() {
1229        let size = Size {
1230            width: 2,
1231            height: 2,
1232        };
1233
1234        assert_eq!(drawn_in(size, &box_with_text(size, "x")), "┌┐\n└x\n");
1235    }
1236
1237    /// Rule 6 - two lines in an 8x5 box: the interior is 6x3, `dy` is zero and both lines start
1238    /// at `(2, 1)` and `(2, 2)`.
1239    #[test]
1240    fn a_box_centres_two_lines_in_the_interior() {
1241        let size = Size {
1242            width: 8,
1243            height: 5,
1244        };
1245
1246        assert_eq!(
1247            drawn_in(size, &box_with_text(size, "one\ntwo")),
1248            "┌──────┐\n│ one  │\n│ two  │\n│      │\n└──────┘\n"
1249        );
1250    }
1251
1252    /// Rule 6 - a label wider than the interior is not clipped to it: in a 5x3 box the 3x1
1253    /// interior puts `hello` at `x` 0, so it covers the border on both sides.
1254    #[test]
1255    fn a_box_draws_text_wider_than_its_interior() {
1256        let size = Size {
1257            width: 5,
1258            height: 3,
1259        };
1260
1261        assert_eq!(
1262            drawn_in(size, &box_with_text(size, "hello")),
1263            "┌───┐\nhello\n└───┘\n"
1264        );
1265    }
1266
1267    /// Rule 7 - a box without text draws exactly what it drew before the field existed.
1268    #[test]
1269    fn a_box_without_text_draws_what_it_drew_before() {
1270        let size = Size {
1271            width: 8,
1272            height: 5,
1273        };
1274        let without = Shape::Box {
1275            at: ORIGIN,
1276            size,
1277            stroke: light(),
1278            fill: None,
1279            text: None,
1280        };
1281
1282        assert_eq!(
1283            drawn_in(size, &without),
1284            "┌──────┐\n│      │\n│      │\n│      │\n└──────┘\n"
1285        );
1286    }
1287
1288    /// Rule 7 - the fill is drawn first and the text second, so the text wins where the two meet.
1289    #[test]
1290    fn a_filled_box_draws_its_text_over_the_fill() {
1291        let size = Size {
1292            width: 6,
1293            height: 3,
1294        };
1295        let filled = Shape::Box {
1296            at: ORIGIN,
1297            size,
1298            stroke: light(),
1299            fill: Some(Glyph::new("░").expect("\"░\" is one glyph")),
1300            text: Text::new("hi"),
1301        };
1302
1303        assert_eq!(drawn_in(size, &filled), "┌────┐\n│░hi░│\n└────┘\n");
1304    }
1305
1306    /// Rule 8 - displacing a box moves its text with it, because the text has no position of its
1307    /// own: the same figure at `(2, 1)` draws the same `hi` two columns right and one row down.
1308    #[test]
1309    fn displacing_a_box_moves_its_text() {
1310        let size = Size {
1311            width: 8,
1312            height: 5,
1313        };
1314        let displaced = box_with_text(size, "hi").displaced_by(Delta { dx: 2, dy: 1 });
1315
1316        assert_eq!(
1317            drawn_in(
1318                Size {
1319                    width: 10,
1320                    height: 6,
1321                },
1322                &displaced,
1323            ),
1324            concat!(
1325                "          \n",
1326                "  ┌──────┐\n",
1327                "  │      │\n",
1328                "  │  hi  │\n",
1329                "  │      │\n",
1330                "  └──────┘\n",
1331            )
1332        );
1333    }
1334
1335    /// Rule 8 - text is not an identity: a box answers the same four anchors with a text as
1336    /// without one, and the text is never an anchor or a reference target of its own.
1337    #[test]
1338    fn a_box_with_text_answers_the_same_anchors() {
1339        let size = Size {
1340            width: 8,
1341            height: 5,
1342        };
1343        let without = box_with_text(size, "");
1344        let with = box_with_text(size, "hi");
1345
1346        for anchor in [Anchor::Top, Anchor::Right, Anchor::Bottom, Anchor::Left] {
1347            assert_eq!(with.anchor(anchor), without.anchor(anchor), "{anchor:?}");
1348        }
1349    }
1350
1351    /// An attached endpoint derives its leaving direction from its anchor's outward normal, draws
1352    /// its route and symbol in that direction, and preserves that leaving direction when frozen by
1353    /// removing the referenced shape.
1354    #[test]
1355    fn an_attached_endpoint_derives_its_leaving_direction_and_preserves_it_on_freeze() {
1356        use monospace_core::SymbolStyleName;
1357
1358        let style = SymbolStyleName::from("solid");
1359        let nothing = Delta { dx: 0, dy: 0 };
1360        let the_box = Shape::Box {
1361            at: Pos { x: 5, y: 5 },
1362            size: Size {
1363                width: 5,
1364                height: 5,
1365            },
1366            stroke: light(),
1367            fill: None,
1368            text: None,
1369        };
1370
1371        for (anchor, expected_leaving, far_point, far_leaving) in [
1372            (
1373                Anchor::Top,
1374                Direction::Up,
1375                Pos { x: 7, y: 1 },
1376                Direction::Down,
1377            ),
1378            (
1379                Anchor::Right,
1380                Direction::Right,
1381                Pos { x: 13, y: 7 },
1382                Direction::Left,
1383            ),
1384            (
1385                Anchor::Bottom,
1386                Direction::Down,
1387                Pos { x: 7, y: 13 },
1388                Direction::Up,
1389            ),
1390            (
1391                Anchor::Left,
1392                Direction::Left,
1393                Pos { x: 1, y: 7 },
1394                Direction::Right,
1395            ),
1396        ] {
1397            let diagram = Diagram::new();
1398            let (d, box_id) = diagram.add(the_box.clone());
1399            let from_endpoint = Endpoint::reference(
1400                Reference::new(box_id, anchor, nothing, 0),
1401                Terminal::Symbol {
1402                    style: style.clone(),
1403                },
1404            );
1405            assert_eq!(from_endpoint.leaving(), expected_leaving);
1406            assert_eq!(from_endpoint.at.leaving(), expected_leaving);
1407
1408            let (d, connector_id) = d.add(Shape::Connector {
1409                from: from_endpoint,
1410                to: Endpoint::absolute(far_point, far_leaving, Terminal::Arm),
1411                stroke: light(),
1412            });
1413
1414            // The diagram draws the connector leaving in the anchor's outward direction.
1415            let mut buffer = Buffer::new(Window {
1416                origin: Pos { x: 0, y: 0 },
1417                size: Size {
1418                    width: 15,
1419                    height: 15,
1420                },
1421            });
1422            d.draw(&mut buffer);
1423
1424            // Removing the box freezes the endpoint to an absolute position with the anchor's leaving direction.
1425            let d_after = d.remove(box_id);
1426            let Shape::Connector {
1427                from: frozen_from, ..
1428            } = d_after
1429                .get(connector_id)
1430                .expect("connector still in diagram")
1431            else {
1432                unreachable!()
1433            };
1434
1435            let border_at = the_box.anchor(anchor).expect("box answers all anchors");
1436            let expected_at = anchor.outward(1).apply(border_at);
1437            assert_eq!(
1438                frozen_from.at,
1439                EndpointAt::Absolute {
1440                    at: expected_at,
1441                    leaving: expected_leaving,
1442                }
1443            );
1444            assert_eq!(frozen_from.leaving(), expected_leaving);
1445        }
1446    }
1447
1448    #[test]
1449    fn a_resized_box_keeps_its_position_stroke_fill_and_text() {
1450        let size = Size {
1451            width: 8,
1452            height: 5,
1453        };
1454        let original = box_with_text(size, "hi");
1455        let resized = original.resized_by(Delta { dx: 2, dy: 1 });
1456
1457        let Shape::Box {
1458            at,
1459            size: new_size,
1460            stroke,
1461            fill,
1462            text,
1463        } = resized
1464        else {
1465            panic!("expected box");
1466        };
1467
1468        assert_eq!(at, Pos { x: 0, y: 0 });
1469        assert_eq!(
1470            new_size,
1471            Size {
1472                width: 10,
1473                height: 6
1474            }
1475        );
1476        assert_eq!(stroke, Stroke::from("light"));
1477        assert_eq!(fill, None);
1478        assert_eq!(text, Some(Text::new("hi").unwrap()));
1479    }
1480
1481    #[test]
1482    fn a_box_does_not_shrink_below_two() {
1483        let the_box = Shape::Box {
1484            at: Pos { x: 0, y: 0 },
1485            size: Size {
1486                width: 2,
1487                height: 2,
1488            },
1489            stroke: light(),
1490            fill: None,
1491            text: None,
1492        };
1493
1494        let shrunk = the_box.resized_by(Delta { dx: -1, dy: -1 });
1495        let Shape::Box { size, .. } = shrunk else {
1496            panic!("expected box");
1497        };
1498        assert_eq!(
1499            size,
1500            Size {
1501                width: 2,
1502                height: 2
1503            }
1504        );
1505    }
1506
1507    #[test]
1508    fn a_box_below_two_is_not_shrunk_further() {
1509        let the_box = Shape::Box {
1510            at: Pos { x: 0, y: 0 },
1511            size: Size {
1512                width: 1,
1513                height: 5,
1514            },
1515            stroke: light(),
1516            fill: None,
1517            text: None,
1518        };
1519
1520        let shrunk = the_box.resized_by(Delta { dx: -1, dy: 0 });
1521        let Shape::Box { size, .. } = shrunk else {
1522            panic!("expected box");
1523        };
1524        assert_eq!(size.width, 1);
1525
1526        let grown = the_box.resized_by(Delta { dx: 1, dy: 0 });
1527        let Shape::Box {
1528            size: grown_size, ..
1529        } = grown
1530        else {
1531            panic!("expected box");
1532        };
1533        assert_eq!(grown_size.width, 2);
1534    }
1535
1536    #[test]
1537    fn growth_stops_at_the_largest_size() {
1538        let the_box = Shape::Box {
1539            at: Pos { x: 0, y: 0 },
1540            size: Size {
1541                width: u16::MAX,
1542                height: u16::MAX,
1543            },
1544            stroke: light(),
1545            fill: None,
1546            text: None,
1547        };
1548
1549        let grown = the_box.resized_by(Delta { dx: 1, dy: 1 });
1550        let Shape::Box { size, .. } = grown else {
1551            panic!("expected box");
1552        };
1553        assert_eq!(
1554            size,
1555            Size {
1556                width: u16::MAX,
1557                height: u16::MAX
1558            }
1559        );
1560    }
1561
1562    #[test]
1563    fn a_horizontal_line_is_resized_along_its_row() {
1564        let line = Shape::Line {
1565            at: Pos { x: 2, y: 3 },
1566            len: 4,
1567            orientation: Orientation::Horizontal,
1568            stroke: light(),
1569        };
1570        assert_eq!(
1571            line.resized_by(Delta { dx: 1, dy: 0 }),
1572            Shape::Line {
1573                at: Pos { x: 2, y: 3 },
1574                len: 5,
1575                orientation: Orientation::Horizontal,
1576                stroke: light(),
1577            }
1578        );
1579        assert_eq!(
1580            line.resized_by(Delta { dx: -1, dy: 0 }),
1581            Shape::Line {
1582                at: Pos { x: 2, y: 3 },
1583                len: 3,
1584                orientation: Orientation::Horizontal,
1585                stroke: light(),
1586            }
1587        );
1588        assert_eq!(line.resized_by(Delta { dx: 0, dy: 1 }), line);
1589        assert_eq!(line.resized_by(Delta { dx: 0, dy: -1 }), line);
1590    }
1591
1592    #[test]
1593    fn a_vertical_line_is_resized_along_its_column() {
1594        let line = Shape::Line {
1595            at: Pos { x: 2, y: 3 },
1596            len: 4,
1597            orientation: Orientation::Vertical,
1598            stroke: light(),
1599        };
1600        assert_eq!(
1601            line.resized_by(Delta { dx: 0, dy: 1 }),
1602            Shape::Line {
1603                at: Pos { x: 2, y: 3 },
1604                len: 5,
1605                orientation: Orientation::Vertical,
1606                stroke: light(),
1607            }
1608        );
1609        assert_eq!(
1610            line.resized_by(Delta { dx: 0, dy: -1 }),
1611            Shape::Line {
1612                at: Pos { x: 2, y: 3 },
1613                len: 3,
1614                orientation: Orientation::Vertical,
1615                stroke: light(),
1616            }
1617        );
1618        assert_eq!(line.resized_by(Delta { dx: 1, dy: 0 }), line);
1619        assert_eq!(line.resized_by(Delta { dx: -1, dy: 0 }), line);
1620    }
1621
1622    #[test]
1623    fn a_line_keeps_its_position_orientation_and_stroke_when_resized() {
1624        let stroke = Stroke::from("heavy");
1625        let line = Shape::Line {
1626            at: Pos { x: 5, y: 7 },
1627            len: 4,
1628            orientation: Orientation::Horizontal,
1629            stroke: stroke.clone(),
1630        };
1631        let resized = line.resized_by(Delta { dx: 2, dy: 0 });
1632        let Shape::Line {
1633            at,
1634            len,
1635            orientation,
1636            stroke: actual_stroke,
1637        } = resized
1638        else {
1639            panic!("expected line");
1640        };
1641        assert_eq!(at, Pos { x: 5, y: 7 });
1642        assert_eq!(len, 6);
1643        assert_eq!(orientation, Orientation::Horizontal);
1644        assert_eq!(actual_stroke, stroke);
1645    }
1646
1647    #[test]
1648    fn a_line_does_not_shrink_below_one() {
1649        let line = Shape::Line {
1650            at: Pos { x: 0, y: 0 },
1651            len: 5,
1652            orientation: Orientation::Horizontal,
1653            stroke: light(),
1654        };
1655        assert_eq!(
1656            line.resized_by(Delta { dx: -1, dy: 0 }),
1657            Shape::Line {
1658                at: Pos { x: 0, y: 0 },
1659                len: 4,
1660                orientation: Orientation::Horizontal,
1661                stroke: light(),
1662            }
1663        );
1664        let min_line = Shape::Line {
1665            at: Pos { x: 0, y: 0 },
1666            len: 1,
1667            orientation: Orientation::Horizontal,
1668            stroke: light(),
1669        };
1670        assert_eq!(min_line.resized_by(Delta { dx: -1, dy: 0 }), min_line);
1671        assert_eq!(min_line.resized_by(Delta { dx: -50, dy: 0 }), min_line);
1672    }
1673
1674    #[test]
1675    fn a_zero_length_line_is_not_shrunk_and_grows_to_one() {
1676        let h_zero = Shape::Line {
1677            at: Pos { x: 0, y: 0 },
1678            len: 0,
1679            orientation: Orientation::Horizontal,
1680            stroke: light(),
1681        };
1682        assert_eq!(h_zero.resized_by(Delta { dx: -1, dy: 0 }), h_zero);
1683        assert_eq!(
1684            h_zero.resized_by(Delta { dx: 1, dy: 0 }),
1685            Shape::Line {
1686                at: Pos { x: 0, y: 0 },
1687                len: 1,
1688                orientation: Orientation::Horizontal,
1689                stroke: light(),
1690            }
1691        );
1692
1693        let v_zero = Shape::Line {
1694            at: Pos { x: 0, y: 0 },
1695            len: 0,
1696            orientation: Orientation::Vertical,
1697            stroke: light(),
1698        };
1699        assert_eq!(v_zero.resized_by(Delta { dx: 0, dy: -1 }), v_zero);
1700        assert_eq!(
1701            v_zero.resized_by(Delta { dx: 0, dy: 1 }),
1702            Shape::Line {
1703                at: Pos { x: 0, y: 0 },
1704                len: 1,
1705                orientation: Orientation::Vertical,
1706                stroke: light(),
1707            }
1708        );
1709    }
1710
1711    #[test]
1712    fn a_line_grows_no_further_than_u16_max() {
1713        let max_line = Shape::Line {
1714            at: Pos { x: 0, y: 0 },
1715            len: u16::MAX,
1716            orientation: Orientation::Horizontal,
1717            stroke: light(),
1718        };
1719        assert_eq!(max_line.resized_by(Delta { dx: 1, dy: 0 }), max_line);
1720    }
1721
1722    #[test]
1723    fn a_connector_is_not_resized() {
1724        let conn = connector(Terminal::Arm, Terminal::Arm);
1725        assert_eq!(conn.resized_by(Delta { dx: 2, dy: 3 }), conn);
1726    }
1727
1728    #[test]
1729    fn a_box_and_a_line_can_be_resized_and_a_connector_cannot() {
1730        assert!(box_at(0).can_resize());
1731        assert!(line_at(0).can_resize());
1732        assert!(!connector(Terminal::Arm, Terminal::Arm).can_resize());
1733    }
1734
1735    #[test]
1736    fn a_connector_is_never_resized() {
1737        let delta = Delta { dx: 2, dy: 3 };
1738        let conn = connector(Terminal::Arm, Terminal::Arm);
1739        assert!(!conn.can_resize());
1740        assert_eq!(conn.resized_by(delta), conn);
1741    }
1742
1743    #[test]
1744    fn a_box_can_be_resized() {
1745        let the_box = box_at(0);
1746        assert!(the_box.can_resize());
1747        assert_ne!(the_box.resized_by(Delta { dx: 2, dy: 3 }), the_box);
1748    }
1749
1750    #[test]
1751    fn a_box_changes_only_its_stroke() {
1752        let the_box = Shape::Box {
1753            at: Pos { x: 2, y: 3 },
1754            size: Size {
1755                width: 5,
1756                height: 4,
1757            },
1758            stroke: light(),
1759            fill: Some(Glyph::new("x").unwrap()),
1760            text: Some(Text::new("hi").unwrap()),
1761        };
1762        let double = Stroke::from("double");
1763        let updated = the_box.with_stroke(double.clone());
1764        assert_eq!(
1765            updated,
1766            Shape::Box {
1767                at: Pos { x: 2, y: 3 },
1768                size: Size {
1769                    width: 5,
1770                    height: 4,
1771                },
1772                stroke: double,
1773                fill: Some(Glyph::new("x").unwrap()),
1774                text: Some(Text::new("hi").unwrap()),
1775            }
1776        );
1777    }
1778
1779    #[test]
1780    fn a_line_changes_only_its_stroke() {
1781        let the_line = Shape::Line {
1782            at: Pos { x: 1, y: 2 },
1783            len: 4,
1784            orientation: Orientation::Vertical,
1785            stroke: light(),
1786        };
1787        let heavy = Stroke::from("heavy");
1788        let updated = the_line.with_stroke(heavy.clone());
1789        assert_eq!(
1790            updated,
1791            Shape::Line {
1792                at: Pos { x: 1, y: 2 },
1793                len: 4,
1794                orientation: Orientation::Vertical,
1795                stroke: heavy,
1796            }
1797        );
1798    }
1799
1800    #[test]
1801    fn a_connector_changes_only_its_stroke() {
1802        let the_connector = connector(Terminal::Arm, Terminal::Arm);
1803        let ascii = Stroke::from("ascii");
1804        let updated = the_connector.with_stroke(ascii.clone());
1805        match (the_connector, updated) {
1806            (
1807                Shape::Connector {
1808                    from: f1,
1809                    to: t1,
1810                    stroke: s1,
1811                },
1812                Shape::Connector {
1813                    from: f2,
1814                    to: t2,
1815                    stroke: s2,
1816                },
1817            ) => {
1818                assert_eq!(f1, f2);
1819                assert_eq!(t1, t2);
1820                assert_eq!(s1, light());
1821                assert_eq!(s2, ascii);
1822            }
1823            _ => unreachable!(),
1824        }
1825    }
1826
1827    #[test]
1828    fn with_rotated_orientation_toggles_a_line_and_keeps_position_length_and_stroke() {
1829        let horizontal = Shape::Line {
1830            at: Pos { x: 2, y: 3 },
1831            len: 5,
1832            orientation: Orientation::Horizontal,
1833            stroke: light(),
1834        };
1835        let rotated = horizontal.with_rotated_orientation();
1836        assert_eq!(
1837            rotated,
1838            Shape::Line {
1839                at: Pos { x: 2, y: 3 },
1840                len: 5,
1841                orientation: Orientation::Vertical,
1842                stroke: light(),
1843            }
1844        );
1845        assert_eq!(rotated.with_rotated_orientation(), horizontal);
1846    }
1847
1848    #[test]
1849    fn with_rotated_orientation_on_a_box_or_connector_changes_nothing() {
1850        let the_box = Shape::Box {
1851            at: Pos { x: 2, y: 3 },
1852            size: Size {
1853                width: 5,
1854                height: 4,
1855            },
1856            stroke: light(),
1857            fill: None,
1858            text: None,
1859        };
1860        assert_eq!(the_box.with_rotated_orientation(), the_box);
1861
1862        let the_connector = connector(Terminal::Arm, Terminal::Arm);
1863        assert_eq!(the_connector.with_rotated_orientation(), the_connector);
1864    }
1865
1866    #[test]
1867    fn with_text_changes_only_a_box() {
1868        let the_box = Shape::Box {
1869            at: Pos { x: 2, y: 3 },
1870            size: Size {
1871                width: 5,
1872                height: 4,
1873            },
1874            stroke: light(),
1875            fill: Some(Glyph::new("x").unwrap()),
1876            text: None,
1877        };
1878        let text = Text::new("hi").unwrap();
1879
1880        assert_eq!(
1881            the_box.with_text(Some(text.clone())),
1882            Shape::Box {
1883                at: Pos { x: 2, y: 3 },
1884                size: Size {
1885                    width: 5,
1886                    height: 4,
1887                },
1888                stroke: light(),
1889                fill: Some(Glyph::new("x").unwrap()),
1890                text: Some(text),
1891            }
1892        );
1893    }
1894
1895    #[test]
1896    fn with_text_on_a_line_or_connector_changes_nothing() {
1897        let the_line = Shape::Line {
1898            at: Pos { x: 1, y: 2 },
1899            len: 4,
1900            orientation: Orientation::Vertical,
1901            stroke: light(),
1902        };
1903        let the_connector = connector(Terminal::Arm, Terminal::Arm);
1904        let text = Text::new("hi").unwrap();
1905
1906        assert_eq!(the_line.with_text(Some(text.clone())), the_line);
1907        assert_eq!(the_connector.with_text(Some(text)), the_connector);
1908    }
1909
1910    #[test]
1911    fn setting_the_same_text_is_no_change() {
1912        let text = Text::new("hi").unwrap();
1913        let the_box = Shape::Box {
1914            at: Pos { x: 2, y: 3 },
1915            size: Size {
1916                width: 5,
1917                height: 4,
1918            },
1919            stroke: light(),
1920            fill: None,
1921            text: Some(text.clone()),
1922        };
1923
1924        assert_eq!(the_box.with_text(Some(text)), the_box);
1925    }
1926
1927    #[test]
1928    fn with_inverted_terminals_swaps_from_and_to_terminals_on_a_connector() {
1929        use monospace_core::SymbolStyleName;
1930
1931        let arm = Terminal::Arm;
1932        let solid = Terminal::Symbol {
1933            style: SymbolStyleName::from("solid"),
1934        };
1935        let connector = connector(arm.clone(), solid.clone());
1936        let inverted = connector.with_inverted_terminals();
1937
1938        match (connector, inverted) {
1939            (
1940                Shape::Connector {
1941                    from: f1,
1942                    to: t1,
1943                    stroke: s1,
1944                },
1945                Shape::Connector {
1946                    from: f2,
1947                    to: t2,
1948                    stroke: s2,
1949                },
1950            ) => {
1951                assert_eq!(f1.terminal, arm);
1952                assert_eq!(t1.terminal, solid);
1953                assert_eq!(f2.terminal, solid);
1954                assert_eq!(t2.terminal, arm);
1955                assert_eq!(f1.at, f2.at);
1956                assert_eq!(t1.at, t2.at);
1957                assert_eq!(s1, s2);
1958            }
1959            _ => unreachable!(),
1960        }
1961    }
1962
1963    #[test]
1964    fn with_inverted_terminals_on_a_box_or_line_changes_nothing() {
1965        let the_box = Shape::Box {
1966            at: Pos { x: 2, y: 3 },
1967            size: Size {
1968                width: 5,
1969                height: 4,
1970            },
1971            stroke: light(),
1972            fill: None,
1973            text: None,
1974        };
1975        let the_line = Shape::Line {
1976            at: Pos { x: 1, y: 2 },
1977            len: 4,
1978            orientation: Orientation::Vertical,
1979            stroke: light(),
1980        };
1981
1982        assert_eq!(the_box.with_inverted_terminals(), the_box);
1983        assert_eq!(the_line.with_inverted_terminals(), the_line);
1984    }
1985
1986    #[test]
1987    fn with_inverted_terminals_preserves_endpoint_geometry_and_attachments() {
1988        use monospace_core::SymbolStyleName;
1989
1990        let (_, box_id) = Diagram::new().add(box_at(0));
1991        let from_at = EndpointAt::Reference(Reference::new(
1992            box_id,
1993            Anchor::Right,
1994            Delta { dx: 1, dy: 2 },
1995            3,
1996        ));
1997        let to_at = EndpointAt::Absolute {
1998            at: Pos { x: 15, y: 8 },
1999            leaving: Direction::Left,
2000        };
2001
2002        let conn = Shape::Connector {
2003            from: Endpoint {
2004                at: from_at,
2005                terminal: Terminal::Arm,
2006            },
2007            to: Endpoint {
2008                at: to_at,
2009                terminal: Terminal::Symbol {
2010                    style: SymbolStyleName::from("solid"),
2011                },
2012            },
2013            stroke: light(),
2014        };
2015
2016        let inverted = conn.with_inverted_terminals();
2017        match inverted {
2018            Shape::Connector { from, to, stroke } => {
2019                assert_eq!(from.at, from_at);
2020                assert_eq!(to.at, to_at);
2021                assert_eq!(
2022                    from.terminal,
2023                    Terminal::Symbol {
2024                        style: SymbolStyleName::from("solid")
2025                    }
2026                );
2027                assert_eq!(to.terminal, Terminal::Arm);
2028                assert_eq!(stroke, light());
2029            }
2030            _ => unreachable!(),
2031        }
2032    }
2033
2034    #[test]
2035    fn effective_out_offsets_non_arm_terminals_by_one_and_is_additive() {
2036        use monospace_core::SymbolStyleName;
2037
2038        let nothing = Delta { dx: 0, dy: 0 };
2039        let symbol = Terminal::Symbol {
2040            style: SymbolStyleName::from("solid"),
2041        };
2042        let glyph = Terminal::Glyph {
2043            glyph: Glyph::new("x").unwrap(),
2044        };
2045        let arm = Terminal::Arm;
2046
2047        let (_, dummy_id) = Diagram::new().add(box_at(0));
2048        let at_ref =
2049            |out: i16| EndpointAt::Reference(Reference::new(dummy_id, Anchor::Right, nothing, out));
2050
2051        // Arm terminal sits on the border (out + 0).
2052        assert_eq!(at_ref(0).effective_out(&arm), 0);
2053        assert_eq!(
2054            Endpoint::reference(
2055                Reference::new(dummy_id, Anchor::Right, nothing, 0),
2056                arm.clone(),
2057            )
2058            .effective_out(),
2059            0
2060        );
2061
2062        // Non-arm terminals stand one cell outward by default (out + 1).
2063        assert_eq!(at_ref(0).effective_out(&symbol), 1);
2064        assert_eq!(at_ref(0).effective_out(&glyph), 1);
2065
2066        // Explicit out is additive (out + 1).
2067        assert_eq!(at_ref(1).effective_out(&symbol), 2);
2068        assert_eq!(at_ref(2).effective_out(&symbol), 3);
2069
2070        // Explicit negative out allows placing back on the border.
2071        assert_eq!(at_ref(-1).effective_out(&symbol), 0);
2072
2073        // Absolute endpoint always yields 0 outward offset.
2074        let absolute = EndpointAt::Absolute {
2075            at: Pos { x: 3, y: 3 },
2076            leaving: Direction::Right,
2077        };
2078        assert_eq!(absolute.effective_out(&symbol), 0);
2079        assert_eq!(absolute.effective_out(&arm), 0);
2080    }
2081
2082    #[test]
2083    fn endpoint_resolves_with_terminal_offsetting_non_arm_terminals_by_one() {
2084        use monospace_core::SymbolStyleName;
2085
2086        let nothing = Delta { dx: 0, dy: 0 };
2087        let symbol = Terminal::Symbol {
2088            style: SymbolStyleName::from("solid"),
2089        };
2090        let glyph = Terminal::Glyph {
2091            glyph: Glyph::new("x").unwrap(),
2092        };
2093        let arm = Terminal::Arm;
2094
2095        let the_box = Shape::Box {
2096            at: Pos { x: 5, y: 5 },
2097            size: Size {
2098                width: 5,
2099                height: 5,
2100            },
2101            stroke: light(),
2102            fill: None,
2103            text: None,
2104        };
2105
2106        let diagram = Diagram::new();
2107        let (diagram, box_id) = diagram.add(the_box);
2108
2109        // Test each of the four anchors: side_centre and outward(1)
2110        for (anchor, expected_normal) in [
2111            (Anchor::Top, Delta { dx: 0, dy: -1 }),
2112            (Anchor::Right, Delta { dx: 1, dy: 0 }),
2113            (Anchor::Bottom, Delta { dx: 0, dy: 1 }),
2114            (Anchor::Left, Delta { dx: -1, dy: 0 }),
2115        ] {
2116            let border_point = diagram.get(box_id).unwrap().anchor(anchor).unwrap();
2117            let outward_point = expected_normal.apply(border_point);
2118
2119            // Arm sits on the border
2120            let arm_ep =
2121                Endpoint::reference(Reference::new(box_id, anchor, nothing, 0), arm.clone());
2122            assert_eq!(arm_ep.resolve(&diagram), Some(border_point));
2123
2124            // Symbol stands 1 cell outward by default (D1)
2125            let symbol_ep =
2126                Endpoint::reference(Reference::new(box_id, anchor, nothing, 0), symbol.clone());
2127            assert_eq!(symbol_ep.resolve(&diagram), Some(outward_point));
2128
2129            // Glyph stands 1 cell outward by default (D1)
2130            let glyph_ep =
2131                Endpoint::reference(Reference::new(box_id, anchor, nothing, 0), glyph.clone());
2132            assert_eq!(glyph_ep.resolve(&diagram), Some(outward_point));
2133
2134            // Explicit out is additive (D2): out: 1 gives 2 cells outward
2135            let outward_2 = Delta {
2136                dx: expected_normal.dx * 2,
2137                dy: expected_normal.dy * 2,
2138            }
2139            .apply(border_point);
2140            let out_1_ep =
2141                Endpoint::reference(Reference::new(box_id, anchor, nothing, 1), symbol.clone());
2142            assert_eq!(out_1_ep.resolve(&diagram), Some(outward_2));
2143
2144            // Explicit out: -1 places non-arm back on the border (D2)
2145            let out_neg1_ep =
2146                Endpoint::reference(Reference::new(box_id, anchor, nothing, -1), symbol.clone());
2147            assert_eq!(out_neg1_ep.resolve(&diagram), Some(border_point));
2148
2149            // Symmetry between from and to (D3): constructing from or to behaves identically
2150            let connector_from =
2151                Endpoint::reference(Reference::new(box_id, anchor, nothing, 0), symbol.clone());
2152            let connector_to =
2153                Endpoint::reference(Reference::new(box_id, anchor, nothing, 0), symbol.clone());
2154            assert_eq!(
2155                connector_from.resolve(&diagram),
2156                connector_to.resolve(&diagram)
2157            );
2158        }
2159
2160        // Resolving an endpoint referencing an absent shape yields None
2161        let absent_id = ShapeId::new(std::num::NonZeroU16::new(999).unwrap());
2162        let absent_ep =
2163            Endpoint::reference(Reference::new(absent_id, Anchor::Top, nothing, 0), symbol);
2164        assert_eq!(absent_ep.resolve(&diagram), None);
2165    }
2166
2167    #[test]
2168    fn attached_connectors_draw_non_arm_terminals_one_cell_outward_preserving_borders() {
2169        use monospace_core::SymbolStyleName;
2170
2171        let nothing = Delta { dx: 0, dy: 0 };
2172        let symbol = Terminal::Symbol {
2173            style: SymbolStyleName::from("solid"),
2174        };
2175        let catalog = GlyphCatalog::union([GlyphCatalog::light(), GlyphCatalog::solid()]);
2176        let window = Window {
2177            origin: ORIGIN,
2178            size: WINDOW,
2179        };
2180
2181        // Example 1: Arm to Symbol. Left box at 0..3, right box at 8..11.
2182        let d = Diagram::new();
2183        let (d, left_id) = d.add(box_at(0));
2184        let (d, right_id) = d.add(box_at(8));
2185        let (d1, _) = d.add(Shape::Connector {
2186            from: Endpoint::reference(
2187                Reference::new(left_id, Anchor::Right, nothing, 0),
2188                Terminal::Arm,
2189            ),
2190            to: Endpoint::reference(
2191                Reference::new(right_id, Anchor::Left, nothing, 0),
2192                symbol.clone(),
2193            ),
2194            stroke: light(),
2195        });
2196
2197        let mut buf1 = Buffer::new(window);
2198        d1.draw(&mut buf1);
2199        assert_eq!(
2200            render(&buf1, &catalog, window),
2201            concat!("┌─┐     ┌─┐\n", "│ ├────▶│ │\n", "└─┘     └─┘\n")
2202        );
2203
2204        // Example 3: Bidirectional arrows between both boxes.
2205        let d = Diagram::new();
2206        let (d, left_id) = d.add(box_at(0));
2207        let (d, right_id) = d.add(box_at(8));
2208        let (d3, _) = d.add(Shape::Connector {
2209            from: Endpoint::reference(
2210                Reference::new(left_id, Anchor::Right, nothing, 0),
2211                symbol.clone(),
2212            ),
2213            to: Endpoint::reference(Reference::new(right_id, Anchor::Left, nothing, 0), symbol),
2214            stroke: light(),
2215        });
2216
2217        let mut buf3 = Buffer::new(window);
2218        d3.draw(&mut buf3);
2219        assert_eq!(
2220            render(&buf3, &catalog, window),
2221            concat!("┌─┐     ┌─┐\n", "│ │◀───▶│ │\n", "└─┘     └─┘\n")
2222        );
2223
2224        // Arm terminals continue composing into junctions on both sides.
2225        let d = Diagram::new();
2226        let (d, left_id) = d.add(box_at(0));
2227        let (d, right_id) = d.add(box_at(8));
2228        let (da, _) = d.add(Shape::Connector {
2229            from: Endpoint::reference(
2230                Reference::new(left_id, Anchor::Right, nothing, 0),
2231                Terminal::Arm,
2232            ),
2233            to: Endpoint::reference(
2234                Reference::new(right_id, Anchor::Left, nothing, 0),
2235                Terminal::Arm,
2236            ),
2237            stroke: light(),
2238        });
2239
2240        let mut buf_junctions = Buffer::new(window);
2241        da.draw(&mut buf_junctions);
2242        assert_eq!(
2243            render(&buf_junctions, &catalog, window),
2244            concat!("┌─┐     ┌─┐\n", "│ ├─────┤ │\n", "└─┘     └─┘\n")
2245        );
2246    }
2247
2248    #[test]
2249    fn with_endpoint_moved_adjusts_attached_offset_on_both_axes_without_clamping() {
2250        let connector = Shape::Connector {
2251            from: Endpoint::reference(
2252                Reference::new(
2253                    ShapeId::new(NonZeroU16::new(1).unwrap()),
2254                    Anchor::Top,
2255                    Delta::ZERO,
2256                    0,
2257                ),
2258                Terminal::Arm,
2259            ),
2260            to: Endpoint::absolute(Pos { x: 20, y: 0 }, Direction::Down, Terminal::Arm),
2261            stroke: light(),
2262        };
2263
2264        // Move horizontally and vertically without clamping or orientation gating
2265        let moved = connector.with_endpoint_moved(End::From, Delta { dx: 15, dy: -8 }, 0);
2266        let Shape::Connector { ref from, .. } = moved else {
2267            panic!()
2268        };
2269        let EndpointAt::Reference(ref r) = from.at else {
2270            panic!()
2271        };
2272        assert_eq!(r.offset, Delta { dx: 15, dy: -8 });
2273
2274        // Further adjustment accumulates freely on both axes
2275        let moved_more = moved.with_endpoint_moved(End::From, Delta { dx: -5, dy: 10 }, 0);
2276        let Shape::Connector { from, .. } = moved_more else {
2277            panic!()
2278        };
2279        let EndpointAt::Reference(ref r) = from.at else {
2280            panic!()
2281        };
2282        assert_eq!(r.offset, Delta { dx: 10, dy: 2 });
2283    }
2284
2285    #[test]
2286    fn with_endpoint_moved_displaces_detached_endpoint_on_both_axes() {
2287        let connector = Shape::Connector {
2288            from: Endpoint::absolute(Pos { x: 5, y: 10 }, Direction::Right, Terminal::Arm),
2289            to: Endpoint::absolute(Pos { x: 20, y: 30 }, Direction::Left, Terminal::Arm),
2290            stroke: light(),
2291        };
2292
2293        let moved = connector.with_endpoint_moved(End::From, Delta { dx: 2, dy: -3 }, 5);
2294        let Shape::Connector { from, to, stroke } = moved else {
2295            panic!()
2296        };
2297        assert_eq!(
2298            from,
2299            Endpoint::absolute(Pos { x: 7, y: 7 }, Direction::Right, Terminal::Arm)
2300        );
2301        assert_eq!(
2302            to,
2303            Endpoint::absolute(Pos { x: 20, y: 30 }, Direction::Left, Terminal::Arm)
2304        );
2305        assert_eq!(stroke, light());
2306    }
2307
2308    #[test]
2309    fn with_endpoint_moved_adjusts_out_including_negative_values() {
2310        let connector = Shape::Connector {
2311            from: Endpoint::reference(
2312                Reference::new(
2313                    ShapeId::new(NonZeroU16::new(1).unwrap()),
2314                    Anchor::Top,
2315                    Delta::ZERO,
2316                    0,
2317                ),
2318                Terminal::Arm,
2319            ),
2320            to: Endpoint::absolute(Pos { x: 20, y: 0 }, Direction::Down, Terminal::Arm),
2321            stroke: light(),
2322        };
2323
2324        // Increment out
2325        let moved_plus = connector.with_endpoint_moved(End::From, Delta::ZERO, 2);
2326        let Shape::Connector { from, .. } = moved_plus else {
2327            panic!()
2328        };
2329        let EndpointAt::Reference(ref r) = from.at else {
2330            panic!()
2331        };
2332        assert_eq!(r.out, 2);
2333
2334        // Negative out is permitted
2335        let moved_neg = connector.with_endpoint_moved(End::From, Delta::ZERO, -3);
2336        let Shape::Connector { from, .. } = moved_neg else {
2337            panic!()
2338        };
2339        let EndpointAt::Reference(ref r) = from.at else {
2340            panic!()
2341        };
2342        assert_eq!(r.out, -3);
2343    }
2344
2345    #[test]
2346    fn with_endpoint_reset_clears_offset_and_out_on_targeted_reference_only() {
2347        let box_id = ShapeId::new(NonZeroU16::new(1).unwrap());
2348        let connector = Shape::Connector {
2349            from: Endpoint::reference(
2350                Reference::new(box_id, Anchor::Top, Delta { dx: 2, dy: 0 }, 3),
2351                Terminal::Arm,
2352            ),
2353            to: Endpoint::reference(
2354                Reference::new(box_id, Anchor::Left, Delta { dx: 0, dy: -1 }, -2),
2355                Terminal::Arm,
2356            ),
2357            stroke: light(),
2358        };
2359
2360        // Reset From
2361        let reset_from = connector.with_endpoint_reset(End::From);
2362        let Shape::Connector { from, to, .. } = reset_from else {
2363            panic!()
2364        };
2365        let EndpointAt::Reference(ref r_from) = from.at else {
2366            panic!()
2367        };
2368        let EndpointAt::Reference(ref r_to) = to.at else {
2369            panic!()
2370        };
2371        assert_eq!(r_from.offset, Delta::ZERO);
2372        assert_eq!(r_from.out, 0);
2373        assert_eq!(r_to.offset, Delta { dx: 0, dy: -1 });
2374        assert_eq!(r_to.out, -2);
2375
2376        // Reset To
2377        let reset_to = connector.with_endpoint_reset(End::To);
2378        let Shape::Connector { from, to, .. } = reset_to else {
2379            panic!()
2380        };
2381        let EndpointAt::Reference(ref r_from) = from.at else {
2382            panic!()
2383        };
2384        let EndpointAt::Reference(ref r_to) = to.at else {
2385            panic!()
2386        };
2387        assert_eq!(r_from.offset, Delta { dx: 2, dy: 0 });
2388        assert_eq!(r_from.out, 3);
2389        assert_eq!(r_to.offset, Delta::ZERO);
2390        assert_eq!(r_to.out, 0);
2391
2392        // Resetting an absolute endpoint is no change
2393        let absolute_conn = Shape::Connector {
2394            from: Endpoint::absolute(Pos { x: 1, y: 1 }, Direction::Up, Terminal::Arm),
2395            to: Endpoint::absolute(Pos { x: 5, y: 5 }, Direction::Down, Terminal::Arm),
2396            stroke: light(),
2397        };
2398        assert_eq!(absolute_conn.with_endpoint_reset(End::From), absolute_conn);
2399    }
2400
2401    #[test]
2402    fn with_endpoint_terminal_sets_targeted_endpoint_terminal_preserving_other_properties() {
2403        use monospace_core::SymbolStyleName;
2404
2405        let box_id = ShapeId::new(NonZeroU16::new(1).unwrap());
2406        let orig_from = Endpoint::reference(
2407            Reference::new(box_id, Anchor::Top, Delta { dx: 2, dy: 1 }, 3),
2408            Terminal::Arm,
2409        );
2410        let orig_to = Endpoint::absolute(Pos { x: 20, y: 10 }, Direction::Down, Terminal::Arm);
2411        let connector = Shape::Connector {
2412            from: orig_from.clone(),
2413            to: orig_to.clone(),
2414            stroke: light(),
2415        };
2416
2417        let new_from_terminal = Terminal::Symbol {
2418            style: SymbolStyleName::from("solid"),
2419        };
2420        let updated_from = connector.with_endpoint_terminal(End::From, new_from_terminal.clone());
2421        let Shape::Connector { from, to, .. } = &updated_from else {
2422            panic!();
2423        };
2424        assert_eq!(from.terminal, new_from_terminal);
2425        let EndpointAt::Reference(ref r) = from.at else {
2426            panic!();
2427        };
2428        assert_eq!(r.offset, Delta { dx: 2, dy: 1 });
2429        assert_eq!(r.out, 3);
2430        assert_eq!(r.id, box_id);
2431        assert_eq!(r.anchor, Anchor::Top);
2432        assert_eq!(to, &orig_to);
2433
2434        let new_to_terminal = Terminal::Glyph {
2435            glyph: Glyph::new("◆").unwrap(),
2436        };
2437        let updated_to = connector.with_endpoint_terminal(End::To, new_to_terminal.clone());
2438        let Shape::Connector { from, to, .. } = &updated_to else {
2439            panic!();
2440        };
2441        assert_eq!(to.terminal, new_to_terminal);
2442        assert_eq!(
2443            to.at,
2444            EndpointAt::Absolute {
2445                at: Pos { x: 20, y: 10 },
2446                leaving: Direction::Down,
2447            }
2448        );
2449        assert_eq!(from, &orig_from);
2450    }
2451
2452    #[test]
2453    fn with_endpoint_terminal_on_identical_terminal_produces_equal_shape() {
2454        let box_id = ShapeId::new(NonZeroU16::new(1).unwrap());
2455        let connector = Shape::Connector {
2456            from: Endpoint::reference(
2457                Reference::new(box_id, Anchor::Top, Delta { dx: 2, dy: 0 }, 1),
2458                Terminal::Arm,
2459            ),
2460            to: Endpoint::absolute(Pos { x: 10, y: 5 }, Direction::Right, Terminal::Arm),
2461            stroke: light(),
2462        };
2463
2464        assert_eq!(
2465            connector.with_endpoint_terminal(End::From, Terminal::Arm),
2466            connector
2467        );
2468        assert_eq!(
2469            connector.with_endpoint_terminal(End::To, Terminal::Arm),
2470            connector
2471        );
2472    }
2473
2474    #[test]
2475    fn with_endpoint_terminal_on_non_connector_returns_shape_unchanged() {
2476        let box_shape = Shape::Box {
2477            at: Pos { x: 0, y: 0 },
2478            size: Size {
2479                width: 10,
2480                height: 5,
2481            },
2482            stroke: light(),
2483            fill: None,
2484            text: None,
2485        };
2486        let line_shape = Shape::Line {
2487            at: Pos { x: 0, y: 0 },
2488            len: 10,
2489            orientation: Orientation::Horizontal,
2490            stroke: light(),
2491        };
2492
2493        assert_eq!(
2494            box_shape.with_endpoint_terminal(End::From, Terminal::Arm),
2495            box_shape
2496        );
2497        assert_eq!(
2498            box_shape.with_endpoint_terminal(End::To, Terminal::Arm),
2499            box_shape
2500        );
2501        assert_eq!(
2502            line_shape.with_endpoint_terminal(End::From, Terminal::Arm),
2503            line_shape
2504        );
2505        assert_eq!(
2506            line_shape.with_endpoint_terminal(End::To, Terminal::Arm),
2507            line_shape
2508        );
2509    }
2510
2511    #[test]
2512    fn with_endpoint_detached_freezes_reference_at_resolved_pos_and_anchor_leaving() {
2513        use monospace_core::SymbolStyleName;
2514
2515        let mut diagram = Diagram::new();
2516        let box_shape = Shape::Box {
2517            at: Pos { x: 10, y: 10 },
2518            size: Size {
2519                width: 8,
2520                height: 4,
2521            },
2522            stroke: light(),
2523            fill: None,
2524            text: None,
2525        };
2526        let (d, box_id) = diagram.add(box_shape);
2527        diagram = d;
2528
2529        let offset = Delta { dx: 1, dy: 2 };
2530        let out = 3;
2531        let arm_terminal = Terminal::Arm;
2532        let symbol_terminal = Terminal::Symbol {
2533            style: SymbolStyleName::from("solid"),
2534        };
2535
2536        let arm_ref_endpoint = Endpoint::reference(
2537            Reference::new(box_id, Anchor::Right, offset, out),
2538            arm_terminal.clone(),
2539        );
2540        let symbol_ref_endpoint = Endpoint::reference(
2541            Reference::new(box_id, Anchor::Right, offset, out),
2542            symbol_terminal.clone(),
2543        );
2544
2545        let resolved_arm_pos = arm_ref_endpoint.resolve(&diagram).unwrap();
2546        let resolved_symbol_pos = symbol_ref_endpoint.resolve(&diagram).unwrap();
2547
2548        assert_eq!(resolved_symbol_pos.x, resolved_arm_pos.x + 1);
2549        assert_eq!(resolved_symbol_pos.y, resolved_arm_pos.y);
2550
2551        let connector = Shape::Connector {
2552            from: arm_ref_endpoint.clone(),
2553            to: symbol_ref_endpoint.clone(),
2554            stroke: light(),
2555        };
2556
2557        let detached_from = connector.with_endpoint_detached(End::From, &diagram);
2558        match detached_from {
2559            Shape::Connector { from, to, stroke } => {
2560                assert_eq!(
2561                    from,
2562                    Endpoint::absolute(resolved_arm_pos, Direction::Right, arm_terminal)
2563                );
2564                assert_eq!(to, symbol_ref_endpoint);
2565                assert_eq!(stroke, light());
2566            }
2567            _ => unreachable!(),
2568        }
2569
2570        let detached_to = connector.with_endpoint_detached(End::To, &diagram);
2571        match detached_to {
2572            Shape::Connector { from, to, stroke } => {
2573                assert_eq!(from, arm_ref_endpoint);
2574                assert_eq!(
2575                    to,
2576                    Endpoint::absolute(resolved_symbol_pos, Direction::Right, symbol_terminal)
2577                );
2578                assert_eq!(stroke, light());
2579            }
2580            _ => unreachable!(),
2581        }
2582    }
2583
2584    #[test]
2585    fn with_endpoint_detached_on_absolute_or_unresolved_or_non_connector_is_unchanged() {
2586        let diagram = Diagram::new();
2587        let missing_id = ShapeId::new(NonZeroU16::new(99).unwrap());
2588
2589        let connector = Shape::Connector {
2590            from: Endpoint::absolute(Pos { x: 5, y: 5 }, Direction::Up, Terminal::Arm),
2591            to: Endpoint::reference(
2592                Reference::new(missing_id, Anchor::Left, Delta::ZERO, 0),
2593                Terminal::Arm,
2594            ),
2595            stroke: light(),
2596        };
2597
2598        assert_eq!(
2599            connector.with_endpoint_detached(End::From, &diagram),
2600            connector
2601        );
2602
2603        assert_eq!(
2604            connector.with_endpoint_detached(End::To, &diagram),
2605            connector
2606        );
2607
2608        let box_shape = Shape::Box {
2609            at: Pos { x: 0, y: 0 },
2610            size: Size {
2611                width: 10,
2612                height: 5,
2613            },
2614            stroke: light(),
2615            fill: None,
2616            text: None,
2617        };
2618        assert_eq!(
2619            box_shape.with_endpoint_detached(End::From, &diagram),
2620            box_shape
2621        );
2622        assert_eq!(
2623            box_shape.with_endpoint_detached(End::To, &diagram),
2624            box_shape
2625        );
2626    }
2627
2628    #[test]
2629    fn with_endpoint_attached_replaces_a_detached_endpoint_and_preserves_its_terminal() {
2630        use monospace_core::SymbolStyleName;
2631
2632        let mut diagram = Diagram::new();
2633        let box_shape = Shape::Box {
2634            at: Pos { x: 10, y: 10 },
2635            size: Size {
2636                width: 8,
2637                height: 4,
2638            },
2639            stroke: light(),
2640            fill: None,
2641            text: None,
2642        };
2643        let (d, box_id) = diagram.add(box_shape);
2644        diagram = d;
2645
2646        let custom_terminal = Terminal::Symbol {
2647            style: SymbolStyleName::from("solid"),
2648        };
2649        let detached_endpoint =
2650            Endpoint::absolute(Pos { x: 5, y: 5 }, Direction::Left, custom_terminal.clone());
2651        let other_endpoint =
2652            Endpoint::absolute(Pos { x: 20, y: 20 }, Direction::Right, Terminal::Arm);
2653
2654        let connector = Shape::Connector {
2655            from: detached_endpoint.clone(),
2656            to: other_endpoint.clone(),
2657            stroke: light(),
2658        };
2659
2660        let target_ref = Reference::new(box_id, Anchor::Top, Delta { dx: 1, dy: 2 }, 3);
2661
2662        let attached_from = connector.with_endpoint_attached(End::From, target_ref, &diagram);
2663        match attached_from {
2664            Shape::Connector { from, to, stroke } => {
2665                assert_eq!(
2666                    from,
2667                    Endpoint::reference(target_ref, custom_terminal.clone())
2668                );
2669                assert_eq!(to, other_endpoint);
2670                assert_eq!(stroke, light());
2671            }
2672            _ => unreachable!(),
2673        }
2674
2675        let attached_to = connector.with_endpoint_attached(End::To, target_ref, &diagram);
2676        match attached_to {
2677            Shape::Connector { from, to, stroke } => {
2678                assert_eq!(from, detached_endpoint);
2679                assert_eq!(to, Endpoint::reference(target_ref, Terminal::Arm));
2680                assert_eq!(stroke, light());
2681            }
2682            _ => unreachable!(),
2683        }
2684    }
2685
2686    #[test]
2687    fn with_endpoint_attached_derives_leaving_from_the_anchor() {
2688        let mut diagram = Diagram::new();
2689        let (d, box_id) = diagram.add(Shape::Box {
2690            at: Pos { x: 0, y: 0 },
2691            size: Size {
2692                width: 10,
2693                height: 10,
2694            },
2695            stroke: light(),
2696            fill: None,
2697            text: None,
2698        });
2699        diagram = d;
2700
2701        for (anchor, expected_leaving) in [
2702            (Anchor::Top, Direction::Up),
2703            (Anchor::Right, Direction::Right),
2704            (Anchor::Bottom, Direction::Down),
2705            (Anchor::Left, Direction::Left),
2706        ] {
2707            let connector = Shape::Connector {
2708                from: Endpoint::absolute(Pos { x: 5, y: 5 }, Direction::Down, Terminal::Arm),
2709                to: Endpoint::absolute(Pos { x: 20, y: 20 }, Direction::Up, Terminal::Arm),
2710                stroke: light(),
2711            };
2712            let reference = Reference::new(box_id, anchor, Delta::ZERO, 0);
2713            let attached = connector.with_endpoint_attached(End::From, reference, &diagram);
2714            let Shape::Connector { from, .. } = attached else {
2715                unreachable!()
2716            };
2717            assert_eq!(from.leaving(), expected_leaving);
2718            assert_eq!(from.at.leaving(), expected_leaving);
2719        }
2720    }
2721
2722    #[test]
2723    fn with_endpoint_attached_on_a_line_target_uses_its_anchor() {
2724        let mut diagram = Diagram::new();
2725        let (d, line_id) = diagram.add(Shape::Line {
2726            at: Pos { x: 4, y: 4 },
2727            len: 12,
2728            orientation: Orientation::Horizontal,
2729            stroke: light(),
2730        });
2731        diagram = d;
2732
2733        let to_endpoint = Endpoint::absolute(Pos { x: 10, y: 10 }, Direction::Down, Terminal::Arm);
2734        let connector = Shape::Connector {
2735            from: Endpoint::absolute(Pos { x: 0, y: 0 }, Direction::Up, Terminal::Arm),
2736            to: to_endpoint.clone(),
2737            stroke: light(),
2738        };
2739
2740        let target_ref = Reference::new(line_id, Anchor::Bottom, Delta::ZERO, 0);
2741        let attached = connector.with_endpoint_attached(End::From, target_ref, &diagram);
2742
2743        match attached {
2744            Shape::Connector { from, to, stroke } => {
2745                assert_eq!(from, Endpoint::reference(target_ref, Terminal::Arm));
2746                assert_eq!(from.resolve(&diagram), Some(Pos { x: 9, y: 4 }));
2747                assert_eq!(to, to_endpoint);
2748                assert_eq!(stroke, light());
2749            }
2750            _ => unreachable!(),
2751        }
2752    }
2753
2754    #[test]
2755    fn with_endpoint_attached_on_an_attached_endpoint_is_unchanged() {
2756        let mut diagram = Diagram::new();
2757        let (d1, box1) = diagram.add(Shape::Box {
2758            at: Pos { x: 0, y: 0 },
2759            size: Size {
2760                width: 6,
2761                height: 4,
2762            },
2763            stroke: light(),
2764            fill: None,
2765            text: None,
2766        });
2767        let (d2, box2) = d1.add(Shape::Box {
2768            at: Pos { x: 20, y: 0 },
2769            size: Size {
2770                width: 6,
2771                height: 4,
2772            },
2773            stroke: light(),
2774            fill: None,
2775            text: None,
2776        });
2777        diagram = d2;
2778
2779        let attached_endpoint = Endpoint::reference(
2780            Reference::new(box1, Anchor::Right, Delta::ZERO, 0),
2781            Terminal::Arm,
2782        );
2783        let connector = Shape::Connector {
2784            from: attached_endpoint.clone(),
2785            to: attached_endpoint.clone(),
2786            stroke: light(),
2787        };
2788
2789        let target_ref = Reference::new(box2, Anchor::Left, Delta::ZERO, 0);
2790        assert_eq!(
2791            connector.with_endpoint_attached(End::From, target_ref, &diagram),
2792            connector
2793        );
2794        assert_eq!(
2795            connector.with_endpoint_attached(End::To, target_ref, &diagram),
2796            connector
2797        );
2798    }
2799
2800    #[test]
2801    fn with_endpoint_attached_on_a_missing_or_connector_target_is_unchanged() {
2802        let mut diagram = Diagram::new();
2803        let (d, other_connector_id) = diagram.add(Shape::Connector {
2804            from: Endpoint::absolute(Pos { x: 0, y: 0 }, Direction::Right, Terminal::Arm),
2805            to: Endpoint::absolute(Pos { x: 5, y: 5 }, Direction::Left, Terminal::Arm),
2806            stroke: light(),
2807        });
2808        diagram = d;
2809
2810        let connector = Shape::Connector {
2811            from: Endpoint::absolute(Pos { x: 1, y: 1 }, Direction::Right, Terminal::Arm),
2812            to: Endpoint::absolute(Pos { x: 6, y: 6 }, Direction::Left, Terminal::Arm),
2813            stroke: light(),
2814        };
2815
2816        let missing_id = ShapeId::new(NonZeroU16::new(99).unwrap());
2817        let missing_ref = Reference::new(missing_id, Anchor::Top, Delta::ZERO, 0);
2818        let connector_ref = Reference::new(other_connector_id, Anchor::Top, Delta::ZERO, 0);
2819
2820        assert_eq!(
2821            connector.with_endpoint_attached(End::From, missing_ref, &diagram),
2822            connector
2823        );
2824        assert_eq!(
2825            connector.with_endpoint_attached(End::To, missing_ref, &diagram),
2826            connector
2827        );
2828        assert_eq!(
2829            connector.with_endpoint_attached(End::From, connector_ref, &diagram),
2830            connector
2831        );
2832        assert_eq!(
2833            connector.with_endpoint_attached(End::To, connector_ref, &diagram),
2834            connector
2835        );
2836    }
2837
2838    #[test]
2839    fn with_endpoint_attached_on_non_connector_is_unchanged() {
2840        let mut diagram = Diagram::new();
2841        let (d, box_id) = diagram.add(Shape::Box {
2842            at: Pos { x: 0, y: 0 },
2843            size: Size {
2844                width: 10,
2845                height: 5,
2846            },
2847            stroke: light(),
2848            fill: None,
2849            text: None,
2850        });
2851        diagram = d;
2852
2853        let target_ref = Reference::new(box_id, Anchor::Top, Delta::ZERO, 0);
2854
2855        let box_shape = Shape::Box {
2856            at: Pos { x: 1, y: 1 },
2857            size: Size {
2858                width: 4,
2859                height: 4,
2860            },
2861            stroke: light(),
2862            fill: None,
2863            text: None,
2864        };
2865        assert_eq!(
2866            box_shape.with_endpoint_attached(End::From, target_ref, &diagram),
2867            box_shape
2868        );
2869        assert_eq!(
2870            box_shape.with_endpoint_attached(End::To, target_ref, &diagram),
2871            box_shape
2872        );
2873
2874        let line_shape = Shape::Line {
2875            at: Pos { x: 2, y: 2 },
2876            len: 5,
2877            orientation: Orientation::Horizontal,
2878            stroke: light(),
2879        };
2880        assert_eq!(
2881            line_shape.with_endpoint_attached(End::From, target_ref, &diagram),
2882            line_shape
2883        );
2884        assert_eq!(
2885            line_shape.with_endpoint_attached(End::To, target_ref, &diagram),
2886            line_shape
2887        );
2888    }
2889
2890    #[test]
2891    fn with_endpoint_attached_is_idempotent() {
2892        let mut diagram = Diagram::new();
2893        let (d1, box1) = diagram.add(Shape::Box {
2894            at: Pos { x: 0, y: 0 },
2895            size: Size {
2896                width: 8,
2897                height: 4,
2898            },
2899            stroke: light(),
2900            fill: None,
2901            text: None,
2902        });
2903        let (d2, box2) = d1.add(Shape::Box {
2904            at: Pos { x: 20, y: 0 },
2905            size: Size {
2906                width: 8,
2907                height: 4,
2908            },
2909            stroke: light(),
2910            fill: None,
2911            text: None,
2912        });
2913        diagram = d2;
2914
2915        let connector = Shape::Connector {
2916            from: Endpoint::absolute(Pos { x: 5, y: 5 }, Direction::Right, Terminal::Arm),
2917            to: Endpoint::absolute(Pos { x: 15, y: 5 }, Direction::Left, Terminal::Arm),
2918            stroke: light(),
2919        };
2920
2921        let ref1 = Reference::new(box1, Anchor::Right, Delta::ZERO, 0);
2922        let ref2 = Reference::new(box2, Anchor::Left, Delta::ZERO, 0);
2923
2924        let attached_once = connector.with_endpoint_attached(End::From, ref1, &diagram);
2925        let attached_twice = attached_once.with_endpoint_attached(End::From, ref1, &diagram);
2926        assert_eq!(attached_once, attached_twice);
2927
2928        // Attempting to attach to a different reference also leaves it unchanged
2929        let reattached = attached_once.with_endpoint_attached(End::From, ref2, &diagram);
2930        assert_eq!(attached_once, reattached);
2931    }
2932
2933    #[test]
2934    fn with_endpoint_leaving_updates_detached_endpoint_and_ignores_attached_or_non_connector() {
2935        let box_id = ShapeId::new(NonZeroU16::new(1).unwrap());
2936        let attached_endpoint = Endpoint::reference(
2937            Reference::new(box_id, Anchor::Left, Delta::ZERO, 0),
2938            Terminal::Arm,
2939        );
2940        let detached_endpoint =
2941            Endpoint::absolute(Pos { x: 10, y: 5 }, Direction::Right, Terminal::Arm);
2942
2943        let connector = Shape::Connector {
2944            from: detached_endpoint.clone(),
2945            to: attached_endpoint.clone(),
2946            stroke: light(),
2947        };
2948
2949        // Updating leaving on detached From updates leaving and preserves pos & terminal
2950        let updated_from = connector.with_endpoint_leaving(End::From, Direction::Down);
2951        match updated_from {
2952            Shape::Connector { from, to, stroke } => {
2953                assert_eq!(
2954                    from,
2955                    Endpoint::absolute(Pos { x: 10, y: 5 }, Direction::Down, Terminal::Arm)
2956                );
2957                assert_eq!(to, attached_endpoint);
2958                assert_eq!(stroke, light());
2959            }
2960            _ => unreachable!(),
2961        }
2962
2963        // Updating to identical leaving returns equal shape
2964        assert_eq!(
2965            connector.with_endpoint_leaving(End::From, Direction::Right),
2966            connector
2967        );
2968
2969        // Updating attached endpoint (To) ignores update and returns unchanged
2970        assert_eq!(
2971            connector.with_endpoint_leaving(End::To, Direction::Up),
2972            connector
2973        );
2974
2975        // Updating detached To
2976        let connector2 = Shape::Connector {
2977            from: attached_endpoint.clone(),
2978            to: detached_endpoint.clone(),
2979            stroke: light(),
2980        };
2981        let updated_to = connector2.with_endpoint_leaving(End::To, Direction::Left);
2982        match updated_to {
2983            Shape::Connector { from, to, stroke } => {
2984                assert_eq!(from, attached_endpoint);
2985                assert_eq!(
2986                    to,
2987                    Endpoint::absolute(Pos { x: 10, y: 5 }, Direction::Left, Terminal::Arm)
2988                );
2989                assert_eq!(stroke, light());
2990            }
2991            _ => unreachable!(),
2992        }
2993
2994        // Non-connector shapes return unchanged
2995        let box_shape = Shape::Box {
2996            at: Pos { x: 0, y: 0 },
2997            size: Size {
2998                width: 10,
2999                height: 5,
3000            },
3001            stroke: light(),
3002            fill: None,
3003            text: None,
3004        };
3005        assert_eq!(
3006            box_shape.with_endpoint_leaving(End::From, Direction::Up),
3007            box_shape
3008        );
3009        assert_eq!(
3010            box_shape.with_endpoint_leaving(End::To, Direction::Up),
3011            box_shape
3012        );
3013    }
3014}