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}