use crate::automation::hit_keys::HitKeyDoc;
use crate::form_view::{form_view, FormViewSpec};
use crate::panels::spline_anchors;
use crate::palette::{Palette, PaletteItem};
use crate::panels::tree::{self, TreeRow};
use brep_render::engine_state::EngineState;
use brep_render::features;
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
const DIALOG_HOVER_OWNER: &str = "history";
const DELETE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
const ERROR_RED: egui::Color32 = egui::Color32::from_rgb(0xff, 0x6b, 0x6b);
const ROLLBACK_BAR_H: f32 = 8.0;
const RUN_SPINNER_DELAY: f64 = 0.2;
fn feature_error_message(report: &serde_json::Value, id: &str) -> Option<String> {
let prefix = format!("{id}: ");
report
.get("featureErrors")?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_str)
.find(|entry| entry.starts_with(&prefix))
.map(|entry| entry[prefix.len()..].to_string())
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub enum PanelMode {
#[default]
Tree,
Form { feature_id: String },
}
impl PanelMode {
fn feature_id(&self) -> Option<&str> {
match self {
PanelMode::Tree => None,
PanelMode::Form { feature_id } => Some(feature_id.as_str()),
}
}
}
#[derive(Default)]
pub struct HistoryPanel {
hits: HashMap<String, egui::Rect>,
mode: PanelMode,
gizmo_armed_for: Option<String>,
drag_src: Option<usize>,
palette: Palette,
palette_display_loaded: bool,
pending_button: Option<(String, String)>,
run_started: Option<f64>,
pending_insert_component: bool,
anchor_selected: Option<usize>,
}
impl HistoryPanel {
pub fn sync_palette_display(&mut self, store: &dyn crate::store::ModelStore) {
use crate::store::FEATURE_PALETTE_DISPLAY_KEY;
if !self.palette_display_loaded {
self.palette.display = store.read(FEATURE_PALETTE_DISPLAY_KEY)
.and_then(|json| serde_json::from_str(&json).ok()).unwrap_or_default();
self.palette_display_loaded = true;
}
if self.palette.take_display_change() {
if let Ok(json) = serde_json::to_string(&self.palette.display) {
let _ = store.write(FEATURE_PALETTE_DISPLAY_KEY, &json);
}
}
}
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
self.hits.clear();
self.hits.insert("panel:clip".into(), ui.clip_rect());
if let Some(id) = self.mode.feature_id() {
if feature_index_of(state, id).is_none() {
self.mode = PanelMode::Tree;
}
}
if let Some((ax, ay)) = state.transform_gizmo_anchor() {
self.hits.insert(
"gizmo-anchor".into(),
egui::Rect::from_min_size(egui::pos2(ax as f32, ay as f32), egui::Vec2::ZERO),
);
}
let report: Value = serde_json::from_str(&state.history_report_json()).unwrap_or(Value::Null);
ui.spacing_mut().item_spacing.y = 2.0;
let now = ui.input(|i| i.time);
match (state.run_pending(), self.run_started) {
(true, None) => self.run_started = Some(now),
(false, Some(_)) => self.run_started = None,
_ => {}
}
match self.mode.clone() {
PanelMode::Tree => {
state.refresh_spline_edit_overlay(None, None);
self.show_tree(ui, state, &report);
}
PanelMode::Form { feature_id } => self.show_form(ui, state, &report, &feature_id),
}
if let Some((fid, key)) = self.pending_button.take() {
self.handle_feature_button(state, &fid, &key);
}
let open_feature = self.mode.feature_id().map(str::to_string);
if self.gizmo_armed_for != open_feature {
self.anchor_selected = None;
state.disarm_transform();
if let Some(id) = open_feature.clone() {
if state.feature_dimension_annotations_json(&id) != "[]" {
state.arm_dimension(&id);
} else if state.feature_has_transform(&id) {
state.arm_transform(&id);
} else if state.is_spline_feature(&id) {
let first_free = state
.spline_anchors(&id)
.iter()
.find(|row| row.attached.is_none())
.map(|row| row.index);
if let Some(index) = first_free {
if state.arm_spline_anchor(&id, index) {
self.anchor_selected = Some(index);
}
}
}
}
self.gizmo_armed_for = open_feature;
}
}
fn show_form(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
report: &Value,
id: &str,
) {
let Some(index) = feature_index_of(state, id) else {
return;
};
let ty = state.feature_type_at(index).unwrap_or_else(|| "?".into());
let title = format!("{id} {}", features::feature_plain_name(&ty));
let fields = features::feature_form_fields(&ty);
let mut params: Value =
serde_json::from_str(&state.feature_params_json(index)).unwrap_or(Value::Null);
let error = feature_error_message(report, id);
let outputs: Vec<String> = report
.get("featureOutputs")
.and_then(|m| m.get(id))
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let trailing = [("Outputs", outputs)];
let is_spline = ty == "SP";
if is_spline {
if let Some(index) = state.take_spline_anchor_pick() {
self.anchor_selected = Some(index);
}
}
let anchor_rows = if is_spline { state.spline_anchors(id) } else { Vec::new() };
let anchor_intents: std::cell::RefCell<Vec<spline_anchors::AnchorIntent>> =
std::cell::RefCell::new(Vec::new());
let anchor_hits: std::cell::RefCell<Vec<(String, egui::Rect)>> =
std::cell::RefCell::new(Vec::new());
let anchor_selected = self.anchor_selected;
let draw_anchors = |ui: &mut egui::Ui| {
spline_anchors::draw(ui, &anchor_rows, anchor_selected, &anchor_intents, &anchor_hits);
};
let spec = FormViewSpec {
title: &title,
subtitle: None,
fields: &fields,
banner: error.as_deref().map(|m| (m, ERROR_RED)),
trailing: Some(&trailing),
exit_label: "Return to tree",
extra: is_spline.then_some(("Anchors", &draw_anchors as &dyn Fn(&mut egui::Ui))),
rollback: true,
hits_prefix: "",
};
let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
for (key, rect) in anchor_hits.into_inner() {
self.hits.insert(key, rect);
}
if is_spline {
if let Some(intent) = anchor_intents.into_inner().into_iter().next() {
self.anchor_selected = spline_anchors::apply(state, id, intent, self.anchor_selected);
}
state.refresh_spline_edit_overlay(Some(id), self.anchor_selected);
} else {
state.refresh_spline_edit_overlay(None, None);
}
let anchor = self
.hits
.get("form:feature")
.map(|r| r.min)
.unwrap_or(egui::Pos2::ZERO);
self.hits.insert(
format!("form:feature:{id}"),
egui::Rect::from_min_size(anchor, egui::Vec2::ZERO),
);
if out.changed {
let _ = state.update_feature_params(id, ¶ms.to_string());
}
if let Some(key) = out.button_clicked {
self.pending_button = Some((id.to_string(), key));
}
if let Some(activate) = out.ref_activate {
state.begin_ref_select(
id,
activate.path,
activate.label,
activate.filter,
activate.multiple,
activate.seed,
);
}
let hover_changed = match &out.hovered_entity {
Some(name) => state.hover_entity_by_name(DIALOG_HOVER_OWNER, name),
None => state.dialog_hover_end(DIALOG_HOVER_OWNER),
};
if hover_changed {
ui.ctx().request_repaint();
}
if out.exit_clicked {
self.mode = PanelMode::Tree;
}
if out.roll_to_tip {
state.roll_to(state.history_len().saturating_sub(1));
}
}
fn show_tree(&mut self, ui: &mut egui::Ui, state: &mut EngineState, report: &Value) {
let now = ui.input(|i| i.time);
let spinning = self
.run_started
.is_some_and(|started| now - started >= RUN_SPINNER_DELAY);
let progress = state.run_progress().cloned();
let cancelled = state.cancelled_run().map(str::to_string);
let mut spinner_rect = egui::Rect::NOTHING;
let mut cancel_rect = egui::Rect::NOTHING;
let mut cancel_clicked = false;
tree::node(
ui,
TreeRow {
guides: &[],
is_last: true,
expandable: true,
expanded: true,
root: true,
glyph: None,
label: "Features",
selected: false,
draggable: false,
tint: None,
},
|ui| {
if spinning {
let cancel = ui.small_button("Cancel").on_hover_text(
"Stop the run. The model keeps the last completed result; \
edit or delete the slow feature to rebuild.",
);
cancel_rect = cancel.rect;
cancel_clicked = cancel.clicked();
spinner_rect = ui.add(egui::Spinner::new().size(12.0)).rect;
if let Some(progress) = &progress {
ui.weak(format!(
"{} · {}/{}",
progress.feature_id,
progress.index + 1,
progress.total
));
}
} else if let Some(cancelled) = &cancelled {
let note = if cancelled.is_empty() {
"run cancelled — the next edit rebuilds".to_string()
} else {
format!("run cancelled at {cancelled} — edit or delete it to rebuild")
};
ui.colored_label(ERROR_RED, note);
}
},
);
if spinning {
self.hits.insert("run:spinner".into(), spinner_rect);
self.hits.insert("run:cancel".into(), cancel_rect);
}
if cancel_clicked {
state.cancel_run();
}
let len = state.history_len();
if len == 0 {
let g = tree::child_guides(&[], true);
tree::node(ui, TreeRow::leaf(&g, true, "(empty — add a feature)"), |_| {});
}
let mut roll: Option<usize> = None;
let mut delete: Option<String> = None;
let mut drag_move: Option<(usize, usize)> = None;
let mut open_form: Option<(usize, String)> = None;
let mut feature_rects: Vec<(usize, egui::Rect)> = Vec::with_capacity(len);
let current = state.history_rollback();
for i in 0..len {
let ty = state.feature_type_at(i).unwrap_or_else(|| "?".into());
let id = state.feature_id_at(i).unwrap_or_else(|| "(no id)".into());
let is_last_feature = i + 1 == len;
let ms = report
.get("featureTimings")
.and_then(|m| m.get(&id))
.and_then(Value::as_f64)
.unwrap_or(0.0);
let glyph = features::feature_icon(&ty).map(String::from);
let label = format!("{id} {}", features::feature_plain_name(&ty));
let pending = i > current;
ui.scope(|ui| {
if pending {
ui.set_opacity(ui.visuals().disabled_alpha());
}
let mut del_rect = egui::Rect::NOTHING;
let mut del_clicked = false;
let mut edit_rect = egui::Rect::NOTHING;
let mut edit_clicked = false;
let resp = tree::node(
ui,
TreeRow::branch(&[], is_last_feature, !pending, &label)
.glyph(glyph.as_deref())
.selected(i == current)
.draggable(true),
|ui| {
let del = ui.add(
crate::icon_text::icon_button_colored(ui, "✕", Some(DELETE_RED))
.stroke(egui::Stroke::new(1.0, DELETE_RED))
.small(),
);
del_rect = del.rect;
del_clicked = del.clicked();
ui.add_space(4.0);
let edit = ui
.add(crate::icon_text::icon_button(ui, "✎").small())
.on_hover_text("Edit this feature");
edit_rect = edit.rect;
edit_clicked = edit.clicked();
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("{} ms", ms.round() as i64)).weak());
},
);
self.hits.insert(format!("step:{i}"), resp.label.rect);
self.hits.insert(format!("box:{i}"), resp.box_rect);
self.hits.insert(format!("del:{i}"), del_rect);
self.hits.insert(format!("edit:{i}"), edit_rect);
feature_rects.push((i, resp.row_rect));
if del_clicked {
delete = Some(id.clone());
}
if resp.toggled {
roll = Some(i);
}
if edit_clicked || resp.label.clicked() {
open_form = Some((i, id.clone()));
}
if resp.label.drag_started() {
self.drag_src = Some(i);
}
if let Some(message) = feature_error_message(report, &id) {
let g = tree::child_guides(&[], is_last_feature);
tree::message_leaf(ui, &g, true, &message, ERROR_RED);
}
});
if i == current {
let bar = rollback_bar(ui);
self.hits.insert("rollback:bar".into(), bar);
}
}
if let Some(src) = self.drag_src {
let released = ui.input(|i| i.pointer.any_released());
let ptr = ui.input(|i| i.pointer.interact_pos());
match (ptr, released) {
(Some(p), released) => {
let target = feature_rects
.iter()
.min_by(|a, b| {
let da = (a.1.center().y - p.y).abs();
let db = (b.1.center().y - p.y).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(idx, _)| *idx)
.unwrap_or(src);
if released {
if target == src {
match state.feature_id_at(src) {
Some(id) => open_form = Some((src, id)),
None => roll = Some(src),
}
} else {
drag_move = Some((src, target));
}
self.drag_src = None;
} else if target != src {
if let Some((_, rect)) = feature_rects.iter().find(|(idx, _)| *idx == target)
{
let y = if target >= src { rect.bottom() } else { rect.top() };
ui.painter().hline(
rect.x_range(),
y,
egui::Stroke::new(2.0, ui.visuals().selection.bg_fill),
);
}
}
}
(None, true) => self.drag_src = None,
_ => {}
}
}
ui.add_space(6.0);
let add = ui.add_sized(
[ui.available_width(), 26.0],
egui::Button::new("Add new feature"),
);
self.hits.insert("add:menu".into(), add.rect);
if add.clicked() {
let items = feature_palette_items(&state.settings.workbench);
self.palette.open(items, "Add feature", "Search features…");
}
if let Some((src, to)) = drag_move {
self.move_feature(state, src, to);
} else if let Some(id) = delete {
state.delete_feature(&id);
} else if let Some((i, id)) = open_form {
self.mode = PanelMode::Form { feature_id: id };
state.roll_to(i);
} else if let Some(i) = roll {
state.roll_to(i);
}
let ctx = ui.ctx().clone();
if let Some(type_code) = self.palette.show(&ctx) {
self.add_feature_of_type(state, &type_code);
}
let palette_hits: Vec<(String, egui::Rect)> = self
.palette
.hits()
.iter()
.map(|(k, r)| (format!("palette:{k}"), *r))
.collect();
self.hits.extend(palette_hits);
}
fn move_feature(&mut self, state: &mut EngineState, from: usize, to: usize) {
if from == to {
return;
}
let mut cur = from;
if to > from {
while cur < to {
state.reorder_feature(cur, false);
cur += 1;
}
} else {
while cur > to {
state.reorder_feature(cur, true);
cur -= 1;
}
}
}
pub fn focus_feature(&mut self, id: String) {
self.mode = PanelMode::Form { feature_id: id };
}
fn handle_feature_button(&mut self, state: &mut EngineState, feature_id: &str, key: &str) {
match key {
"editSketch" => match state.enter_sketch_mode(feature_id) {
Ok(_) => self.mode = PanelMode::Tree,
Err(_err) => {
#[cfg(not(target_arch = "wasm32"))]
eprintln!("Edit Sketch failed for '{feature_id}': {_err}");
}
},
_ => {}
}
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
pub fn take_insert_component_request(&mut self) -> bool {
std::mem::take(&mut self.pending_insert_component)
}
pub(crate) fn add_feature_of_type(&mut self, state: &mut EngineState, type_code: &str) {
if type_code == "ACOMP" {
self.pending_insert_component = true;
return;
}
let id = state.next_feature_id(&features::feature_short_name(type_code));
let mut params = features::feature_default_params(type_code);
if let Value::Object(map) = &mut params {
map.insert("id".into(), Value::String(id.clone()));
}
let feature = serde_json::json!({
"type": type_code, "inputParams": params, "persistentData": {}
});
if state.add_feature(&feature.to_string()).is_ok() {
self.mode = PanelMode::Form { feature_id: id };
}
}
}
fn feature_index_of(state: &EngineState, id: &str) -> Option<usize> {
(0..state.history_len()).find(|i| state.feature_id_at(*i).as_deref() == Some(id))
}
fn rollback_bar(ui: &mut egui::Ui) -> egui::Rect {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), ROLLBACK_BAR_H),
egui::Sense::hover(),
);
ui.painter().hline(
rect.x_range(),
rect.center().y,
egui::Stroke::new(2.0, ui.visuals().selection.bg_fill),
);
rect
}
fn feature_palette_items(workbench: &str) -> Vec<PaletteItem> {
let catalogue = features::feature_catalogue();
let mut items = Vec::new();
if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
for feature in list {
let ty = feature.get("type").and_then(Value::as_str).unwrap_or("");
if ty.is_empty() {
continue;
}
if !crate::workbench::includes_feature(workbench, ty) {
continue;
}
let long = features::feature_long_name(ty);
let short = feature.get("shortName").and_then(Value::as_str).unwrap_or(ty);
let mut keywords = vec![ty.to_string()];
if short != ty {
keywords.push(short.to_string());
}
items.push(PaletteItem::new(ty, long, keywords));
}
}
items
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "history", prefix: "step:", meaning: "roll the history to feature i (step:i)", command: None },
HitKeyDoc { panel: "history", prefix: "edit:", meaning: "open feature i's form (edit:i)", command: None },
HitKeyDoc { panel: "history", prefix: "del:", meaning: "delete feature i (del:i)", command: None },
HitKeyDoc { panel: "history", prefix: "box:", meaning: "feature i's visibility box (box:i)", command: None },
HitKeyDoc { panel: "history", prefix: "add:menu", meaning: "open the add-feature palette", command: None },
HitKeyDoc { panel: "history", prefix: "palette:", meaning: "add the feature of that type from the open palette", command: None },
HitKeyDoc { panel: "history", prefix: "form:return", meaning: "close the open form and roll to the tip", command: None },
HitKeyDoc { panel: "history", prefix: "form:feature", meaning: "the open form's title row (form:feature) and its feature chip (form:feature:id)", command: None },
HitKeyDoc { panel: "history", prefix: "form:feature:", meaning: "the open form's feature chip (form:feature:id)", command: None },
HitKeyDoc { panel: "history", prefix: "gizmo-anchor", meaning: "the open form's gizmo anchor control", command: None },
HitKeyDoc { panel: "history", prefix: "form:section:", meaning: "toggle an accordion section of the open form", command: None },
HitKeyDoc { panel: "history", prefix: "field:", meaning: "a field of the open form, by param path", command: None },
HitKeyDoc { panel: "history", prefix: "form:trailing:", meaning: "a read-only line of the open form (form:trailing:Outputs:i) — hovering it lights that entity", command: None },
HitKeyDoc { panel: "history", prefix: "run:cancel", meaning: "cancel the in-flight run", command: None },
HitKeyDoc { panel: "history", prefix: "run:spinner", meaning: "the in-flight run indicator", command: None },
HitKeyDoc { panel: "history", prefix: "rollback:bar", meaning: "the executed-up-to indicator", command: None },
HitKeyDoc { panel: "history", prefix: "panel:clip", meaning: "the visible region of the pane", command: None },
];