Skip to main content

brep_app/panels/
dock.rs

1//! The dockable / tabbed side-panel layout — an `egui_tiles` tree that hosts
2//! every side-panel section AND the 3D viewport as tiles the user can split,
3//! tab, resize, and drag-rearrange (IDE-style), with the layout persisted.
4//!
5//! This is **workbench-agnostic**: the same tree hosts whatever sections a
6//! workbench exposes. Which side panes are *visible* is filtered per-workbench
7//! by [`workbench::panel_visible`] (e.g. the two Assembly panes show only under
8//! the Assembly workbench); positions / tabs / splits come from one shared,
9//! persisted layout. New workbench panes plug in with one [`PaneKind`] arm.
10//!
11//! Structure:
12//! * [`PaneKind`] — the serde discriminant of a tile; carries NO state.
13//! * [`DockState`] — owns the `Tree<PaneKind>`, load/save/reconcile, and the
14//!   per-frame workbench visibility pass. One field on `BrepApp`.
15//! * [`DockBehavior`] — a transient, per-frame `egui_tiles::Behavior` built from
16//!   disjoint `&mut` borrows of the app's panels + engine (see [`DockContext`]);
17//!   its `pane_ui` just delegates to each panel's existing `show(...)`.
18//!
19//! The shell draws the tree ONLY in normal modeling mode. In sketch / ref-select
20//! mode it bypasses the tree and draws the viewport directly (see `app.rs`), so
21//! the side panes simply don't appear — no reliance on container-visibility
22//! edge cases.
23
24use eframe::egui;
25use egui_tiles::{
26    Behavior, Container, EditAction, TabState, Tile, TileId, Tiles, Tree, UiResponse,
27};
28use serde::{Deserialize, Serialize};
29
30use crate::document::Documents;
31use crate::panels::assembly_constraints::AssemblyConstraintsPanel;
32use crate::panels::component_actions::ComponentActionRequest;
33use crate::panels::bom::BomPanel;
34use crate::panels::document_tabs::TabsOutcome;
35use crate::panels::expressions::ExpressionsPanel;
36use crate::panels::history::HistoryPanel;
37use crate::panels::scene::ScenePanel;
38use crate::panels::update_components::UpdateComponents;
39use crate::store::{ModelStore, DOCK_LAYOUT_KEY};
40use crate::viewport::Viewport;
41use crate::workbench;
42
43/// One tile in the dock tree. A pure discriminant — every panel's real state
44/// lives on its own struct (a field of `BrepApp`), reached in `pane_ui`.
45#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
46pub enum PaneKind {
47    /// ONE OPEN MODEL's 3D view, keyed by [`crate::document::Document::id`].
48    ///
49    /// These are the document tabs: they all live in a single `Tabs` container
50    /// — the DOCUMENT GROUP — whose tab bar IS the model switcher, so the pane
51    /// showing 3D views is tabbed by egui_tiles itself rather than carrying a
52    /// second hand-drawn strip inside it.
53    ///
54    /// The id is process-unique and therefore meaningless in a RELOADED layout;
55    /// [`DockState::sync_document_panes`] renumbers whatever it finds onto the
56    /// live documents, which is what makes the persisted layout survive.
57    Document(u64),
58    History,
59    AssemblyConstraints,
60    Bom,
61    Scene,
62    Expressions,
63}
64
65impl PaneKind {
66    /// The side panes in default top-to-bottom order (excludes the viewport).
67    const SIDE: [PaneKind; 5] = [
68        PaneKind::History,
69        PaneKind::Bom,
70        PaneKind::AssemblyConstraints,
71        PaneKind::Scene,
72        PaneKind::Expressions,
73    ];
74
75    /// Side panes that are ALWAYS in the tree (unclaimed by any workbench). The
76    /// THREE Assembly panes (Structure, Constraints, BOM) are excluded — they're
77    /// added/removed by workbench membership
78    /// ([`DockState::apply_workbench_membership`]).
79    const ALWAYS: [PaneKind; 3] = [
80        PaneKind::History,
81        PaneKind::Scene,
82        PaneKind::Expressions,
83    ];
84
85    /// Human tab title.
86    fn title(self) -> &'static str {
87        match self {
88            // Only a fallback: the real per-document title (file name + dirty
89            // marker) comes from `DockBehavior::tab_title_for_pane`, which can
90            // reach the open documents.
91            PaneKind::Document(_) => "3D View",
92            PaneKind::History => "History",
93            // The component TREE. It was titled "BOM" before the BOM
94            // existed; now that there is a real columned parts list next to
95            // it, the honest name is what it draws.
96            PaneKind::Bom => "BOM",
97            PaneKind::AssemblyConstraints => "Constraints",
98            PaneKind::Scene => "Scene",
99            PaneKind::Expressions => "Expressions",
100        }
101    }
102
103    /// The workbench-registry panel id this pane is claimed under, or `None` for
104    /// the viewport (which is not a workbench-filterable panel). Must match the
105    /// ids used in `app.rs`'s old `panel_visible` gates + `workbench::assembly`.
106    fn panel_id(self) -> Option<&'static str> {
107        match self {
108            PaneKind::Document(_) => None,
109            PaneKind::History => Some("history"),
110            PaneKind::Bom => Some(workbench::assembly::BOM_PANEL_ID),
111            PaneKind::AssemblyConstraints => Some(workbench::assembly::CONSTRAINTS_PANEL_ID),
112            PaneKind::Scene => Some("scene"),
113            PaneKind::Expressions => Some("expressions"),
114        }
115    }
116
117    /// Whether this pane is visible under workbench `wb`. The viewport is always
118    /// visible; side panes defer to the workbench claim system.
119    fn visible_in(self, wb: &str) -> bool {
120        match self.panel_id() {
121            None => true,
122            Some(id) => workbench::panel_visible(wb, id),
123        }
124    }
125}
126
127/// The dock layout: the tile tree + a dirty flag so a user layout edit persists.
128/// One field on `BrepApp`.
129pub struct DockState {
130    tree: Tree<PaneKind>,
131    /// Set by [`DockBehavior::on_edit`] when the user drags / resizes a tile;
132    /// drained in [`DockState::ui`] to persist the new layout.
133    dirty: bool,
134    /// The workbench id membership was last reconciled against. Workbench-claimed
135    /// panes (the two Assembly panels) are added to / removed from the tree
136    /// *structurally* when the workbench changes — NOT hidden via `set_visible`,
137    /// because a hidden pane still owns an (empty) tab bar once panes are tabbed.
138    /// Reconciling only on change avoids per-frame tree churn.
139    last_wb: Option<String>,
140    /// Per-pane `(kind, present, rendered-this-frame)` from the last `ui()` — the
141    /// source for the `__brepDock` verifier global. `rendered` is false for a pane
142    /// sitting behind an inactive tab (egui_tiles skips its `pane_ui`), so an e2e
143    /// script knows to activate that tab before asserting on its widgets.
144    snapshot: Vec<(PaneKind, bool, bool)>,
145}
146
147/// The disjoint `&mut` borrows the dock needs to draw a frame — assembled by the
148/// shell from `BrepApp`'s fields (all distinct, so the borrow checker allows it).
149pub struct DockContext<'a> {
150    /// The open documents. The dock reaches the ACTIVE engine through
151    /// `docs.engine_mut()` — a borrow of one FIELD, so it still composes with
152    /// the disjoint panel borrows beside it (see `crate::document`).
153    pub docs: &'a mut Documents,
154    pub viewport: &'a mut Viewport,
155    pub history: &'a mut HistoryPanel,
156    pub bom: &'a mut BomPanel,
157    pub assembly_constraints: &'a mut AssemblyConstraintsPanel,
158    pub scene: &'a mut ScenePanel,
159    pub expressions: &'a mut ExpressionsPanel,
160    pub update_components: &'a mut UpdateComponents,
161    pub model_store: &'a dyn ModelStore,
162}
163
164/// What a dock frame hands back to the shell — the SAME cross-panel requests the
165/// old left-panel closure bubbled out (borrows inside prevent acting there).
166#[derive(Default)]
167pub struct DockOutcome {
168    /// The ACOMP palette pick asked to open the component-selector modal.
169    pub insert_component_requested: bool,
170    /// A structure-tree Edit asked to expand this feature in the history tree.
171    pub feature_focus: Option<String>,
172    /// Structure-tree row interactions (Move / Edit-in-place / Open Part).
173    /// A document-level component flow a pane's row menu asked for (the BOM's;
174    /// the engine-mutating half already ran in the shared dispatcher).
175    pub component_request: Option<ComponentActionRequest>,
176    /// What the DOCUMENT TAB STRIP inside the viewport tile was clicked for —
177    /// acted on by the shell, which owns the unsaved-changes prompt (close) and
178    /// the shared-panel reset (activate).
179    pub document_tabs: TabsOutcome,
180}
181
182impl DockState {
183    /// Load the persisted layout (reconciled against the current pane set), or
184    /// fall back to the default layout.
185    pub fn new(store: &dyn ModelStore) -> Self {
186        let tree = store
187            .read(DOCK_LAYOUT_KEY)
188            .and_then(|json| serde_json::from_str::<Tree<PaneKind>>(&json).ok())
189            .and_then(|mut t| reconcile(&mut t).then_some(t))
190            .unwrap_or_else(default_tree);
191        Self {
192            tree,
193            dirty: false,
194            last_wb: None,
195            snapshot: Vec::new(),
196        }
197    }
198
199    /// Draw the dock tree, delegating each visible pane to its panel's `show`.
200    /// Applies workbench visibility first, then persists if the user re-laid it.
201    pub fn ui(&mut self, ui: &mut egui::Ui, ctx: DockContext<'_>) -> DockOutcome {
202        let wb = ctx.docs.engine().settings.workbench.clone();
203        self.apply_workbench_membership(&wb);
204        // One tab per open model, active tab = active model. Must run BEFORE the
205        // draw so a document opened or closed last frame is already reflected in
206        // the bar the user is about to see.
207        if self.sync_document_panes(ctx.docs) {
208            self.dirty = true;
209        }
210        // Restore the group's exclusivity, so a pane the user dropped in there
211        // last frame never gets a second frame among the document tabs.
212        if self.evict_foreign_panes_from_document_group() {
213            self.dirty = true;
214        }
215
216        // Snapshot the document identity before the behavior takes `&mut docs`,
217        // so the post-draw tab-click read has something to compare against.
218        let active_id = ctx.docs.active_id();
219        let document_ids: Vec<u64> = ctx.docs.iter().map(|d| d.id()).collect();
220
221        let store = ctx.model_store;
222        let mut behavior = DockBehavior {
223            docs: ctx.docs,
224            viewport: ctx.viewport,
225            history: ctx.history,
226            bom: ctx.bom,
227            assembly_constraints: ctx.assembly_constraints,
228            scene: ctx.scene,
229            expressions: ctx.expressions,
230            update_components: ctx.update_components,
231            model_store: ctx.model_store,
232            insert_component_requested: false,
233            feature_focus: None,
234            component_request: None,
235            document_tabs: TabsOutcome::default(),
236            tab_title_spacing: 0.0,
237            layout_changed: false,
238            rendered: Vec::new(),
239        };
240        behavior.tab_title_spacing = behavior.tab_title_spacing(ui.visuals());
241        self.tree.ui(&mut behavior, ui);
242
243        // A tab CLICK shows up as egui_tiles' own active tab disagreeing with
244        // `Documents`; the shell resolves it by activating that document.
245        behavior.document_tabs.activate = self.tab_bar_selection(active_id, &document_ids);
246
247        let outcome = DockOutcome {
248            insert_component_requested: behavior.insert_component_requested,
249            feature_focus: behavior.feature_focus.take(),
250            component_request: behavior.component_request.take(),
251            document_tabs: std::mem::take(&mut behavior.document_tabs),
252        };
253        if behavior.layout_changed {
254            self.dirty = true;
255        }
256        let rendered = std::mem::take(&mut behavior.rendered);
257        drop(behavior);
258
259        // Snapshot for the `__brepDock` verifier global (in-tree order).
260        self.snapshot = self
261            .tree
262            .tiles
263            .iter()
264            .filter_map(|(id, tile)| match tile {
265                Tile::Pane(kind) => Some((
266                    *kind,
267                    self.tree.tiles.is_visible(*id),
268                    rendered.contains(kind),
269                )),
270                Tile::Container(_) => None,
271            })
272            .collect();
273
274        // Persist the layout, but DEBOUNCED: `on_edit` fires every frame while a
275        // tile is being drag-resized (the shares change per mouse-move), so only
276        // serialize once the pointer is released — mirrors how the shell defers
277        // `applied_ui_scale`. A dirty flag set mid-drag simply waits for release.
278        if self.dirty && !ui.ctx().input(|i| i.pointer.any_down()) {
279            self.save(store);
280            self.dirty = false;
281        }
282        outcome
283    }
284
285    /// The `__brepDock` verifier global: `{active, panes:[{kind,visible,rendered}]}`.
286    /// `active` is whether the dock owns the layout right now (false in sketch /
287    /// ref-select mode, where the shell draws the viewport directly). When
288    /// inactive, no side pane is rendered — the caller passes `active=false`.
289    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
290    pub fn state_json(&self, active: bool) -> String {
291        let panes: Vec<serde_json::Value> = self
292            .snapshot
293            .iter()
294            .map(|(kind, visible, rendered)| {
295                serde_json::json!({
296                    "kind": format!("{kind:?}"),
297                    "visible": visible,
298                    "rendered": active && *rendered,
299                })
300            })
301            .collect();
302        serde_json::json!({ "active": active, "panes": panes }).to_string()
303    }
304
305    /// Surface the pane of `kind` — make it the ACTIVE tab in its tab group so a
306    /// pane sitting behind another tab becomes visible. No-op if it is already
307    /// active. Used to bring the History tab forward when a feature is added (a
308    /// context-bar create can happen while another side tab is showing), so the
309    /// new row is actually seen (`app.rs`, paired with `history.focus_feature`).
310    pub fn show_pane(&mut self, kind: PaneKind) {
311        self.tree
312            .make_active(|_id, tile| matches!(tile, Tile::Pane(k) if *k == kind));
313    }
314
315    /// Reconcile which workbench-claimed panes EXIST in the tree against the
316    /// active workbench (only when it changed). Claimed panes present in a
317    /// workbench that doesn't claim them are removed; ones that should show but
318    /// are absent are (re)inserted into the side column. Unclaimed panes
319    /// (History / Scene / Expressions / Viewport) are never touched here — they
320    /// live in the tree permanently and are arranged only by the user.
321    fn apply_workbench_membership(&mut self, wb: &str) {
322        if self.last_wb.as_deref() == Some(wb) {
323            return;
324        }
325        self.last_wb = Some(wb.to_string());
326
327        for kind in [PaneKind::AssemblyConstraints, PaneKind::Bom] {
328            let should_show = kind.visible_in(wb);
329            match (should_show, self.find_pane(kind)) {
330                (true, None) => self.insert_side_pane(kind),
331                (false, Some(id)) => {
332                    self.tree.remove_recursively(id);
333                }
334                _ => {}
335            }
336        }
337    }
338
339    /// The tile id of the pane of `kind`, if present.
340    fn find_pane(&self, kind: PaneKind) -> Option<TileId> {
341        self.tree.tiles.iter().find_map(|(id, tile)| match tile {
342            Tile::Pane(k) if *k == kind => Some(*id),
343            _ => None,
344        })
345    }
346
347    /// The DOCUMENT GROUP: the `Tabs` container whose tab bar is the model
348    /// switcher. Identified by content — the container holding document panes —
349    /// so it survives the user re-docking it anywhere in the tree.
350    fn document_group(&self) -> Option<TileId> {
351        let pane = self.tree.tiles.iter().find_map(|(id, tile)| {
352            matches!(tile, Tile::Pane(PaneKind::Document(_))).then_some(*id)
353        })?;
354        let parent = self.tree.tiles.parent_of(pane)?;
355        matches!(self.tree.tiles.get(parent), Some(Tile::Container(Container::Tabs(_))))
356            .then_some(parent)
357    }
358
359    /// Match the document panes to the OPEN DOCUMENTS: one pane per document, in
360    /// the documents' own order, with the active document's pane as the active
361    /// tab.
362    ///
363    /// This is what lets the tab bar be egui_tiles' own rather than a strip drawn
364    /// inside a pane. It also absorbs the id problem: `Document` ids are
365    /// process-unique, so the panes in a RELOADED layout carry ids from a dead
366    /// session. Rather than special-casing that, the pass simply rewrites
367    /// whatever it finds onto the live documents — a restored layout keeps its
368    /// shape (where the group sits, how wide it is) and gets this session's
369    /// documents in it.
370    ///
371    /// Returns whether the tree changed, so the caller can persist.
372    fn sync_document_panes(&mut self, docs: &Documents) -> bool {
373        let Some(group) = self.document_group() else {
374            return false;
375        };
376        let wanted: Vec<u64> = docs.iter().map(|d| d.id()).collect();
377        let present: Vec<(TileId, u64)> = match self.tree.tiles.get(group) {
378            Some(Tile::Container(Container::Tabs(tabs))) => tabs
379                .children
380                .iter()
381                .filter_map(|id| match self.tree.tiles.get(*id) {
382                    Some(Tile::Pane(PaneKind::Document(doc))) => Some((*id, *doc)),
383                    _ => None,
384                })
385                .collect(),
386            _ => return false,
387        };
388
389        let mut changed = false;
390
391        // Re-key the panes we already have onto the wanted documents, in order.
392        // A reloaded layout hits this path for every pane; a steady-state frame
393        // hits it for none.
394        for ((tile, current), want) in present.iter().zip(wanted.iter()) {
395            if current != want {
396                if let Some(Tile::Pane(kind)) = self.tree.tiles.get_mut(*tile) {
397                    *kind = PaneKind::Document(*want);
398                    changed = true;
399                }
400            }
401        }
402
403        // Too few panes: a document was opened. Too many: one was closed.
404        for want in wanted.iter().skip(present.len()) {
405            let tile = self.tree.tiles.insert_pane(PaneKind::Document(*want));
406            if let Some(Tile::Container(container)) = self.tree.tiles.get_mut(group) {
407                container.add_child(tile);
408                changed = true;
409            }
410        }
411        for (tile, _) in present.iter().skip(wanted.len()) {
412            self.tree.remove_recursively(*tile);
413            changed = true;
414        }
415
416        // Point the tab bar at the active document. Done every frame (not only
417        // on change) because egui_tiles also moves `active` itself — when a tab
418        // is closed, say — and the two must not drift apart.
419        let active_id = docs.active_id();
420        let active_tile = self.tree.tiles.iter().find_map(|(id, tile)| {
421            matches!(tile, Tile::Pane(PaneKind::Document(d)) if *d == active_id).then_some(*id)
422        });
423        if let (Some(active_tile), Some(Tile::Container(Container::Tabs(tabs)))) =
424            (active_tile, self.tree.tiles.get_mut(group))
425        {
426            if tabs.active != Some(active_tile) {
427                tabs.set_active(active_tile);
428            }
429        }
430        changed
431    }
432
433    /// Which document the tab bar is currently showing, if it disagrees with
434    /// `Documents`. That disagreement is exactly how a TAB CLICK reaches us:
435    /// egui_tiles moves its own `active` when the user clicks, and the shell
436    /// then activates that document (which `sync_document_panes` will agree with
437    /// on the next frame).
438    /// Takes an id SNAPSHOT rather than `&Documents` because the live
439    /// `Documents` is mutably borrowed by the behavior while the tree draws.
440    fn tab_bar_selection(&self, active_id: u64, ids: &[u64]) -> Option<usize> {
441        let group = self.document_group()?;
442        let Some(Tile::Container(Container::Tabs(tabs))) = self.tree.tiles.get(group) else {
443            return None;
444        };
445        let Some(Tile::Pane(PaneKind::Document(id))) = self.tree.tiles.get(tabs.active?) else {
446            return None;
447        };
448        (*id != active_id).then(|| ids.iter().position(|d| d == id))?
449    }
450
451    /// Turf any pane that is not a document out of the DOCUMENT GROUP.
452    ///
453    /// The group's tab bar must list open models and nothing else. egui_tiles
454    /// offers no hook to refuse a drop into a container — `Behavior` can say a
455    /// tile is not draggable, which stops a document tab being torn OUT, but
456    /// nothing stops a side pane being dropped IN. So the invariant is restored
457    /// after the fact instead: a pane dropped in there is moved back to the side
458    /// column on the very same frame, before anything is drawn or persisted.
459    ///
460    /// Returns whether it moved anything, so the caller can persist the layout —
461    /// otherwise the eviction would silently repeat on every reload.
462    fn evict_foreign_panes_from_document_group(&mut self) -> bool {
463        let Some(group) = self.document_group() else {
464            return false;
465        };
466        let intruders: Vec<TileId> = match self.tree.tiles.get(group) {
467            Some(Tile::Container(Container::Tabs(tabs))) => tabs
468                .children
469                .iter()
470                .copied()
471                .filter(|id| {
472                    !matches!(self.tree.tiles.get(*id), Some(Tile::Pane(PaneKind::Document(_))))
473                })
474                .collect(),
475            _ => return false,
476        };
477        if intruders.is_empty() {
478            return false;
479        }
480
481        // Detach first, then re-home. The side column is looked up AFTER
482        // detaching so it can never resolve to the group itself.
483        if let Some(Tile::Container(container)) = self.tree.tiles.get_mut(group) {
484            for id in &intruders {
485                container.remove_child(*id);
486            }
487        }
488        let target = self.side_column().or_else(|| self.tree.root());
489        match target {
490            Some(target) if target != group => {
491                if let Some(Tile::Container(container)) = self.tree.tiles.get_mut(target) {
492                    for id in &intruders {
493                        container.add_child(*id);
494                    }
495                    return true;
496                }
497                self.put_back(group, &intruders);
498                false
499            }
500            _ => {
501                self.put_back(group, &intruders);
502                false
503            }
504        }
505    }
506
507    /// Return detached panes to `group`. Losing a pane the user can no longer
508    /// reach would be a worse outcome than the layout violation being fixed.
509    fn put_back(&mut self, group: TileId, panes: &[TileId]) {
510        if let Some(Tile::Container(container)) = self.tree.tiles.get_mut(group) {
511            for id in panes {
512                container.add_child(*id);
513            }
514        }
515    }
516
517    /// Insert a side pane into the vertical side column (the first vertical
518    /// `Linear` container), or the root as a fallback. `all_panes_must_have_tabs`
519    /// wraps it in its own tab group on the next frame.
520    fn insert_side_pane(&mut self, kind: PaneKind) {
521        let id = self.tree.tiles.insert_pane(kind);
522        let target = self.side_column().or_else(|| self.tree.root());
523        if let Some(target) = target {
524            if let Some(Tile::Container(container)) = self.tree.tiles.get_mut(target) {
525                container.add_child(id);
526            }
527        }
528    }
529
530    /// The first vertical `Linear` container (the side stack), if any.
531    fn side_column(&self) -> Option<TileId> {
532        self.tree.tiles.iter().find_map(|(id, tile)| match tile {
533            Tile::Container(Container::Linear(lin))
534                if lin.dir == egui_tiles::LinearDir::Vertical =>
535            {
536                Some(*id)
537            }
538            _ => None,
539        })
540    }
541
542    /// Serialize the layout through the unified persistence seam (best-effort).
543    fn save(&self, store: &dyn ModelStore) {
544        if let Ok(json) = serde_json::to_string(&self.tree) {
545            let _ = store.write(DOCK_LAYOUT_KEY, &json);
546        }
547    }
548}
549
550/// The default layout: a horizontal split of `[ vertical(side panes) | viewport ]`
551/// with the side column biased to ≈ the old 320 px width.
552fn default_tree() -> Tree<PaneKind> {
553    let mut tiles = Tiles::default();
554    let side: Vec<TileId> = PaneKind::SIDE
555        .into_iter()
556        .map(|k| tiles.insert_pane(k))
557        .collect();
558    let side_container = tiles.insert_vertical_tile(side);
559    // A placeholder document: ids are process-unique, so the real one is put in
560    // by `sync_document_panes` on the first frame. It lives in a `Tabs`
561    // container from the start — that container IS the document tab bar.
562    let placeholder = tiles.insert_pane(PaneKind::Document(0));
563    let viewport = tiles.insert_tab_tile(vec![placeholder]);
564    let root = tiles.insert_horizontal_tile(vec![side_container, viewport]);
565    // Bias the root split so the side column starts narrow (relative shares).
566    if let Some(Tile::Container(Container::Linear(linear))) = tiles.get_mut(root) {
567        linear.shares.set_share(side_container, 0.30);
568        linear.shares.set_share(viewport, 0.70);
569    }
570    Tree::new("brep-dock", root, tiles)
571}
572
573/// Bring a deserialized (possibly stale) tree in line with the current pane set:
574/// require exactly one viewport (else the layout is unusable → rebuild default),
575/// and append any side pane the saved layout predates. Returns `false` when the
576/// tree can't be salvaged, so the caller uses [`default_tree`].
577fn reconcile(tree: &mut Tree<PaneKind>) -> bool {
578    use std::collections::HashSet;
579    let mut document_panes = 0usize;
580    let mut present: HashSet<PaneKind> = HashSet::new();
581    for tile in tree.tiles.tiles() {
582        if let Tile::Pane(kind) = tile {
583            if matches!(kind, PaneKind::Document(_)) {
584                document_panes += 1;
585            }
586            present.insert(*kind);
587        }
588    }
589    // A layout with no document pane has nowhere to put the 3D views and no
590    // record of where the group belonged, so it is not salvageable — fall back
591    // to the default tree. (The COUNT is not checked: a saved layout legitimately
592    // holds as many document panes as were open, and `sync_document_panes`
593    // renumbers them onto this session's documents.)
594    if document_panes == 0 {
595        return false;
596    }
597    let Some(root) = tree.root() else {
598        return false;
599    };
600    // Only the ALWAYS-present panes are required; the workbench-claimed Assembly
601    // panes are managed per-workbench and may legitimately be absent.
602    for kind in PaneKind::ALWAYS {
603        if !present.contains(&kind) {
604            let id = tree.tiles.insert_pane(kind);
605            if let Some(Tile::Container(container)) = tree.tiles.get_mut(root) {
606                container.add_child(id);
607            }
608        }
609    }
610    true
611}
612
613/// The per-frame `egui_tiles::Behavior`: draws each pane by delegating to the
614/// owning panel's existing `show(...)`, and collects the cross-panel requests +
615/// a layout-edit flag for the shell to act on after `Tree::ui`.
616struct DockBehavior<'a> {
617    docs: &'a mut Documents,
618    viewport: &'a mut Viewport,
619    history: &'a mut HistoryPanel,
620    bom: &'a mut BomPanel,
621    assembly_constraints: &'a mut AssemblyConstraintsPanel,
622    scene: &'a mut ScenePanel,
623    expressions: &'a mut ExpressionsPanel,
624    update_components: &'a mut UpdateComponents,
625    model_store: &'a dyn ModelStore,
626    // --- outputs, drained after Tree::ui -----------------------------------
627    insert_component_requested: bool,
628    feature_focus: Option<String>,
629    component_request: Option<ComponentActionRequest>,
630    document_tabs: TabsOutcome,
631    /// The tab bar's own title spacing, captured at construction from the live
632    /// visuals. `on_tab_button` gets no `Ui`, and it needs this to reproduce
633    /// egui_tiles' close-button geometry for the verifier's hit rect.
634    tab_title_spacing: f32,
635    layout_changed: bool,
636    /// Panes whose `pane_ui` ran this frame (drawn = visible AND, if tabbed, the
637    /// active tab) — feeds the `__brepDock` snapshot.
638    rendered: Vec<PaneKind>,
639}
640
641/// Whether `tile_id` is a DOCUMENT tab. That single predicate is the whole rule
642/// for the document group: such a tab closes (its `✕` shuts the model) and
643/// cannot be dragged (tearing it out would put a 3D view outside the group).
644fn is_document_tile(tiles: &Tiles<PaneKind>, tile_id: TileId) -> bool {
645    matches!(tiles.get(tile_id), Some(Tile::Pane(PaneKind::Document(_))))
646}
647
648impl<'a> Behavior<PaneKind> for DockBehavior<'a> {
649    fn pane_ui(
650        &mut self,
651        ui: &mut egui::Ui,
652        _tile_id: TileId,
653        pane: &mut PaneKind,
654    ) -> UiResponse {
655        self.rendered.push(*pane);
656        // Paint the side-pane background with the SAME fill the old
657        // `Panel::left("brep-controls")` used (`visuals().panel_fill`), so the
658        // docked panels read exactly like the previous side panel — not the
659        // egui_tiles default (which leaves the pane transparent over the darker
660        // central fill). The viewport paints its own 3D, so skip it.
661        if !matches!(*pane, PaneKind::Document(_)) {
662            let visuals = ui.visuals();
663            ui.painter()
664                .rect_filled(ui.max_rect(), 0.0, visuals.panel_fill);
665        }
666        match *pane {
667            // Only the ACTIVE document's pane is ever drawn — egui_tiles shows
668            // one tab at a time, and the tab bar was pointed at the active
669            // document by `sync_document_panes` before this ran. The 3D body
670            // keeps its own click/drag (camera orbit + picking), so we NEVER
671            // report a pane drag here.
672            PaneKind::Document(_) => {
673                self.viewport.show(ui, self.docs.engine_mut());
674            }
675            PaneKind::History => scroll(ui, "dock-history", |ui| {
676                self.history.show(ui, self.docs.engine_mut());
677                // The ACOMP palette pick opens the COMPONENT SELECTOR, not a bare
678                // feature dialog — bubbled to the shell (the file dialog is shell-owned).
679                self.insert_component_requested |= self.history.take_insert_component_request();
680            }),
681            PaneKind::Bom => {
682                // VERTICAL only, even though a BOM is as wide as its configured
683                // columns: the column tree owns its own HORIZONTAL scrolling,
684                // because a scroll area out here would carry the frozen columns
685                // away with everything else. (The shared `scroll` helper is
686                // vertical-only too, but the BOM wants `auto_shrink` off.)
687                egui::ScrollArea::vertical()
688                    .id_salt("dock-bom")
689                    .auto_shrink([false, false])
690                    .show(ui, |ui| {
691                        let outcome = self.bom.show(
692                            ui,
693                            self.docs.engine_mut(),
694                            self.model_store,
695                            self.update_components,
696                        );
697                        if outcome.focus.is_some() {
698                            // The BOM's Edit action: roll to the owning feature
699                            // and open it in the history panel.
700                            self.feature_focus = outcome.focus;
701                        }
702                        if outcome.component.is_some() {
703                            self.component_request = outcome.component;
704                        }
705                    });
706            }
707            PaneKind::AssemblyConstraints => scroll(ui, "dock-constraints", |ui| {
708                self.assembly_constraints.show(
709                    ui,
710                    self.docs.engine_mut(),
711                    self.model_store,
712                    self.update_components,
713                );
714            }),
715            PaneKind::Scene => scroll(ui, "dock-scene", |ui| {
716                self.scene.show(ui, self.docs.engine_mut());
717            }),
718            PaneKind::Expressions => {
719                // Expressions self-scrolls (its own ScrollArea) — no outer wrap.
720                self.expressions.show(ui, self.docs.engine_mut());
721            }
722        }
723        UiResponse::None
724    }
725
726    /// A document tab is titled by its FILE, with a bullet while it has unsaved
727    /// changes — the tab bar is the only place that state is visible now that
728    /// several models are open at once. Every other pane keeps its fixed title.
729    fn tab_title_for_pane(&mut self, pane: &PaneKind) -> egui::WidgetText {
730        match pane {
731            PaneKind::Document(id) => match self.docs.iter().find(|d| d.id() == *id) {
732                Some(doc) => {
733                    let title = doc.title();
734                    // U+2022, not U+25CF: the icon font draws the latter as a
735                    // hollow ring, which reads as a status light rather than
736                    // "unsaved".
737                    if doc.dirty_marker() {
738                        format!("{title} \u{2022}").into()
739                    } else {
740                        title.into()
741                    }
742                }
743                // A pane whose document is gone is about to be removed by
744                // `sync_document_panes`; it must not panic in the meantime.
745                None => pane.title().into(),
746            },
747            _ => pane.title().into(),
748        }
749    }
750
751    /// Only a DOCUMENT tab closes — that is the model's `✕`. Side panels have no
752    /// re-open affordance, so they are shown/hidden by workbench and rearranged
753    /// by drag, never destroyed.
754    fn is_tab_closable(&self, tiles: &Tiles<PaneKind>, tile_id: TileId) -> bool {
755        is_document_tile(tiles, tile_id)
756    }
757
758    /// A `✕` click. We REFUSE the removal (`false`) and hand the request to the
759    /// shell instead: closing a model has to run the unsaved-changes prompt and
760    /// drop the document, and only then does its pane go — removed by
761    /// `sync_document_panes`. Letting egui_tiles delete the tile here would
762    /// close the tab while leaving the document open.
763    fn on_tab_close(&mut self, tiles: &mut Tiles<PaneKind>, tile_id: TileId) -> bool {
764        if let Some(Tile::Pane(PaneKind::Document(id))) = tiles.get(tile_id) {
765            if let Some(index) = self.docs.iter().position(|d| d.id() == *id) {
766                self.document_tabs.close = Some(index);
767            }
768        }
769        false
770    }
771
772    /// Publish each document tab's screen rect for the headed verifier, keyed
773    /// `doctab:<index>` exactly as the old hand-drawn strip did, so the existing
774    /// browser checks drive the real tab bar unchanged. This is the hook the
775    /// default tab renderer offers for precisely this — it hands back the tab
776    /// button's own `Response`, so we keep egui_tiles' native tab drawing.
777    fn on_tab_button(
778        &mut self,
779        tiles: &mut Tiles<PaneKind>,
780        tile_id: TileId,
781        button_response: egui::Response,
782    ) -> egui::Response {
783        if let Some(Tile::Pane(PaneKind::Document(id))) = tiles.get(tile_id) {
784            if let Some(index) = self.docs.iter().position(|d| d.id() == *id) {
785                let tab = button_response.rect;
786                // The `✕` is not routed through this hook (egui_tiles calls it
787                // once per tab, with the whole tab), so its rect is DERIVED the
788                // same way the default tab renderer lays it out: a
789                // `close_button_outer_size` square, right-centered in the tab
790                // inset by the title spacing. Both inputs come from this same
791                // `Behavior`, so an override moves the published rect with it.
792                let close = egui::Align2::RIGHT_CENTER.align_size_within_rect(
793                    egui::Vec2::splat(self.close_button_outer_size()),
794                    tab.shrink(self.tab_title_spacing),
795                );
796                self.document_tabs.hits.push((format!("doctab:{index}"), tab));
797                self.document_tabs
798                    .hits
799                    .push((format!("doctab:{index}:close"), close));
800            }
801        }
802        button_response
803    }
804
805    /// A document tab can't be picked up: dragging one out would tear a 3D view
806    /// into its own container somewhere else in the tree, which is the mirror
807    /// image of the violation `evict_foreign_panes_from_document_group` guards —
808    /// the group must be the ONLY home for 3D views, as well as holding nothing
809    /// but them. Side panes drag freely to re-dock.
810    fn is_tile_draggable(&self, tiles: &Tiles<PaneKind>, tile_id: TileId) -> bool {
811        !is_document_tile(tiles, tile_id)
812    }
813
814    fn on_edit(&mut self, _edit_action: EditAction) {
815        self.layout_changed = true;
816    }
817
818    /// Every pane gets its own tab bar — that tab is the label AND the drag
819    /// handle, so a default vertical stack still reads as titled, re-dockable
820    /// panes (egui_tiles gives bare linear panes neither). Other simplifications
821    /// stay at their defaults so the tree tidies itself after a drag / a
822    /// workbench-membership removal.
823    fn simplification_options(&self) -> egui_tiles::SimplificationOptions {
824        egui_tiles::SimplificationOptions {
825            all_panes_must_have_tabs: true,
826            ..Default::default()
827        }
828    }
829
830    /// Match the tab strip to the old side panel's fill (`panel_fill`) so a docked
831    /// panel reads as one continuous surface (tab bar + body) in the SAME colour
832    /// the previous `Panel::left` used — not the egui_tiles default strip colour.
833    fn tab_bar_color(&self, visuals: &egui::Visuals) -> egui::Color32 {
834        visuals.panel_fill
835    }
836
837    /// Give EVERY tab a visible chip so it reads as a tab — egui_tiles' default
838    /// leaves inactive tabs fully transparent (they vanish into the strip). Reuse
839    /// egui's standard widget fills: the active tab uses the "active" fill (it
840    /// stands out as selected), inactive tabs the "inactive" resting fill (a muted
841    /// but clearly-there chip). No hand-picked colours — same DRY rule as the rest.
842    fn tab_bg_color(
843        &self,
844        visuals: &egui::Visuals,
845        _tiles: &Tiles<PaneKind>,
846        _tile_id: TileId,
847        state: &TabState,
848    ) -> egui::Color32 {
849        if state.active {
850            visuals.widgets.active.bg_fill
851        } else {
852            visuals.widgets.inactive.bg_fill
853        }
854    }
855}
856
857/// Wrap a pane body in its own vertical scroll area (unique id per pane so egui
858/// never conflates their scroll state). Panels that self-scroll skip this.
859fn scroll(ui: &mut egui::Ui, salt: &str, add: impl FnOnce(&mut egui::Ui)) {
860    egui::ScrollArea::vertical()
861        .id_salt(salt)
862        .auto_shrink([false, false])
863        .show(ui, add);
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use brep_render::engine_state::EngineState;
870
871    fn active_tab(tree: &Tree<PaneKind>) -> Option<TileId> {
872        tree.tiles.iter().find_map(|(_, tile)| match tile {
873            Tile::Container(Container::Tabs(t)) => t.active,
874            _ => None,
875        })
876    }
877
878    /// `show_pane` makes a BACKGROUNDED pane the active tab — the History-surfacing
879    /// behavior the shell pairs with `history.focus_feature` when a feature is
880    /// added (`app.rs`), so the new row is actually seen.
881    #[test]
882    fn show_pane_activates_a_backgrounded_tab() {
883        let mut tiles = Tiles::default();
884        let history = tiles.insert_pane(PaneKind::History);
885        let scene = tiles.insert_pane(PaneKind::Scene);
886        let tabs = tiles.insert_tab_tile(vec![history, scene]);
887        // Scene in front, History behind it.
888        if let Some(Tile::Container(Container::Tabs(t))) = tiles.get_mut(tabs) {
889            t.set_active(scene);
890        }
891        let tree = Tree::new("test-dock", tabs, tiles);
892        let mut dock = DockState { tree, dirty: false, last_wb: None, snapshot: Vec::new() };
893        assert_eq!(active_tab(&dock.tree), Some(scene), "precondition: History is behind");
894
895        dock.show_pane(PaneKind::History);
896        assert_eq!(
897            active_tab(&dock.tree),
898            Some(history),
899            "show_pane brings the History tab to the front"
900        );
901
902        // Idempotent + doesn't disturb an already-active pane.
903        dock.show_pane(PaneKind::History);
904        assert_eq!(active_tab(&dock.tree), Some(history));
905    }
906
907    /// A tiny `Documents` for the layout tests: N untitled documents, `active`
908    /// selected. The engine is the real one — `Documents` owns it — but nothing
909    /// here draws, so no GPU is involved.
910    fn docs_with(count: usize, active: usize) -> Documents {
911        let mut docs = Documents::new(Box::new(EngineState::new));
912        for _ in 1..count {
913            let doc = crate::document::Document::new(docs.spawn_engine());
914            docs.open_document(doc);
915        }
916        docs.activate(active);
917        docs
918    }
919
920    fn document_ids_in_group(dock: &DockState) -> Vec<u64> {
921        let group = dock.document_group().expect("document group");
922        match dock.tree.tiles.get(group) {
923            Some(Tile::Container(Container::Tabs(tabs))) => tabs
924                .children
925                .iter()
926                .filter_map(|id| match dock.tree.tiles.get(*id) {
927                    Some(Tile::Pane(PaneKind::Document(d))) => Some(*d),
928                    _ => None,
929                })
930                .collect(),
931            _ => panic!("the group must be a Tabs container"),
932        }
933    }
934
935    fn fresh_dock() -> DockState {
936        DockState { tree: default_tree(), dirty: false, last_wb: None, snapshot: Vec::new() }
937    }
938
939    /// One tab per open model, in the documents' order — this IS the tab bar.
940    #[test]
941    fn sync_gives_the_group_one_pane_per_open_document() {
942        let docs = docs_with(3, 0);
943        let mut dock = fresh_dock();
944        assert!(dock.sync_document_panes(&docs), "the placeholder must be re-keyed");
945        assert_eq!(
946            document_ids_in_group(&dock),
947            docs.iter().map(|d| d.id()).collect::<Vec<_>>()
948        );
949        // Idempotent: a steady-state frame must not report a change, or the
950        // layout would be re-serialized forever.
951        assert!(!dock.sync_document_panes(&docs), "second pass must be a no-op");
952    }
953
954    /// Opening and closing models add and remove tabs.
955    #[test]
956    fn sync_tracks_documents_opening_and_closing() {
957        let mut docs = docs_with(1, 0);
958        let mut dock = fresh_dock();
959        dock.sync_document_panes(&docs);
960        assert_eq!(document_ids_in_group(&dock).len(), 1);
961
962        let doc = crate::document::Document::new(docs.spawn_engine());
963        docs.open_document(doc);
964        assert!(dock.sync_document_panes(&docs));
965        assert_eq!(document_ids_in_group(&dock), docs.iter().map(|d| d.id()).collect::<Vec<_>>());
966
967        docs.close(1);
968        assert!(dock.sync_document_panes(&docs));
969        assert_eq!(document_ids_in_group(&dock), docs.iter().map(|d| d.id()).collect::<Vec<_>>());
970    }
971
972    /// The active document is the active TAB — that is how the bar shows which
973    /// model you are looking at.
974    #[test]
975    fn sync_points_the_tab_bar_at_the_active_document() {
976        let docs = docs_with(3, 2);
977        let mut dock = fresh_dock();
978        dock.sync_document_panes(&docs);
979        let group = dock.document_group().unwrap();
980        let active = match dock.tree.tiles.get(group) {
981            Some(Tile::Container(Container::Tabs(tabs))) => tabs.active.unwrap(),
982            _ => panic!("group"),
983        };
984        assert!(
985            matches!(dock.tree.tiles.get(active), Some(Tile::Pane(PaneKind::Document(d))) if *d == docs.active_id())
986        );
987    }
988
989    /// A RELOADED layout carries document ids from a dead session. They must be
990    /// re-keyed onto this session's documents rather than leaving empty tabs.
991    #[test]
992    fn a_reloaded_layout_is_rekeyed_onto_the_live_documents() {
993        let docs = docs_with(2, 0);
994        let mut dock = fresh_dock();
995        // Stand in for a persisted layout: panes carrying ids nothing owns.
996        let group = dock.document_group().unwrap();
997        let stale = dock.tree.tiles.insert_pane(PaneKind::Document(9_999));
998        if let Some(Tile::Container(container)) = dock.tree.tiles.get_mut(group) {
999            container.add_child(stale);
1000        }
1001        assert!(dock.sync_document_panes(&docs));
1002        assert_eq!(
1003            document_ids_in_group(&dock),
1004            docs.iter().map(|d| d.id()).collect::<Vec<_>>(),
1005            "stale ids must be replaced, not appended to"
1006        );
1007    }
1008
1009    /// A tab click reaches the shell as egui_tiles' active tab disagreeing with
1010    /// `Documents` — and only then.
1011    #[test]
1012    fn a_tab_click_is_reported_once_and_not_when_in_sync() {
1013        let docs = docs_with(3, 0);
1014        let mut dock = fresh_dock();
1015        dock.sync_document_panes(&docs);
1016        let ids: Vec<u64> = docs.iter().map(|d| d.id()).collect();
1017        assert_eq!(dock.tab_bar_selection(docs.active_id(), &ids), None, "in sync: nothing to report");
1018
1019        // The user clicks the third tab; egui_tiles moves its own active tab.
1020        let group = dock.document_group().unwrap();
1021        let third = match dock.tree.tiles.get(group) {
1022            Some(Tile::Container(Container::Tabs(tabs))) => tabs.children[2],
1023            _ => panic!("group"),
1024        };
1025        if let Some(Tile::Container(Container::Tabs(tabs))) = dock.tree.tiles.get_mut(group) {
1026            tabs.set_active(third);
1027        }
1028        assert_eq!(dock.tab_bar_selection(docs.active_id(), &ids), Some(2));
1029    }
1030
1031    /// The bar must hold models and nothing else. egui_tiles cannot refuse the
1032    /// drop, so this simulates one — putting a side pane into the document
1033    /// group, exactly as dropping History onto the tab bar would — and asserts
1034    /// the next frame throws it back out.
1035    #[test]
1036    fn a_pane_dropped_into_the_document_group_is_evicted() {
1037        let docs = docs_with(1, 0);
1038        let mut dock = fresh_dock();
1039        dock.sync_document_panes(&docs);
1040        let group = dock.document_group().expect("group");
1041        let history = dock.find_pane(PaneKind::History).expect("history");
1042        let side = dock.side_column().expect("side column");
1043
1044        if let Some(Tile::Container(container)) = dock.tree.tiles.get_mut(side) {
1045            container.remove_child(history);
1046        }
1047        if let Some(Tile::Container(container)) = dock.tree.tiles.get_mut(group) {
1048            container.add_child(history);
1049        }
1050        assert_eq!(dock.tree.tiles.parent_of(history), Some(group), "setup");
1051
1052        assert!(dock.evict_foreign_panes_from_document_group(), "should report a change");
1053        assert_ne!(
1054            dock.tree.tiles.parent_of(history),
1055            Some(group),
1056            "History must not share the document tab bar"
1057        );
1058        // Evicted, not lost — a pane the user can no longer reach would be worse
1059        // than the violation it fixes.
1060        assert!(dock.find_pane(PaneKind::History).is_some(), "History must survive");
1061        assert_eq!(
1062            document_ids_in_group(&dock).len(),
1063            1,
1064            "the document itself must not be disturbed"
1065        );
1066    }
1067
1068    /// The common case: nothing to do, and it must say so — a pass that reported
1069    /// a change every frame would mark the layout dirty and re-save forever.
1070    #[test]
1071    fn a_healthy_tree_is_left_alone() {
1072        let docs = docs_with(2, 0);
1073        let mut dock = fresh_dock();
1074        dock.sync_document_panes(&docs);
1075        let before = document_ids_in_group(&dock);
1076        assert!(!dock.evict_foreign_panes_from_document_group(), "nothing to evict");
1077        assert_eq!(document_ids_in_group(&dock), before);
1078    }
1079
1080    /// A document tab must not be draggable: tearing one out would put a 3D view
1081    /// in a container outside the group, the mirror of a foreign pane landing in.
1082    #[test]
1083    fn document_tabs_cannot_be_dragged_out_but_side_panes_can() {
1084        let docs = docs_with(1, 0);
1085        let mut dock = fresh_dock();
1086        dock.sync_document_panes(&docs);
1087        let doc_pane = dock.tree.tiles.iter().find_map(|(id, tile)| {
1088            matches!(tile, Tile::Pane(PaneKind::Document(_))).then_some(*id)
1089        }).expect("document pane");
1090        let history = dock.find_pane(PaneKind::History).expect("history");
1091
1092        // `is_document_tile` is what both `is_tile_draggable` and
1093        // `is_tab_closable` are defined in terms of.
1094        assert!(is_document_tile(&dock.tree.tiles, doc_pane), "a model tab is a document tile");
1095        assert!(!is_document_tile(&dock.tree.tiles, history), "a side pane is not");
1096    }
1097}