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}