Skip to main content

blockworx/presentation/
mod.rs

1//! How the document actually appears on the canvas: where wires run
2//! (solved route geometry), how big text really is (measured extents),
3//! what color pin stubs show (accents propagated from route roles).
4//! Everything here is computed *from* the authored document and lives
5//! *beside* it — never authored, never undone, never persisted (the
6//! editor-swap playbook, step 7; renamed from `derived` 2026-08-19,
7//! which said how this is computed rather than what it is for).
8//!
9//! Owned by the app for the life of a session and threaded through
10//! [`Drawing`](crate::widget::drawing::Drawing) like the spatial index,
11//! mutably, because the passes that fill it run inside `Drawing` methods.
12//! Each part is gated on the document's own stamp, so a commit that landed
13//! anywhere — local, foreign, or an undo — re-derives it on the next
14//! borrow and nothing has to remember to invalidate it. A live preview is
15//! the one writer that does not follow the document: it *supposes*
16//! geometry and says so ([`Presentation::routes_supposed`]), which is what
17//! takes an abandoned drag's supposition back.
18
19pub mod route;
20pub mod store;
21
22pub use route::{Crossing, LocAndDirection, RouteDirection, RouteEdge, RouteGeometry};
23
24use ahash::HashMap;
25
26use blockworx_doc::{
27    document::{IndexedDocument, chronological},
28    geometry::GridSize,
29    id::{PinId, RouteId, TextId},
30    rev::DocStamp,
31};
32
33use crate::edit::lower::accent_from_role;
34use crate::path::Scope;
35
36/// Solved geometry per route. Flat: route ids are document-wide unique,
37/// so the scope key the map carried until 2026-08-19 (when a flat map let
38/// each materialized scope overwrite the last one's geometry, because
39/// legacy ids were minted per block) no longer distinguishes anything.
40pub type RouteGeometries = HashMap<RouteId, RouteGeometry>;
41
42#[derive(Default)]
43pub struct Presentation {
44    pub pin_accents: PinAccents,
45    pub text_extents: TextExtents,
46    /// A missing entry is a route that has not been materialized —
47    /// nothing to draw or hit-test yet.
48    pub routes: RouteGeometries,
49    /// The document value [`Self::routes`] was materialized from, or `None`
50    /// when a preview supposed geometry the document does not hold.
51    routes_stamp: Option<DocStamp>,
52}
53
54impl Presentation {
55    /// Bring the accent propagation up to date with the document behind
56    /// `indexed`. Gated on the document's stamp, so the once-per-borrow
57    /// call sites stay cheap.
58    pub fn refresh_accents(&mut self, indexed: &IndexedDocument<'_>) {
59        if self.pin_accents.stamp == Some(indexed.doc.stamp()) {
60            return;
61        }
62        self.pin_accents.recompute(indexed);
63    }
64
65    /// Rebuild every scope's wire geometry from the authored waypoints
66    /// behind `indexed`. Gated on the document's stamp like
67    /// [`Self::refresh_accents`], so a borrow that changed nothing costs a
68    /// comparison — and a commit that landed anywhere, local or foreign,
69    /// re-derives the wires without anyone asking.
70    ///
71    /// The stamp is claimed before the pass runs: the materialization builds
72    /// its own scoped [`Drawing`](crate::widget::drawing::Drawing)s, whose
73    /// constructor calls back in here.
74    pub fn refresh_routes(&mut self, indexed: &IndexedDocument<'_>) {
75        if self.routes_stamp == Some(indexed.doc.stamp()) {
76            return;
77        }
78        self.routes_stamp = Some(indexed.doc.stamp());
79        crate::widget::drawing::materialize_document(indexed, self);
80    }
81
82    /// A throwaway presentation for a solve against a document that does not
83    /// exist yet: the geometry the editor is showing, held as already
84    /// materialized so the pass compares against what the user can see rather
85    /// than re-deriving it. Measurement caches start empty — the solve reads
86    /// neither.
87    pub fn scratch(&self, indexed: &IndexedDocument<'_>) -> Self {
88        Self {
89            routes: self.routes.clone(),
90            routes_stamp: Some(indexed.doc.stamp()),
91            ..Self::default()
92        }
93    }
94
95    /// A preview drew geometry the document does not hold, so the next
96    /// borrow must re-derive it. Previews never move the document, hence
97    /// never its stamp — this is how an abandoned drag's supposition is
98    /// taken back.
99    pub fn routes_supposed(&mut self) {
100        self.routes_stamp = None;
101    }
102}
103
104/// Route-role propagation onto pin stubs: a wire's accent colors the pins
105/// it lands on. Presentation fresh from the routes — the last route at a
106/// scope to touch a pin wins, including a plain (`None`) role — where the
107/// document used to store the propagation and let it go stale.
108///
109/// Keyed by the *scope* the coloring happened in, not the pin's owner: one
110/// pin is drawn twice, as its own block's boundary port and as a stub on
111/// that block seen from the parent, and each drawing takes the accent of
112/// the wires in the scope it is drawn in.
113#[derive(Default)]
114pub struct PinAccents {
115    stamp: Option<DocStamp>,
116    accents: HashMap<(Scope, PinId), Option<u8>>,
117}
118
119impl PinAccents {
120    /// The stub accent of `pin` as drawn in `scope`.
121    pub fn pin(&self, scope: Scope, pin: PinId) -> Option<u8> {
122        self.accents.get(&(scope, pin)).copied().flatten()
123    }
124
125    fn recompute(&mut self, indexed: &IndexedDocument<'_>) {
126        self.accents.clear();
127        for id in chronological(indexed.doc.routes()) {
128            let Some(route) = indexed.doc.route(&id) else {
129                continue;
130            };
131            let accent = accent_from_role(route.role);
132            let scope = Scope::from_wire(route.owner);
133            for endpoint in [route.from, route.to] {
134                self.accents.insert((scope, endpoint), accent);
135            }
136        }
137        self.stamp = Some(indexed.doc.stamp());
138    }
139}
140
141/// Galley-measured text-box extents, keyed by the text they were
142/// measured for: an entry is consulted only while the box still holds
143/// that text, so the cache can never serve a stale rect — undo, load,
144/// or restore leave it self-invalidated and readers fall back to the
145/// character-count estimate until the box is next edited.
146#[derive(Default)]
147pub struct TextExtents {
148    map: HashMap<TextId, (String, GridSize)>,
149}
150
151impl TextExtents {
152    pub fn set(&mut self, id: TextId, text: String, size: GridSize) {
153        self.map.insert(id, (text, size));
154    }
155
156    /// The measured extent, if it was measured for exactly `current_text`.
157    pub fn valid_for(&self, id: TextId, current_text: &str) -> Option<GridSize> {
158        self.map
159            .get(&id)
160            .filter(|(text, _)| text == current_text)
161            .map(|&(_, size)| size)
162    }
163}
164
165/// The accent lookup for the pins of one shape, resolved by a caller that
166/// knows which scope the shape is being drawn in.
167#[derive(Clone, Copy)]
168pub struct ShapeAccents<'a> {
169    scope: Scope,
170    accents: &'a PinAccents,
171}
172
173impl<'a> ShapeAccents<'a> {
174    pub fn new(scope: Scope, accents: &'a PinAccents) -> Self {
175        Self { scope, accents }
176    }
177
178    pub fn pin(&self, pin: PinId) -> Option<u8> {
179        self.accents.pin(self.scope, pin)
180    }
181}