Skip to main content

blockworx/widget/
drawing.rs

1//! [`Drawing`]: the editing surface for one level of the document.
2//!
3//! This module holds the view itself — construction, the per-kind accessors that
4//! add, read, and edit shapes, deletion, and the broad-phase candidate queries.
5//! The rest of the surface lives in sibling modules, each carrying its own
6//! `impl Drawing` block:
7//!
8//! - [`hit_target`](crate::widget::hit_target) — what sits at a point, in z-order.
9//! - [`clipboard`](crate::widget::clipboard) — copy, cut, and paste.
10//! - [`routing`](crate::widget::routing) — solving and re-solving a level's wires.
11//! - [`movement`](crate::widget::movement) — moving shapes and the routes that follow.
12
13/// Paint-layer precedence of a shape, matching [`Drawing::shapes`] order
14/// (blocks, ports, texts, images) with areas last. Paired with the
15/// entity's write order it reproduces that walk exactly, which is how the
16/// index-limited candidate list and the linear scan stay one order.
17fn shape_layer_rank(id: ShapeId) -> u8 {
18    match id {
19        ShapeId::Rect(_) => 0,
20        ShapeId::Port(_) => 1,
21        ShapeId::Text(_) => 2,
22        ShapeId::Image(_) => 3,
23        ShapeId::Area(_) => 4,
24        // Icons have their own foreground hit-test (`icon_at_pos`) and never enter
25        // the shared candidate scan, so this rank is never actually consulted.
26        ShapeId::Icon(_) => 5,
27    }
28}
29
30/// The entity a shape names to the delete cascade. An icon has none — it is
31/// a value on its block, not an entity, so it is zeroed rather than
32/// deleted.
33fn title_target(id: ShapeId) -> Option<crate::edit::naming::TitleTarget> {
34    use crate::edit::naming::TitleTarget;
35    match id {
36        ShapeId::Rect(id) => Some(TitleTarget::Block(id)),
37        ShapeId::Area(id) => Some(TitleTarget::Area(id)),
38        ShapeId::Port(_) | ShapeId::Text(_) | ShapeId::Image(_) | ShapeId::Icon(_) => None,
39    }
40}
41
42fn delete_target(id: ShapeId) -> Option<crate::edit::delete::Target> {
43    use crate::edit::delete::Target;
44    Some(match id {
45        ShapeId::Rect(id) => Target::Block(id),
46        ShapeId::Port(id) => Target::Pin(id),
47        ShapeId::Text(id) => Target::Text(id),
48        ShapeId::Area(id) => Target::Area(id),
49        ShapeId::Image(id) => Target::Image(id),
50        ShapeId::Icon(_) => return None,
51    })
52}
53
54use blockworx_doc::{
55    block_model::{Area, Asset, Block, Icon, Image, Pin, Route, Text},
56    commit::CommitBuilder,
57    document::{BlockIndex, Document as DocDocument, IndexedDocument, chronological},
58    entity::Entity,
59    geometry::{PinSlot, Waypoint},
60    hash::AssetHash,
61    id::{Allocator, AreaId, BlockId, Id, IdKind, ImageId, PinId, RouteId, RouteLabelId, TextId},
62    values::PinDir,
63};
64use blockworx_geom::{Pos2, Rect};
65
66use crate::edit::naming::Authoring;
67use crate::theme::Style;
68use crate::{
69    edit::{
70        self,
71        geometry::PinMove,
72        naming::{InterfaceLock, LabelFitWidth, TagVisibility},
73    },
74    grid::{artwork_rect, pin_slot},
75    path::BlockPath,
76    shape::{BlockShape, PinLocation, PortShape, ShapeId, ShapeRef},
77    tools::tool::{Deletable, RoleTarget},
78    widget::{
79        auto_route::{RouteGeometry, Wire, route_labels},
80        spatial::{HitId, SpatialIndex},
81    },
82};
83use blockworx_paint::Renderer;
84
85/// The slot a boundary offset in world pixels names.
86fn slot_at(location: PinLocation) -> PinSlot {
87    PinSlot {
88        side: location.side,
89        offset: pin_slot(location.offset),
90    }
91}
92
93/// What a preview supposes this frame that the document does not hold:
94/// shapes at hypothetical rects, and the routes re-solved against them. The
95/// cull set reads it — a shape whose committed rect is off screen must still
96/// draw when its preview is on screen — and a [`Drawing`] is one frame, so a
97/// supposition cannot outlive the frame that made it.
98#[derive(Default)]
99pub(crate) struct Supposed {
100    shapes: Vec<(ShapeId, Rect)>,
101    routes: Vec<RouteId>,
102}
103
104/// A transient view onto one scope of the document. Reads project the
105/// scope's entities out of `indexed`; writes are emitted into `sink` and
106/// only become document state when the gesture seals and submits.
107pub struct Drawing<'a> {
108    /// The session's prediction. Reads go through [`Drawing::indexed`],
109    /// which overlays whatever the gesture has already authored — never
110    /// this field directly, which is what makes a gesture able to read its
111    /// own writes.
112    base: IndexedDocument<'a>,
113    path: &'a BlockPath,
114    /// Optional spatial index over the current level's hittables, for broad-phase
115    /// viewport culling and hit-testing. Built and cached by `App`; `None` on the
116    /// paths that don't need it (SVG export, tests, popups), where queries fall
117    /// back to a full linear scan with identical results.
118    index: Option<&'a SpatialIndex>,
119    /// The document's derived state — solver geometry, measurement caches,
120    /// propagated accents. Mandatory where the index is optional: derived
121    /// state has no linear-scan fallback, and the solver passes that fill
122    /// it run inside `Drawing` methods, so it rides every borrow.
123    pub(super) presentation: &'a mut crate::presentation::Presentation,
124    /// The gesture in progress. Private, and reachable only through
125    /// [`Drawing::author`]: a tool cannot hold the sink, and a write cannot
126    /// skip advancing the prediction the next read will see.
127    gesture: &'a mut crate::gesture::Gesture,
128    supposed: Supposed,
129}
130
131/// A committed title/type-label placement: the resolved offset (world px
132/// — grid-rounded and clamped by the caller, who owns the text metrics)
133/// and the side the release height chose, when it chose one.
134#[derive(Clone, Copy, Debug)]
135pub struct LabelPlacement {
136    pub offset: f32,
137    pub side: Option<blockworx_doc::values::LabelSide>,
138}
139
140impl From<LabelPlacement> for edit::naming::LabelPlacement {
141    fn from(placement: LabelPlacement) -> Self {
142        edit::naming::LabelPlacement {
143            offset: placement.offset.into(),
144            side: placement.side,
145        }
146    }
147}
148
149/// Materialize the whole document for display. A route's full path is stored
150/// as its corner waypoints, never as solved edge geometry, so every scope's
151/// wires are *reconstructed* from those corners — straight legs drawn
152/// directly, only genuinely blocked/non-colinear legs routed (see
153/// [`Drawing::materialize_routes`]). A clean document is rebuilt with zero
154/// pathfinding, so persisted and hand-adjusted geometry survives unchanged.
155/// This runs for *all* scopes, not just the current view: navigation does not
156/// re-materialize, and a foreign commit can land anywhere.
157///
158/// Read-only — the sink it opens is local and discarded, because a
159/// materialization pass has nothing to author. Call it through
160/// [`Presentation::refresh_routes`](crate::presentation::Presentation::refresh_routes),
161/// which owns the stamp gate.
162pub(crate) fn materialize_document(
163    indexed: &IndexedDocument<'_>,
164    presentation: &mut crate::presentation::Presentation,
165) {
166    let scopes: Vec<crate::path::Scope> = indexed
167        .index
168        .blocks
169        .iter()
170        .filter(|(_, entry)| !entry.routes.is_empty())
171        .map(|(&id, _)| crate::path::Scope::from_wire(id))
172        .collect();
173    // A deleted wire has no geometry, and dropping it here is what keeps
174    // the map from outliving the document (the removal and the deletion were
175    // one statement before the deletion became an op).
176    presentation
177        .routes
178        .retain(|id, _| indexed.doc.route(id).is_some());
179    let mut discarded = crate::gesture::Gesture::idle();
180    for scope in scopes {
181        let mut path = BlockPath::empty();
182        if let crate::path::Scope::Block(id) = scope {
183            path.push(id);
184        }
185        Drawing::new(*indexed, &path, presentation, &mut discarded).materialize_routes();
186    }
187    debug_assert!(
188        discarded.ops().is_empty(),
189        "materialization is a read; it must author nothing"
190    );
191}
192
193impl<'a> Drawing<'a> {
194    pub fn new(
195        base: IndexedDocument<'a>,
196        path: &'a BlockPath,
197        presentation: &'a mut crate::presentation::Presentation,
198        gesture: &'a mut crate::gesture::Gesture,
199    ) -> Self {
200        let view = gesture.view(base);
201        presentation.refresh_accents(&view);
202        presentation.refresh_routes(&view);
203        Self {
204            base,
205            path,
206            index: None,
207            presentation,
208            gesture,
209            supposed: Supposed::default(),
210        }
211    }
212
213    /// The document as this gesture has left it: the session's prediction
214    /// with the gesture's own ops folded on. Every read goes through here,
215    /// so a tool that just created a block can name it in the same frame.
216    pub(super) fn indexed(&self) -> IndexedDocument<'_> {
217        self.gesture.view(self.base)
218    }
219
220    /// [`Self::indexed`] and the presentation together, borrowing only the
221    /// fields each reads — for a pass that fills solved geometry while
222    /// reading the document it was solved from.
223    pub(super) fn split(
224        &mut self,
225    ) -> (IndexedDocument<'_>, &mut crate::presentation::Presentation) {
226        (self.gesture.view(self.base), self.presentation)
227    }
228
229    /// Whether this session may write at all — the answer the sink itself
230    /// gives, so what the tools offer and what the write door accepts come
231    /// from one value.
232    pub fn writability(&self) -> blockworx_store::doc::Writability {
233        self.gesture.writability()
234    }
235
236    /// Whether the add-affordances that belong to no particular shape — the
237    /// route-start targets, a wire's handles — are on offer this frame.
238    pub fn authoring(&self) -> Authoring {
239        self.writability().into()
240    }
241
242    /// Whether `shape`'s own add-affordances are on offer: the session must
243    /// be writable *and* the shape's interface unlocked.
244    pub fn authoring_of(&self, shape: ShapeId) -> Authoring {
245        Authoring::of(self.writability(), self.shape_owner_locked(shape).into())
246    }
247
248    /// The proof a material interface edit needs on `block`, or `None` when
249    /// the lock refuses one — the read half of `crate::edit::lock`, for a
250    /// tool that must decide before it commits.
251    pub fn unlocked_scope(&self, block: BlockId) -> Option<edit::lock::UnlockedScope> {
252        edit::lock::UnlockedScope::of(&self.indexed(), crate::path::Scope::Block(block))
253    }
254
255    /// A fresh document-global id, taken from the document's marks as this
256    /// gesture has left them. The tools take the minted id back, so a
257    /// gesture can anchor to what it just made.
258    pub(crate) fn mint<K: IdKind>(&mut self) -> Id<K> {
259        self.gesture.mint(self.base.doc)
260    }
261
262    /// The allocator an emitter mints a data-dependent number of ids from
263    /// (paste). Every other creating emitter takes its ids pre-minted.
264    pub(super) fn ids(&self) -> Allocator {
265        self.gesture.ids(self.base.doc)
266    }
267
268    /// Author into the gesture. The one write door in the waist: `emit`
269    /// receives the document as [`Self::indexed`] would report it and the
270    /// sink to push into, and the prediction advances before it returns.
271    pub(super) fn author(
272        &mut self,
273        what: &'static str,
274        emit: impl FnOnce(&IndexedDocument<'_>, &mut CommitBuilder),
275    ) {
276        self.gesture.author(self.base, what, emit);
277    }
278
279    /// Like [`Self::new`], but with a spatial index (see the `index` field) so
280    /// broad-phase queries (culling, hit-testing) skip the full linear scan.
281    pub fn new_indexed(
282        base: IndexedDocument<'a>,
283        path: &'a BlockPath,
284        index: &'a SpatialIndex,
285        presentation: &'a mut crate::presentation::Presentation,
286        gesture: &'a mut crate::gesture::Gesture,
287    ) -> Self {
288        let view = gesture.view(base);
289        presentation.refresh_accents(&view);
290        presentation.refresh_routes(&view);
291        Self {
292            base,
293            path,
294            index: Some(index),
295            presentation,
296            gesture,
297            supposed: Supposed::default(),
298        }
299    }
300
301    /// The scope being drawn: the path's last segment, or the document
302    /// root, which is a scope like any other (F9).
303    pub fn current_scope(&self) -> crate::path::Scope {
304        self.path.scope()
305    }
306
307    /// What the current scope holds. `None` only when the scope's block
308    /// has been deleted under the path.
309    pub(super) fn scope(&self) -> Option<&BlockIndex> {
310        self.indexed().index.scope(self.current_scope().wire_id())
311    }
312
313    /// The block whose interior is being drawn, or `None` at the document
314    /// root — which has a scope but no block entity behind it.
315    pub(super) fn current(&self) -> Option<&Block> {
316        self.current_scope()
317            .block()
318            .and_then(|id| self.held_block(id))
319    }
320
321    pub(super) fn held_block(&self, id: BlockId) -> Option<&Block> {
322        self.indexed().doc.block(&id)
323    }
324
325    pub(crate) fn held_pin(&self, id: PinId) -> Option<&Pin> {
326        self.indexed().doc.pin(&id)
327    }
328
329    /// Which shape draws `pin` in this scope: the scope's own boundary
330    /// port, or a stub on the child block that owns it. The port-vs-pin
331    /// split `LineAnchor` used to encode, resolved once as the document
332    /// read it always was.
333    pub(crate) fn pin_shape(&self, pin: PinId) -> Option<ShapeId> {
334        let owner = self.held_pin(pin)?.owner;
335        Some(
336            if crate::path::Scope::from_wire(owner) == self.current_scope() {
337                ShapeId::Port(pin)
338            } else {
339                ShapeId::Rect(owner)
340            },
341        )
342    }
343
344    /// A child block as the geometry layer draws it — body plus the pins that
345    /// hang off it. The one unwrap of [`Self::shape`]'s block arm, so callers
346    /// reaching for `BaseShape`'s block-only methods do not each spell it.
347    pub(crate) fn block_shape(&self, id: BlockId) -> Option<BlockShape<'_>> {
348        match self.shape(ShapeId::Rect(id))? {
349            ShapeRef::Block(block) => Some(block),
350            _ => None,
351        }
352    }
353
354    /// The shape drawing `pin` here, paired with the pin entity: the stub's
355    /// geometry hangs off the shape, its labels off the pin, and every
356    /// pin-level read wants both.
357    pub(crate) fn pin_on_shape(&self, pin: PinId) -> Option<(ShapeRef<'_>, &Pin)> {
358        Some((self.shape(self.pin_shape(pin)?)?, self.held_pin(pin)?))
359    }
360
361    /// Whether the block that owns `pin` is locked. A locked block keeps
362    /// its pin/port interface frozen, so callers consult this before any
363    /// material pin edit.
364    pub(crate) fn pin_owner_locked(&self, pin: PinId) -> bool {
365        self.held_pin(pin)
366            .and_then(|pin| self.held_block(pin.owner))
367            .is_some_and(|owner| owner.locked)
368    }
369
370    /// Whether the current block (whose ports these are) is locked. The
371    /// document root is never locked — there is no block to freeze.
372    pub(crate) fn current_locked(&self) -> bool {
373        self.current().is_some_and(|b| b.locked)
374    }
375
376    /// Whether the block that owns `id`'s pins is locked: a child block by its own
377    /// flag, a port by the current block's flag. Shapes without a pin interface
378    /// (text, area, image) are never locked.
379    pub(crate) fn shape_owner_locked(&self, id: ShapeId) -> bool {
380        match id {
381            ShapeId::Rect(rid) => self.held_block(rid).is_some_and(|b| b.locked),
382            ShapeId::Port(_) => self.current_locked(),
383            ShapeId::Text(_) | ShapeId::Area(_) | ShapeId::Image(_) | ShapeId::Icon(_) => false,
384        }
385    }
386
387    /// The derived pin-accent lookup for the shapes of this scope: every
388    /// stub drawn here takes its color from the wires routed here, whether
389    /// it belongs to a child block or to the scope's own boundary.
390    pub fn shape_accents(&self) -> crate::presentation::ShapeAccents<'_> {
391        crate::presentation::ShapeAccents::new(self.current_scope(), &self.presentation.pin_accents)
392    }
393
394    // ── Scope reads ──────────────────────────────────────────────────────────
395    //
396    // What this scope holds, resolved out of the index and handed to the
397    // geometry layer, which never looks anything up itself.
398
399    /// `ids` resolved to their entities in `chronological` order — the
400    /// doc crate's one draw-order policy, so painting, hit-testing, and the
401    /// router cannot disagree about which shape is on top.
402    fn ordered<'s, K, T>(
403        &'s self,
404        ids: impl IntoIterator<Item = Id<K>>,
405        lookup: impl Fn(&'s DocDocument, &Id<K>) -> Option<&'s T>,
406    ) -> Vec<(Id<K>, &'s T)>
407    where
408        K: IdKind + Ord + Copy,
409        T: Entity + 's,
410    {
411        let doc = self.indexed().doc;
412        let held = |id: &Id<K>| lookup(doc, id);
413        let entries: Vec<(Id<K>, &T)> = ids
414            .into_iter()
415            .filter_map(|id| Some((id, held(&id)?)))
416            .collect();
417        chronological(entries.iter().copied())
418            .into_iter()
419            .filter_map(|id| Some((id, held(&id)?)))
420            .collect()
421    }
422
423    /// The scope's child blocks, in draw order.
424    pub(super) fn child_blocks(&self) -> Vec<(BlockId, &Block)> {
425        crate::path::child_blocks(&self.indexed(), self.current_scope())
426            .into_iter()
427            .filter_map(|id| Some((id, self.held_block(id)?)))
428            .collect()
429    }
430
431    /// One child block of this scope. A block the scope does not hold is not
432    /// this drawing's to read, even though the document holds it.
433    pub fn block(&self, id: BlockId) -> Option<&Block> {
434        self.scope()?
435            .children
436            .contains(&id)
437            .then(|| self.held_block(id))
438            .flatten()
439    }
440
441    /// A scope's own pins, in draw order — the set the geometry layer is
442    /// handed alongside the block (or, at the root, the document's own
443    /// boundary), since a pin is its own entity.
444    pub(super) fn block_pins(&self, scope: crate::path::Scope) -> Vec<(PinId, &Pin)> {
445        let Some(entry) = self.indexed().index.scope(scope.wire_id()) else {
446            return Vec::new();
447        };
448        self.ordered(entry.pins.iter().copied(), |doc, id| doc.pin(id))
449    }
450
451    fn scope_texts(&self) -> Vec<(TextId, &Text)> {
452        let Some(scope) = self.scope() else {
453            return Vec::new();
454        };
455        self.ordered(scope.texts.iter().copied(), |doc, id| doc.text(id))
456    }
457
458    fn scope_areas(&self) -> Vec<(AreaId, &Area)> {
459        let Some(scope) = self.scope() else {
460            return Vec::new();
461        };
462        self.ordered(scope.areas.iter().copied(), |doc, id| doc.area(id))
463    }
464
465    fn scope_images(&self) -> Vec<(ImageId, &Image)> {
466        let Some(scope) = self.scope() else {
467            return Vec::new();
468        };
469        self.ordered(scope.images.iter().copied(), |doc, id| doc.image(id))
470    }
471
472    /// Whether this scope holds what `owner` names. A scope whose block has
473    /// been deleted under the path holds nothing, which is what keeps a
474    /// single lookup agreeing with a walk of the index's per-scope sets.
475    fn scope_owns(&self, owner: BlockId) -> bool {
476        self.scope().is_some() && crate::path::Scope::from_wire(owner) == self.current_scope()
477    }
478
479    /// An entity, iff this scope owns it. Ownership is the `owner`
480    /// register, which is what the index's per-scope sets are built from —
481    /// so this answers the same question as a scan of them, in one lookup.
482    fn scope_owned<'s, T: Entity>(
483        &'s self,
484        entity: Option<&'s T>,
485        owner: impl Fn(&T) -> BlockId,
486    ) -> Option<&'s T> {
487        entity.filter(|entity| self.scope_owns(owner(entity)))
488    }
489
490    /// The bytes an asset hash names, or `None` when the document does not
491    /// hold them — which the zero icon's null hash also reads as.
492    fn asset(&self, hash: &AssetHash) -> Option<&Asset> {
493        self.indexed().doc.asset(hash)
494    }
495
496    /// Delete a shape or route. The cascade — a block's subtree, the wires
497    /// landing on any pin it carries, each wire's labels — is the delete
498    /// emitter's own closure, so one selection is one commit however deep
499    /// it reaches.
500    pub fn delete(&mut self, what: Deletable) {
501        let targets: Vec<edit::delete::Target> = match what {
502            Deletable::Shape(id) => self.shape_targets(&[id]),
503            Deletable::Shapes(ids) => self.shape_targets(&ids),
504            Deletable::Route(rid) => vec![edit::delete::Target::Route(rid)],
505            Deletable::Pins(pins) => pins.into_iter().map(edit::delete::Target::Pin).collect(),
506        };
507        self.author("delete", |indexed, sink| {
508            edit::delete::selection(indexed, &targets, sink);
509        });
510    }
511
512    /// The entities `shapes` name, zeroing every icon among them on the way:
513    /// an icon is a value on its block, so it has no id for the cascade to
514    /// delete and its own emitter clears it instead.
515    fn shape_targets(&mut self, shapes: &[ShapeId]) -> Vec<edit::delete::Target> {
516        for &id in shapes {
517            if let ShapeId::Icon(block) = id {
518                self.author("shape_targets", |indexed, sink| {
519                    edit::assets::delete_icon(indexed.doc, block, sink);
520                });
521            }
522        }
523        shapes.iter().filter_map(|&id| delete_target(id)).collect()
524    }
525
526    // ── Route accessors ──────────────────────────────────────────────────────
527
528    /// The scope's wires, in draw order — the doc crate's one
529    /// `chronological` policy, so painting, hit-testing, and the crossing
530    /// pass all walk the same sequence.
531    pub(super) fn scope_route_ids(&self) -> Vec<RouteId> {
532        let Some(scope) = self.scope() else {
533            return Vec::new();
534        };
535        let doc = self.indexed().doc;
536        let held: Vec<(RouteId, &Route)> = scope
537            .routes
538            .iter()
539            .filter_map(|&id| Some((id, doc.route(&id)?)))
540            .collect();
541        chronological(held.iter().copied())
542    }
543
544    pub(super) fn route(&self, id: RouteId) -> Option<&Route> {
545        self.indexed().doc.route(&id)
546    }
547
548    pub fn auto_routes(&self) -> impl Iterator<Item = (RouteId, Wire<'_>)> {
549        self.scope_route_ids()
550            .into_iter()
551            .filter_map(|id| Some((id, self.auto_route(id)?)))
552            .collect::<Vec<_>>()
553            .into_iter()
554    }
555
556    /// One wire of this scope: the authored route bundled with the labels
557    /// the index hangs off it. A route the scope does not hold is not this
558    /// drawing's to read, even though the document holds it.
559    pub fn auto_route(&self, id: RouteId) -> Option<Wire<'_>> {
560        let route = self
561            .route(id)
562            .filter(|route| self.scope_owns(route.owner))?;
563        Some(Wire {
564            route,
565            labels: route_labels(&self.indexed(), id),
566        })
567    }
568    /// Wire `from` to `to` along the solved `waypoints`, in this scope. A
569    /// destination the gesture drew onto a free slot arrives as
570    /// [`RouteEnd::Fresh`](crate::edit::create::RouteEnd::Fresh) and is
571    /// stamped in the same commit; an owner that refuses it takes the wire
572    /// with it.
573    pub fn add_route(
574        &mut self,
575        from: PinId,
576        to: crate::edit::create::RouteEnd,
577        waypoints: Vec<Waypoint>,
578    ) -> RouteId {
579        let id = self.mint();
580        let owner = self.current_scope();
581        self.author("add_route", |indexed, sink| {
582            edit::create::route(
583                indexed,
584                edit::create::NewRoute {
585                    id,
586                    owner,
587                    from,
588                    to,
589                    waypoints,
590                },
591                sink,
592            );
593        });
594        id
595    }
596    /// The solved geometry beside `id`, or `None` for a route that has not
597    /// been materialized — nothing to draw or hit-test yet.
598    pub fn route_geometry(&self, id: RouteId) -> Option<&RouteGeometry> {
599        self.presentation.routes.get(&id)
600    }
601
602    // ── Image (background image) accessors ───────────────────────────────────
603
604    /// Create a free-floating background image with `asset` as its content,
605    /// boxed by `placement`, returning its id as a `ShapeId`.
606    pub fn add_image(&mut self, placement: edit::assets::Placement, asset: &Asset) -> ShapeId {
607        let id: ImageId = self.mint();
608        let owner = self.current_scope();
609        self.author("add_image", |indexed, sink| {
610            edit::assets::image(
611                indexed.doc,
612                edit::assets::NewImage {
613                    id,
614                    owner,
615                    placement,
616                },
617                asset,
618                sink,
619            );
620        });
621        ShapeId::Image(id)
622    }
623    #[cfg_attr(not(test), allow(dead_code))]
624    pub fn image(&self, id: ImageId) -> Option<&Image> {
625        self.scope_owned(self.indexed().doc.image(&id), |i| i.owner)
626    }
627
628    // ── Icon (block foreground image) accessors ───────────────────────────────
629
630    /// The artwork block `id` carries, or `None` for the zero icon — an
631    /// empty box with the null asset is "no image", not a blank picture.
632    pub fn icon(&self, id: BlockId) -> Option<&Icon> {
633        self.block(id)
634            .and_then(|b| crate::edit::geometry::artwork(&b.icon))
635    }
636    /// Attach (or replace) block `id`'s icon with `asset`, sized to a default
637    /// box centered on the block.
638    pub fn set_icon(&mut self, id: BlockId, asset: &Asset) {
639        self.author("set_icon", |indexed, sink| {
640            edit::assets::set_icon(indexed.doc, id, asset, sink);
641        });
642    }
643
644    // ── Block (rect) accessors ────────────────────────────────────────────────
645
646    pub fn add_block(&mut self, start: Pos2, end: Pos2) -> BlockId {
647        let id = self.mint();
648        let scope = self.current_scope();
649        self.author("add_block", |indexed, sink| {
650            edit::create::block(
651                indexed.doc,
652                edit::create::NewBlock {
653                    id,
654                    scope,
655                    start,
656                    end,
657                },
658                sink,
659            );
660        });
661        id
662    }
663    /// Alias kept for callers that haven't been updated yet.
664    pub fn add_rect_box(&mut self, start: Pos2, end: Pos2) -> BlockId {
665        self.add_block(start, end)
666    }
667
668    // ── Text box accessors ────────────────────────────────────────────────────
669
670    /// Create an empty, default-sized text box with its top-left corner at
671    /// `pos`, returning its freshly assigned id. The caller typically follows up
672    /// by opening the text editor on the returned box.
673    pub fn add_text_box(&mut self, pos: Pos2) -> TextId {
674        let id = self.mint();
675        let scope = self.current_scope();
676        self.author("add_text_box", |_indexed, sink| {
677            edit::create::text_box(id, scope, pos, sink);
678        });
679        id
680    }
681    pub fn text_box(&self, id: TextId) -> Option<&Text> {
682        self.scope_owned(self.indexed().doc.text(&id), |t| t.owner)
683    }
684
685    // ── Area accessors ─────────────────────────────────────────────────────
686
687    /// Create a boundary area covering the drag rectangle `start..end`,
688    /// returning its id as a `ShapeId`. The caller (the `NewArea` tool)
689    /// typically selects it afterwards.
690    pub fn add_area(&mut self, start: Pos2, end: Pos2) -> ShapeId {
691        let id: AreaId = self.mint();
692        let scope = self.current_scope();
693        self.author("add_area", |_indexed, sink| {
694            edit::create::area(id, scope, start, end, sink);
695        });
696        ShapeId::Area(id)
697    }
698    /// Iterate the current level's areas — the top layer, drawn above the
699    /// blocks and routes. Kept separate from [`Self::shapes`] so areas neither
700    /// render in the base layer nor act as routing obstacles.
701    pub fn areas(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
702        self.scope_areas()
703            .into_iter()
704            .map(|(id, c)| (ShapeId::Area(id), ShapeRef::Area(c)))
705    }
706
707    // ── Port accessors ───────────────────────────────────────────────────────
708
709    /// Insert a port whose name is auto-derived from its freshly assigned id
710    /// (`"Port N"`). The id is only known after insertion, hence the two-step
711    /// insert-then-rename. The boundary `side`+`offset` is picked from the
712    /// first entry of `current().new_pin_locations()`; the current block is
713    /// grown by one pin slot (one [`PIN_PITCH`](crate::grid::PIN_PITCH)) in height if no boundary
714    /// location is available.
715    pub fn add_port_auto_named(&mut self, inner: Rect) -> PinId {
716        let id = self.mint();
717        let scope = self.current_scope();
718        self.author("add_port_auto_named", |indexed, sink| {
719            // A port is a material edit, so the scope has to accept one.
720            let Some(owner) = edit::lock::UnlockedScope::of(indexed, scope) else {
721                return;
722            };
723            edit::create::port(
724                indexed,
725                edit::create::NewPort {
726                    id,
727                    owner,
728                    start: inner.min,
729                    end: inner.max,
730                },
731                sink,
732            );
733        });
734        id
735    }
736
737    // ── Combined shape accessors ──────────────────────────────────────────────
738
739    /// Generic lookup: returns a `ShapeRef` regardless of kind.
740    pub fn shape(&self, id: ShapeId) -> Option<ShapeRef<'_>> {
741        match id {
742            ShapeId::Rect(rid) => Some(ShapeRef::Block(self.shape_of_block(rid, self.block(rid)?))),
743            // A port body is the scope's *own* pin; the same pin seen from the
744            // parent is a stub on this block's rect instead (`pin_shape`).
745            ShapeId::Port(pid) => {
746                let pin = self
747                    .held_pin(pid)
748                    .filter(|pin| self.scope_owns(pin.owner))?;
749                Some(ShapeRef::Port(PortShape { id: pid, pin }))
750            }
751            ShapeId::Text(tid) => {
752                let text = self.scope_owned(self.indexed().doc.text(&tid), |t| t.owner)?;
753                let extent = self.presentation.text_extents.valid_for(tid, &text.text);
754                Some(ShapeRef::text(text, extent))
755            }
756            ShapeId::Area(cid) => self
757                .scope_owned(self.indexed().doc.area(&cid), |c| c.owner)
758                .map(ShapeRef::Area),
759            ShapeId::Image(sid) => {
760                let image = self.scope_owned(self.indexed().doc.image(&sid), |i| i.owner)?;
761                Some(ShapeRef::artwork(
762                    artwork_rect(image.rect),
763                    self.asset(&image.asset),
764                ))
765            }
766            ShapeId::Icon(rid) => {
767                let icon = crate::edit::geometry::artwork(&self.block(rid)?.icon)?;
768                Some(ShapeRef::artwork(
769                    artwork_rect(icon.rect),
770                    self.asset(&icon.asset),
771                ))
772            }
773        }
774    }
775    /// The pin-tag visibility of a selected port, for the selection bar's toggle.
776    /// `None` for shapes that carry no such tag (blocks, text boxes, areas,
777    /// images).
778    pub fn shape_tag_hidden(&self, id: ShapeId) -> Option<bool> {
779        match id {
780            // A port body *is* one pin, so the port's own id is the pin's.
781            ShapeId::Port(pid) => Some(self.shape(id)?.pin(pid)?.tag_hidden),
782            ShapeId::Rect(_)
783            | ShapeId::Text(_)
784            | ShapeId::Area(_)
785            | ShapeId::Image(_)
786            | ShapeId::Icon(_) => None,
787        }
788    }
789
790    /// Set the pin-tag visibility of a port — the counterpart to
791    /// [`shape_tag_hidden`](Self::shape_tag_hidden). No-op for other shapes.
792    pub fn set_shape_tag_hidden(&mut self, id: ShapeId, tag: TagVisibility) {
793        // A port body *is* one pin, so the port's own id is the pin's.
794        if let ShapeId::Port(pid) = id {
795            self.author("set_shape_tag_hidden", |indexed, sink| {
796                edit::naming::set_tag_visibility(indexed.doc, &[pid], tag, sink);
797            });
798        }
799    }
800
801    /// Give `target` the accent role `role` (`None` clears it). A route's
802    /// role repropagates the derived pin accents when the commit lands.
803    pub fn set_role(&mut self, target: RoleTarget, role: Option<u8>) {
804        let target = match target {
805            RoleTarget::Block(id) => edit::naming::AccentTarget::Block(id),
806            RoleTarget::Port(id) => edit::naming::AccentTarget::Port(id),
807            RoleTarget::Route(id) => edit::naming::AccentTarget::Route(id),
808            RoleTarget::Area(id) => edit::naming::AccentTarget::Area(id),
809            RoleTarget::Text(id) => edit::naming::AccentTarget::Text(id),
810        };
811        self.author("set_role", |indexed, sink| {
812            edit::naming::set_accent(indexed.doc, target, role, sink);
813        });
814    }
815
816    /// Set the I/O direction on every pin in the group whose owner is
817    /// unlocked — a locked block keeps its pin interface frozen.
818    pub fn set_pins_kind(&mut self, pins: &[PinId], dir: PinDir) {
819        self.author("set_pins_kind", |indexed, sink| {
820            let targets = edit::lock::MaterialPin::all(indexed.doc, pins);
821            edit::naming::set_dirs(indexed.doc, &targets, dir, sink);
822        });
823    }
824
825    /// Cycle one pin's I/O direction in place — the selected-stub click.
826    /// A locked owner declines: direction is what a pin *is*.
827    pub fn cycle_pin_kind(&mut self, pin: PinId) {
828        self.author("cycle_pin_kind", |indexed, sink| {
829            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
830                return;
831            };
832            edit::naming::cycle_dir(indexed.doc, target, sink);
833        });
834    }
835
836    /// Show or hide the location tags on a pin group.
837    pub fn set_pins_tag_hidden(&mut self, pins: &[PinId], tag: TagVisibility) {
838        self.author("set_pins_tag_hidden", |indexed, sink| {
839            edit::naming::set_tag_visibility(indexed.doc, pins, tag, sink);
840        });
841    }
842
843    /// Freeze or thaw a block's pin interface.
844    pub fn set_block_locked(&mut self, block: BlockId, lock: InterfaceLock) {
845        self.author("set_block_locked", |indexed, sink| {
846            edit::naming::set_locked(indexed.doc, block, lock, sink);
847        });
848    }
849
850    /// Write `text` to `shape`'s title, if it carries one — a block or a
851    /// area.
852    pub fn set_title_text(&mut self, shape: ShapeId, text: &str) {
853        let Some(target) = title_target(shape) else {
854            return;
855        };
856        self.author("set_title_text", |indexed, sink| {
857            edit::naming::rename_title(indexed.doc, target, text, sink);
858        });
859    }
860
861    /// Commit a dragged title's placement.
862    pub fn place_title(&mut self, shape: ShapeId, placement: LabelPlacement) {
863        let target = match shape {
864            ShapeId::Rect(id) => edit::naming::LabelTarget::BlockTitle(id),
865            ShapeId::Area(id) => edit::naming::LabelTarget::AreaTitle(id),
866            _ => return,
867        };
868        self.author("place_title", |indexed, sink| {
869            edit::naming::place_label(indexed.doc, target, placement.into(), sink);
870        });
871    }
872
873    /// Write `text` to block `rect`'s type label.
874    pub fn set_type_label_text(&mut self, rect: BlockId, text: &str) {
875        self.author("set_type_label_text", |indexed, sink| {
876            edit::naming::rename_type(indexed.doc, rect, text, sink);
877        });
878    }
879
880    /// Commit a dragged type label's placement.
881    pub fn place_type_label(&mut self, rect: BlockId, placement: LabelPlacement) {
882        self.author("place_type_label", |indexed, sink| {
883            edit::naming::place_label(
884                indexed.doc,
885                edit::naming::LabelTarget::BlockType(rect),
886                placement.into(),
887                sink,
888            );
889        });
890    }
891
892    /// The width pin `pin`'s port body must reach to hold its labels, with
893    /// `name`/`type_name` overriding what it holds now — the measurement a
894    /// rename or retype owes its emitter, which cannot make one for itself.
895    pub fn label_fit(
896        &self,
897        pin: PinId,
898        name: Option<&str>,
899        type_name: Option<&str>,
900    ) -> LabelFitWidth {
901        let held = self.held_pin(pin);
902        let name = name.unwrap_or_else(|| held.map_or("", |pin| &pin.name));
903        let type_name = type_name.unwrap_or_else(|| held.map_or("", |pin| &pin.type_name));
904        LabelFitWidth::new(crate::shape::port::width_for_labels(name, type_name))
905    }
906
907    /// Rename a pin, widening the port body to fit its labels. A locked
908    /// block keeps its pin interface frozen — the commit is dropped.
909    pub fn rename_pin(&mut self, pin: PinId, text: &str, fit: LabelFitWidth) {
910        self.author("rename_pin", |indexed, sink| {
911            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
912                return;
913            };
914            edit::naming::rename_pin(indexed.doc, target, text, fit, sink);
915        });
916    }
917
918    /// Set a pin's location tag. Tags extend outward over the stub, so
919    /// no widening. Locked owners drop the commit, as with renames.
920    pub fn set_pin_tag(&mut self, pin: PinId, text: &str) {
921        self.author("set_pin_tag", |indexed, sink| {
922            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
923                return;
924            };
925            edit::naming::set_tag(indexed.doc, target, text, sink);
926        });
927    }
928
929    /// Set a pin's type label, widening the port body to fit. Locked
930    /// owners drop the commit.
931    pub fn retype_pin(&mut self, pin: PinId, text: &str, fit: LabelFitWidth) {
932        self.author("retype_pin", |indexed, sink| {
933            let Some(target) = edit::lock::MaterialPin::of(indexed.doc, pin) else {
934                return;
935            };
936            edit::naming::retype_pin(indexed.doc, target, text, fit, sink);
937        });
938    }
939
940    /// Add a pin at `loc` on `block`, auto-named "Port N" from its ordinal.
941    /// Returns the id so the caller can anchor a route to it. A locked
942    /// block keeps its pin interface frozen — `None`.
943    pub fn add_named_pin(&mut self, block: BlockId, loc: PinLocation) -> Option<PinId> {
944        // Asked here as well as inside, because the caller needs the answer:
945        // a declined stamp has no id to hand back.
946        if edit::lock::UnlockedScope::of(&self.indexed(), crate::path::Scope::Block(block))
947            .is_none()
948        {
949            tracing::debug!(target: "edit", edit = "add_named_pin", "declined");
950            return None;
951        }
952        let id = self.mint();
953        self.author("add_named_pin", |indexed, sink| {
954            let Some(owner) =
955                edit::lock::UnlockedScope::of(indexed, crate::path::Scope::Block(block))
956            else {
957                return;
958            };
959            edit::create::pin(
960                indexed,
961                edit::create::NewPin {
962                    id,
963                    owner,
964                    slot: slot_at(loc),
965                },
966                sink,
967            );
968        });
969        Some(id)
970    }
971
972    /// Commit a handle-drag resize: the caller resolved the final rect
973    /// (constraints, snapping, magnetism, which corner moved), this writes
974    /// it — geometry, carried pins, and icon box.
975    pub fn apply_resize(&mut self, shape: ShapeId, new_rect: Rect) {
976        let target = match shape {
977            ShapeId::Rect(id) => edit::geometry::ResizeTarget::Block(id),
978            ShapeId::Port(id) => edit::geometry::ResizeTarget::Port(id),
979            ShapeId::Area(id) => edit::geometry::ResizeTarget::Area(id),
980            ShapeId::Image(id) => edit::geometry::ResizeTarget::Image(id),
981            ShapeId::Icon(id) => edit::geometry::ResizeTarget::Icon(id),
982            // A text box's extent follows its content, so it carries no
983            // handles to drag.
984            ShapeId::Text(_) => return,
985        };
986        self.author("apply_resize", |indexed, sink| {
987            edit::geometry::resize(indexed, target, new_rect, sink);
988        });
989    }
990
991    /// Move a pin to `to`, which the gesture already resolved against the
992    /// live preview ([`BaseShape::pin_drop_candidate`](crate::shape::BaseShape::pin_drop_candidate))
993    /// — so the drop lands exactly where the drag promised. A destination
994    /// another pin holds writes nothing.
995    pub fn move_pin_snapped(&mut self, pin: PinId, to: PinSlot) {
996        self.author("move_pin_snapped", |indexed, sink| {
997            edit::geometry::move_pin(indexed, pin, to, sink);
998        });
999    }
1000
1001    /// Name a wire. An empty/whitespace name removes `label` entirely and
1002    /// clears the shared name, so the wire goes back to unlabeled rather
1003    /// than carrying a blank slot.
1004    pub fn set_route_name(&mut self, route: RouteId, label: RouteLabelId, text: &str) {
1005        if text.trim().is_empty() {
1006            self.author("set_route_name", |indexed, sink| {
1007                edit::naming::clear_wire_label(indexed.doc, label, sink);
1008            });
1009        } else {
1010            self.author("set_route_name", |indexed, sink| {
1011                edit::naming::rename_route(indexed.doc, route, text, sink);
1012            });
1013        }
1014    }
1015
1016    /// Commit a text box's content. Emptying the box discards it rather than
1017    /// leaving it invisible.
1018    ///
1019    /// The extent is not written here: it is measured on demand by
1020    /// [`Self::refresh_text_extents`], because the text can change without an
1021    /// editor being involved and a cache only filled by the editor goes
1022    /// stale the moment it does.
1023    pub fn set_text_box_content(&mut self, id: TextId, text: &str) {
1024        self.author("set_text_box_content", |indexed, sink| {
1025            edit::naming::edit_text(indexed.doc, id, text, sink);
1026        });
1027    }
1028
1029    /// Measure any text box in this scope whose cached extent no longer
1030    /// matches its text, so the box draws and hit-tests at its real size.
1031    ///
1032    /// On demand rather than on commit. A box's text changes under the cache
1033    /// whenever *this* client was not the one typing — an undo, a redo, a
1034    /// collaborator's edit — and an extent written only by the editor leaves
1035    /// those boxes on the character-count estimate until someone re-opens
1036    /// them, which is what they did. Cheap by construction: an entry that
1037    /// still matches its text is not measured again, so a frame that changed
1038    /// no text measures nothing.
1039    pub fn refresh_text_extents(&mut self, painter: &Style<'_, impl Renderer>) {
1040        let stale: Vec<(TextId, String)> = self
1041            .scope_texts()
1042            .into_iter()
1043            .filter(|(id, text)| {
1044                self.presentation
1045                    .text_extents
1046                    .valid_for(*id, &text.text)
1047                    .is_none()
1048            })
1049            .map(|(id, text)| (id, text.text.clone()))
1050            .collect();
1051        for (id, text) in stale {
1052            // The same measurement the editor used to commit, so the box does
1053            // not change size the first time it is re-opened.
1054            let size = crate::render::text_box::measure_box_size(painter, &text);
1055            self.presentation.text_extents.set(id, text, size);
1056        }
1057    }
1058
1059    /// Drop a name label on `route` at the projection of `pos` onto its
1060    /// polyline. `None` for a wire with no solved geometry to project onto.
1061    pub fn add_route_label(&mut self, route: RouteId, pos: Pos2) -> Option<RouteLabelId> {
1062        let distance = self.route_geometry(route)?.distance_along(pos);
1063        let id = self.mint();
1064        self.author("add_route_label", |indexed, sink| {
1065            edit::create::wire_label(indexed.doc, id, route, distance, sink);
1066        });
1067        Some(id)
1068    }
1069
1070    /// Place a wire's name label at an arc length along its route — the label
1071    /// drag's one document write, of the distance the preview settled on.
1072    pub fn place_route_label(
1073        &mut self,
1074        label: RouteLabelId,
1075        dist: blockworx_doc::geometry::FracVal,
1076    ) {
1077        self.author("place_route_label", |indexed, sink| {
1078            edit::geometry::place_wire_label(indexed.doc, label, dist, sink);
1079        });
1080    }
1081
1082    /// Mirror a selected shape left/right. No-op for shapes without pins; the
1083    /// caller re-routes afterwards.
1084    ///
1085    /// A **port** flips only its `port_orientation` (the way its stub faces), so
1086    /// the pin it represents on the parent block doesn't move. A **block** flips
1087    /// the edge `side` of all its pins, but first freezes each pin's
1088    /// `port_orientation` to its current value — so descending into the block
1089    /// shows the same boundary ports as before and its internal routes are left
1090    /// intact.
1091    pub fn flip_shape_pins(&mut self, id: ShapeId) {
1092        let target = match id {
1093            ShapeId::Rect(rid) => edit::geometry::FlipTarget::Block(rid),
1094            ShapeId::Port(pid) => edit::geometry::FlipTarget::Port(pid),
1095            ShapeId::Text(_) | ShapeId::Area(_) | ShapeId::Image(_) | ShapeId::Icon(_) => return,
1096        };
1097        self.author("flip_shape_pins", |indexed, sink| {
1098            edit::geometry::flip_pins(indexed, target, sink);
1099        });
1100    }
1101
1102    /// Add a level above the whole document: a fresh root whose sole child
1103    /// is the old one, which demotes to a child rect.
1104    ///
1105    /// Reachable from nothing on screen: it used to be "Go Up" at the
1106    /// document root, which read as a navigation button that quietly wrote
1107    /// a block (docs/ui-issues-2.md, item 8). The emitter keeps working;
1108    /// where the user asks for it from is still open.
1109    #[expect(
1110        dead_code,
1111        reason = "unhooked from Go Up until it is given a home of its own"
1112    )]
1113    pub fn wrap_top(&mut self) {
1114        let id = self.mint();
1115        self.author("wrap_top", |indexed, sink| {
1116            edit::create::wrap_top(indexed, id, sink);
1117        });
1118    }
1119
1120    /// Vertically mirror a block's pins about its center: a pin in slot `offset`
1121    /// moves to slot `h - offset`, where `h` ([`crate::grid::max_pin_slot`]) is
1122    /// the block's
1123    /// height in slots. Each pin keeps its `side`; only its vertical slot moves.
1124    /// A double flip is an exact identity. The caller re-routes afterwards.
1125    pub fn flip_block_vertical(&mut self, block: BlockId) {
1126        self.author("flip_block_vertical", |indexed, sink| {
1127            edit::geometry::flip_vertical(indexed, block, sink);
1128        });
1129    }
1130
1131    /// The current level's child blocks paired with their ids, for callers that
1132    /// need the `Block` directly rather than as a `ShapeRef`.
1133    pub fn current_blocks(&self) -> impl Iterator<Item = (BlockId, &Block)> {
1134        self.child_blocks().into_iter()
1135    }
1136
1137    /// A block as the renderer sees it: its pins, and whether it opens a scope
1138    /// of its own. The one place a `Block` becomes a [`BlockShape`], so no
1139    /// caller can hand the renderer a block whose interior it never asked about.
1140    fn shape_of_block<'s>(&'s self, id: BlockId, block: &'s Block) -> BlockShape<'s> {
1141        BlockShape::new(
1142            block,
1143            self.block_pins(crate::path::Scope::Block(id)),
1144            crate::path::structure(&self.indexed(), id),
1145        )
1146    }
1147
1148    /// The child blocks of the current level, as renderable shapes — each
1149    /// bundled with the pins that hang off it.
1150    pub fn blocks_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1151        self.child_blocks().into_iter().map(|(id, block)| {
1152            (
1153                ShapeId::Rect(id),
1154                ShapeRef::Block(self.shape_of_block(id, block)),
1155            )
1156        })
1157    }
1158    /// The current level's boundary ports, as renderable shapes: the scope's
1159    /// own pins, drawn as the port bodies their `rect` register places.
1160    pub fn ports_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1161        self.block_pins(self.current_scope())
1162            .into_iter()
1163            .map(|(id, pin)| (ShapeId::Port(id), ShapeRef::Port(PortShape { id, pin })))
1164    }
1165    /// The current level's text boxes, as renderable shapes.
1166    pub fn texts_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1167        self.scope_texts().into_iter().map(|(id, text)| {
1168            let extent = self.presentation.text_extents.valid_for(id, &text.text);
1169            (ShapeId::Text(id), ShapeRef::text(text, extent))
1170        })
1171    }
1172    /// The current level's free-floating background images, as renderable shapes.
1173    pub fn images_layer(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1174        self.scope_images().into_iter().map(|(id, image)| {
1175            (
1176                ShapeId::Image(id),
1177                ShapeRef::artwork(artwork_rect(image.rect), self.asset(&image.asset)),
1178            )
1179        })
1180    }
1181    /// The current level's block icons, as renderable shapes — the foreground
1182    /// layer, drawn on top of everything (see
1183    /// [`DrawingPasses`](crate::widget::DrawingPasses)).
1184    pub fn icons(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1185        self.child_blocks().into_iter().filter_map(|(id, block)| {
1186            let icon = crate::edit::geometry::artwork(&block.icon)?;
1187            Some((
1188                ShapeId::Icon(id),
1189                ShapeRef::artwork(artwork_rect(icon.rect), self.asset(&icon.asset)),
1190            ))
1191        })
1192    }
1193    /// Iterate over all shapes (blocks first, then ports, then text boxes, then
1194    /// images). This order is for non-render uses (hit-testing, copy); the
1195    /// painted layer order lives in [`DrawingPasses`](crate::widget::DrawingPasses).
1196    pub fn shapes(&self) -> impl Iterator<Item = (ShapeId, ShapeRef<'_>)> {
1197        self.blocks_layer()
1198            .chain(self.ports_layer())
1199            .chain(self.texts_layer())
1200            .chain(self.images_layer())
1201    }
1202
1203    /// Whether every destination is free and in bounds — the gate a group
1204    /// pin move's *preview* asks before drawing it as valid. The same
1205    /// predicate the commit runs, so the preview cannot promise a move the
1206    /// emitter will refuse.
1207    pub fn can_relocate_pins(&self, moves: &[PinMove]) -> bool {
1208        crate::edit::geometry::relocation_fits(&self.indexed(), moves)
1209    }
1210
1211    /// Apply a group pin move iff [`Self::can_relocate_pins`] holds; returns
1212    /// whether it was applied. On success each pin takes its new slot.
1213    pub fn relocate_pins(&mut self, moves: &[PinMove]) -> bool {
1214        let applied = self.can_relocate_pins(moves);
1215        self.author("relocate_pins", |indexed, sink| {
1216            edit::geometry::relocate_pins(indexed, moves, sink);
1217        });
1218        applied
1219    }
1220
1221    /// Shift `anchors` rigidly by `slot_delta` slots (negative = up), keeping
1222    /// each pin's side. The shift is clamped to the tightest range that keeps
1223    /// every pin on its block — like a [`MoveMultiPin`](crate::tools) drag — so a
1224    /// group moves as one and never runs off an edge.
1225    pub fn nudge_pins(&mut self, pins: &[PinId], slot_delta: i32) {
1226        self.author("nudge_pins", |indexed, sink| {
1227            edit::geometry::nudge_pins(
1228                indexed,
1229                pins,
1230                edit::geometry::SlotDelta::new(slot_delta),
1231                sink,
1232            );
1233        });
1234    }
1235
1236    /// World-space bounding box of everything this scope *draws* — the whole
1237    /// picture, wires, labels and waypoints included, not just the shapes that
1238    /// hold them. Taken by running the real draw passes into a measuring
1239    /// backend, so a shape kind added later joins the extent the moment it is
1240    /// drawn. `None` when the scope draws nothing.
1241    pub fn content_bounds(&self, painter: &Style<'_, impl Renderer>) -> Option<Rect> {
1242        let mut extent = blockworx_paint::Extent::measuring_through(painter.renderer());
1243        crate::widget::DrawingPasses::new(self).draw(&mut Style::new(painter.theme(), &mut extent));
1244        extent.finish()
1245    }
1246
1247    // ── Broad-phase candidate selection ─────────────────────────────────────
1248    //
1249    // Shared by hit-testing (query rect = the cursor neighborhood) and, via the
1250    // same index, viewport culling. With an index only nearby candidates are
1251    // returned; without one, the full collection in its usual order — so the
1252    // no-index paths (SVG export, tests) behave exactly as before.
1253
1254    /// Where `id` sits in the draw order: its paint layer, then the
1255    /// document's one chronological order — ascending id — within that
1256    /// layer. Sorting the index's (unordered) hits by this reproduces the
1257    /// linear [`Self::shapes`] walk exactly, so the two broad phases
1258    /// cannot disagree about which shape is on top.
1259    fn draw_key(id: ShapeId) -> (u8, ShapeId) {
1260        (shape_layer_rank(id), id)
1261    }
1262
1263    /// Shapes (blocks/ports/texts/images — **not** areas) whose coarse bounds
1264    /// fall near `query`, in draw order. Index-limited when available;
1265    /// otherwise every shape in [`Self::shapes`] order, which is the same
1266    /// order. Areas are queried by their own (short) linear scan, so they
1267    /// are excluded here.
1268    pub(crate) fn shape_candidates(&self, query: Rect) -> Vec<(ShapeId, ShapeRef<'_>)> {
1269        match self.index {
1270            Some(idx) => {
1271                let mut v: Vec<(ShapeId, ShapeRef<'_>)> = idx
1272                    .in_rect(query)
1273                    .filter_map(|hit| match hit {
1274                        // Areas run their own linear scan; icons are hit-tested
1275                        // only in their block's context (see `icon_at_pos`), never
1276                        // as free candidates — but both stay in the index for
1277                        // culling. Excluding them here keeps the indexed hit-test and
1278                        // marquee identical to the no-index `shapes()` path.
1279                        HitId::Shape(id) if !matches!(id, ShapeId::Area(_) | ShapeId::Icon(_)) => {
1280                            self.shape(id).map(|s| (id, s))
1281                        }
1282                        _ => None,
1283                    })
1284                    .collect();
1285                v.sort_by_key(|(id, _)| Self::draw_key(*id));
1286                v
1287            }
1288            None => self.shapes().collect(),
1289        }
1290    }
1291
1292    /// [`Self::shape_candidates`] back-to-front: the shape painted last —
1293    /// the one on top — comes first, so a top-down hit test takes the first
1294    /// match. Hit order is the reverse of draw order, and this is the only
1295    /// place that says so.
1296    pub(crate) fn hit_candidates(&self, query: Rect) -> Vec<(ShapeId, ShapeRef<'_>)> {
1297        let mut v = self.shape_candidates(query);
1298        v.reverse();
1299        v
1300    }
1301
1302    /// Record where this frame's preview puts each shape. A block's icon rides
1303    /// inside its block, so the block's previewed rect is where the icon goes
1304    /// too — recorded here rather than by each tool that draws them together.
1305    pub(super) fn suppose_shapes(&mut self, previews: &[(ShapeId, Rect)]) {
1306        self.supposed.shapes.clear();
1307        for &(id, rect) in previews {
1308            self.supposed.shapes.push((id, rect));
1309            if let Some(icon) = id.block().map(ShapeId::Icon)
1310                && self.shape(icon).is_some()
1311            {
1312                self.supposed.shapes.push((icon, rect));
1313            }
1314        }
1315    }
1316
1317    /// Record the wires this frame's preview re-solved — the ones now drawn
1318    /// somewhere the document does not put them.
1319    pub(super) fn suppose_routes(&mut self, routes: Vec<RouteId>) {
1320        self.supposed.routes = routes;
1321    }
1322
1323    /// Ids to draw when culling the scene to `viewport` (world space): the
1324    /// hittables whose bounds are on screen. `None` means "no index — draw
1325    /// everything", which is what the SVG export and tests want (they must not
1326    /// cull). Shares the one index with the hit-test broad phase.
1327    ///
1328    /// The index is keyed by the document value, so it answers for *committed*
1329    /// geometry only. A live preview moves where a shape paints without moving
1330    /// its rect, so this frame's supposition is asked too and its answers are
1331    /// unioned in — that set is the dragged shapes and the wires re-solved
1332    /// against them, never the document.
1333    pub(crate) fn visible_ids(&self, viewport: Rect) -> Option<std::collections::HashSet<HitId>> {
1334        let idx = self.index?;
1335        let mut ids: std::collections::HashSet<HitId> = idx.in_rect(viewport).collect();
1336        for &(id, rect) in &self.supposed.shapes {
1337            if self.shape(id).is_some_and(|shape| {
1338                crate::render::bounds::shape_bounds_at(&shape, rect).intersects(viewport)
1339            }) {
1340                ids.insert(HitId::Shape(id));
1341            }
1342        }
1343        for &id in &self.supposed.routes {
1344            if self
1345                .auto_route(id)
1346                .zip(self.route_geometry(id))
1347                .is_some_and(|(wire, geometry)| {
1348                    crate::render::bounds::route_bounds(&wire, geometry).intersects(viewport)
1349                })
1350            {
1351                ids.insert(HitId::Route(id));
1352            }
1353        }
1354        Some(ids)
1355    }
1356
1357    /// Routes whose coarse bounds fall near `query`. Index-limited when available,
1358    /// sorted back into `auto_routes()` (insertion) order so that when wires
1359    /// overlap — e.g. at a crossing — the same one wins as in the linear scan;
1360    /// otherwise every route in `auto_routes()` order.
1361    pub(crate) fn route_candidates(&self, query: Rect) -> Vec<(RouteId, Wire<'_>)> {
1362        match self.index {
1363            Some(idx) => {
1364                let order = self.scope_route_ids();
1365                let mut v: Vec<(RouteId, Wire<'_>)> = idx
1366                    .in_rect(query)
1367                    .filter_map(|hit| match hit {
1368                        HitId::Route(id) => self.auto_route(id).map(|r| (id, r)),
1369                        HitId::Shape(_) => None,
1370                    })
1371                    .collect();
1372                v.sort_by_key(|(id, _)| {
1373                    order
1374                        .iter()
1375                        .position(|other| other == id)
1376                        .unwrap_or(usize::MAX)
1377                });
1378                v
1379            }
1380            None => self.auto_routes().collect(),
1381        }
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use crate::path::Scope;
1389    use crate::{
1390        edit::lower::slot_capacity,
1391        grid::GRID_SIZE,
1392        tools::tool::Deletable,
1393        widget::test_fixtures::{self as fx, Scene},
1394    };
1395    use blockworx_doc::{
1396        fixtures::{block_id, pin_id, route_id},
1397        values::PinSide as DocPinSide,
1398    };
1399    use blockworx_geom::{pos2, vec2};
1400
1401    /// The whole minting path, through the waist the tools hold: a document
1402    /// loaded with blocks up to `b7` mints `b8` next, per kind, and a deleted
1403    /// entity keeps its number out of circulation.
1404    #[test]
1405    fn a_tool_mints_one_past_the_highest_id_the_document_holds() {
1406        let mut scene = Scene::new(vec![fx::block(7, 0.0), fx::pin(2, 7, DocPinSide::East, 0)]);
1407        assert!(
1408            scene.drawing().held_block(block_id(8)).is_none(),
1409            "precondition: b8 is free before the gesture",
1410        );
1411
1412        let (made, _) = scene.authored(|drawing| {
1413            (
1414                drawing.add_block(pos2(200.0, 0.0), pos2(240.0, 40.0)),
1415                drawing.add_text_box(pos2(300.0, 0.0)),
1416            )
1417        });
1418        assert_eq!(
1419            made,
1420            (block_id(8), blockworx_doc::fixtures::text_id(1)),
1421            "the block counts on from b7 while the texts start their own space at 1",
1422        );
1423
1424        scene.authored(|drawing| drawing.delete(Deletable::Shape(ShapeId::Rect(block_id(8)))));
1425        assert!(
1426            scene.drawing().held_block(block_id(8)).is_none(),
1427            "precondition: b8 is deleted",
1428        );
1429        let (again, _) =
1430            scene.authored(|drawing| drawing.add_block(pos2(0.0, 200.0), pos2(40.0, 240.0)));
1431        assert_eq!(
1432            again,
1433            block_id(9),
1434            "the marks never fall, so the next block cannot land on a departed id",
1435        );
1436    }
1437
1438    /// A scene whose wire is dragged well clear of both blocks by a locked
1439    /// waypoint — the shape of the user report "fit to extent does not
1440    /// include the routes".
1441    fn wire_outside_the_blocks() -> Scene {
1442        Scene::new(vec![
1443            fx::block(1, 0.0),
1444            fx::block(2, 120.0),
1445            fx::pin(3, 1, DocPinSide::East, 0),
1446            fx::pin(4, 2, DocPinSide::West, 0),
1447            fx::route(5, Scope::Root, 3, 4, &[(8, -30), (8, -30)]),
1448        ])
1449    }
1450
1451    /// What a fit frames is the whole picture, not the shapes that hold it:
1452    /// a wire routed far above every block is still part of the drawing, and
1453    /// a view that leaves it out has cropped the document.
1454    #[test]
1455    fn the_content_bounds_hold_the_wires_too() {
1456        let mut scene = wire_outside_the_blocks();
1457        let theme = crate::theme::Theme::default();
1458        let mut backend = crate::export::svg::SvgRenderer::new(
1459            theme.palette().clone(),
1460            blockworx_paint::FontChoice::default(),
1461        );
1462        let style = Style::new(&theme, &mut backend);
1463        let drawing = scene.drawing();
1464
1465        let blocks = drawing
1466            .blocks_layer()
1467            .map(|(_, shape)| shape.gui_rect())
1468            .reduce(Rect::union)
1469            .expect("the scene holds blocks");
1470        let wire = drawing
1471            .auto_routes()
1472            .filter_map(|(id, _)| drawing.route_geometry(id))
1473            .flat_map(|geometry| geometry.points())
1474            .fold(Rect::NOTHING, |acc, p| acc.union(Rect::from_min_max(p, p)));
1475        assert!(
1476            wire.is_positive() && !blocks.contains_rect(wire),
1477            "precondition: the wire must escape the blocks it joins \
1478             (wire {wire:?}, blocks {blocks:?})",
1479        );
1480
1481        let bounds = drawing
1482            .content_bounds(&style)
1483            .expect("the scene draws something");
1484        assert!(
1485            bounds.contains_rect(wire),
1486            "the fit cropped the wire: {bounds:?} does not hold {wire:?}",
1487        );
1488    }
1489
1490    /// B1, structurally: a gesture reads the session's prediction, which only
1491    /// advances at submit — so before the write door staged the sink, a block
1492    /// created mid-gesture was invisible to the rest of that gesture, and four
1493    /// tools carried an `Arming` state to defer the read by a frame.
1494    ///
1495    /// The claim is not just "something is there": what the gesture reads is
1496    /// what the sealed commit produces, so a tool seeding an editor from the
1497    /// prediction cannot be seeded from a different document than the one the
1498    /// user ends up with.
1499    #[test]
1500    fn a_gesture_reads_the_block_it_just_created() {
1501        let mut scene = Scene::new(vec![]);
1502        let (created, staged) = scene.commit(|drawing| {
1503            let created = drawing.add_rect_box(pos2(0.0, 0.0), pos2(40.0, 40.0));
1504            let seen = drawing
1505                .shape(ShapeId::Rect(created))
1506                .expect("the gesture's own block is in the document it reads");
1507            assert!(
1508                seen.title().is_some(),
1509                "including the title an editor would be seeded from",
1510            );
1511            (created, seen.gui_rect())
1512        });
1513        let settled = scene
1514            .drawing()
1515            .shape(ShapeId::Rect(created))
1516            .expect("the sealed commit folded")
1517            .gui_rect();
1518        assert_eq!(
1519            staged, settled,
1520            "the geometry read mid-gesture is the geometry the commit lands",
1521        );
1522        assert!(
1523            staged.area() > 0.0,
1524            "and it is a real rect, so the comparison is not two empties",
1525        );
1526    }
1527
1528    /// The other half: what the gesture has *not* written is not there. A
1529    /// staged prediction that invented rows would hide the errors the fold
1530    /// exists to catch.
1531    #[test]
1532    fn a_gesture_reads_nothing_it_did_not_write() {
1533        let mut scene = Scene::new(vec![tall_block(1)]);
1534        scene.commit(|drawing| {
1535            assert!(
1536                drawing.shape(ShapeId::Rect(block_id(1))).is_some(),
1537                "the scene's own block is there",
1538            );
1539            assert!(
1540                drawing.shape(ShapeId::Rect(block_id(2))).is_none(),
1541                "a block no one authored is not",
1542            );
1543        });
1544    }
1545
1546    /// B9: a text box's bounds went stale whenever this client was not the
1547    /// one typing. The extent was written only by the editor's commit, so an
1548    /// undo — or a collaborator's edit — left the box drawn and hit-tested at
1549    /// its character-count estimate until someone double-clicked it to start
1550    /// another edit cycle.
1551    ///
1552    /// Measured on demand now, so the frame that shows the changed text is
1553    /// the frame that measures it.
1554    #[test]
1555    fn a_text_boxs_extent_is_measured_for_whatever_text_it_holds() {
1556        /// One frame's worth: measure what is stale, then read back the
1557        /// extent for the text the box actually holds.
1558        fn frame(
1559            ctx: &egui::Context,
1560            scene: &mut Scene,
1561            id: TextId,
1562        ) -> Option<blockworx_doc::geometry::GridSize> {
1563            let mut out = None;
1564            ctx.run_ui(egui::RawInput::default(), |ui| {
1565                let theme = crate::theme::Theme::default();
1566                let mut painter =
1567                    crate::canvas::Painter::headless(ui.painter().clone(), theme.palette().clone());
1568                let style = crate::theme::Style::new(&theme, &mut painter);
1569                let mut drawing = scene.drawing();
1570                drawing.refresh_text_extents(&style);
1571                let Some(text) = drawing.text_box(id).map(|t| t.text.clone()) else {
1572                    return;
1573                };
1574                out = drawing.presentation.text_extents.valid_for(id, &text);
1575            })
1576            .drop_without_applying_deltas();
1577            out
1578        }
1579
1580        let id = blockworx_doc::fixtures::text_id(1);
1581        let mut scene = Scene::new(vec![fx::text(1, Scope::Root, "short", pos2(0.0, 0.0))]);
1582        let ctx = egui::Context::default();
1583        ctx.set_fonts(crate::canvas::build_fonts(
1584            blockworx_paint::FontChoice::default(),
1585        ));
1586        // Fonts land from the next frame, and this measures text.
1587        ctx.run_ui(egui::RawInput::default(), |_| {})
1588            .drop_without_applying_deltas();
1589
1590        // Nothing has typed into this box, so nothing ever wrote its extent —
1591        // the state a freshly opened document is in, and the one an undo
1592        // leaves behind.
1593        assert!(
1594            scene
1595                .drawing()
1596                .presentation
1597                .text_extents
1598                .valid_for(id, "short")
1599                .is_none(),
1600            "precondition: no extent has been measured yet",
1601        );
1602        let short = frame(&ctx, &mut scene, id).expect("the frame measured the box it found");
1603
1604        // The text changes with no editor involved: what an undo does.
1605        scene.apply(vec![fx::text_content(
1606            1,
1607            "a much longer line of text that has to wrap onto several lines",
1608        )]);
1609        let grown = frame(&ctx, &mut scene, id).expect("the next frame measured the new text");
1610
1611        assert!(
1612            grown.h > short.h,
1613            "the box grew to fit the longer text ({short:?} -> {grown:?}) with no \
1614             edit cycle to trigger it",
1615        );
1616    }
1617
1618    /// A block tall enough to offer several pin slots, so the slot arithmetic
1619    /// under test has room to move.
1620    fn tall_block(n: u32) -> blockworx_doc::opcode::OpCodes {
1621        fx::block_in(
1622            n,
1623            Scope::Root,
1624            Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 150.0)),
1625        )
1626    }
1627
1628    fn slot(scene: &mut Scene, pin: PinId) -> PinSlot {
1629        scene
1630            .drawing()
1631            .held_pin(pin)
1632            .expect("the pin is in the document")
1633            .slot
1634    }
1635
1636    /// The highest slot the block `n` can hold — the clamp every pin move
1637    /// is bounded by.
1638    fn capacity(scene: &mut Scene, block: BlockId) -> u32 {
1639        slot_capacity(
1640            scene
1641                .drawing()
1642                .held_block(block)
1643                .expect("the block is in the document")
1644                .rect
1645                .size
1646                .h,
1647        )
1648    }
1649
1650    #[test]
1651    fn add_port_auto_named_adds_a_boundary_port() {
1652        // Both doors onto a boundary port — the tool's drag and a cell
1653        // dropped on the canvas — do nothing but call this, so it must
1654        // actually produce a port on the current scope.
1655        let mut scene = Scene::new(vec![]);
1656        assert!(
1657            scene.drawing().ports_layer().next().is_none(),
1658            "precondition: the root scope starts with no ports"
1659        );
1660
1661        let rect = Rect::from_min_size(pos2(10.0, 10.0), vec2(4.0 * GRID_SIZE, GRID_SIZE));
1662        let id = scene.commit(|drawing| drawing.add_port_auto_named(rect));
1663
1664        let ports: Vec<PinId> = scene.drawing().ports_layer().map(|(_, _)| id).collect();
1665        assert_eq!(ports, vec![id], "exactly the stamped port is drawn");
1666        assert_eq!(
1667            scene
1668                .drawing()
1669                .held_pin(id)
1670                .expect("the port was inserted")
1671                .name,
1672            "Port 1",
1673        );
1674    }
1675
1676    /// Editing a pin's tag is a register write: the pin keeps its id, so the
1677    /// wires anchored to it stay anchored.
1678    #[test]
1679    fn editing_a_tag_keeps_the_route_anchors() {
1680        let (a, b, rid) = (pin_id(3), pin_id(4), route_id(5));
1681        let mut scene = Scene::new(vec![
1682            fx::block(1, 0.0),
1683            fx::block(2, 80.0),
1684            fx::pin(3, 1, DocPinSide::East, 0),
1685            fx::pin(4, 2, DocPinSide::West, 0),
1686            fx::route(5, Scope::Root, 3, 4, &[]),
1687        ]);
1688
1689        scene.commit(|drawing| drawing.set_pin_tag(a, "A1"));
1690
1691        let drawing = scene.drawing();
1692        assert_eq!(drawing.held_pin(a).expect("the pin survives").tag, "A1");
1693        let wire = drawing.auto_route(rid).expect("the wire survives");
1694        assert_eq!((wire.route.from, wire.route.to), (a, b));
1695    }
1696
1697    #[test]
1698    fn deleting_a_block_drops_its_whole_subtree() {
1699        let (parent, grandchild) = (block_id(1), block_id(2));
1700        let mut scene = Scene::new(vec![
1701            fx::block(1, 0.0),
1702            fx::block_in(
1703                2,
1704                Scope::Block(parent),
1705                Rect::from_min_max(pos2(8.0, 8.0), pos2(48.0, 48.0)),
1706            ),
1707        ]);
1708        assert!(
1709            scene.drawing().block(parent).is_some()
1710                && scene.drawing().held_block(grandchild).is_some(),
1711            "precondition: both blocks are in the document"
1712        );
1713
1714        scene.commit(|drawing| drawing.delete(Deletable::Shape(ShapeId::Rect(parent))));
1715
1716        let drawing = scene.drawing();
1717        assert!(
1718            drawing.held_block(parent).is_none(),
1719            "deleted block is gone"
1720        );
1721        assert!(
1722            drawing.held_block(grandchild).is_none(),
1723            "descendant subtree is gone too (no orphans)"
1724        );
1725        assert!(
1726            drawing.child_blocks().is_empty(),
1727            "the block is unlinked from the scope that held it"
1728        );
1729    }
1730
1731    // Arrow-key pin moves: one slot per nudge, clamped at the block's top and
1732    // bottom slots.
1733    #[test]
1734    fn nudge_pins_shifts_one_slot_and_clamps_at_the_edges() {
1735        let (a, pa) = (block_id(1), pin_id(3));
1736        let mut scene = Scene::new(vec![tall_block(1), fx::pin(3, 1, DocPinSide::East, 1)]);
1737        let max = capacity(&mut scene, a);
1738        assert!(max >= 2, "the tall block must hold at least slots 0..=2");
1739
1740        assert_eq!(slot(&mut scene, pa).offset, 1);
1741        scene.commit(|d| d.nudge_pins(&[pa], 1));
1742        assert_eq!(slot(&mut scene, pa).offset, 2);
1743        scene.commit(|d| d.nudge_pins(&[pa], -1));
1744        assert_eq!(slot(&mut scene, pa).offset, 1);
1745        // Clamp at the top.
1746        scene.commit(|d| d.nudge_pins(&[pa], -1));
1747        assert_eq!(slot(&mut scene, pa).offset, 0);
1748        scene.commit(|d| d.nudge_pins(&[pa], -1));
1749        assert_eq!(slot(&mut scene, pa).offset, 0);
1750        // Clamp at the bottom.
1751        scene.commit(|d| d.nudge_pins(&[pa], max as i32 + 5));
1752        assert_eq!(slot(&mut scene, pa).offset, max);
1753        scene.commit(|d| d.nudge_pins(&[pa], 1));
1754        assert_eq!(slot(&mut scene, pa).offset, max);
1755    }
1756
1757    // A group of pins shifts rigidly, and is held in place when any member is
1758    // already at the boundary in the nudge direction.
1759    #[test]
1760    fn nudge_pins_moves_a_group_rigidly_and_stops_at_a_member_boundary() {
1761        let (p0, p1) = (pin_id(3), pin_id(4));
1762        let mut scene = Scene::new(vec![
1763            tall_block(1),
1764            fx::pin(3, 1, DocPinSide::East, 0),
1765            fx::pin(4, 1, DocPinSide::East, 1),
1766        ]);
1767
1768        // p0 is at the top, so the whole group can't move up.
1769        scene.commit(|d| d.nudge_pins(&[p0, p1], -1));
1770        assert_eq!(
1771            (slot(&mut scene, p0).offset, slot(&mut scene, p1).offset),
1772            (0, 1)
1773        );
1774        // Down by one: both shift together.
1775        scene.commit(|d| d.nudge_pins(&[p0, p1], 1));
1776        assert_eq!(
1777            (slot(&mut scene, p0).offset, slot(&mut scene, p1).offset),
1778            (1, 2)
1779        );
1780    }
1781
1782    // Deleting a multi-selection drops every shape in it.
1783    #[test]
1784    fn delete_shapes_removes_all_of_them() {
1785        let (a, b) = (block_id(1), block_id(2));
1786        let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::block(2, 80.0)]);
1787
1788        scene.commit(|d| d.delete(Deletable::Shapes(vec![ShapeId::Rect(a), ShapeId::Rect(b)])));
1789
1790        let drawing = scene.drawing();
1791        assert!(drawing.block(a).is_none());
1792        assert!(drawing.block(b).is_none());
1793    }
1794
1795    #[test]
1796    fn delete_pins_drops_the_pins_and_their_routes() {
1797        let (pa, pb, rid) = (pin_id(3), pin_id(4), route_id(5));
1798        let mut scene = Scene::new(vec![
1799            fx::block(1, 0.0),
1800            fx::block(2, 120.0),
1801            fx::pin(3, 1, DocPinSide::East, 0),
1802            fx::pin(4, 2, DocPinSide::West, 0),
1803            fx::route(5, Scope::Root, 3, 4, &[]),
1804        ]);
1805        assert!(
1806            scene.drawing().auto_route(rid).is_some(),
1807            "precondition: the wire is in this scope"
1808        );
1809
1810        scene.commit(|d| d.delete(Deletable::Pins(vec![pa])));
1811
1812        let drawing = scene.drawing();
1813        assert!(drawing.held_pin(pa).is_none(), "the pin is gone");
1814        assert!(drawing.auto_route(rid).is_none(), "and the wire with it");
1815        assert!(drawing.held_pin(pb).is_some(), "the far endpoint survives");
1816    }
1817
1818    #[test]
1819    fn relocate_pins_moves_a_free_group_but_rejects_a_collision() {
1820        // A tall block so several pin slots fit.
1821        let (p0, p1, blocker) = (pin_id(3), pin_id(4), pin_id(5));
1822        let mut scene = Scene::new(vec![
1823            fx::block_in(
1824                1,
1825                Scope::Root,
1826                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 240.0)),
1827            ),
1828            fx::pin(3, 1, DocPinSide::East, 0),
1829            fx::pin(4, 1, DocPinSide::East, 1),
1830            fx::pin(5, 1, DocPinSide::East, 4),
1831        ]);
1832        let to = |pin, offset| PinMove {
1833            pin,
1834            to: PinSlot {
1835                side: DocPinSide::East,
1836                offset,
1837            },
1838        };
1839
1840        // Shift the group {slot0, slot1} down by two slots → {2, 3}: free.
1841        let ok = [to(p0, 2), to(p1, 3)];
1842        assert!(scene.drawing().can_relocate_pins(&ok));
1843        assert!(scene.commit(|d| d.relocate_pins(&ok)));
1844        assert_eq!(slot(&mut scene, p0).offset, 2);
1845        assert_eq!(slot(&mut scene, p1).offset, 3);
1846
1847        // Now shifting onto the blocker's slot (4) collides → rejected, no change.
1848        let bad = [to(p0, 3), to(p1, 4)];
1849        assert!(!scene.drawing().can_relocate_pins(&bad));
1850        assert!(!scene.commit(|d| d.relocate_pins(&bad)));
1851        assert_eq!(slot(&mut scene, p1).offset, 3);
1852        assert_eq!(slot(&mut scene, blocker).offset, 4);
1853    }
1854
1855    // Vertical flip mirrors each pin's slot about the block center (offset →
1856    // h - offset), so a top pin lands at the bottom; a double flip is identity.
1857    #[test]
1858    fn flip_block_vertical_mirrors_pin_slots_and_is_an_involution() {
1859        let (a, top, mid, low) = (block_id(1), pin_id(3), pin_id(4), pin_id(5));
1860        let mut scene = Scene::new(vec![
1861            tall_block(1),
1862            fx::pin(3, 1, DocPinSide::East, 0),
1863            fx::pin(4, 1, DocPinSide::West, 1),
1864            fx::pin(5, 1, DocPinSide::East, 2),
1865        ]);
1866        let h = capacity(&mut scene, a);
1867        assert!(h >= 2, "the tall block must hold at least slots 0..=2");
1868        let before = (
1869            slot(&mut scene, top),
1870            slot(&mut scene, mid),
1871            slot(&mut scene, low),
1872        );
1873
1874        scene.commit(|d| d.flip_block_vertical(a));
1875
1876        // The top pin lands at the bottom slot; each pin keeps its side.
1877        assert_eq!(slot(&mut scene, top).offset, h);
1878        assert_eq!(slot(&mut scene, mid).offset, h - 1);
1879        assert_eq!(slot(&mut scene, low).offset, h - 2);
1880        assert_eq!(slot(&mut scene, top).side, before.0.side);
1881        assert_eq!(slot(&mut scene, mid).side, before.1.side);
1882        assert_eq!(slot(&mut scene, low).side, before.2.side);
1883
1884        // Flipping again restores the original layout exactly.
1885        scene.commit(|d| d.flip_block_vertical(a));
1886        assert_eq!(slot(&mut scene, top).offset, before.0.offset);
1887        assert_eq!(slot(&mut scene, mid).offset, before.1.offset);
1888        assert_eq!(slot(&mut scene, low).offset, before.2.offset);
1889    }
1890
1891    // ── Locked blocks ─────────────────────────────────────────────────────────
1892
1893    #[test]
1894    fn pin_owner_locked_reads_the_pins_owning_block() {
1895        let mut scene = Scene::new(vec![
1896            fx::block(1, 0.0),
1897            fx::block(2, 80.0),
1898            fx::pin(3, 1, DocPinSide::East, 0),
1899            fx::pin(4, 2, DocPinSide::East, 0),
1900            fx::locked(1),
1901        ]);
1902        let drawing = scene.drawing();
1903        assert!(drawing.pin_owner_locked(pin_id(3)));
1904        assert!(!drawing.pin_owner_locked(pin_id(4)));
1905        // A pin the document does not hold cannot be frozen by anything.
1906        assert!(!drawing.pin_owner_locked(pin_id(9)));
1907    }
1908
1909    #[test]
1910    fn shape_owner_locked_covers_blocks_and_ports() {
1911        // Descend into `c` so its own pin renders as a port and the current
1912        // block (`c`) governs the port lock.
1913        let (c, g, port) = (block_id(1), block_id(2), pin_id(3));
1914        let mut scene = Scene::new(vec![
1915            fx::block(1, 0.0),
1916            fx::block_in(
1917                2,
1918                Scope::Block(c),
1919                Rect::from_min_max(pos2(60.0, 0.0), pos2(100.0, 40.0)),
1920            ),
1921            fx::pin_at(
1922                3,
1923                Scope::Block(c),
1924                "io",
1925                fx::slot(DocPinSide::West, 0),
1926                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 16.0)),
1927            ),
1928            fx::locked(1),
1929        ])
1930        .inside(c);
1931        let drawing = scene.drawing();
1932        // The current block `c` is locked, so its port reports locked.
1933        assert!(drawing.shape_owner_locked(ShapeId::Port(port)));
1934        assert!(drawing.current_locked());
1935        // The child block `g` is unlocked.
1936        assert!(!drawing.shape_owner_locked(ShapeId::Rect(g)));
1937    }
1938
1939    /// F9: the document root is a scope without a block entity behind it,
1940    /// so there is nothing to freeze — its ports are always editable.
1941    #[test]
1942    fn the_root_scope_is_never_locked() {
1943        let mut scene = Scene::new(vec![fx::pin_at(
1944            3,
1945            Scope::Root,
1946            "io",
1947            fx::slot(DocPinSide::West, 0),
1948            Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 16.0)),
1949        )]);
1950        let drawing = scene.drawing();
1951        assert!(
1952            drawing.current().is_none(),
1953            "the root has no block entity, or this proves nothing"
1954        );
1955        assert!(!drawing.current_locked());
1956        assert!(!drawing.shape_owner_locked(ShapeId::Port(pin_id(3))));
1957    }
1958
1959    #[test]
1960    fn delete_pin_is_a_no_op_on_a_locked_block() {
1961        let pa = pin_id(3);
1962        let mut scene = Scene::new(vec![
1963            fx::block(1, 0.0),
1964            fx::pin(3, 1, DocPinSide::East, 0),
1965            fx::locked(1),
1966        ]);
1967        let before = scene.doc.stamp();
1968
1969        scene.commit(|d| d.delete(Deletable::Pins(vec![pa])));
1970
1971        assert_eq!(
1972            scene.doc.stamp(),
1973            before,
1974            "a refused delete authors nothing"
1975        );
1976        assert!(
1977            scene.drawing().held_pin(pa).is_some(),
1978            "locked block keeps its pin"
1979        );
1980    }
1981
1982    #[test]
1983    fn delete_port_is_a_no_op_on_a_locked_block() {
1984        // A port lives on the current scope's own boundary; lock that block and
1985        // delete one of its ports from inside.
1986        let (c, pc) = (block_id(1), pin_id(3));
1987        let mut scene = Scene::new(vec![
1988            fx::block(1, 0.0),
1989            fx::pin_at(
1990                3,
1991                Scope::Block(c),
1992                "io",
1993                fx::slot(DocPinSide::West, 0),
1994                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 16.0)),
1995            ),
1996            fx::locked(1),
1997        ])
1998        .inside(c);
1999        let before = scene.doc.stamp();
2000
2001        scene.commit(|d| d.delete(Deletable::Shape(ShapeId::Port(pc))));
2002
2003        assert_eq!(
2004            scene.doc.stamp(),
2005            before,
2006            "a refused delete authors nothing"
2007        );
2008        assert!(
2009            scene.drawing().shape(ShapeId::Port(pc)).is_some(),
2010            "locked block keeps its port"
2011        );
2012    }
2013
2014    #[test]
2015    fn set_block_locked_flips_the_flag() {
2016        let a = block_id(1);
2017        let mut scene = Scene::new(vec![fx::block(1, 0.0)]);
2018        let locked = |scene: &mut Scene| {
2019            scene
2020                .drawing()
2021                .held_block(a)
2022                .expect("the block is in the document")
2023                .locked
2024        };
2025        assert!(!locked(&mut scene));
2026
2027        scene.commit(|d| d.set_block_locked(a, InterfaceLock::Locked));
2028        assert!(locked(&mut scene));
2029        scene.commit(|d| d.set_block_locked(a, InterfaceLock::Unlocked));
2030        assert!(!locked(&mut scene));
2031    }
2032
2033    /// The lock's scope, settled 2026-08-24: a frozen interface is about what
2034    /// a pin *is*, not where it sits, so a locked block still repositions its
2035    /// pins. This asserted the opposite until then — step 10's lock
2036    /// unification had widened the guard past the interface and into layout.
2037    #[test]
2038    fn a_locked_block_still_repositions_its_pins() {
2039        let pa = pin_id(3);
2040        let mut scene = Scene::new(vec![
2041            tall_block(1),
2042            fx::pin(3, 1, DocPinSide::East, 0),
2043            fx::locked(1),
2044        ]);
2045        assert_eq!(slot(&mut scene, pa).offset, 0);
2046        assert!(scene.drawing().pin_owner_locked(pa), "precondition: frozen");
2047
2048        scene.commit(|d| {
2049            d.move_pin_snapped(
2050                pa,
2051                PinSlot {
2052                    side: DocPinSide::East,
2053                    offset: 1,
2054                },
2055            );
2056        });
2057
2058        assert_eq!(
2059            slot(&mut scene, pa).offset,
2060            1,
2061            "moving a pin along its block is presentation, which a lock allows",
2062        );
2063    }
2064
2065    /// And the half the lock does keep: a frozen block refuses the edit that
2066    /// changes what its pin carries.
2067    #[test]
2068    fn a_locked_block_refuses_its_pins_direction() {
2069        let pa = pin_id(3);
2070        let mut scene = Scene::new(vec![
2071            tall_block(1),
2072            fx::pin(3, 1, DocPinSide::East, 0),
2073            fx::locked(1),
2074        ]);
2075        assert!(scene.drawing().pin_owner_locked(pa), "precondition: frozen");
2076        let before = scene.doc.stamp();
2077
2078        scene.commit(|d| d.cycle_pin_kind(pa));
2079
2080        assert_eq!(
2081            scene.doc.stamp(),
2082            before,
2083            "I/O direction is what the pin means, and a lock protects it",
2084        );
2085    }
2086}