Skip to main content

blockworx_editor/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.
6//!
7//! Owned by the app for the life of a session and threaded through
8//! [`Drawing`](crate::widget::drawing::Drawing) like the spatial index,
9//! mutably, because the passes that fill it run inside `Drawing` methods.
10//! Each part is gated on the document's own stamp, so a commit that landed
11//! anywhere — local, foreign, or an undo — re-derives it on the next
12//! borrow and nothing has to remember to invalidate it. A live preview is
13//! the one writer that does not follow the document: it *previews*
14//! geometry and says so ([`Presentation::routes_previewed`]), which is what
15//! takes an abandoned drag's preview back.
16
17pub mod route;
18pub mod store;
19
20pub use route::{Crossing, LocAndDirection, RouteDirection, RouteEdge, RouteGeometry};
21
22use ahash::HashMap;
23
24use crate::render::text_box::BoxWidth;
25
26use blockworx_doc::{
27    block_model::Text,
28    document::{IndexedDocument, chronological},
29    geometry::GridSize,
30    id::{PinId, RouteId, TextId},
31    rev::DocStamp,
32};
33
34use crate::edit::lower::accent_from_role;
35use crate::path::Scope;
36
37/// Solved geometry per route. Flat rather than keyed by scope: route ids are
38/// document-wide unique, so a scope key would distinguish nothing.
39pub type RouteGeometries = HashMap<RouteId, RouteGeometry>;
40
41#[derive(Default)]
42pub struct Presentation {
43    pub pin_accents: PinAccents,
44    pub text_extents: TextExtents,
45    /// A missing entry is a route that has not been reconstructed —
46    /// nothing to draw or hit-test yet.
47    pub routes: RouteGeometries,
48    /// The document value [`Self::routes`] was reconstructed from.
49    routes_stamp: Option<DocStamp>,
50    /// Wires a preview drew geometry for that the document does not hold. The
51    /// next borrow re-derives exactly these, and no others.
52    previewed: std::collections::HashSet<RouteId>,
53    /// The scope those previews happened in, since taking them back means
54    /// reconstructing them where they live.
55    previewed_in: Option<crate::path::BlockPath>,
56}
57
58impl Presentation {
59    /// Forget the geometry of every wire `live` does not hold: a deleted
60    /// wire has none.
61    pub(crate) fn keep_routes(&mut self, live: impl Fn(RouteId) -> bool) {
62        self.routes.retain(|&id, _| live(id));
63    }
64
65    /// Bring the accent propagation up to date with the document behind
66    /// `indexed`. Gated on the document's stamp, so the once-per-borrow
67    /// call sites stay cheap.
68    pub fn refresh_accents(&mut self, indexed: &IndexedDocument<'_>) {
69        if self.pin_accents.stamp == Some(indexed.doc.stamp()) {
70            return;
71        }
72        self.pin_accents.recompute(indexed);
73    }
74
75    /// Rebuild every scope's wire geometry from the authored waypoints
76    /// behind `indexed`. Gated on the document's stamp like
77    /// [`Self::refresh_accents`], so a borrow that changed nothing costs a
78    /// comparison — and a commit that landed anywhere, local or foreign,
79    /// re-derives the wires without anyone asking.
80    ///
81    /// The stamp is claimed before the pass runs: the reconstruction builds
82    /// its own scoped [`Drawing`](crate::widget::drawing::Drawing)s, whose
83    /// constructor calls back in here.
84    pub fn refresh_routes(&mut self, indexed: &IndexedDocument<'_>) {
85        if self.routes_stamp == Some(indexed.doc.stamp()) {
86            self.take_previews_back(indexed);
87            return;
88        }
89        self.routes_stamp = Some(indexed.doc.stamp());
90        self.previewed.clear();
91        self.previewed_in = None;
92        let _s = tracing::info_span!("present_document").entered();
93        crate::widget::drawing::present_document(indexed, self);
94    }
95
96    /// Re-derive the wires a preview drew for, and only those. The document
97    /// never moved — a preview supposes, it does not write — so every other
98    /// wire is already showing what the document implies.
99    fn take_previews_back(&mut self, indexed: &IndexedDocument<'_>) {
100        if self.previewed.is_empty() {
101            return;
102        }
103        let _s = tracing::info_span!("take_previews_back", routes = self.previewed.len()).entered();
104        let taking_back = std::mem::take(&mut self.previewed);
105        let Some(path) = self.previewed_in.take() else {
106            return;
107        };
108        crate::widget::drawing::reconstruct_scope(
109            indexed,
110            self,
111            &path,
112            crate::widget::routing::Reconstructing::These(&taking_back),
113        );
114    }
115
116    /// Bring the wires `disturbed` reaches up to date against `indexed`, and
117    /// claim its stamp — what a commit does when it knows what it touched.
118    ///
119    /// The gate in [`Self::refresh_routes`] is all-or-nothing: a moved stamp
120    /// re-derives every wire in the document. That is right for an open, an
121    /// undo or a commit from elsewhere, and wrong for a nudge, which knows the
122    /// rectangle it disturbed and leaves 7,549 wires drawn exactly as they
123    /// were.
124    ///
125    /// Correctness rests on `disturbed` covering everything the commit wrote —
126    /// the same superset rule the foreground is tested against
127    /// (`a_move_writes_nothing_outside_the_foreground`).
128    pub fn reconstruct_within(
129        &mut self,
130        indexed: &IndexedDocument<'_>,
131        path: &crate::path::BlockPath,
132        disturbed: blockworx_geom::Rect,
133    ) {
134        let _s = tracing::info_span!("reconstruct_within").entered();
135        // Claimed before the pass runs, as [`Self::refresh_routes`] claims it:
136        // the pass builds a `Drawing`, whose constructor calls back in here,
137        // and a stamp still showing the old document would send it off to
138        // re-derive every wire before this one re-derives a few.
139        self.routes_stamp = Some(indexed.doc.stamp());
140        let previewed = if self.previewed_in.as_ref() == Some(path) {
141            self.previewed_in = None;
142            std::mem::take(&mut self.previewed)
143        } else {
144            std::collections::HashSet::new()
145        };
146        crate::widget::drawing::reconstruct_scope(
147            indexed,
148            self,
149            path,
150            crate::widget::routing::Reconstructing::Within {
151                disturbed,
152                previewed: &previewed,
153            },
154        );
155    }
156
157    /// A throwaway presentation for a solve against a document that does not
158    /// exist yet: the geometry the editor is showing, held as already
159    /// reconstructed so the pass compares against what the user can see rather
160    /// than re-deriving it. Measurement caches start empty — the solve reads
161    /// neither.
162    #[must_use]
163    pub fn scratch(&self, indexed: &IndexedDocument<'_>) -> Self {
164        Self {
165            routes: self.routes.clone(),
166            routes_stamp: Some(indexed.doc.stamp()),
167            ..Self::default()
168        }
169    }
170
171    /// A preview drew geometry the document does not hold for *these* wires,
172    /// so the next borrow must take it back. Previews never move the document,
173    /// hence never its stamp — this is how an abandoned drag's preview is
174    /// undone.
175    ///
176    /// Named wire by wire rather than by dropping the stamp: a drag previews a
177    /// handful and blanking the stamp had the next borrow re-derive the whole
178    /// document, every frame.
179    pub fn routes_previewed(
180        &mut self,
181        routes: impl IntoIterator<Item = RouteId>,
182        in_scope: &crate::path::BlockPath,
183    ) {
184        self.previewed.extend(routes);
185        self.previewed_in = Some(in_scope.clone());
186    }
187}
188
189/// Route-role propagation onto pin stubs: a wire's accent colors the pins
190/// it lands on. Derived fresh from the routes on every borrow — the last
191/// route at a scope to touch a pin wins, including a plain (`None`) role —
192/// so it cannot go stale the way a stored propagation would.
193///
194/// Keyed by the *scope* the coloring happened in, not the pin's owner: one
195/// pin is drawn twice, as its own block's boundary port and as a stub on
196/// that block seen from the parent, and each drawing takes the accent of
197/// the wires in the scope it is drawn in.
198#[derive(Default)]
199pub struct PinAccents {
200    stamp: Option<DocStamp>,
201    accents: HashMap<(Scope, PinId), Option<u8>>,
202}
203
204impl PinAccents {
205    /// The stub accent of `pin` as drawn in `scope`.
206    pub fn pin(&self, scope: Scope, pin: PinId) -> Option<u8> {
207        self.accents.get(&(scope, pin)).copied().flatten()
208    }
209
210    fn recompute(&mut self, indexed: &IndexedDocument<'_>) {
211        let _s = tracing::info_span!("pin_accents").entered();
212        self.accents.clear();
213        for id in chronological(indexed.doc.routes()) {
214            let Some(route) = indexed.doc.route(&id) else {
215                continue;
216            };
217            let accent = accent_from_role(route.role);
218            let scope = Scope::from_wire(route.owner);
219            for endpoint in [route.from, route.to] {
220                self.accents.insert((scope, endpoint), accent);
221            }
222        }
223        self.stamp = Some(indexed.doc.stamp());
224    }
225}
226
227/// Galley-measured text-box extents, keyed by the text and the width they
228/// were measured for: an entry is consulted only while the box still holds
229/// that text at that width, so the cache can never serve a stale rect —
230/// undo, load, or restore leave it self-invalidated and readers fall back
231/// to the character-count estimate until the box is next measured.
232#[derive(Default)]
233pub struct TextExtents {
234    map: HashMap<TextId, (String, BoxWidth, GridSize)>,
235}
236
237impl TextExtents {
238    pub fn set(&mut self, id: TextId, measured: &Text, size: GridSize) {
239        self.map
240            .insert(id, (measured.text.clone(), BoxWidth::of(measured), size));
241    }
242
243    /// The measured extent, if it was measured for exactly `current`'s text
244    /// and width.
245    pub fn valid_for(&self, id: TextId, current: &Text) -> Option<GridSize> {
246        self.map
247            .get(&id)
248            .filter(|(text, width, _)| *text == current.text && *width == BoxWidth::of(current))
249            .map(|&(_, _, size)| size)
250    }
251}
252
253/// The accent lookup for the pins of one shape, resolved by a caller that
254/// knows which scope the shape is being drawn in.
255#[derive(Clone, Copy)]
256pub struct ShapeAccents<'a> {
257    scope: Scope,
258    accents: &'a PinAccents,
259}
260
261impl<'a> ShapeAccents<'a> {
262    pub fn new(scope: Scope, accents: &'a PinAccents) -> Self {
263        Self { scope, accents }
264    }
265
266    pub fn pin(&self, pin: PinId) -> Option<u8> {
267        self.accents.pin(self.scope, pin)
268    }
269}