Skip to main content

brep_app/panels/
history.rs

1//! History panel — a feature **tree** that switches to a full-panel **form**.
2//!
3//! The panel has exactly two modes ([`PanelMode`]):
4//!
5//! * **Tree** — one row per feature: `[+/-] id LongName   N ms  [✎] [✕]`. The
6//!   collapse box is a PURE ROLL control (roll the model to that step, nothing
7//!   opens); the `✎` edit button and a plain LABEL CLICK both open that
8//!   feature's form (and roll to it); rows drag-reorder; `✕` deletes. Built on
9//!   the reusable [`tree`] node helper (connector lines + `[+]`/`[-]` boxes) the
10//!   rest of the sidebar reuses.
11//! * **Form** — the whole panel is replaced by ONE feature's dialog, drawn by
12//!   the shared [`crate::form_view`], with a single `Return to tree` button at
13//!   the bottom. There is no OK/Cancel and no buffer: editing is LIVE and UNDO
14//!   is the revert mechanism (`History::set_feature_params` checkpoints and
15//!   coalesces per feature).
16//!
17//! Both entry points roll the model to the feature, exactly as expanding did;
18//! returning to the tree rolls to the TIP so downstream features rebuild and the
19//! edit becomes visible.
20//!
21//! This panel OWNS NO model state — it calls the ENGINE's history methods
22//! (`state.*`) and reads the history + last-run report back to draw. The engine
23//! core (`EngineState.history`) is the single source of truth. The panel holds
24//! only transient UI state: the mode, an in-flight drag, the add-menu toggle,
25//! the run-spinner clock, and the per-frame `hits` map (widget screen rects) the
26//! headed verifier reads to drive real clicks.
27//!
28//! The tree also shows HOW FAR the model is built: the rolled-to feature is the
29//! last one EXECUTED, so a rollback BAR is drawn under its block, every feature
30//! below that bar — not yet executed — is dimmed, and its `[+]`/`[-]` box reads
31//! `[-]` down to the rolled-to step and `[+]` below it.
32
33use crate::form_view::{form_view, FormViewSpec};
34use crate::palette::{Palette, PaletteItem};
35use crate::panels::tree::{self, TreeRow};
36use brep_render::engine_state::EngineState;
37use brep_render::features;
38use eframe::egui;
39use serde_json::Value;
40use std::collections::HashMap;
41
42/// The red of the per-feature delete affordance (theme-independent — it must read
43/// as "destructive" in both light and dark).
44const DELETE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
45
46/// The red of a feature's error message node — a brighter, clearly-legible red for
47/// wrapped body text (the delete red is tuned for a small glyph). Matches the
48/// hardcoded-chrome-red convention of `DELETE_RED`.
49const ERROR_RED: egui::Color32 = egui::Color32::from_rgb(0xff, 0x6b, 0x6b);
50
51/// The height of the ROLLBACK BAR row — the horizontal rule painted after the
52/// rolled-to feature ("the model is executed up to HERE"). Tall enough to read as
53/// a break between the executed block above and the dimmed, not-yet-executed rows
54/// below.
55const ROLLBACK_BAR_H: f32 = 8.0;
56
57/// How long a run must be in flight before the tree header's spinner appears, in
58/// seconds. A single param edit lands in a frame or two, and a spinner that blinks
59/// for one frame reads as a glitch — only a run the user actually WAITS on spins.
60const RUN_SPINNER_DELAY: f64 = 0.2;
61
62/// The error message for feature `id` from the run report's `featureErrors` array,
63/// or `None` when that feature ran clean. The kernel records each hard failure as
64/// `"<feature id>: <message>"` (see `pipeline::SceneBuildReport`); this matches the
65/// `"<id>: "` prefix (the delimiter after the exact id stops a shorter id from
66/// matching a longer one) and returns just the message.
67fn feature_error_message(report: &serde_json::Value, id: &str) -> Option<String> {
68    let prefix = format!("{id}: ");
69    report
70        .get("featureErrors")?
71        .as_array()?
72        .iter()
73        .filter_map(serde_json::Value::as_str)
74        .find(|entry| entry.starts_with(&prefix))
75        .map(|entry| entry[prefix.len()..].to_string())
76}
77
78/// What the panel is showing: the feature TREE, or ONE feature's FORM filling
79/// the whole panel. Exclusive by construction — there is no "expanded feature"
80/// inside the tree any more, so there is nothing to reconcile between them.
81#[derive(Default, Debug, Clone, PartialEq, Eq)]
82pub enum PanelMode {
83    /// The feature tree (the resting state).
84    #[default]
85    Tree,
86    /// `feature_id`'s dialog, replacing the whole panel. Falls back to
87    /// [`PanelMode::Tree`] the moment that id stops resolving (undo, delete,
88    /// document load) — see the validity guard at the top of
89    /// [`HistoryPanel::show`].
90    Form { feature_id: String },
91}
92
93impl PanelMode {
94    /// The open form's feature id, or `None` in tree mode.
95    fn feature_id(&self) -> Option<&str> {
96        match self {
97            PanelMode::Tree => None,
98            PanelMode::Form { feature_id } => Some(feature_id.as_str()),
99        }
100    }
101}
102
103/// The history panel's transient UI state (the model lives in the engine).
104#[derive(Default)]
105pub struct HistoryPanel {
106    /// The per-frame map of egui widget screen rects, published to JS for the
107    /// headed verifier. Rebuilt every frame.
108    hits: HashMap<String, egui::Rect>,
109    /// Tree, or one feature's full-panel form.
110    mode: PanelMode,
111    /// The feature id the panel last AUTO-ARMED a dimension gizmo for (gizmo-on-
112    /// open). Compared to the OPEN FORM's feature id each frame: on a CHANGE
113    /// (open a different feature's form, or return to the tree) the panel
114    /// disarms the old gizmo and arms the dimension gizmo for the newly-opened
115    /// feature IF it has dimensions — once per transition, so the in-viewport
116    /// sphere/center toggle (transform↔dimension) isn't clobbered back to
117    /// dimension each frame.
118    gizmo_armed_for: Option<String>,
119    /// The feature index currently being drag-reordered (None = not dragging).
120    drag_src: Option<usize>,
121    /// The reusable searchable command palette that `Add new feature` opens,
122    /// populated from the kernel feature catalogue. Generic + engine-agnostic —
123    /// this panel drives it and acts on the returned type code.
124    palette: Palette,
125    palette_display_loaded: bool,
126    /// A schema `button` field click staged this frame — `(feature id, button
127    /// key)` — applied AFTER the draw loop (so no engine mutation runs mid-render).
128    /// E.g. `editSketch` on a SKETCH feature → `enter_sketch_mode`.
129    pending_button: Option<(String, String)>,
130    /// The egui clock time (`Input::time`, seconds) at which the CURRENT in-flight
131    /// run was FIRST seen pending, or `None` when nothing is running. Drives the
132    /// header spinner's anti-flicker delay ([`RUN_SPINNER_DELAY`]); egui's own
133    /// clock, not `Instant` (which panics on wasm32).
134    run_started: Option<f64>,
135    /// Set when the palette picked the ACOMP type: the insert flow must NOT
136    /// open a bare feature dialog — the shell polls this
137    /// ([`Self::take_insert_component_request`]) and opens the COMPONENT
138    /// SELECTOR (the file dialog's insert mode) instead.
139    pending_insert_component: bool,
140}
141
142impl HistoryPanel {
143    /// Load once per panel (including document switches), save only user changes.
144    pub fn sync_palette_display(&mut self, store: &dyn crate::store::ModelStore) {
145        use crate::store::FEATURE_PALETTE_DISPLAY_KEY;
146        if !self.palette_display_loaded {
147            self.palette.display = store.read(FEATURE_PALETTE_DISPLAY_KEY)
148                .and_then(|json| serde_json::from_str(&json).ok()).unwrap_or_default();
149            self.palette_display_loaded = true;
150        }
151        if self.palette.take_display_change() {
152            if let Ok(json) = serde_json::to_string(&self.palette.display) {
153                let _ = store.write(FEATURE_PALETTE_DISPLAY_KEY, &json);
154            }
155        }
156    }
157
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Draw the feature tree. While a reference-selection picker is active the
163    /// shell HIDES this whole panel (the design doc's "hide the rest of the UI")
164    /// and shows the picker in the top-right mode card ([`super::mode_bar`]), so
165    /// this method is not called in that mode.
166    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
167        self.hits.clear();
168
169        // The panel's VISIBLE region (the enclosing dock pane's scroll viewport).
170        // Every other rect below is a raw LAYOUT rect: a long feature list — or a
171        // long form — runs past the pane's bottom, where egui clips it and it
172        // stops being clickable even though the rect is still published. The
173        // headed verifier intersects against this to
174        // know when it must scroll a row into view first — without it a script
175        // clicks dead space outside the panel and silently no-ops.
176        self.hits.insert("panel:clip".into(), ui.clip_rect());
177
178        // --- validity guard: a form whose SUBJECT is gone falls back, silently.
179        // One check covers every hazard — undo that removed the feature, a delete
180        // from another surface, a document load that changed the ids wholesale.
181        // With no Cancel there is no dirty state to protect, so there is nothing
182        // to ask the user about (params edits are already committed and undoable).
183        if let Some(id) = self.mode.feature_id() {
184            if feature_index_of(state, id).is_none() {
185                self.mode = PanelMode::Tree;
186            }
187        }
188
189        // Publish the armed transform gizmo's origin in VIEWPORT-LOCAL px (the
190        // center handle sits there) so the headed verifier can locate + drag it.
191        // Map to page px with `window.__brepView`'s origin (viewport rect).
192        if let Some((ax, ay)) = state.transform_gizmo_anchor() {
193            self.hits.insert(
194                "gizmo-anchor".into(),
195                egui::Rect::from_min_size(egui::pos2(ax as f32, ay as f32), egui::Vec2::ZERO),
196            );
197        }
198
199        // Last-run report → per-feature timing + output solid names (parsed once).
200        let report: Value = serde_json::from_str(&state.history_report_json()).unwrap_or(Value::Null);
201
202        // Tight, tree-like row spacing so connector verticals read continuously.
203        ui.spacing_mut().item_spacing.y = 2.0;
204
205        // --- run-indicator bookkeeping (both modes) ---------------------------
206        // Tracked in EVERY mode so a run that starts in the form and finishes
207        // while the tree is up can't strand a stale start time (which would flash
208        // the spinner on the next tree frame).
209        let now = ui.input(|i| i.time);
210        match (state.run_pending(), self.run_started) {
211            (true, None) => self.run_started = Some(now),
212            (false, Some(_)) => self.run_started = None,
213            _ => {}
214        }
215
216        // --- THE MODE SWITCH: the tree, or ONE feature's form -----------------
217        match self.mode.clone() {
218            PanelMode::Tree => self.show_tree(ui, state, &report),
219            PanelMode::Form { feature_id } => self.show_form(ui, state, &report, &feature_id),
220        }
221
222        // --- apply a staged schema-button click (after the draw loop) ----------
223        if let Some((fid, key)) = self.pending_button.take() {
224            self.handle_feature_button(state, &fid, &key);
225        }
226
227        // --- gizmo-on-open (Phase 1) ------------------------------------------
228        // Arm the DIMENSION gizmo for the feature whose FORM is open so its
229        // draggable arrows appear on open (the reported bug), and DISARM on
230        // return to the tree so gizmos don't leak. Runs only on an open/close
231        // TRANSITION (the open feature changed since last frame) so it never
232        // thrashes per frame — that lets the in-viewport sphere/center toggle flip
233        // a feature to transform mode and STAY there (a per-frame re-arm would
234        // snap it back to dimension). Guarded on the feature actually having
235        // dimension annotations or a schema-declared Transform group.
236        let open_feature = self.mode.feature_id().map(str::to_string);
237        if self.gizmo_armed_for != open_feature {
238            state.disarm_transform();
239            if let Some(id) = open_feature.clone() {
240                if state.feature_dimension_annotations_json(&id) != "[]" {
241                    // Has dimensions → dimension arrows (sphere-toggle to transform).
242                    state.arm_dimension(&id);
243                } else if state.feature_has_transform(&id) {
244                    // No dimensions but transformable (Transform/datum/helix/…) →
245                    // arm the TRANSFORM gizmo directly, so it isn't stranded without
246                    // a gizmo now that the ◎ arm button is gone.
247                    state.arm_transform(&id);
248                }
249            }
250            self.gizmo_armed_for = open_feature;
251        }
252    }
253
254    /// Draw ONE feature's dialog filling the whole panel, through the SHARED
255    /// [`form_view`]. The panel supplies the schema + the live params and acts on
256    /// the intents that come back — it is the engine side of a form view that
257    /// holds no engine itself. Editing is LIVE (every change commits and
258    /// re-runs); the single bottom button returns to the tree and rolls to the
259    /// TIP, so downstream features rebuild and the edit becomes visible — the
260    /// same reason collapsing an inline dialog used to roll to the tip.
261    fn show_form(
262        &mut self,
263        ui: &mut egui::Ui,
264        state: &mut EngineState,
265        report: &Value,
266        id: &str,
267    ) {
268        // The guard in `show` already proved the id resolves.
269        let Some(index) = feature_index_of(state, id) else {
270            return;
271        };
272        let ty = state.feature_type_at(index).unwrap_or_else(|| "?".into());
273        // NO glyph: an egui window title is plain text and cannot hold a widget,
274        // so it is the one place an icon cannot be drawn as artwork — and with
275        // no icon font there is nothing else to draw a private-use character
276        // with. The id and name identify the form; the tree row behind it shows
277        // the icon.
278        let title = format!("{id}  {}", features::feature_plain_name(&ty));
279        let fields = features::feature_form_fields(&ty);
280        let mut params: Value =
281            serde_json::from_str(&state.feature_params_json(index)).unwrap_or(Value::Null);
282        // The feature's error shows HERE, as a banner above the fields — and the
283        // tree row keeps its own error leaf, so a failure is still visible while
284        // scanning the tree.
285        let error = feature_error_message(report, id);
286        let outputs: Vec<String> = report
287            .get("featureOutputs")
288            .and_then(|m| m.get(id))
289            .and_then(Value::as_array)
290            .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
291            .unwrap_or_default();
292        let trailing = [("Outputs", outputs)];
293
294        let spec = FormViewSpec {
295            title: &title,
296            subtitle: None,
297            fields: &fields,
298            banner: error.as_deref().map(|m| (m, ERROR_RED)),
299            trailing: Some(&trailing),
300            exit_label: "Return to tree",
301            // A feature LIVES in the rolled history, so leaving its form rolls to
302            // the tip (Q2). Declared here, acted on below via `out.roll_to_tip`.
303            rollback: true,
304            // History shows ONE form at a time, so its field keys stay exactly
305            // the tree's — `field:sizeX`, `field:boolean.operation` — and every
306            // verifier field flow keeps working unchanged.
307            hits_prefix: "",
308        };
309        let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
310
311        // WHICH feature the form is showing, as a presence-only zero-size rect
312        // beside the header's real rect (`form:feature`) — the same convention
313        // `run:spinner` uses for "this is showing right now".
314        let anchor = self
315            .hits
316            .get("form:feature")
317            .map(|r| r.min)
318            .unwrap_or(egui::Pos2::ZERO);
319        self.hits.insert(
320            format!("form:feature:{id}"),
321            egui::Rect::from_min_size(anchor, egui::Vec2::ZERO),
322        );
323
324        if out.changed {
325            let _ = state.update_feature_params(id, &params.to_string());
326        }
327        // A button click (e.g. `editSketch`) binds to no param — stage it as a
328        // deferred action keyed by (feature id, button key); `show` acts after the
329        // draw so no engine mutation happens mid-render.
330        if let Some(key) = out.button_clicked {
331            self.pending_button = Some((id.to_string(), key));
332        }
333        if let Some(activate) = out.ref_activate {
334            state.begin_ref_select(
335                id,
336                activate.path,
337                activate.label,
338                activate.filter,
339                activate.multiple,
340                activate.seed,
341            );
342        }
343        if out.exit_clicked {
344            self.mode = PanelMode::Tree;
345        }
346        if out.roll_to_tip {
347            // Finished editing → return the model to the TIP so the WHOLE history
348            // runs and every downstream feature (e.g. a boolean that consumes this
349            // one) reappears and reflects the edit. Without this the view stays
350            // rolled at the just-edited feature and the result never updates — half
351            // of the reported "edit the cylinder, close it, nothing changes" bug
352            // (the other half was the stale cache, fixed in the kernel). The cache
353            // makes this cheap: unchanged features replay instantly.
354            //
355            // The ROLL half is gated by `spec.rollback` (the form view's one-place
356            // decision), not by an `if self is the history panel` here — a consumer
357            // with no rollback simply never receives this intent.
358            state.roll_to(state.history_len().saturating_sub(1));
359        }
360    }
361
362    /// Draw the feature TREE — one row per feature, the rollback bar, and the
363    /// add-feature palette.
364    fn show_tree(&mut self, ui: &mut egui::Ui, state: &mut EngineState, report: &Value) {
365        // --- run indicator (INTERIM) ------------------------------------------
366        // A history run is ATOMIC from the app's side — the runner (browser worker /
367        // native thread) computes the WHOLE history and replies once, so the engine
368        // can only say "something is running" ([`EngineState::run_pending`]), never
369        // WHICH feature is executing. So the spinner rides the tree HEADER, not a
370        // feature row: it is honest about what is actually known. (A true
371        // per-feature spinner needs a progress channel out of the kernel's execute
372        // loop — planned, not built.) The shell already repaints every frame while a
373        // run is pending, so the spinner animates; under the synchronous Inline
374        // runner `run_pending()` is never true, so nothing ever spins there.
375        let now = ui.input(|i| i.time);
376        let spinning = self
377            .run_started
378            .is_some_and(|started| now - started >= RUN_SPINNER_DELAY);
379        // What the runner says it is executing (posted before each feature it
380        // runs), and the feature a cancelled run was stuck on until the next
381        // rebuild.
382        let progress = state.run_progress().cloned();
383        let cancelled = state.cancelled_run().map(str::to_string);
384
385        // --- ROOT: `[-] Features` (always open) -------------------------------
386        let mut spinner_rect = egui::Rect::NOTHING;
387        let mut cancel_rect = egui::Rect::NOTHING;
388        let mut cancel_clicked = false;
389        tree::node(
390            ui,
391            TreeRow {
392                guides: &[],
393                is_last: true,
394                expandable: true,
395                expanded: true,
396                root: true,
397                glyph: None,
398                label: "Features",
399                selected: false,
400                draggable: false,
401            },
402            |ui| {
403                // The row's right-hand slot lays out RIGHT TO LEFT, so the
404                // first widget sits at the pane's edge: the button goes first,
405                // where a narrow pane can never push it under the row label.
406                if spinning {
407                    let cancel = ui.small_button("Cancel").on_hover_text(
408                        "Stop the run. The model keeps the last completed result; \
409                         edit or delete the slow feature to rebuild.",
410                    );
411                    cancel_rect = cancel.rect;
412                    cancel_clicked = cancel.clicked();
413                    spinner_rect = ui.add(egui::Spinner::new().size(12.0)).rect;
414                    if let Some(progress) = &progress {
415                        ui.weak(format!(
416                            "{} · {}/{}",
417                            progress.feature_id,
418                            progress.index + 1,
419                            progress.total
420                        ));
421                    }
422                } else if let Some(cancelled) = &cancelled {
423                    let note = if cancelled.is_empty() {
424                        "run cancelled — the next edit rebuilds".to_string()
425                    } else {
426                        format!("run cancelled at {cancelled} — edit or delete it to rebuild")
427                    };
428                    ui.colored_label(ERROR_RED, note);
429                }
430            },
431        );
432        // Published only while it SHOWS, so the verifier reads presence, not a rect.
433        if spinning {
434            self.hits.insert("run:spinner".into(), spinner_rect);
435            self.hits.insert("run:cancel".into(), cancel_rect);
436        }
437        if cancel_clicked {
438            state.cancel_run();
439        }
440
441        let len = state.history_len();
442        if len == 0 {
443            let g = tree::child_guides(&[], true);
444            tree::node(ui, TreeRow::leaf(&g, true, "(empty — add a feature)"), |_| {});
445        }
446
447        // Deferred engine mutations (applied after the draw loop so no borrow of
448        // `self`/`state` is held across them).
449        let mut roll: Option<usize> = None;
450        let mut delete: Option<String> = None;
451        let mut drag_move: Option<(usize, usize)> = None;
452        // The feature whose FORM to open — `(index, id)`. Applied after the draw
453        // loop, with the roll, so the mode flip and the roll are one step.
454        let mut open_form: Option<(usize, String)> = None;
455        let mut feature_rects: Vec<(usize, egui::Rect)> = Vec::with_capacity(len);
456
457        let current = state.history_rollback();
458        for i in 0..len {
459            let ty = state.feature_type_at(i).unwrap_or_else(|| "?".into());
460            let id = state.feature_id_at(i).unwrap_or_else(|| "(no id)".into());
461            let is_last_feature = i + 1 == len;
462            let ms = report
463                .get("featureTimings")
464                .and_then(|m| m.get(&id))
465                .and_then(Value::as_f64)
466                .unwrap_or(0.0);
467            // The feature's glyph goes in the tree's OWN glyph column rather
468            // than inline in the label, so the icons line up down the tree and
469            // a catalogued COLOUR icon is drawn as its artwork instead of as a
470            // one-colour font character (see `tree::node`). `feature_plain_name`
471            // is `feature_long_name` without the glyph it would prepend.
472            let glyph = features::feature_icon(&ty).map(String::from);
473            let label = format!("{id}  {}", features::feature_plain_name(&ty));
474
475            // Features AFTER the rollback point have NOT been executed: dim the
476            // WHOLE row — header, timing, edit + delete buttons and the connector
477            // lines — with egui's own disabled dimming, the app's existing
478            // "not active" language (see `panels::toolbar_button`). Dimmed, NOT
479            // disabled: a click on one still rolls the model FORWARD to it.
480            let pending = i > current;
481            ui.scope(|ui| {
482                if pending {
483                    ui.set_opacity(ui.visuals().disabled_alpha());
484                }
485                // --- feature header row: [+/-] {glyph} id LongName  N ms [✎] [X] --
486                // The per-type glyph sits in the tree's own glyph column, so it
487                // is drawn as real COLOUR artwork from the icon catalog rather
488                // than as a one-colour font character, and the icons line up in
489                // a column down the tree. See `tree::node`.
490                //
491                // The `[+]`/`[-]` box is the BUILT-UP-TO marker: `[-]` down to the
492                // rolled-to feature, `[+]` on the not-yet-executed ones below it —
493                // the same boundary the rollback bar and the dimming draw. Clicking
494                // one MOVES that boundary (a pure roll); nothing expands, because a
495                // feature's fields no longer live in the tree.
496                let mut del_rect = egui::Rect::NOTHING;
497                let mut del_clicked = false;
498                let mut edit_rect = egui::Rect::NOTHING;
499                let mut edit_clicked = false;
500                let resp = tree::node(
501                    ui,
502                    TreeRow::branch(&[], is_last_feature, !pending, &label)
503                        .glyph(glyph.as_deref())
504                        .selected(i == current)
505                        .draggable(true),
506                    |ui| {
507                        // right-to-left: X first (rightmost), then the edit pencil,
508                        // then the timing. Both buttons are `small()` so a third
509                        // control costs the label as little width as possible.
510                        let del = ui.add(
511                            crate::icon_text::icon_button_colored(ui, "✕", Some(DELETE_RED))
512                                .stroke(egui::Stroke::new(1.0, DELETE_RED))
513                                .small(),
514                        );
515                        del_rect = del.rect;
516                        del_clicked = del.clicked();
517                        ui.add_space(4.0);
518                        let edit = ui
519                            .add(crate::icon_text::icon_button(ui, "✎").small())
520                            .on_hover_text("Edit this feature");
521                        edit_rect = edit.rect;
522                        edit_clicked = edit.clicked();
523                        ui.add_space(6.0);
524                        ui.label(egui::RichText::new(format!("{} ms", ms.round() as i64)).weak());
525                    },
526                );
527                self.hits.insert(format!("step:{i}"), resp.label.rect);
528                self.hits.insert(format!("box:{i}"), resp.box_rect);
529                self.hits.insert(format!("del:{i}"), del_rect);
530                self.hits.insert(format!("edit:{i}"), edit_rect);
531                feature_rects.push((i, resp.row_rect));
532
533                if del_clicked {
534                    delete = Some(id.clone());
535                }
536                // Collapse box → a PURE ROLL to that step (no dialog), so the model
537                // can be rolled around without opening anything. Edit button OR a
538                // plain label click → open that feature's form AND roll to it (one
539                // gesture, two affordances: the button is the discoverable one, the
540                // label click is the one the hand already does). Drag to ANOTHER row
541                // → reorder. A drag that ends back on its OWN row is a click that
542                // egui timed out of the click window — the drag-resolution block
543                // below routes it here-equivalently (open + roll).
544                if resp.toggled {
545                    roll = Some(i);
546                }
547                if edit_clicked || resp.label.clicked() {
548                    open_form = Some((i, id.clone()));
549                }
550                if resp.label.drag_started() {
551                    self.drag_src = Some(i);
552                }
553
554                // --- error node: shown under a FAILING feature, ALWAYS, so a
555                // failure is visible while SCANNING the tree (the form's banner
556                // shows the same message to whoever opens the feature), and gone
557                // the moment the feature runs clean.
558                if let Some(message) = feature_error_message(report, &id) {
559                    let g = tree::child_guides(&[], is_last_feature);
560                    tree::message_leaf(ui, &g, true, &message, ERROR_RED);
561                }
562            });
563
564            // --- the EXECUTED-UP-TO boundary --------------------------------
565            // The rolled-to feature IS executed (a run builds `features[0..=rollback]`
566            // — see `brep_render::history`; the request's `stopAtId` stops AFTER that
567            // feature), so the bar goes BELOW its whole block: everything above the
568            // bar is live, everything below it is dimmed and not yet built. Drawn
569            // OUTSIDE the dim scope (and at the tip too, where it simply reports that
570            // the model is built to the end).
571            if i == current {
572                let bar = rollback_bar(ui);
573                self.hits.insert("rollback:bar".into(), bar);
574            }
575        }
576
577        // --- resolve an in-flight drag ----------------------------------------
578        if let Some(src) = self.drag_src {
579            let released = ui.input(|i| i.pointer.any_released());
580            let ptr = ui.input(|i| i.pointer.interact_pos());
581            match (ptr, released) {
582                (Some(p), released) => {
583                    let target = feature_rects
584                        .iter()
585                        .min_by(|a, b| {
586                            let da = (a.1.center().y - p.y).abs();
587                            let db = (b.1.center().y - p.y).abs();
588                            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
589                        })
590                        .map(|(idx, _)| *idx)
591                        .unwrap_or(src);
592                    if released {
593                        if target == src {
594                            // NOT a reorder — a press that STARTED and ENDED on the
595                            // same row. egui reclassifies a press as a DRAG once it
596                            // outlives `max_click_duration` (0.8 s) or drifts past
597                            // `max_click_dist` (6 pt), so an ordinary human click on
598                            // a label — which routinely lingers or wobbles a few
599                            // pixels — fires `drag_started` and NEVER `clicked`.
600                            // Routing that through the reorder arm below opened the
601                            // feature's dialog but silently dropped the roll (the
602                            // reported "clicking a feature's label doesn't move the
603                            // model" bug), because `drag_move` shadows `roll` in the
604                            // apply chain. A same-slot release IS the label click:
605                            // open that feature's form and roll, exactly like
606                            // `resp.label.clicked()` does. egui never fires both a
607                            // click and a drag for one press, so this can't
608                            // double-roll.
609                            match state.feature_id_at(src) {
610                                Some(id) => open_form = Some((src, id)),
611                                // No id to open a form for — still roll, which is
612                                // the half of the gesture that must never be lost.
613                                None => roll = Some(src),
614                            }
615                        } else {
616                            drag_move = Some((src, target));
617                        }
618                        self.drag_src = None;
619                    } else if target != src {
620                        // Draw an insertion indicator at the target row edge.
621                        if let Some((_, rect)) = feature_rects.iter().find(|(idx, _)| *idx == target)
622                        {
623                            let y = if target >= src { rect.bottom() } else { rect.top() };
624                            ui.painter().hline(
625                                rect.x_range(),
626                                y,
627                                egui::Stroke::new(2.0, ui.visuals().selection.bg_fill),
628                            );
629                        }
630                    }
631                }
632                (None, true) => self.drag_src = None,
633                _ => {}
634            }
635        }
636
637        // --- Add new feature (full-width) → open the searchable palette -------
638        ui.add_space(6.0);
639        let add = ui.add_sized(
640            [ui.available_width(), 26.0],
641            egui::Button::new("Add new feature"),
642        );
643        self.hits.insert("add:menu".into(), add.rect);
644        if add.clicked() {
645            // The active workbench TRIMS the creation palette (a UI filter only —
646            // the history/execution surface is untouched).
647            let items = feature_palette_items(&state.settings.workbench);
648            self.palette.open(items, "Add feature", "Search features…");
649        }
650
651        // --- apply deferred engine mutations (one per frame) ------------------
652        // A reorder does NOT open the moved feature's form: a drag is a
653        // restructuring gesture, and replacing the whole panel with a dialog
654        // after one would hide the tree the user was just arranging.
655        if let Some((src, to)) = drag_move {
656            self.move_feature(state, src, to);
657        } else if let Some(id) = delete {
658            // A deleted feature can't be the open form's subject (the form has no
659            // delete affordance), and if another surface deletes it the validity
660            // guard in `show` falls back to the tree next frame.
661            state.delete_feature(&id);
662        } else if let Some((i, id)) = open_form {
663            // Opening ROLLS to that feature, exactly as expanding it used to:
664            // the dialog and the model it describes must agree.
665            self.mode = PanelMode::Form { feature_id: id };
666            state.roll_to(i);
667        } else if let Some(i) = roll {
668            state.roll_to(i);
669        }
670
671        // --- the command palette (a ctx-level modal; drawn last) --------------
672        // A pick returns the chosen feature TYPE CODE; add that feature to the
673        // engine-owned history with schema-derived defaults + a unique id.
674        let ctx = ui.ctx().clone();
675        if let Some(type_code) = self.palette.show(&ctx) {
676            self.add_feature_of_type(state, &type_code);
677        }
678        // Republish the palette's widget rects (prefixed) so the headed verifier
679        // can locate + drive the modal without the app shell knowing about it.
680        let palette_hits: Vec<(String, egui::Rect)> = self
681            .palette
682            .hits()
683            .iter()
684            .map(|(k, r)| (format!("palette:{k}"), *r))
685            .collect();
686        self.hits.extend(palette_hits);
687    }
688
689    /// Move feature `from` to slot `to` via the engine's adjacent-swap reorder
690    /// (each swap re-runs the truncated history — small N, and the ONE reorder
691    /// primitive the engine exposes).
692    fn move_feature(&mut self, state: &mut EngineState, from: usize, to: usize) {
693        if from == to {
694            return;
695        }
696        let mut cur = from;
697        if to > from {
698            while cur < to {
699                state.reorder_feature(cur, false);
700                cur += 1;
701            }
702        } else {
703            while cur > to {
704                state.reorder_feature(cur, true);
705                cur -= 1;
706            }
707        }
708    }
709
710    /// Open the feature `id`'s FORM — the panel shows one form at a time, so
711    /// this replaces whatever was open. The context action bar calls this via the
712    /// shell after creating a feature from the selection or opening a selection's
713    /// owning feature (both of which also rolled the model to that step), so the
714    /// target feature's dialog is up for tweaking on the next frame. If the id
715    /// does not resolve, the validity guard in [`Self::show`] drops straight back
716    /// to the tree.
717    pub fn focus_feature(&mut self, id: String) {
718        self.mode = PanelMode::Form { feature_id: id };
719    }
720
721    /// Act on a schema `button` field click on a feature. `editSketch` on a SKETCH
722    /// feature opens the engine-native sketcher on THAT feature (roll-to-before +
723    /// plane orient) and returns the panel to the tree (the sketch-mode bar takes
724    /// over the UI). `enter_sketch_mode` guards the feature is a sketch, so a
725    /// stray click on a non-sketch is a harmless no-op.
726    fn handle_feature_button(&mut self, state: &mut EngineState, feature_id: &str, key: &str) {
727        match key {
728            "editSketch" => match state.enter_sketch_mode(feature_id) {
729                Ok(_) => self.mode = PanelMode::Tree,
730                Err(_err) => {
731                    #[cfg(not(target_arch = "wasm32"))]
732                    eprintln!("Edit Sketch failed for '{feature_id}': {_err}");
733                }
734            },
735            _ => {}
736        }
737    }
738
739    /// The published widget hit-rects (egui points) for the headed verifier.
740    #[cfg(target_arch = "wasm32")]
741    pub fn hits_json(&self) -> String {
742        let map: serde_json::Map<String, Value> = self
743            .hits
744            .iter()
745            .map(|(k, r)| {
746                (
747                    k.clone(),
748                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
749                )
750            })
751            .collect();
752        Value::Object(map).to_string()
753    }
754
755    /// Whether the palette requested a COMPONENT INSERT this frame (the ACOMP
756    /// palette entry routes to the component selector, never a bare feature
757    /// dialog). Consumed by the shell, which opens the file dialog's insert
758    /// mode.
759    pub fn take_insert_component_request(&mut self) -> bool {
760        std::mem::take(&mut self.pending_insert_component)
761    }
762
763    /// Append a feature of type `type_code` to the engine-owned history: build a
764    /// fresh descriptor whose `inputParams` are the schema DEFAULTS
765    /// ([`features::feature_default_params`]) with an engine-unique `id` assigned,
766    /// hand it to `EngineState::add_feature` (which appends + rolls to it), and
767    /// open the new feature's FORM. Works for ANY registered feature type — the
768    /// catalogue drives both the palette and the defaults.
769    ///
770    /// EXCEPTION — `ACOMP` (assembly component): inserting an instance needs a
771    /// parts-library payload first, so the palette pick surfaces an
772    /// insert-component REQUEST to the shell (which opens the component
773    /// selector) instead of appending an empty feature that could only fail.
774    pub(crate) fn add_feature_of_type(&mut self, state: &mut EngineState, type_code: &str) {
775        if type_code == "ACOMP" {
776            self.pending_insert_component = true;
777            return;
778        }
779        let id = state.next_feature_id(&features::feature_short_name(type_code));
780        let mut params = features::feature_default_params(type_code);
781        if let Value::Object(map) = &mut params {
782            map.insert("id".into(), Value::String(id.clone()));
783        }
784        let feature = serde_json::json!({
785            "type": type_code, "inputParams": params, "persistentData": {}
786        });
787        if state.add_feature(&feature.to_string()).is_ok() {
788            self.mode = PanelMode::Form { feature_id: id };
789        }
790    }
791}
792
793/// The history index of feature `id`, by linear scan over the engine's history.
794/// `EngineState` exposes `feature_id_at` but no public `index_of`, and the panel
795/// needs one for the form's per-frame validity guard (does the open form's
796/// subject still exist?). Small N, once per frame.
797fn feature_index_of(state: &EngineState, id: &str) -> Option<usize> {
798    (0..state.history_len()).find(|i| state.feature_id_at(*i).as_deref() == Some(id))
799}
800
801/// Paint the ROLLBACK BAR: a full-width horizontal rule marking the step the model
802/// is EXECUTED UP TO. It reuses the drag-reorder insertion indicator's look (a 2 px
803/// line in the theme's selection accent — see the drag branch of
804/// [`HistoryPanel::show`]) because it says the same thing: "the boundary is HERE".
805/// Returns its row rect, which the panel publishes for the headed verifier.
806fn rollback_bar(ui: &mut egui::Ui) -> egui::Rect {
807    let (rect, _) = ui.allocate_exact_size(
808        egui::vec2(ui.available_width(), ROLLBACK_BAR_H),
809        egui::Sense::hover(),
810    );
811    ui.painter().hline(
812        rect.x_range(),
813        rect.center().y,
814        egui::Stroke::new(2.0, ui.visuals().selection.bg_fill),
815    );
816    rect
817}
818
819/// Build one [`PaletteItem`] per registered feature from the kernel catalogue:
820/// `id` = the feature TYPE CODE (e.g. `P.CU`), `label` = its long name (e.g.
821/// `Primitive Cube`), `keywords` = the type code + short name (aliases the user
822/// might type). The palette sorts them alphabetically by label on open.
823///
824/// `workbench` is the active workbench id: only entries that workbench INCLUDES
825/// (each workbench classifies off the feature TYPE CODE) are offered. This is a
826/// pure UI filter over CREATION — it does not touch the existing history, so a
827/// document with sheet-metal features still shows and edits them in Modeling; only
828/// the "Add new feature" list is trimmed.
829fn feature_palette_items(workbench: &str) -> Vec<PaletteItem> {
830    let catalogue = features::feature_catalogue();
831    let mut items = Vec::new();
832    if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
833        for feature in list {
834            let ty = feature.get("type").and_then(Value::as_str).unwrap_or("");
835            if ty.is_empty() {
836                continue;
837            }
838            if !crate::workbench::includes_feature(workbench, ty) {
839                continue;
840            }
841            // `feature_long_name` prepends the glyph; the palette sorts/searches
842            // on a glyph-stripped key so it stays alphabetical.
843            let long = features::feature_long_name(ty);
844            let short = feature.get("shortName").and_then(Value::as_str).unwrap_or(ty);
845            let mut keywords = vec![ty.to_string()];
846            if short != ty {
847                keywords.push(short.to_string());
848            }
849            items.push(PaletteItem::new(ty, long, keywords));
850        }
851    }
852    items
853}
854
855#[cfg(test)]
856mod tests {
857    use super::{feature_error_message, feature_palette_items, HistoryPanel};
858    use brep_render::engine_state::EngineState;
859    use eframe::egui;
860    use std::collections::HashMap;
861
862    #[test]
863    fn palette_display_defaults_and_restores_across_document_panels() {
864        use crate::palette::PaletteDisplay;
865        use crate::store::{MemModelStore, ModelStore, FEATURE_PALETTE_DISPLAY_KEY};
866        let store = MemModelStore::new();
867        let mut panel = HistoryPanel::new();
868        panel.sync_palette_display(&store);
869        assert_eq!(panel.palette.display, PaletteDisplay::LargeIcons);
870        assert_eq!(store.read(FEATURE_PALETTE_DISPLAY_KEY), None);
871        store.write(FEATURE_PALETTE_DISPLAY_KEY, "\"compact_multi\"").unwrap();
872        let mut next_document_panel = HistoryPanel::new();
873        next_document_panel.sync_palette_display(&store);
874        assert_eq!(next_document_panel.palette.display, PaletteDisplay::CompactMulti);
875        store.write(FEATURE_PALETTE_DISPLAY_KEY, "unknown future value").unwrap();
876        let mut fallback_panel = HistoryPanel::new();
877        fallback_panel.sync_palette_display(&store);
878        assert_eq!(fallback_panel.palette.display, PaletteDisplay::LargeIcons);
879    }
880
881    /// Codes offered by the "Add feature" palette for a given workbench.
882    fn palette_codes(workbench: &str) -> Vec<String> {
883        feature_palette_items(workbench)
884            .iter()
885            .map(|item| item.id.clone())
886            .collect()
887    }
888
889    #[test]
890    fn palette_filters_by_active_workbench() {
891        let all = palette_codes("all");
892        let modeling = palette_codes("modeling");
893        let sheet = palette_codes("sheetMetal");
894
895        // Modeling: excludes sheet metal, includes a modeling code + the shared
896        // building blocks S / D / P.
897        assert!(!modeling.contains(&"SM.TAB".to_string()), "modeling hides SM.TAB: {modeling:?}");
898        assert!(!modeling.iter().any(|c| c.starts_with("SM.")), "modeling hides all SM.*: {modeling:?}");
899        for want in ["E", "S", "D", "P"] {
900            assert!(modeling.contains(&want.to_string()), "modeling includes {want}: {modeling:?}");
901        }
902
903        // Sheet Metal: includes SM.* + the common building blocks, excludes a pure
904        // modeling code (E).
905        assert!(sheet.contains(&"SM.TAB".to_string()), "sheet metal includes SM.TAB: {sheet:?}");
906        for want in ["S", "D", "P"] {
907            assert!(sheet.contains(&want.to_string()), "sheet metal includes common {want}: {sheet:?}");
908        }
909        assert!(!sheet.contains(&"E".to_string()), "sheet metal hides Extrude: {sheet:?}");
910
911        // All: the superset — includes everything both others do.
912        for want in ["SM.TAB", "E", "S", "D", "P"] {
913            assert!(all.contains(&want.to_string()), "All includes {want}: {all:?}");
914        }
915        // An unknown workbench id falls back to Modeling's filtering.
916        assert_eq!(palette_codes("bogus"), modeling);
917    }
918
919    /// THE INVARIANT: the workbench is a UI filter over CREATION only. It must NOT
920    /// reduce the history / execution surface — a document that ALREADY contains a
921    /// sheet-metal feature keeps it in the engine's feature list under Modeling,
922    /// even though Modeling's creation palette hides SM.* (asserted above).
923    #[test]
924    fn workbench_does_not_reduce_history_or_execution() {
925        use brep_render::engine_state::EngineState;
926
927        let mut state = EngineState::new();
928        state.settings.workbench = "modeling".to_string();
929
930        // Append an SM.* feature to the document (default params + a unique id).
931        let mut params = brep_render::features::feature_default_params("SM.TAB");
932        if let serde_json::Value::Object(map) = &mut params {
933            map.insert("id".into(), serde_json::Value::String("SM.TAB1".into()));
934        }
935        let feature = serde_json::json!({
936            "type": "SM.TAB", "inputParams": params, "persistentData": {}
937        });
938        state.add_feature(&feature.to_string()).expect("append SM.TAB");
939
940        // The feature is in the engine's history regardless of the Modeling
941        // workbench (whether or not it builds successfully).
942        assert_eq!(
943            state.feature_type_at(0).as_deref(),
944            Some("SM.TAB"),
945            "SM.TAB stays in history under the Modeling workbench"
946        );
947        assert_eq!(state.feature_id_at(0).as_deref(), Some("SM.TAB1"));
948        assert!(
949            state.history_listing_json().contains("SM.TAB"),
950            "the history listing retains SM.TAB under Modeling"
951        );
952
953        // And the Modeling creation palette still hides SM.TAB — filtered UI,
954        // intact history, in one assertion pair.
955        assert!(!palette_codes(&state.settings.workbench).contains(&"SM.TAB".to_string()));
956    }
957
958    /// An engine holding `n` cheap cube features, appended through the real
959    /// add-feature path (so the history, ids and run report are the genuine ones).
960    /// `add_feature` rolls to the feature it appends, so the tip is `n - 1`.
961    fn cube_history(n: usize) -> EngineState {
962        let mut state = EngineState::new();
963        for _ in 0..n {
964            let id = state.next_feature_id("Cube");
965            let mut params = brep_render::features::feature_default_params("P.CU");
966            if let serde_json::Value::Object(map) = &mut params {
967                map.insert("id".into(), serde_json::Value::String(id));
968            }
969            let feature = serde_json::json!({
970                "type": "P.CU", "inputParams": params, "persistentData": {}
971            });
972            state.add_feature(&feature.to_string()).expect("append P.CU");
973        }
974        state
975    }
976
977    /// Draw the REAL panel for one frame on a CONTROLLED clock (`time`, egui
978    /// seconds) and return the widget rects it published — the same `hits` map the
979    /// headed verifier reads, so these assert on the shipped signal.
980    fn panel_hits(
981        panel: &mut HistoryPanel,
982        ctx: &egui::Context,
983        state: &mut EngineState,
984        time: f64,
985    ) -> HashMap<String, egui::Rect> {
986        let raw = egui::RawInput {
987            screen_rect: Some(egui::Rect::from_min_size(
988                egui::pos2(0.0, 0.0),
989                egui::vec2(320.0, 700.0),
990            )),
991            time: Some(time),
992            ..Default::default()
993        };
994        let _ = ctx.run_ui(raw, |ui| panel.show(ui, state));
995        panel.hits.clone()
996    }
997
998    /// THE BOUNDARY: a run builds `features[0..=rollback]`, so the rolled-to
999    /// feature IS executed and the "executed up to here" bar belongs BELOW its row
1000    /// — above the first not-yet-executed one. Asserted at every rollback point,
1001    /// including the tip (where the bar simply trails the last feature).
1002    #[test]
1003    fn rollback_bar_sits_below_the_rolled_to_feature() {
1004        let ctx = egui::Context::default();
1005        let mut state = cube_history(3);
1006        let mut panel = HistoryPanel::new();
1007
1008        for rolled in 0..3 {
1009            state.roll_to(rolled);
1010            let hits = panel_hits(&mut panel, &ctx, &mut state, 0.0);
1011            let bar = hits["rollback:bar"];
1012            let row = hits[&format!("step:{rolled}")];
1013            assert!(
1014                bar.center().y > row.center().y,
1015                "rolled to {rolled}: that feature IS executed, so the bar is below it"
1016            );
1017            if let Some(next) = hits.get(&format!("step:{}", rolled + 1)) {
1018                assert!(
1019                    bar.center().y < next.center().y,
1020                    "rolled to {rolled}: the bar is above the first pending feature"
1021                );
1022            }
1023        }
1024    }
1025
1026    /// A dimmed row is DIMMED, not disabled: clicking a not-yet-executed feature
1027    /// must still roll the model FORWARD to it (the click path runs inside the
1028    /// opacity scope, so this guards that the scope changed painting only).
1029    #[test]
1030    fn clicking_a_not_yet_executed_row_rolls_forward_to_it() {
1031        let ctx = egui::Context::default();
1032        let mut state = cube_history(3);
1033        let mut panel = HistoryPanel::new();
1034        state.roll_to(0);
1035
1036        // Learn the rects, then press + release on the LAST (dimmed) feature's label.
1037        let target = panel_hits(&mut panel, &ctx, &mut state, 0.0)["step:2"].center();
1038        for (time, pressed) in [(0.1, true), (0.2, false)] {
1039            let raw = egui::RawInput {
1040                screen_rect: Some(egui::Rect::from_min_size(
1041                    egui::pos2(0.0, 0.0),
1042                    egui::vec2(320.0, 700.0),
1043                )),
1044                time: Some(time),
1045                events: vec![
1046                    egui::Event::PointerMoved(target),
1047                    egui::Event::PointerButton {
1048                        pos: target,
1049                        button: egui::PointerButton::Primary,
1050                        pressed,
1051                        modifiers: egui::Modifiers::default(),
1052                    },
1053                ],
1054                ..Default::default()
1055            };
1056            let _ = ctx.run_ui(raw, |ui| panel.show(ui, &mut state));
1057        }
1058        assert_eq!(state.history_rollback(), 2, "the click rolled the model forward");
1059
1060        // The label click also OPENED that feature's form (Q9), so the tree — and
1061        // its rollback bar — are off screen until we return. Returning rolls to the
1062        // TIP, which for this 3-cube history IS feature 2, so the bar must have
1063        // followed the roll to the same boundary.
1064        assert!(
1065            click_key(&mut panel, &ctx, &mut state, "form:return", 0.3),
1066            "the form's return button is published"
1067        );
1068        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.6);
1069        assert_eq!(state.history_rollback(), 2, "returning rolled to the tip");
1070        assert!(hits["rollback:bar"].center().y > hits["step:2"].center().y);
1071    }
1072
1073    /// Feed one raw egui frame at `time`, with the pointer at `pos` and an
1074    /// optional press/release, into the REAL panel. `pressed = None` is a frame
1075    /// with no button event — the button simply stays in whatever state it was.
1076    fn pointer_frame(
1077        panel: &mut HistoryPanel,
1078        ctx: &egui::Context,
1079        state: &mut EngineState,
1080        time: f64,
1081        pos: egui::Pos2,
1082        pressed: Option<bool>,
1083    ) {
1084        let mut events = vec![egui::Event::PointerMoved(pos)];
1085        if let Some(pressed) = pressed {
1086            events.push(egui::Event::PointerButton {
1087                pos,
1088                button: egui::PointerButton::Primary,
1089                pressed,
1090                modifiers: egui::Modifiers::default(),
1091            });
1092        }
1093        let raw = egui::RawInput {
1094            screen_rect: Some(egui::Rect::from_min_size(
1095                egui::pos2(0.0, 0.0),
1096                egui::vec2(320.0, 700.0),
1097            )),
1098            time: Some(time),
1099            events,
1100            ..Default::default()
1101        };
1102        let _ = ctx.run_ui(raw, |ui| panel.show(ui, state));
1103    }
1104
1105    /// CRISP-click the centre of the published rect `key` (press + release two
1106    /// frames apart, well inside egui's 0.8 s click window). Returns false when
1107    /// the key is not published this frame.
1108    fn click_key(
1109        panel: &mut HistoryPanel,
1110        ctx: &egui::Context,
1111        state: &mut EngineState,
1112        key: &str,
1113        time: f64,
1114    ) -> bool {
1115        let Some(rect) = panel_hits(panel, ctx, state, time).get(key).copied() else {
1116            return false;
1117        };
1118        let c = rect.center();
1119        pointer_frame(panel, ctx, state, time + 0.02, c, Some(true));
1120        pointer_frame(panel, ctx, state, time + 0.05, c, Some(false));
1121        true
1122    }
1123
1124    /// THE REGRESSION (the reported "clicking a feature's label doesn't roll the
1125    /// model" bug): a press on a feature's label that egui classifies as a DRAG
1126    /// rather than a crisp click — held past `max_click_duration` (0.8 s), or
1127    /// drifted past `max_click_dist` (6 pt) — but RELEASED ON ITS OWN ROW is not
1128    /// a reorder. It must open that feature's form AND roll the model, exactly
1129    /// like a quick click. The panel used to route it through the drag-reorder arm,
1130    /// which opened the dialog (so the click clearly registered) but silently
1131    /// dropped the roll, because `drag_move` shadows `roll` in the apply chain.
1132    /// A human's click routinely lingers past 0.8 s or drifts a few pixels — the
1133    /// synthetic clicks in the other tests never do, which is why this only ever
1134    /// showed up when driving the app by hand / in the browser.
1135    #[test]
1136    fn slow_label_press_on_its_own_row_still_rolls() {
1137        let ctx = egui::Context::default();
1138        let mut state = cube_history(3);
1139        let mut panel = HistoryPanel::new();
1140        state.roll_to(0);
1141
1142        let target = panel_hits(&mut panel, &ctx, &mut state, 0.0)["step:2"].center();
1143        // Press, hold past egui's 0.8 s click window (so `drag_started` fires on
1144        // the label), then release on the SAME row.
1145        for (time, pressed) in [(0.1, Some(true)), (1.0, None), (1.5, None), (1.6, Some(false))] {
1146            pointer_frame(&mut panel, &ctx, &mut state, time, target, pressed);
1147        }
1148        assert_eq!(
1149            state.history_rollback(),
1150            2,
1151            "a slow press released on its own row rolls like a click"
1152        );
1153        // …and it opened that feature's FORM, like a click does. (Adapted from the
1154        // old `sub:{id}/Outputs` assertion: the dialog is no longer a set of
1155        // inline tree sub-nodes, so the equivalent signal is the form's own
1156        // "which feature am I showing" marker. The ROLL assertion above — the
1157        // regression this test exists for — is untouched.)
1158        let hits = panel_hits(&mut panel, &ctx, &mut state, 2.0);
1159        let id = state.feature_id_at(2).expect("feature 2 id");
1160        assert!(
1161            hits.contains_key(&format!("form:feature:{id}")),
1162            "the slow press also opened feature 2's form"
1163        );
1164    }
1165
1166    /// The same press, but DRIFTED sideways past egui's 6 pt click distance while
1167    /// staying on its own row — the other way a human click becomes a "drag".
1168    /// Still a click: open + roll.
1169    #[test]
1170    fn drifted_label_press_on_its_own_row_still_rolls() {
1171        let ctx = egui::Context::default();
1172        let mut state = cube_history(3);
1173        let mut panel = HistoryPanel::new();
1174        state.roll_to(0);
1175
1176        let target = panel_hits(&mut panel, &ctx, &mut state, 0.0)["step:2"].center();
1177        pointer_frame(&mut panel, &ctx, &mut state, 0.1, target, Some(true));
1178        for k in 1..=5 {
1179            let drift = egui::pos2(target.x + k as f32 * 2.0, target.y);
1180            pointer_frame(&mut panel, &ctx, &mut state, 0.1 + k as f64 * 0.02, drift, None);
1181        }
1182        let end = egui::pos2(target.x + 10.0, target.y);
1183        pointer_frame(&mut panel, &ctx, &mut state, 0.3, end, Some(false));
1184        assert_eq!(
1185            state.history_rollback(),
1186            2,
1187            "a press that drifted within its own row rolls like a click"
1188        );
1189    }
1190
1191    /// The other half of the invariant: a press that ends on a DIFFERENT row is
1192    /// still a REORDER, not a click — the fix must not disarm drag-to-reorder.
1193    #[test]
1194    fn dragging_a_label_onto_another_row_still_reorders() {
1195        let ctx = egui::Context::default();
1196        let mut state = cube_history(3);
1197        let mut panel = HistoryPanel::new();
1198        let ids: Vec<String> = (0..3).map(|i| state.feature_id_at(i).unwrap()).collect();
1199
1200        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.0);
1201        let from = hits["step:0"].center();
1202        let to = hits["step:2"].center();
1203        pointer_frame(&mut panel, &ctx, &mut state, 0.1, from, Some(true));
1204        for k in 1..=6 {
1205            let t = k as f32 / 6.0;
1206            let p = egui::pos2(from.x, from.y + (to.y - from.y) * t);
1207            pointer_frame(&mut panel, &ctx, &mut state, 0.1 + k as f64 * 0.05, p, None);
1208        }
1209        pointer_frame(&mut panel, &ctx, &mut state, 0.5, to, Some(false));
1210
1211        assert_eq!(
1212            state.feature_id_at(2).as_deref(),
1213            Some(ids[0].as_str()),
1214            "the dragged feature moved to the drop row"
1215        );
1216    }
1217
1218    // --- the tree ⇄ form mode switch ------------------------------------------
1219
1220    /// Whether the panel is currently showing feature `id`'s form, read off the
1221    /// SHIPPED signal (the published hit map) rather than the private field, so
1222    /// these tests assert exactly what a verifier script can see.
1223    fn form_open_for(hits: &HashMap<String, egui::Rect>, id: &str) -> bool {
1224        hits.contains_key(&format!("form:feature:{id}"))
1225    }
1226
1227    /// THE ENTRY POINT the design depends on: the per-row `✎` button opens that
1228    /// feature's form (replacing the whole panel) AND rolls the model to it, so
1229    /// the dialog and the model it describes agree (Q1).
1230    #[test]
1231    fn edit_button_opens_the_form_and_rolls_to_it() {
1232        let ctx = egui::Context::default();
1233        let mut state = cube_history(3);
1234        let mut panel = HistoryPanel::new();
1235        state.roll_to(0);
1236        let id = state.feature_id_at(2).expect("feature 2 id");
1237
1238        assert!(
1239            click_key(&mut panel, &ctx, &mut state, "edit:2", 0.0),
1240            "every feature row publishes an edit button"
1241        );
1242        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.3);
1243        assert!(form_open_for(&hits, &id), "the edit button opened feature 2's form");
1244        assert_eq!(state.history_rollback(), 2, "opening rolled the model to it");
1245        // R2: the form replaces the WHOLE panel — no tree rows survive behind it.
1246        assert!(!hits.contains_key("step:0"), "the tree is gone in form mode");
1247        assert!(!hits.contains_key("add:menu"), "so is the add button");
1248        assert!(hits.contains_key("form:return"), "and the ONE exit button is there");
1249    }
1250
1251    /// Q9: a plain label click does what the edit button does — enter the form
1252    /// for that feature (and roll). The button is the discoverable affordance,
1253    /// not the only way in; the two must not disagree.
1254    #[test]
1255    fn a_plain_label_click_opens_the_same_form_the_edit_button_does() {
1256        let ctx = egui::Context::default();
1257        let mut state = cube_history(3);
1258        let mut panel = HistoryPanel::new();
1259        state.roll_to(0);
1260        let id = state.feature_id_at(1).expect("feature 1 id");
1261
1262        assert!(click_key(&mut panel, &ctx, &mut state, "step:1", 0.0));
1263        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.3);
1264        assert!(form_open_for(&hits, &id), "the label click opened feature 1's form");
1265        assert_eq!(state.history_rollback(), 1, "and rolled to it");
1266    }
1267
1268    /// THE COLLAPSE BOX is now a PURE ROLL control: it moves the executed-up-to
1269    /// boundary and opens NOTHING, so the model can be rolled around without a
1270    /// dialog taking over the panel. (It is also idempotent — clicking the box of
1271    /// the already-rolled-to feature must not bounce the model to the tip the way
1272    /// collapsing an inline dialog used to.)
1273    #[test]
1274    fn the_collapse_box_rolls_without_opening_a_form() {
1275        let ctx = egui::Context::default();
1276        let mut state = cube_history(3);
1277        let mut panel = HistoryPanel::new();
1278        state.roll_to(0);
1279
1280        assert!(click_key(&mut panel, &ctx, &mut state, "box:2", 0.0));
1281        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.3);
1282        assert_eq!(state.history_rollback(), 2, "the box rolled the model");
1283        assert!(hits.contains_key("step:0"), "…and stayed in the tree");
1284        assert!(
1285            !hits.keys().any(|k| k.starts_with("form:")),
1286            "the box opened no form: {:?}",
1287            hits.keys().filter(|k| k.starts_with("form:")).collect::<Vec<_>>()
1288        );
1289
1290        // Clicking the box of the rolled-to feature is a no-op roll, not a jump.
1291        assert!(click_key(&mut panel, &ctx, &mut state, "box:2", 0.4));
1292        assert_eq!(state.history_rollback(), 2, "re-clicking the same box holds");
1293    }
1294
1295    /// Q2: the ONE bottom button returns to the tree AND rolls to the TIP, so
1296    /// every downstream feature rebuilds and the edit becomes visible — the
1297    /// reason collapsing an inline dialog used to roll to the tip.
1298    #[test]
1299    fn return_button_restores_the_tree_and_rolls_to_the_tip() {
1300        let ctx = egui::Context::default();
1301        let mut state = cube_history(3);
1302        let mut panel = HistoryPanel::new();
1303        state.roll_to(0);
1304
1305        assert!(click_key(&mut panel, &ctx, &mut state, "edit:0", 0.0));
1306        assert_eq!(state.history_rollback(), 0);
1307        assert!(click_key(&mut panel, &ctx, &mut state, "form:return", 0.3));
1308        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.6);
1309        assert_eq!(state.history_rollback(), 2, "returning rolled to the tip");
1310        for key in ["step:0", "step:1", "step:2", "add:menu"] {
1311            assert!(hits.contains_key(key), "the tree is back ({key})");
1312        }
1313        assert!(!hits.keys().any(|k| k.starts_with("form:")), "and the form is gone");
1314    }
1315
1316    /// THE VALIDITY GUARD: a form whose subject is deleted from another surface
1317    /// falls back to the tree silently — no stale dialog, no "are you sure"
1318    /// (there is no uncommitted state to protect; edits commit live and undo).
1319    #[test]
1320    fn form_falls_back_to_tree_when_the_feature_is_deleted() {
1321        let ctx = egui::Context::default();
1322        let mut state = cube_history(3);
1323        let mut panel = HistoryPanel::new();
1324        let id = state.feature_id_at(1).expect("feature 1 id");
1325
1326        assert!(click_key(&mut panel, &ctx, &mut state, "edit:1", 0.0));
1327        assert!(form_open_for(&panel_hits(&mut panel, &ctx, &mut state, 0.3), &id));
1328
1329        state.delete_feature(&id);
1330        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.4);
1331        assert!(!form_open_for(&hits, &id), "the deleted feature's form closed");
1332        assert!(hits.contains_key("step:0"), "…back to the tree");
1333    }
1334
1335    /// The same guard, reached the other way: UNDO removes the open feature.
1336    #[test]
1337    fn form_falls_back_to_tree_after_undo_removes_the_feature() {
1338        let ctx = egui::Context::default();
1339        let mut state = cube_history(3);
1340        let mut panel = HistoryPanel::new();
1341        let id = state.feature_id_at(2).expect("feature 2 id");
1342
1343        assert!(click_key(&mut panel, &ctx, &mut state, "edit:2", 0.0));
1344        assert!(form_open_for(&panel_hits(&mut panel, &ctx, &mut state, 0.3), &id));
1345
1346        let _ = state.undo();
1347        assert_eq!(state.history_len(), 2, "undo really removed the third cube");
1348        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.4);
1349        assert!(!form_open_for(&hits, &id), "the undone feature's form closed");
1350        assert!(hits.contains_key("step:0"), "…back to the tree");
1351    }
1352
1353    /// A press that STARTS on the edit button is consumed by the button, so it
1354    /// never reaches the row label's drag sense: dragging off it must not
1355    /// reorder the history. (The row is `draggable(true)`, so this is the one
1356    /// interaction a third control could plausibly break.)
1357    #[test]
1358    fn pressing_the_edit_button_does_not_start_a_reorder_drag() {
1359        let ctx = egui::Context::default();
1360        let mut state = cube_history(3);
1361        let mut panel = HistoryPanel::new();
1362        let ids: Vec<String> = (0..3).map(|i| state.feature_id_at(i).unwrap()).collect();
1363
1364        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.0);
1365        let from = hits["edit:0"].center();
1366        let to = hits["step:2"].center();
1367        pointer_frame(&mut panel, &ctx, &mut state, 0.1, from, Some(true));
1368        for k in 1..=6 {
1369            let t = k as f32 / 6.0;
1370            let p = egui::pos2(from.x, from.y + (to.y - from.y) * t);
1371            pointer_frame(&mut panel, &ctx, &mut state, 0.1 + k as f64 * 0.05, p, None);
1372        }
1373        pointer_frame(&mut panel, &ctx, &mut state, 0.5, to, Some(false));
1374
1375        let after: Vec<String> = (0..3).map(|i| state.feature_id_at(i).unwrap()).collect();
1376        assert_eq!(after, ids, "a press on the edit button never reorders the history");
1377    }
1378
1379    #[test]
1380    fn opening_transform_feature_arms_shared_gizmo_and_returning_disarms() {
1381        let ctx = egui::Context::default();
1382        let mut state = cube_history(1);
1383        let source = state.feature_id_at(0).unwrap();
1384        let mut params = brep_render::features::feature_default_params("XFORM");
1385        params["id"] = serde_json::json!("Move");
1386        params["solids"] = serde_json::json!([source]);
1387        state.add_feature(&serde_json::json!({"type": "XFORM", "inputParams": params}).to_string()).unwrap();
1388        let mut panel = HistoryPanel::new();
1389        assert!(click_key(&mut panel, &ctx, &mut state, "edit:1", 0.0));
1390        assert!(state.transform_armed_for("Move"));
1391        assert!(state.transform_gizmo_anchor().is_some());
1392        panel_hits(&mut panel, &ctx, &mut state, 0.3);
1393        assert!(state.transform_armed_for("Move"));
1394        assert!(click_key(&mut panel, &ctx, &mut state, "form:return", 0.5));
1395        assert!(!state.transform_armed());
1396    }
1397
1398    /// THE GIZMO RE-KEY (silently breakable, hence this test): the auto-arm is
1399    /// keyed off the OPEN FORM's feature id and fires only on a TRANSITION. So
1400    /// opening a form arms the dimension gizmo ONCE — and a later in-viewport
1401    /// sphere toggle to the transform gizmo must NOT be snapped back to dimension
1402    /// on the next frame — and returning to the tree disarms.
1403    #[test]
1404    fn opening_a_form_arms_the_dimension_gizmo_once_and_returning_disarms() {
1405        let ctx = egui::Context::default();
1406        let mut state = cube_history(2);
1407        let mut panel = HistoryPanel::new();
1408        let id = state.feature_id_at(1).expect("feature 1 id");
1409        assert_ne!(
1410            state.feature_dimension_annotations_json(&id),
1411            "[]",
1412            "precondition: a cube HAS dimension annotations to arm"
1413        );
1414
1415        assert!(click_key(&mut panel, &ctx, &mut state, "edit:1", 0.0));
1416        assert_eq!(
1417            state.dimension_armed_feature(),
1418            id,
1419            "opening the form armed the dimension gizmo for that feature"
1420        );
1421
1422        // The in-viewport orange sphere flips dimension -> transform. The panel
1423        // must leave it there: a per-frame re-arm would clobber the toggle.
1424        state.arm_transform(&id);
1425        assert_eq!(state.dimension_armed_feature(), "", "the toggle took effect");
1426        panel_hits(&mut panel, &ctx, &mut state, 0.3);
1427        panel_hits(&mut panel, &ctx, &mut state, 0.4);
1428        assert_eq!(
1429            state.dimension_armed_feature(),
1430            "",
1431            "no transition, no re-arm — the sphere toggle survives"
1432        );
1433
1434        assert!(click_key(&mut panel, &ctx, &mut state, "form:return", 0.5));
1435        assert_eq!(state.dimension_armed_feature(), "");
1436        assert!(
1437            state.transform_gizmo_anchor().is_none(),
1438            "returning to the tree disarmed both gizmos"
1439        );
1440    }
1441
1442    /// THE VERIFIER CONTRACT: the form publishes the SAME `field:` keys the
1443    /// inline tree did — `verify_history.mjs` drives `field:sizeX` and
1444    /// `field:boolean.operation` by name, so re-keying them would break it
1445    /// silently. Only the way IN changed.
1446    #[test]
1447    fn the_form_publishes_the_same_field_keys_the_inline_tree_did() {
1448        let ctx = egui::Context::default();
1449        let mut state = cube_history(1);
1450        let mut panel = HistoryPanel::new();
1451
1452        assert!(click_key(&mut panel, &ctx, &mut state, "edit:0", 0.0));
1453        let hits = panel_hits(&mut panel, &ctx, &mut state, 0.3);
1454        for key in ["field:sizeX", "field:sizeY", "field:sizeZ"] {
1455            assert!(hits.contains_key(key), "{key} survives the move into the form");
1456        }
1457        assert!(
1458            hits.contains_key("form:section:Transform"),
1459            "the Transform group is a form SECTION now: {:?}",
1460            hits.keys().filter(|k| k.starts_with("form:")).collect::<Vec<_>>()
1461        );
1462        assert!(
1463            !hits.keys().any(|k| k.starts_with("sub:")),
1464            "…and no inline-tree sub-node keys remain"
1465        );
1466    }
1467
1468    /// A runner that ACCEPTS a run and never replies, so `run_pending()` stays true
1469    /// for as many frames as the test wants. The real background runners (native
1470    /// thread / browser worker) finish whenever they finish — not something a
1471    /// timing assertion can stand on.
1472    struct StuckRunner;
1473
1474    impl brep_render::runner::HistoryRunner for StuckRunner {
1475        fn submit_run(&mut self, _request: brep_render::brep_kernel::HistoryRequest, _gen: u64) {}
1476        fn poll_run(&mut self) -> Option<brep_render::runner::RunReply> {
1477            None
1478        }
1479        fn submit_query(&mut self, _query: brep_render::runner::MeasureQuery) {}
1480        fn poll_query(&mut self) -> Option<brep_render::runner::MeasureReply> {
1481            None
1482        }
1483        fn submit_mesh_import(&mut self, _request: brep_render::runner::MeshImportRequest) {}
1484        fn poll_mesh_import(&mut self) -> Option<brep_render::runner::MeshImportReply> {
1485            None
1486        }
1487        fn submit_step_probe(&mut self, _request: brep_render::runner::StepProbeRequest) {}
1488        fn poll_step_probe(&mut self) -> Option<brep_render::runner::StepProbeReply> {
1489            None
1490        }
1491        fn reset(&mut self) {}
1492    }
1493
1494    /// The INTERIM run indicator: the header spinner appears only once a run has
1495    /// been in flight longer than `RUN_SPINNER_DELAY` (a run that lands in a frame
1496    /// or two must not blink), and never when nothing is running.
1497    #[test]
1498    fn header_spinner_waits_out_a_short_run() {
1499        let ctx = egui::Context::default();
1500        let mut state = cube_history(2);
1501        let mut panel = HistoryPanel::new();
1502
1503        // Nothing in flight (the synchronous Inline runner) → no spinner, ever.
1504        assert!(!panel_hits(&mut panel, &ctx, &mut state, 0.0).contains_key("run:spinner"));
1505
1506        // A run that never lands: still silent below the delay, spinning above it.
1507        state.set_runner(Box::new(StuckRunner));
1508        state.roll_to(0);
1509        assert!(state.run_pending(), "the stuck runner keeps the run in flight");
1510        assert!(
1511            !panel_hits(&mut panel, &ctx, &mut state, 1.0).contains_key("run:spinner"),
1512            "the frame a run starts on must not flash the spinner"
1513        );
1514        assert!(
1515            !panel_hits(&mut panel, &ctx, &mut state, 1.1).contains_key("run:spinner"),
1516            "a run shorter than the delay never spins"
1517        );
1518        let hits = panel_hits(&mut panel, &ctx, &mut state, 1.5);
1519        assert!(hits.contains_key("run:spinner"), "a run the user actually waits on spins");
1520        assert!(
1521            hits.contains_key("run:cancel"),
1522            "…and offers Cancel beside the spinner, on the same delay"
1523        );
1524    }
1525
1526    #[test]
1527    fn error_message_matches_by_exact_id_prefix() {
1528        let report = serde_json::json!({
1529            "featureErrors": [
1530                "Box: makeBoxSolid: sizes must be positive",
1531                "R6: boolean UNION failed: invalid topology"
1532            ]
1533        });
1534        // The `"<id>: "` prefix is stripped, the (colon-bearing) message is kept.
1535        assert_eq!(
1536            feature_error_message(&report, "Box").as_deref(),
1537            Some("makeBoxSolid: sizes must be positive")
1538        );
1539        assert_eq!(
1540            feature_error_message(&report, "R6").as_deref(),
1541            Some("boolean UNION failed: invalid topology")
1542        );
1543        // A feature that ran clean has no entry.
1544        assert_eq!(feature_error_message(&report, "Pin"), None);
1545    }
1546
1547    #[test]
1548    fn shorter_id_does_not_match_a_longer_ids_error() {
1549        // "R6" must NOT pick up "R60"'s error — the ": " delimiter guards the prefix.
1550        let report = serde_json::json!({ "featureErrors": ["R60: boom"] });
1551        assert_eq!(feature_error_message(&report, "R6"), None);
1552        assert_eq!(feature_error_message(&report, "R60").as_deref(), Some("boom"));
1553    }
1554
1555    #[test]
1556    fn missing_or_empty_errors_yield_none() {
1557        assert_eq!(feature_error_message(&serde_json::json!({}), "Box"), None);
1558        assert_eq!(
1559            feature_error_message(&serde_json::json!({ "featureErrors": [] }), "Box"),
1560            None
1561        );
1562    }
1563}