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}