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, ¶ms.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}