use crate::form;
use brep_render::style::{FieldKind, FormField};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
const COLLAPSED_BY_DEFAULT: &[&str] = &["Transform", "Outputs"];
pub struct FormViewSpec<'a> {
pub title: &'a str,
pub subtitle: Option<&'a str>,
pub fields: &'a [FormField],
pub banner: Option<(&'a str, egui::Color32)>,
pub trailing: Option<&'a [(&'a str, Vec<String>)]>,
pub exit_label: &'a str,
pub rollback: bool,
pub hits_prefix: &'a str,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RefActivate {
pub path: Vec<String>,
pub label: String,
pub filter: Vec<String>,
pub multiple: bool,
pub seed: Vec<String>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FormViewOut {
pub changed: bool,
pub button_clicked: Option<String>,
pub ref_activate: Option<RefActivate>,
pub exit_clicked: bool,
pub roll_to_tip: bool,
}
const FORM_MARGIN: i8 = 10;
const FORM_OUTER_MARGIN: i8 = 6;
pub fn form_view(
ui: &mut egui::Ui,
spec: &FormViewSpec<'_>,
params: &mut Value,
mut hits: Option<&mut HashMap<String, egui::Rect>>,
) -> FormViewOut {
let mut out = FormViewOut::default();
egui::Frame::group(ui.style())
.inner_margin(egui::Margin::same(FORM_MARGIN))
.outer_margin(egui::Margin::same(FORM_OUTER_MARGIN))
.show(ui, |ui| {
form_body(ui, spec, params, &mut hits, &mut out);
});
out
}
fn form_body(
ui: &mut egui::Ui,
spec: &FormViewSpec<'_>,
params: &mut Value,
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
out: &mut FormViewOut,
) {
ui.set_width(ui.available_width());
let head = ui.heading(spec.title);
publish(spec, hits, "form:feature", head.rect);
if let Some(subtitle) = spec.subtitle {
ui.label(egui::RichText::new(subtitle).weak());
}
ui.separator();
if let Some((text, color)) = spec.banner {
egui::Frame::group(ui.style())
.stroke(egui::Stroke::new(1.0, color))
.fill(color.gamma_multiply(0.12))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(
egui::Label::new(egui::RichText::new(text).color(color))
.wrap_mode(egui::TextWrapMode::Wrap),
);
});
ui.add_space(4.0);
}
let mut references: Vec<&FormField> = Vec::new();
let mut param_leaves: Vec<&FormField> = Vec::new();
let mut groups: Vec<(&str, Vec<&FormField>)> = Vec::new();
for f in spec.fields {
match f.group.as_str() {
"References" => references.push(f),
"Parameters" => {
if matches!(f.kind, FieldKind::Text { read_only: true }) {
continue; }
param_leaves.push(f);
}
group => match groups.iter_mut().find(|(name, _)| *name == group) {
Some(existing) => existing.1.push(f),
None => groups.push((group, vec![f])),
},
}
}
groups.sort_by_key(|(name, _)| usize::from(*name == "Transform"));
for f in references.iter().chain(param_leaves.iter()) {
draw_field(ui, spec, f, params, hits, out);
}
for (name, fields) in &groups {
section(ui, spec, name, hits, |ui, hits| {
for f in fields {
draw_field(ui, spec, f, params, hits, out);
}
});
}
for (name, values) in spec.trailing.unwrap_or(&[]) {
section(ui, spec, name, hits, |ui, _hits| {
if values.is_empty() {
ui.label(egui::RichText::new("(none)").weak());
}
for value in values {
ui.label(format!("• {value}"));
}
});
}
ui.add_space(10.0);
let exit = ui.add_sized(
[ui.available_width(), 26.0],
egui::Button::new(spec.exit_label),
);
publish(spec, hits, "form:return", exit.rect);
out.exit_clicked = exit.clicked();
out.roll_to_tip = out.exit_clicked && spec.rollback;
ui.add_space(8.0);
}
fn publish(
spec: &FormViewSpec<'_>,
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
key: &str,
rect: egui::Rect,
) {
if let Some(map) = hits.as_deref_mut() {
map.insert(format!("{}{key}", spec.hits_prefix), rect);
}
}
fn section(
ui: &mut egui::Ui,
spec: &FormViewSpec<'_>,
name: &str,
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
body: impl FnOnce(&mut egui::Ui, &mut Option<&mut HashMap<String, egui::Rect>>),
) {
ui.add_space(6.0);
let open = !COLLAPSED_BY_DEFAULT.contains(&name);
let response = egui::CollapsingHeader::new(egui::RichText::new(name).strong())
.id_salt(("form-view-section", spec.hits_prefix, spec.title, name))
.default_open(open)
.show(ui, |ui| body(ui, hits));
publish(
spec,
hits,
&format!("form:section:{name}"),
response.header_response.rect,
);
}
fn draw_field(
ui: &mut egui::Ui,
spec: &FormViewSpec<'_>,
field: &FormField,
params: &mut Value,
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
out: &mut FormViewOut,
) {
let mut probe: HashMap<String, egui::Rect> = HashMap::new();
let mut rect = egui::Rect::NOTHING;
let mut clicked: Option<String> = None;
ui.add_space(4.0);
if !matches!(field.kind, FieldKind::Button { .. }) {
ui.label(&field.label);
}
ui.push_id((spec.title, field.key()), |ui| {
let (changed, r) = form::field_input(ui, field, params, Some(&mut probe), &mut clicked);
out.changed |= changed;
rect = r;
});
if let Some(map) = hits.as_deref_mut() {
let prefix = spec.hits_prefix;
map.insert(format!("{prefix}field:{}", field.path.join(".")), rect);
for (key, r) in probe {
map.insert(format!("{prefix}field:{key}"), r);
}
}
if clicked.is_some() {
match &field.kind {
FieldKind::Reference { filter, multiple } => {
out.ref_activate = Some(RefActivate {
path: field.path.clone(),
label: field.label.clone(),
filter: filter.clone(),
multiple: *multiple,
seed: form::reference_names(form::value_at(params, &field.path)),
});
}
_ => out.button_clicked = clicked,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brep_render::style::FieldKind;
use serde_json::json;
fn field(path: &str, label: &str, group: &str, kind: FieldKind) -> FormField {
FormField {
path: path.split('.').map(str::to_string).collect(),
label: label.into(),
group: group.into(),
kind,
}
}
fn fields() -> Vec<FormField> {
vec![
field("id", "Id", "Parameters", FieldKind::Text { read_only: true }),
field("distance", "Distance", "Parameters", FieldKind::Scalar { step: 0.5 }),
field(
"transform.position",
"Position",
"Transform",
FieldKind::Vec3 { step: 0.1 },
),
field(
"profile",
"Profile",
"References",
FieldKind::Reference { filter: vec!["sketch".into()], multiple: false },
),
field(
"boolean.operation",
"Operation",
"Boolean",
FieldKind::Enum { variants: vec!["NONE".into(), "UNION".into()] },
),
field("editSketch", "Edit Sketch", "Parameters", FieldKind::Button {
label: "Edit Sketch".into(),
}),
]
}
fn run(
ctx: &egui::Context,
params: &mut Value,
events: Vec<egui::Event>,
) -> (FormViewOut, HashMap<String, egui::Rect>) {
run_with(ctx, params, events, true)
}
fn run_with(
ctx: &egui::Context,
params: &mut Value,
events: Vec<egui::Event>,
rollback: bool,
) -> (FormViewOut, HashMap<String, egui::Rect>) {
let fields = fields();
let outputs = vec!["Extrude1_solid".to_string()];
let trailing = [("Outputs", outputs)];
let spec = FormViewSpec {
title: "E3 Extrude",
subtitle: None,
fields: &fields,
banner: None,
trailing: Some(&trailing),
exit_label: "Return to tree",
rollback,
hits_prefix: "",
};
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(320.0, 700.0),
)),
events,
..Default::default()
};
let mut hits = HashMap::new();
let mut out = FormViewOut::default();
let _ = ctx.run_ui(raw, |ui| {
out = form_view(ui, &spec, params, Some(&mut hits));
});
(out, hits)
}
fn click_at(pos: egui::Pos2) -> Vec<egui::Event> {
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
},
]
}
#[test]
fn transform_collapses_by_default_and_boolean_does_not() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3", "distance": 25.0 });
let (_, hits) = run(&ctx, &mut params, vec![]);
for key in ["form:section:Transform", "form:section:Boolean", "form:section:Outputs"] {
assert!(hits.contains_key(key), "{key} header: {:?}", hits.keys());
}
assert!(
!hits.contains_key("field:transform.position"),
"Transform's fields stay behind its collapsed accordion"
);
assert!(
hits.contains_key("field:boolean.operation"),
"Boolean's operation is visible without a click: {:?}",
hits.keys()
);
assert!(hits.contains_key("field:distance"), "plain params are un-sectioned");
assert!(!hits.contains_key("field:id"), "the read-only id is the title, not a field");
}
#[test]
fn the_form_publishes_its_chrome() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3" });
let (_, hits) = run(&ctx, &mut params, vec![]);
for key in ["form:feature", "form:return", "field:profile#activate"] {
assert!(hits.contains_key(key), "{key}: {:?}", hits.keys());
}
}
#[test]
fn pressing_select_returns_a_ref_activate_intent() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3", "profile": "Sketch1" });
let (_, hits) = run(&ctx, &mut params, vec![]);
let at = hits["field:profile#activate"].center();
let (out, _) = run(&ctx, &mut params, click_at(at));
let activate = out.ref_activate.expect("Select surfaced an activation");
assert_eq!(activate.path, vec!["profile".to_string()]);
assert_eq!(activate.label, "Profile");
assert_eq!(activate.filter, vec!["sketch".to_string()]);
assert!(!activate.multiple);
assert_eq!(activate.seed, vec!["Sketch1".to_string()], "the picker seeds from the value");
assert_eq!(out.button_clicked, None, "a Select is not a schema button");
assert!(!out.changed, "activating the picker edits nothing");
}
#[test]
fn pressing_a_schema_button_returns_its_key() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3" });
let (_, hits) = run(&ctx, &mut params, vec![]);
let at = hits["field:editSketch"].center();
let (out, _) = run(&ctx, &mut params, click_at(at));
assert_eq!(out.button_clicked.as_deref(), Some("editSketch"));
assert_eq!(out.ref_activate, None);
}
#[test]
fn the_exit_button_reports_itself() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3" });
let (_, hits) = run(&ctx, &mut params, vec![]);
let at = hits["form:return"].center();
let (out, _) = run(&ctx, &mut params, click_at(at));
assert!(out.exit_clicked);
assert!(!out.changed);
assert_eq!(out.button_clicked, None);
}
#[test]
fn rollback_gates_the_roll_to_tip_intent() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3" });
let (_, hits) = run_with(&ctx, &mut params, vec![], false);
let at = hits["form:return"].center();
let (rolling, _) = run_with(&ctx, &mut params, click_at(at), true);
assert!(rolling.exit_clicked && rolling.roll_to_tip, "a rollback consumer rolls on exit");
let (flat, _) = run_with(&ctx, &mut params, click_at(at), false);
assert!(flat.exit_clicked, "the exit itself is unconditional");
assert!(!flat.roll_to_tip, "a consumer with no rollback never carries the roll");
}
#[test]
fn no_exit_means_no_roll() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3" });
let (out, _) = run_with(&ctx, &mut params, vec![], true);
assert!(!out.exit_clicked && !out.roll_to_tip);
}
#[test]
fn inputs_fill_the_form_width() {
let ctx = egui::Context::default();
let mut params = json!({ "id": "E3", "distance": 25.0 });
let (_, hits) = run(&ctx, &mut params, vec![]);
let distance = hits["field:distance"];
assert!(
distance.width() > 200.0,
"a 320 pt panel gives a full-width field, not a 72 pt one: {}",
distance.width()
);
}
}