use brep_render::style::{parse_css_hex, FieldKind, FormField};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
pub trait ReferenceRenderer {
fn render_reference(
&mut self,
ui: &mut egui::Ui,
field: &FormField,
filter: &[String],
multiple: bool,
current: &mut Value,
probe: Option<&mut HashMap<String, egui::Rect>>,
) -> (bool, egui::Rect);
}
pub fn field_input(
ui: &mut egui::Ui,
field: &FormField,
current: &mut Value,
mut probe: Option<&mut HashMap<String, egui::Rect>>,
ref_renderer: Option<&mut dyn ReferenceRenderer>,
clicked: &mut Option<String>,
) -> (bool, egui::Rect) {
let path = &field.path;
match &field.kind {
FieldKind::Color => {
let mut rgb = read_rgb(value_at(current, path));
let r = ui.color_edit_button_srgb(&mut rgb);
if r.changed() {
set_at(current, path, Value::String(rgb_to_hex(rgb)));
}
(r.changed(), r.rect)
}
FieldKind::Bool => {
let mut b = value_at(current, path).and_then(Value::as_bool).unwrap_or(false);
let r = ui.checkbox(&mut b, "");
if r.changed() {
set_at(current, path, Value::Bool(b));
}
(r.changed(), r.rect)
}
FieldKind::Enum { variants } => {
let orig = value_at(current, path)
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let mut sel = orig.clone();
let combo = egui::ComboBox::from_id_salt(("form-enum", field.key()))
.selected_text(&sel)
.show_ui(ui, |ui| {
for v in variants {
let item = ui.selectable_value(&mut sel, (*v).to_string(), *v);
if let Some(map) = probe.as_deref_mut() {
map.insert(format!("{}#{}", path.join("."), v), item.rect);
}
}
});
let changed = sel != orig;
if changed {
set_at(current, path, Value::String(sel));
}
(changed, combo.response.rect)
}
FieldKind::Number { min, max, step } | FieldKind::Range { min, max, step } => {
let mut v = value_at(current, path).and_then(Value::as_f64).unwrap_or(*min);
let r = ui.add(egui::Slider::new(&mut v, *min..=*max).step_by(*step));
if r.changed() {
set_at(current, path, serde_json::json!(v));
}
(r.changed(), r.rect)
}
FieldKind::Scalar { step } => {
let buf_id = ui.make_persistent_id(("scalar-edit", path.join(".")));
let stored_text = scalar_display(value_at(current, path));
let mut buf = ui
.data_mut(|d| d.get_temp::<String>(buf_id))
.unwrap_or_else(|| stored_text.clone());
let r = ui.add(
egui::TextEdit::singleline(&mut buf)
.id(buf_id)
.desired_width(72.0),
);
let mut changed = false;
if r.gained_focus() {
buf = stored_text.clone();
}
let pointer_over_field = r.hovered()
|| ui
.input(|i| i.pointer.latest_pos())
.is_some_and(|pos| r.rect.contains(pos));
if r.has_focus() && pointer_over_field {
let notches = wheel_notches(ui);
ui.input_mut(|i| {
i.smooth_scroll_delta = egui::Vec2::ZERO;
i.events
.retain(|e| !matches!(e, egui::Event::MouseWheel { .. }));
});
if notches != 0.0 {
if let Some(stepped) = scroll_step_scalar(&buf, notches, *step) {
buf = stepped;
set_at(current, path, scalar_store(&buf));
changed = true;
}
}
}
if r.lost_focus() {
let trimmed = buf.trim();
if !trimmed.is_empty() && trimmed != stored_text {
set_at(current, path, scalar_store(&buf));
changed = true;
}
ui.data_mut(|d| d.remove::<String>(buf_id));
} else if r.has_focus() {
ui.data_mut(|d| d.insert_temp(buf_id, buf.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(buf_id));
}
(changed, r.rect)
}
FieldKind::Text { read_only } => {
let mut s = value_at(current, path)
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if *read_only {
let r = ui.add_enabled(false, egui::TextEdit::singleline(&mut s));
(false, r.rect)
} else {
let r = ui.add(egui::TextEdit::singleline(&mut s));
if r.changed() {
set_at(current, path, Value::String(s));
}
(r.changed(), r.rect)
}
}
FieldKind::Vec3 { step } => {
let mut v = read_vec3(value_at(current, path));
let mut edited = false;
let mut rect = egui::Rect::NOTHING;
let order: [usize; 3] = if ui.layout().prefer_right_to_left() {
[2, 1, 0]
} else {
[0, 1, 2]
};
for &i in &order {
let r = ui.add(egui::DragValue::new(&mut v[i]).speed(*step));
edited |= r.changed();
rect = rect.union(r.rect);
}
if edited {
set_at(current, path, serde_json::json!([v[0], v[1], v[2]]));
}
(edited, rect)
}
FieldKind::Button { label } => {
let r = ui.add(egui::Button::new(label.as_str()));
if r.clicked() {
*clicked = Some(field.key().to_string());
}
(false, r.rect)
}
FieldKind::Reference { filter, multiple } => {
if let Some(renderer) = ref_renderer {
renderer.render_reference(ui, field, filter, *multiple, current, probe)
} else {
ui.vertical(|ui| {
ui.label(&field.label);
let hint = format!(
"▣ selection ({}{})",
filter.join("/"),
if *multiple { ", multiple" } else { "" }
);
let r = ui.add_enabled(false, egui::Button::new(hint));
for name in reference_names(value_at(current, path)) {
ui.add_enabled(false, egui::Label::new(format!(" • {name}")));
}
(false, r.rect)
})
.inner
}
}
}
}
pub(crate) fn value_at<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
let mut cur = root;
for seg in path {
cur = cur.get(seg.as_str())?;
}
Some(cur)
}
pub(crate) fn set_at(root: &mut Value, path: &[String], new_val: Value) {
if path.is_empty() {
*root = new_val;
return;
}
if !root.is_object() {
*root = Value::Object(serde_json::Map::new());
}
let mut cur = root;
for seg in &path[..path.len() - 1] {
let obj = cur.as_object_mut().expect("object by construction");
cur = obj
.entry(seg.clone())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !cur.is_object() {
*cur = Value::Object(serde_json::Map::new());
}
}
cur.as_object_mut()
.expect("object by construction")
.insert(path[path.len() - 1].clone(), new_val);
}
fn scalar_display(value: Option<&Value>) -> String {
match value {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.as_f64().map(|f| f.to_string()).unwrap_or_default(),
_ => String::new(),
}
}
fn scalar_store(text: &str) -> Value {
let trimmed = text.trim();
match serde_json::from_str::<Value>(trimmed) {
Ok(v @ Value::Number(_)) => v,
_ => Value::String(trimmed.to_string()),
}
}
fn scroll_step_scalar(text: &str, notches: f64, step: f64) -> Option<String> {
let base: f64 = text.trim().parse().ok()?;
Some(format_scalar_number(base + notches * step, step))
}
fn format_scalar_number(v: f64, step: f64) -> String {
let decimals = step_decimals(step);
let mut s = format!("{:.*}", decimals, v);
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
s
}
fn step_decimals(step: f64) -> usize {
let step = step.abs();
if step == 0.0 || !step.is_finite() {
return 3;
}
let mut d = 0usize;
let mut s = step;
while (s - s.round()).abs() > 1e-9 && d < 6 {
s *= 10.0;
d += 1;
}
d
}
fn wheel_notches(ui: &egui::Ui) -> f64 {
const LINE_POINTS: f32 = 40.0;
let raw: f32 = ui.input(|i| {
i.events
.iter()
.filter_map(|event| match event {
egui::Event::MouseWheel { unit, delta, .. } => Some(match unit {
egui::MouseWheelUnit::Line => delta.y,
egui::MouseWheelUnit::Point => delta.y / LINE_POINTS,
egui::MouseWheelUnit::Page => delta.y * 20.0,
}),
_ => None,
})
.sum()
});
(raw as f64).round()
}
fn read_vec3(value: Option<&Value>) -> [f64; 3] {
let mut out = [0.0; 3];
if let Some(Value::Array(items)) = value {
for (i, slot) in out.iter_mut().enumerate() {
if let Some(n) = items.get(i).and_then(Value::as_f64) {
*slot = n;
}
}
}
out
}
pub(crate) fn reference_names(value: Option<&Value>) -> Vec<String> {
match value {
Some(Value::Array(items)) => items.iter().filter_map(name_of).collect(),
Some(other) => name_of(other).into_iter().collect(),
None => Vec::new(),
}
}
fn name_of(value: &Value) -> Option<String> {
let raw = match value {
Value::String(s) => Some(s.as_str()),
Value::Object(map) => map.get("name").and_then(Value::as_str),
_ => None,
}?;
let t = raw.trim();
(!t.is_empty()).then(|| t.to_string())
}
fn read_rgb(value: Option<&Value>) -> [u8; 3] {
let hex = value.and_then(Value::as_str).unwrap_or("#000000");
let rgb = parse_css_hex(hex).unwrap_or([0.0, 0.0, 0.0]);
[
(rgb[0] * 255.0).round() as u8,
(rgb[1] * 255.0).round() as u8,
(rgb[2] * 255.0).round() as u8,
]
}
fn rgb_to_hex(rgb: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scalar_display_shows_expression_verbatim_not_zero() {
assert_eq!(scalar_display(Some(&json!("width * 2"))), "width * 2");
assert_eq!(scalar_display(Some(&json!("boxW"))), "boxW");
assert_eq!(scalar_display(Some(&json!("10 + 5"))), "10 + 5");
}
#[test]
fn scalar_display_shows_number_shortest() {
assert_eq!(scalar_display(Some(&json!(20))), "20");
assert_eq!(scalar_display(Some(&json!(20.0))), "20");
assert_eq!(scalar_display(Some(&json!(20.5))), "20.5");
assert_eq!(scalar_display(Some(&json!(0.1))), "0.1");
assert_eq!(scalar_display(None), "");
assert_eq!(scalar_display(Some(&json!(true))), "");
}
#[test]
fn scalar_store_number_vs_string_contract() {
assert_eq!(scalar_store("10"), json!(10));
assert_eq!(scalar_store(" 20 "), json!(20));
assert_eq!(scalar_store("10.5"), json!(10.5));
assert!(scalar_store("10").is_number());
assert_eq!(scalar_store("width * 2"), json!("width * 2"));
assert_eq!(scalar_store("10 + 5"), json!("10 + 5"));
assert_eq!(scalar_store("boxW"), json!("boxW"));
assert_eq!(scalar_store(" width * 2 "), json!("width * 2"));
}
#[test]
fn scalar_store_display_round_trip() {
assert_eq!(scalar_display(Some(&scalar_store("width * 2"))), "width * 2");
assert_eq!(scalar_display(Some(&scalar_store("10"))), "10");
assert_eq!(scalar_display(Some(&scalar_store("10.5"))), "10.5");
}
#[test]
fn scroll_step_numeric_and_expression() {
assert_eq!(scroll_step_scalar("10", 1.0, 0.5).as_deref(), Some("10.5"));
assert_eq!(scroll_step_scalar("10", -2.0, 0.5).as_deref(), Some("9"));
assert_eq!(scroll_step_scalar("10.5", 1.0, 0.5).as_deref(), Some("11"));
assert_eq!(scroll_step_scalar("width * 2", 1.0, 0.5), None);
assert_eq!(scroll_step_scalar("10 + 5", 3.0, 0.5), None);
assert_eq!(scroll_step_scalar("", 1.0, 0.5), None);
}
#[test]
fn scroll_step_kills_float_noise() {
assert_eq!(scroll_step_scalar("10", 3.0, 0.1).as_deref(), Some("10.3"));
assert_eq!(scroll_step_scalar("0", 7.0, 0.1).as_deref(), Some("0.7"));
assert_eq!(scroll_step_scalar("5", 2.0, 1.0).as_deref(), Some("7"));
}
#[test]
fn step_decimals_matches_step_precision() {
assert_eq!(step_decimals(1.0), 0);
assert_eq!(step_decimals(0.5), 1);
assert_eq!(step_decimals(0.1), 1);
assert_eq!(step_decimals(0.01), 2);
}
use eframe::egui;
fn scalar_field() -> FormField {
FormField {
path: vec!["distance".to_string()],
label: "Distance".to_string(),
group: "Parameters".to_string(),
kind: FieldKind::Scalar { step: 0.5 },
}
}
fn run_scalar_frame(
ctx: &egui::Context,
field: &FormField,
current: &mut Value,
events: Vec<egui::Event>,
) -> (bool, egui::Rect) {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 300.0),
)),
events,
..Default::default()
};
let mut out = (false, egui::Rect::NOTHING);
let _ = ctx.run_ui(raw, |ui| {
let (ch, r) = field_input(ui, field, current, None, None, &mut None);
out = (ch, r);
});
out
}
#[test]
fn scalar_field_render_does_not_clobber_string_param() {
let ctx = egui::Context::default();
let field = scalar_field();
let mut current = serde_json::json!({ "distance": "width * 2" });
let (changed, _) = run_scalar_frame(&ctx, &field, &mut current, vec![]);
assert!(!changed, "passive render must not report a change");
assert_eq!(
current["distance"],
serde_json::json!("width * 2"),
"the expression must survive a render untouched (not clobbered to 0)"
);
let mut num = serde_json::json!({ "distance": 20.0 });
let (changed, _) = run_scalar_frame(&ctx, &field, &mut num, vec![]);
assert!(!changed);
assert_eq!(num["distance"], serde_json::json!(20.0));
}
#[test]
fn scalar_field_typed_number_commits_on_enter() {
let ctx = egui::Context::default();
let field = scalar_field();
let mut current = serde_json::json!({});
let (_, rect) = run_scalar_frame(&ctx, &field, &mut current, vec![]);
let pos = rect.center();
run_scalar_frame(
&ctx,
&field,
&mut current,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
run_scalar_frame(
&ctx,
&field,
&mut current,
vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
);
run_scalar_frame(
&ctx,
&field,
&mut current,
vec![egui::Event::Text("50".to_string())],
);
let (changed, _) = run_scalar_frame(
&ctx,
&field,
&mut current,
vec![egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
}],
);
assert!(changed, "committing a typed number must report a change");
assert_eq!(
current["distance"],
serde_json::json!(50),
"a typed plain number must be stored as a usable JSON number"
);
assert!(
current["distance"].is_number(),
"a pure numeric literal is stored as a Number, not a String"
);
}
}