Skip to main content

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