BREP_app 0.2.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
//! The dockable / tabbed side-panel layout — an `egui_tiles` tree that hosts
//! every side-panel section AND the 3D viewport as tiles the user can split,
//! tab, resize, and drag-rearrange (IDE-style), with the layout persisted.
//!
//! This is **workbench-agnostic**: the same tree hosts whatever sections a
//! workbench exposes. Which side panes are *visible* is filtered per-workbench
//! by [`workbench::panel_visible`] (e.g. the two Assembly panes show only under
//! the Assembly workbench); positions / tabs / splits come from one shared,
//! persisted layout. New workbench panes plug in with one [`PaneKind`] arm.
//!
//! Structure:
//! * [`PaneKind`] — the serde discriminant of a tile; carries NO state.
//! * [`DockState`] — owns the `Tree<PaneKind>`, load/save/reconcile, and the
//!   per-frame workbench visibility pass. One field on `BrepApp`.
//! * [`DockBehavior`] — a transient, per-frame `egui_tiles::Behavior` built from
//!   disjoint `&mut` borrows of the app's panels + engine (see [`DockContext`]);
//!   its `pane_ui` just delegates to each panel's existing `show(...)`.
//!
//! The shell draws the tree ONLY in normal modeling mode. In sketch / ref-select
//! mode it bypasses the tree and draws the viewport directly (see `app.rs`), so
//! the side panes simply don't appear — no reliance on container-visibility
//! edge cases.

use eframe::egui;
use egui_tiles::{
    Behavior, Container, EditAction, TabState, Tile, TileId, Tiles, Tree, UiResponse,
};
use serde::{Deserialize, Serialize};

use brep_render::engine_state::EngineState;

use crate::panels::assembly_constraints::AssemblyConstraintsPanel;
use crate::panels::component_actions::ComponentActionRequest;
use crate::panels::bom::BomPanel;
use crate::panels::expressions::ExpressionsPanel;
use crate::panels::history::HistoryPanel;
use crate::panels::scene::ScenePanel;
use crate::panels::update_components::UpdateComponents;
use crate::store::{ModelStore, DOCK_LAYOUT_KEY};
use crate::viewport::Viewport;
use crate::workbench;

/// One tile in the dock tree. A pure discriminant — every panel's real state
/// lives on its own struct (a field of `BrepApp`), reached in `pane_ui`.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub enum PaneKind {
    /// The 3D viewport. Always present, never closable, never draggable.
    Viewport,
    History,
    AssemblyConstraints,
    Bom,
    Scene,
    Expressions,
}

impl PaneKind {
    /// The side panes in default top-to-bottom order (excludes the viewport).
    const SIDE: [PaneKind; 5] = [
        PaneKind::History,
        PaneKind::Bom,
        PaneKind::AssemblyConstraints,
        PaneKind::Scene,
        PaneKind::Expressions,
    ];

    /// Side panes that are ALWAYS in the tree (unclaimed by any workbench). The
    /// THREE Assembly panes (Structure, Constraints, BOM) are excluded — they're
    /// added/removed by workbench membership
    /// ([`DockState::apply_workbench_membership`]).
    const ALWAYS: [PaneKind; 3] = [
        PaneKind::History,
        PaneKind::Scene,
        PaneKind::Expressions,
    ];

    /// Human tab title.
    fn title(self) -> &'static str {
        match self {
            PaneKind::Viewport => "3D View",
            PaneKind::History => "History",
            // The component TREE. It was titled "BOM" before the BOM
            // existed; now that there is a real columned parts list next to
            // it, the honest name is what it draws.
            PaneKind::Bom => "BOM",
            PaneKind::AssemblyConstraints => "Constraints",
            PaneKind::Scene => "Scene",
            PaneKind::Expressions => "Expressions",
        }
    }

    /// The workbench-registry panel id this pane is claimed under, or `None` for
    /// the viewport (which is not a workbench-filterable panel). Must match the
    /// ids used in `app.rs`'s old `panel_visible` gates + `workbench::assembly`.
    fn panel_id(self) -> Option<&'static str> {
        match self {
            PaneKind::Viewport => None,
            PaneKind::History => Some("history"),
            PaneKind::Bom => Some(workbench::assembly::BOM_PANEL_ID),
            PaneKind::AssemblyConstraints => Some(workbench::assembly::CONSTRAINTS_PANEL_ID),
            PaneKind::Scene => Some("scene"),
            PaneKind::Expressions => Some("expressions"),
        }
    }

    /// Whether this pane is visible under workbench `wb`. The viewport is always
    /// visible; side panes defer to the workbench claim system.
    fn visible_in(self, wb: &str) -> bool {
        match self.panel_id() {
            None => true,
            Some(id) => workbench::panel_visible(wb, id),
        }
    }
}

/// The dock layout: the tile tree + a dirty flag so a user layout edit persists.
/// One field on `BrepApp`.
pub struct DockState {
    tree: Tree<PaneKind>,
    /// Set by [`DockBehavior::on_edit`] when the user drags / resizes a tile;
    /// drained in [`DockState::ui`] to persist the new layout.
    dirty: bool,
    /// The workbench id membership was last reconciled against. Workbench-claimed
    /// panes (the two Assembly panels) are added to / removed from the tree
    /// *structurally* when the workbench changes — NOT hidden via `set_visible`,
    /// because a hidden pane still owns an (empty) tab bar once panes are tabbed.
    /// Reconciling only on change avoids per-frame tree churn.
    last_wb: Option<String>,
    /// Per-pane `(kind, present, rendered-this-frame)` from the last `ui()` — the
    /// source for the `__brepDock` verifier global. `rendered` is false for a pane
    /// sitting behind an inactive tab (egui_tiles skips its `pane_ui`), so an e2e
    /// script knows to activate that tab before asserting on its widgets.
    snapshot: Vec<(PaneKind, bool, bool)>,
}

/// The disjoint `&mut` borrows the dock needs to draw a frame — assembled by the
/// shell from `BrepApp`'s fields (all distinct, so the borrow checker allows it).
pub struct DockContext<'a> {
    pub state: &'a mut EngineState,
    pub viewport: &'a mut Viewport,
    pub history: &'a mut HistoryPanel,
    pub bom: &'a mut BomPanel,
    pub assembly_constraints: &'a mut AssemblyConstraintsPanel,
    pub scene: &'a mut ScenePanel,
    pub expressions: &'a mut ExpressionsPanel,
    pub update_components: &'a mut UpdateComponents,
    pub model_store: &'a dyn ModelStore,
}

/// What a dock frame hands back to the shell — the SAME cross-panel requests the
/// old left-panel closure bubbled out (borrows inside prevent acting there).
#[derive(Default)]
pub struct DockOutcome {
    /// The ACOMP palette pick asked to open the component-selector modal.
    pub insert_component_requested: bool,
    /// A structure-tree Edit asked to expand this feature in the history tree.
    pub feature_focus: Option<String>,
    /// Structure-tree row interactions (Move / Edit-in-place / Open Part).
    /// A document-level component flow a pane's row menu asked for (the BOM's;
    /// the engine-mutating half already ran in the shared dispatcher).
    pub component_request: Option<ComponentActionRequest>,
}

impl DockState {
    /// Load the persisted layout (reconciled against the current pane set), or
    /// fall back to the default layout.
    pub fn new(store: &dyn ModelStore) -> Self {
        let tree = store
            .read(DOCK_LAYOUT_KEY)
            .and_then(|json| serde_json::from_str::<Tree<PaneKind>>(&json).ok())
            .and_then(|mut t| reconcile(&mut t).then_some(t))
            .unwrap_or_else(default_tree);
        Self {
            tree,
            dirty: false,
            last_wb: None,
            snapshot: Vec::new(),
        }
    }

    /// Draw the dock tree, delegating each visible pane to its panel's `show`.
    /// Applies workbench visibility first, then persists if the user re-laid it.
    pub fn ui(&mut self, ui: &mut egui::Ui, ctx: DockContext<'_>) -> DockOutcome {
        let wb = ctx.state.settings.workbench.clone();
        self.apply_workbench_membership(&wb);

        let store = ctx.model_store;
        let mut behavior = DockBehavior {
            state: ctx.state,
            viewport: ctx.viewport,
            history: ctx.history,
            bom: ctx.bom,
            assembly_constraints: ctx.assembly_constraints,
            scene: ctx.scene,
            expressions: ctx.expressions,
            update_components: ctx.update_components,
            model_store: ctx.model_store,
            insert_component_requested: false,
            feature_focus: None,
            component_request: None,
            layout_changed: false,
            rendered: Vec::new(),
        };
        self.tree.ui(&mut behavior, ui);

        let outcome = DockOutcome {
            insert_component_requested: behavior.insert_component_requested,
            feature_focus: behavior.feature_focus.take(),
            component_request: behavior.component_request.take(),
        };
        if behavior.layout_changed {
            self.dirty = true;
        }
        let rendered = std::mem::take(&mut behavior.rendered);
        drop(behavior);

        // Snapshot for the `__brepDock` verifier global (in-tree order).
        self.snapshot = self
            .tree
            .tiles
            .iter()
            .filter_map(|(id, tile)| match tile {
                Tile::Pane(kind) => Some((
                    *kind,
                    self.tree.tiles.is_visible(*id),
                    rendered.contains(kind),
                )),
                Tile::Container(_) => None,
            })
            .collect();

        // Persist the layout, but DEBOUNCED: `on_edit` fires every frame while a
        // tile is being drag-resized (the shares change per mouse-move), so only
        // serialize once the pointer is released — mirrors how the shell defers
        // `applied_ui_scale`. A dirty flag set mid-drag simply waits for release.
        if self.dirty && !ui.ctx().input(|i| i.pointer.any_down()) {
            self.save(store);
            self.dirty = false;
        }
        outcome
    }

    /// The `__brepDock` verifier global: `{active, panes:[{kind,visible,rendered}]}`.
    /// `active` is whether the dock owns the layout right now (false in sketch /
    /// ref-select mode, where the shell draws the viewport directly). When
    /// inactive, no side pane is rendered — the caller passes `active=false`.
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
    pub fn state_json(&self, active: bool) -> String {
        let panes: Vec<serde_json::Value> = self
            .snapshot
            .iter()
            .map(|(kind, visible, rendered)| {
                serde_json::json!({
                    "kind": format!("{kind:?}"),
                    "visible": visible,
                    "rendered": active && *rendered,
                })
            })
            .collect();
        serde_json::json!({ "active": active, "panes": panes }).to_string()
    }

    /// Surface the pane of `kind` — make it the ACTIVE tab in its tab group so a
    /// pane sitting behind another tab becomes visible. No-op if it is already
    /// active. Used to bring the History tab forward when a feature is added (a
    /// context-bar create can happen while another side tab is showing), so the
    /// new row is actually seen (`app.rs`, paired with `history.focus_feature`).
    pub fn show_pane(&mut self, kind: PaneKind) {
        self.tree
            .make_active(|_id, tile| matches!(tile, Tile::Pane(k) if *k == kind));
    }

    /// Reconcile which workbench-claimed panes EXIST in the tree against the
    /// active workbench (only when it changed). Claimed panes present in a
    /// workbench that doesn't claim them are removed; ones that should show but
    /// are absent are (re)inserted into the side column. Unclaimed panes
    /// (History / Scene / Expressions / Viewport) are never touched here — they
    /// live in the tree permanently and are arranged only by the user.
    fn apply_workbench_membership(&mut self, wb: &str) {
        if self.last_wb.as_deref() == Some(wb) {
            return;
        }
        self.last_wb = Some(wb.to_string());

        for kind in [PaneKind::AssemblyConstraints, PaneKind::Bom] {
            let should_show = kind.visible_in(wb);
            match (should_show, self.find_pane(kind)) {
                (true, None) => self.insert_side_pane(kind),
                (false, Some(id)) => {
                    self.tree.remove_recursively(id);
                }
                _ => {}
            }
        }
    }

    /// The tile id of the pane of `kind`, if present.
    fn find_pane(&self, kind: PaneKind) -> Option<TileId> {
        self.tree.tiles.iter().find_map(|(id, tile)| match tile {
            Tile::Pane(k) if *k == kind => Some(*id),
            _ => None,
        })
    }

    /// Insert a side pane into the vertical side column (the first vertical
    /// `Linear` container), or the root as a fallback. `all_panes_must_have_tabs`
    /// wraps it in its own tab group on the next frame.
    fn insert_side_pane(&mut self, kind: PaneKind) {
        let id = self.tree.tiles.insert_pane(kind);
        let target = self.side_column().or_else(|| self.tree.root());
        if let Some(target) = target {
            if let Some(Tile::Container(container)) = self.tree.tiles.get_mut(target) {
                container.add_child(id);
            }
        }
    }

    /// The first vertical `Linear` container (the side stack), if any.
    fn side_column(&self) -> Option<TileId> {
        self.tree.tiles.iter().find_map(|(id, tile)| match tile {
            Tile::Container(Container::Linear(lin))
                if lin.dir == egui_tiles::LinearDir::Vertical =>
            {
                Some(*id)
            }
            _ => None,
        })
    }

    /// Serialize the layout through the unified persistence seam (best-effort).
    fn save(&self, store: &dyn ModelStore) {
        if let Ok(json) = serde_json::to_string(&self.tree) {
            let _ = store.write(DOCK_LAYOUT_KEY, &json);
        }
    }
}

/// The default layout: a horizontal split of `[ vertical(side panes) | viewport ]`
/// with the side column biased to ≈ the old 320 px width.
fn default_tree() -> Tree<PaneKind> {
    let mut tiles = Tiles::default();
    let side: Vec<TileId> = PaneKind::SIDE
        .into_iter()
        .map(|k| tiles.insert_pane(k))
        .collect();
    let side_container = tiles.insert_vertical_tile(side);
    let viewport = tiles.insert_pane(PaneKind::Viewport);
    let root = tiles.insert_horizontal_tile(vec![side_container, viewport]);
    // Bias the root split so the side column starts narrow (relative shares).
    if let Some(Tile::Container(Container::Linear(linear))) = tiles.get_mut(root) {
        linear.shares.set_share(side_container, 0.30);
        linear.shares.set_share(viewport, 0.70);
    }
    Tree::new("brep-dock", root, tiles)
}

/// Bring a deserialized (possibly stale) tree in line with the current pane set:
/// require exactly one viewport (else the layout is unusable → rebuild default),
/// and append any side pane the saved layout predates. Returns `false` when the
/// tree can't be salvaged, so the caller uses [`default_tree`].
fn reconcile(tree: &mut Tree<PaneKind>) -> bool {
    use std::collections::HashSet;
    let mut viewport_count = 0usize;
    let mut present: HashSet<PaneKind> = HashSet::new();
    for tile in tree.tiles.tiles() {
        if let Tile::Pane(kind) = tile {
            if *kind == PaneKind::Viewport {
                viewport_count += 1;
            }
            present.insert(*kind);
        }
    }
    if viewport_count != 1 {
        return false;
    }
    let Some(root) = tree.root() else {
        return false;
    };
    // Only the ALWAYS-present panes are required; the workbench-claimed Assembly
    // panes are managed per-workbench and may legitimately be absent.
    for kind in PaneKind::ALWAYS {
        if !present.contains(&kind) {
            let id = tree.tiles.insert_pane(kind);
            if let Some(Tile::Container(container)) = tree.tiles.get_mut(root) {
                container.add_child(id);
            }
        }
    }
    true
}

/// The per-frame `egui_tiles::Behavior`: draws each pane by delegating to the
/// owning panel's existing `show(...)`, and collects the cross-panel requests +
/// a layout-edit flag for the shell to act on after `Tree::ui`.
struct DockBehavior<'a> {
    state: &'a mut EngineState,
    viewport: &'a mut Viewport,
    history: &'a mut HistoryPanel,
    bom: &'a mut BomPanel,
    assembly_constraints: &'a mut AssemblyConstraintsPanel,
    scene: &'a mut ScenePanel,
    expressions: &'a mut ExpressionsPanel,
    update_components: &'a mut UpdateComponents,
    model_store: &'a dyn ModelStore,
    // --- outputs, drained after Tree::ui -----------------------------------
    insert_component_requested: bool,
    feature_focus: Option<String>,
    component_request: Option<ComponentActionRequest>,
    layout_changed: bool,
    /// Panes whose `pane_ui` ran this frame (drawn = visible AND, if tabbed, the
    /// active tab) — feeds the `__brepDock` snapshot.
    rendered: Vec<PaneKind>,
}

impl<'a> Behavior<PaneKind> for DockBehavior<'a> {
    fn pane_ui(
        &mut self,
        ui: &mut egui::Ui,
        _tile_id: TileId,
        pane: &mut PaneKind,
    ) -> UiResponse {
        self.rendered.push(*pane);
        // Paint the side-pane background with the SAME fill the old
        // `Panel::left("brep-controls")` used (`visuals().panel_fill`), so the
        // docked panels read exactly like the previous side panel — not the
        // egui_tiles default (which leaves the pane transparent over the darker
        // central fill). The viewport paints its own 3D, so skip it.
        if *pane != PaneKind::Viewport {
            let visuals = ui.visuals();
            ui.painter()
                .rect_filled(ui.max_rect(), 0.0, visuals.panel_fill);
        }
        match *pane {
            PaneKind::Viewport => {
                // The 3D view fills its tile; its body keeps its own click/drag
                // (camera orbit + picking), so we NEVER report a pane drag here.
                self.viewport.show(ui, self.state);
            }
            PaneKind::History => scroll(ui, "dock-history", |ui| {
                self.history.show(ui, self.state);
                // The ACOMP palette pick opens the COMPONENT SELECTOR, not a bare
                // feature dialog — bubbled to the shell (the file dialog is shell-owned).
                self.insert_component_requested |= self.history.take_insert_component_request();
            }),
            PaneKind::Bom => {
                // VERTICAL only, even though a BOM is as wide as its configured
                // columns: the column tree owns its own HORIZONTAL scrolling,
                // because a scroll area out here would carry the frozen columns
                // away with everything else. (The shared `scroll` helper is
                // vertical-only too, but the BOM wants `auto_shrink` off.)
                egui::ScrollArea::vertical()
                    .id_salt("dock-bom")
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        let outcome =
                            self.bom
                                .show(ui, self.state, self.model_store, self.update_components);
                        if outcome.focus.is_some() {
                            // The BOM's Edit action: roll to the owning feature
                            // and open it in the history panel.
                            self.feature_focus = outcome.focus;
                        }
                        if outcome.component.is_some() {
                            self.component_request = outcome.component;
                        }
                    });
            }
            PaneKind::AssemblyConstraints => scroll(ui, "dock-constraints", |ui| {
                self.assembly_constraints.show(
                    ui,
                    self.state,
                    self.model_store,
                    self.update_components,
                );
            }),
            PaneKind::Scene => scroll(ui, "dock-scene", |ui| {
                self.scene.show(ui, self.state);
            }),
            PaneKind::Expressions => {
                // Expressions self-scrolls (its own ScrollArea) — no outer wrap.
                self.expressions.show(ui, self.state);
            }
        }
        UiResponse::None
    }

    fn tab_title_for_pane(&mut self, pane: &PaneKind) -> egui::WidgetText {
        pane.title().into()
    }

    /// v1: no pane is closable (there is no re-open affordance yet; panels are
    /// shown/hidden by workbench and rearranged by drag, never destroyed).
    fn is_tab_closable(&self, _tiles: &Tiles<PaneKind>, _tile_id: TileId) -> bool {
        false
    }

    /// The viewport can't be picked up (so it can't be reordered/torn out); side
    /// panes drag freely to re-dock.
    fn is_tile_draggable(&self, tiles: &Tiles<PaneKind>, tile_id: TileId) -> bool {
        !matches!(tiles.get(tile_id), Some(Tile::Pane(PaneKind::Viewport)))
    }

    fn on_edit(&mut self, _edit_action: EditAction) {
        self.layout_changed = true;
    }

    /// Every pane gets its own tab bar — that tab is the label AND the drag
    /// handle, so a default vertical stack still reads as titled, re-dockable
    /// panes (egui_tiles gives bare linear panes neither). Other simplifications
    /// stay at their defaults so the tree tidies itself after a drag / a
    /// workbench-membership removal.
    fn simplification_options(&self) -> egui_tiles::SimplificationOptions {
        egui_tiles::SimplificationOptions {
            all_panes_must_have_tabs: true,
            ..Default::default()
        }
    }

    /// Match the tab strip to the old side panel's fill (`panel_fill`) so a docked
    /// panel reads as one continuous surface (tab bar + body) in the SAME colour
    /// the previous `Panel::left` used — not the egui_tiles default strip colour.
    fn tab_bar_color(&self, visuals: &egui::Visuals) -> egui::Color32 {
        visuals.panel_fill
    }

    /// Give EVERY tab a visible chip so it reads as a tab — egui_tiles' default
    /// leaves inactive tabs fully transparent (they vanish into the strip). Reuse
    /// egui's standard widget fills: the active tab uses the "active" fill (it
    /// stands out as selected), inactive tabs the "inactive" resting fill (a muted
    /// but clearly-there chip). No hand-picked colours — same DRY rule as the rest.
    fn tab_bg_color(
        &self,
        visuals: &egui::Visuals,
        _tiles: &Tiles<PaneKind>,
        _tile_id: TileId,
        state: &TabState,
    ) -> egui::Color32 {
        if state.active {
            visuals.widgets.active.bg_fill
        } else {
            visuals.widgets.inactive.bg_fill
        }
    }
}

/// Wrap a pane body in its own vertical scroll area (unique id per pane so egui
/// never conflates their scroll state). Panels that self-scroll skip this.
fn scroll(ui: &mut egui::Ui, salt: &str, add: impl FnOnce(&mut egui::Ui)) {
    egui::ScrollArea::vertical()
        .id_salt(salt)
        .auto_shrink([false, false])
        .show(ui, add);
}

#[cfg(test)]
mod tests {
    use super::*;

    fn active_tab(tree: &Tree<PaneKind>) -> Option<TileId> {
        tree.tiles.iter().find_map(|(_, tile)| match tile {
            Tile::Container(Container::Tabs(t)) => t.active,
            _ => None,
        })
    }

    /// `show_pane` makes a BACKGROUNDED pane the active tab — the History-surfacing
    /// behavior the shell pairs with `history.focus_feature` when a feature is
    /// added (`app.rs`), so the new row is actually seen.
    #[test]
    fn show_pane_activates_a_backgrounded_tab() {
        let mut tiles = Tiles::default();
        let history = tiles.insert_pane(PaneKind::History);
        let scene = tiles.insert_pane(PaneKind::Scene);
        let tabs = tiles.insert_tab_tile(vec![history, scene]);
        // Scene in front, History behind it.
        if let Some(Tile::Container(Container::Tabs(t))) = tiles.get_mut(tabs) {
            t.set_active(scene);
        }
        let tree = Tree::new("test-dock", tabs, tiles);
        let mut dock = DockState { tree, dirty: false, last_wb: None, snapshot: Vec::new() };
        assert_eq!(active_tab(&dock.tree), Some(scene), "precondition: History is behind");

        dock.show_pane(PaneKind::History);
        assert_eq!(
            active_tab(&dock.tree),
            Some(history),
            "show_pane brings the History tab to the front"
        );

        // Idempotent + doesn't disturb an already-active pane.
        dock.show_pane(PaneKind::History);
        assert_eq!(active_tab(&dock.tree), Some(history));
    }
}