brep_app/document.rs
1//! Open documents and their active tab. Each document owns an engine, store
2//! identity, and clean baseline for dirty tracking. Keeping engine access on
3//! `Documents` allows separate mutable borrows of other app fields.
4//!
5//! Every engine has its own history runner and resident kernel state. All open
6//! documents are pumped each frame, so switching tabs cannot redirect a pending
7//! result to another document. Each open document retains its runner and scene.
8//!
9//! Display settings are shared across tabs by [`carry_settings`]; workbench
10//! selection belongs to each document. A new engine initially inherits the
11//! current workbench, which a loaded document can override.
12
13use crate::store::ModelStore;
14use brep_render::engine_state::EngineState;
15
16/// Builds a fresh engine for a new document — the platform runner, the viewcube,
17/// and the persisted display settings, all applied before anything is loaded
18/// into it. Injected by the shell so tests (which need the SYNCHRONOUS inline
19/// runner) can construct documents without a background thread.
20pub type EngineFactory = Box<dyn Fn() -> EngineState>;
21
22/// The empty model a **New** document starts from.
23pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;
24
25/// ONE open model: the engine that owns it plus the identity the file lane needs
26/// — the store name it was opened from / saved to, and the clean baseline.
27pub struct Document {
28 /// The windowing-agnostic brain for THIS document (scene, camera, history,
29 /// settings, selection). Panels borrow it; it is never forked.
30 pub engine: EngineState,
31 /// The store identity (`None` = a never-saved "untitled" model). The RAW
32 /// identity, not the display name — a native dialog hands back a full path
33 /// and a plain Save must write back to it.
34 name: Option<String>,
35 /// The model request JSON as of the last New / Open / Save — the baseline
36 /// the dirty flag compares the live history against.
37 saved_signature: Option<String>,
38 /// The cached tab-strip dirty dot. See [`Document::dirty_marker`] for why
39 /// this is not simply `is_dirty()`.
40 dirty_marker: bool,
41 /// The applied-run generation `dirty_marker` was computed at.
42 marker_generation: Option<u64>,
43 /// A process-unique handle, so the shell can notice "the active document is
44 /// a different one now" without comparing indices (closing a tab BEFORE the
45 /// active one moves the index without changing the document).
46 id: u64,
47}
48
49impl Document {
50 /// Wrap `engine` as a document, taking its CURRENT model as the clean
51 /// baseline — so a freshly loaded (or freshly emptied) document is clean and
52 /// the first edit marks it dirty.
53 pub fn new(engine: EngineState) -> Self {
54 let saved_signature = Some(engine.history_request_json());
55 Self {
56 engine,
57 name: None,
58 saved_signature,
59 dirty_marker: false,
60 marker_generation: None,
61 id: next_document_id(),
62 }
63 }
64
65 /// Wrap `engine` as a document RESTORED from the autosave blob
66 /// (`crate::recovery`): it carries the store identity it had (`None` for
67 /// an untitled one) and NO clean baseline, so it is dirty from its first
68 /// frame — the work it holds is exactly what was never saved, and the close
69 /// guard and the tab dot must say so until a Save writes it somewhere.
70 pub fn recovered(engine: EngineState, name: Option<String>) -> Self {
71 Self {
72 engine,
73 name,
74 saved_signature: None,
75 dirty_marker: true,
76 marker_generation: None,
77 id: next_document_id(),
78 }
79 }
80
81 /// The raw store identity, or `None` for a never-saved document.
82 pub fn name(&self) -> Option<&str> {
83 self.name.as_deref()
84 }
85
86 pub fn set_name(&mut self, name: Option<String>) {
87 self.name = name;
88 }
89
90 /// The tab label: the bare display name, or `untitled`.
91 pub fn title(&self) -> String {
92 match &self.name {
93 Some(name) => crate::store::model_display_name(name),
94 None => "untitled".to_string(),
95 }
96 }
97
98 /// Snapshot the current model as the clean baseline (after New / Open /
99 /// Save, or a `?loadModel=` boot-load).
100 pub fn mark_clean(&mut self) {
101 self.saved_signature = Some(self.engine.history_request_json());
102 self.dirty_marker = false;
103 self.marker_generation = Some(self.engine.applied_generation());
104 }
105
106 /// Dirty = the live model differs from the last saved/opened baseline.
107 /// (Rolling the history does NOT change the request document, so navigating
108 /// steps never marks dirty — only real edits/add/delete/reorder do.)
109 ///
110 /// HONEST but not free: it serializes the whole request document, which for
111 /// an assembly carrying an embedded parts library is megabytes. Every place
112 /// where being wrong would cost the user work — the close prompt, Save —
113 /// calls THIS. The per-frame tab dot calls [`Self::dirty_marker`] instead.
114 pub fn is_dirty(&self) -> bool {
115 match &self.saved_signature {
116 Some(saved) => *saved != self.engine.history_request_json(),
117 None => true,
118 }
119 }
120
121 /// The cached dirty flag the tab strip draws, recomputed only when the
122 /// document's APPLIED RUN generation moved. Every ordinary edit (a param
123 /// change, add, delete, reorder, undo) re-runs the history and bumps that
124 /// generation, so the dot tracks editing; a mutation that changes the
125 /// document WITHOUT a re-run (an object-metadata write) can leave the dot
126 /// one edit behind. That is a marker being briefly optimistic, never a lost
127 /// edit: [`Self::is_dirty`] is recomputed honestly at the close prompt.
128 ///
129 /// The alternative — serializing every open document every frame — is a
130 /// per-frame multi-megabyte cost per tab, and the app pays no such cost today.
131 pub fn refresh_dirty_marker(&mut self) {
132 let generation = self.engine.applied_generation();
133 if self.marker_generation == Some(generation) {
134 return;
135 }
136 self.marker_generation = Some(generation);
137 self.dirty_marker = self.is_dirty();
138 }
139
140 pub fn dirty_marker(&self) -> bool {
141 self.dirty_marker
142 }
143
144 /// The document's process-unique handle (identity across index shuffles).
145 pub fn id(&self) -> u64 {
146 self.id
147 }
148}
149
150/// Every open document + which one is active. Always holds AT LEAST ONE
151/// document: closing the last tab leaves a fresh untitled one in its place, so
152/// [`Documents::engine_mut`] is infallible and the shell never has to render a
153/// "no document" state that would be an empty viewport with extra steps.
154pub struct Documents {
155 open: Vec<Document>,
156 active: usize,
157 /// How a new tab's engine is built (platform runner + persisted settings).
158 new_engine: EngineFactory,
159}
160
161impl Documents {
162 /// A session holding ONE empty document built by `new_engine`.
163 pub fn new(new_engine: EngineFactory) -> Self {
164 let first = Document::new(new_engine());
165 Self {
166 open: vec![first],
167 active: 0,
168 new_engine,
169 }
170 }
171
172 /// A fresh engine for a document about to be opened — the caller loads into
173 /// it and hands the result to [`Self::open_document`].
174 ///
175 /// Seeded with the WHOLE of the session's settings, workbench included, so
176 /// a new tab continues what you were doing. The load that follows overrides
177 /// the workbench if the document names one (see the module header).
178 pub fn spawn_engine(&self) -> EngineState {
179 let mut engine = (self.new_engine)();
180 carry_settings(&self.engine().settings_json(), &mut engine, true);
181 engine
182 }
183
184 pub fn engine(&self) -> &EngineState {
185 &self.open[self.active].engine
186 }
187
188 pub fn engine_mut(&mut self) -> &mut EngineState {
189 &mut self.open[self.active].engine
190 }
191
192 pub fn active(&self) -> &Document {
193 &self.open[self.active]
194 }
195
196 pub fn active_mut(&mut self) -> &mut Document {
197 &mut self.open[self.active]
198 }
199
200 pub fn active_index(&self) -> usize {
201 self.active
202 }
203
204 /// The ACTIVE document's handle — the shell compares it frame to frame to
205 /// notice a switch from ANY source (a tab click, a close, New, Open, Open
206 /// Part, the session restore) with one check instead of a hook per site.
207 pub fn active_id(&self) -> u64 {
208 self.open[self.active].id
209 }
210
211 pub fn len(&self) -> usize {
212 self.open.len()
213 }
214
215 pub fn get(&self, index: usize) -> Option<&Document> {
216 self.open.get(index)
217 }
218
219 pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
220 self.open.get_mut(index)
221 }
222
223 pub fn iter(&self) -> std::slice::Iter<'_, Document> {
224 self.open.iter()
225 }
226
227 pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
228 self.open.iter_mut()
229 }
230
231 /// The tab holding the document stored under `name`, if any.
232 pub fn index_of(&self, name: &str) -> Option<usize> {
233 self.open
234 .iter()
235 .position(|doc| doc.name.as_deref() == Some(name))
236 }
237
238 /// Activate the tab already holding `name`; `false` when it is not open.
239 /// The "focus the existing tab" half of open-or-focus.
240 pub fn focus_named(&mut self, name: &str) -> bool {
241 match self.index_of(name) {
242 Some(index) => {
243 self.activate(index);
244 true
245 }
246 None => false,
247 }
248 }
249
250 /// Add `doc` as a new tab and make it active.
251 pub fn open_document(&mut self, doc: Document) -> usize {
252 self.open.push(doc);
253 let index = self.open.len() - 1;
254 self.activate(index);
255 index
256 }
257
258 /// Make tab `index` active, carrying the session's display settings over to
259 /// it. Out-of-range indices are ignored (a stale click never panics).
260 pub fn activate(&mut self, index: usize) {
261 if index >= self.open.len() || index == self.active {
262 return;
263 }
264 let settings = self.open[self.active].engine.settings_json();
265 self.active = index;
266 carry_settings(&settings, &mut self.open[index].engine, false);
267 }
268
269 /// Close tab `index`. Closing the LAST document leaves a fresh untitled one
270 /// (the always-one-document invariant); closing a tab before the active one
271 /// keeps the same document active at its new index.
272 ///
273 /// The caller is responsible for the unsaved-changes prompt — this is the
274 /// mechanical close (`panels::file` owns the confirmation).
275 pub fn close(&mut self, index: usize) {
276 if index >= self.open.len() {
277 return;
278 }
279 // Taken BEFORE the removal: if the closing tab was the active one, its
280 // settings are the session's and must survive into whatever is shown next.
281 let settings = self.open[self.active].engine.settings_json();
282 let closed_active = index == self.active;
283 self.open.remove(index);
284 // The replacement for a session closed down to nothing is a brand-new
285 // tab, so it takes the WHOLE of the session's settings — workbench
286 // included — exactly as `New` does through `spawn_engine`.
287 let refilled = self.open.is_empty();
288 if refilled {
289 self.open.push(Document::new((self.new_engine)()));
290 self.active = 0;
291 } else if closed_active {
292 self.active = index.min(self.open.len() - 1);
293 } else if index < self.active {
294 self.active -= 1;
295 }
296 if closed_active {
297 let active = self.active;
298 carry_settings(&settings, &mut self.open[active].engine, refilled);
299 }
300 }
301
302 /// Refresh every tab's dirty dot (see [`Document::refresh_dirty_marker`]).
303 pub fn refresh_dirty_markers(&mut self) {
304 for doc in &mut self.open {
305 doc.refresh_dirty_marker();
306 }
307 }
308
309 // --- session persistence --------------------------------------------------
310
311 /// The persisted session: the NAMED documents in tab order plus the active
312 /// one's name. A never-saved document has no store identity, so it cannot be
313 /// restored and is not listed — reloading loses an unsaved scratch document
314 /// exactly as it did when the app held one document and persisted none.
315 pub fn session_json(&self) -> String {
316 let open: Vec<&str> = self.open.iter().filter_map(Document::name).collect();
317 serde_json::json!({
318 "open": open,
319 "active": self.active().name(),
320 })
321 .to_string()
322 }
323
324 /// Restore a persisted session, REPLACING the current open list. Names the
325 /// store no longer holds (or cannot load) are skipped — a deleted file must
326 /// not stop the rest of the session coming back — and the count of documents
327 /// actually restored is returned, so the caller can seed instead when it is 0.
328 pub fn restore_session(&mut self, store: &dyn ModelStore, json: &str) -> usize {
329 let Ok(session) = serde_json::from_str::<serde_json::Value>(json) else {
330 return 0;
331 };
332 let names: Vec<String> = session["open"]
333 .as_array()
334 .map(|list| {
335 list.iter()
336 .filter_map(|name| name.as_str().map(str::to_string))
337 .collect()
338 })
339 .unwrap_or_default();
340 let wanted_active = session["active"].as_str().unwrap_or_default().to_string();
341
342 let mut restored: Vec<Document> = Vec::new();
343 let mut active = 0usize;
344 for name in names {
345 let Some(contents) = store.read(&name) else {
346 continue;
347 };
348 let mut engine = (self.new_engine)();
349 if engine.load_model_and_fit(&contents).is_err() {
350 continue;
351 }
352 let mut doc = Document::new(engine);
353 doc.set_name(Some(name.clone()));
354 if name == wanted_active {
355 active = restored.len();
356 }
357 restored.push(doc);
358 }
359 if restored.is_empty() {
360 return 0;
361 }
362 self.open = restored;
363 self.active = active;
364 self.open.len()
365 }
366}
367
368/// Copy the SESSION-scoped display settings (theme, UI scale, colors, wireframe,
369/// projection, lod…) from a `settings_json()` snapshot into `target`.
370/// `with_workbench` is true ONLY when seeding a brand-new engine — see the
371/// module header for why activation must never carry it.
372fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
373 let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
374 return;
375 };
376 if !with_workbench {
377 if let Some(object) = settings.as_object_mut() {
378 object.remove("workbench");
379 }
380 }
381 let _ = target.apply_settings_json(&settings.to_string());
382}
383
384/// Hand out the next document handle. A plain process counter: handles only ever
385/// need to be distinct WITHIN a session, and they never reach storage.
386fn next_document_id() -> u64 {
387 use std::sync::atomic::{AtomicU64, Ordering};
388 static NEXT: AtomicU64 = AtomicU64::new(1);
389 NEXT.fetch_add(1, Ordering::Relaxed)
390}
391
392// BREP private tests: 85869bb1b2ce9045