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