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