pub(crate) use brep_render::brep_kernel::reference_names;
use crate::color::rgb_to_hex;
use brep_render::style::{parse_css_hex, FieldKind, FormField};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
const REMOVE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
fn input_width(ui: &egui::Ui, compact: f32) -> f32 {
if ui.layout().prefer_right_to_left() {
compact
} else {
ui.available_width()
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FieldActions {
pub clicked: Option<String>,
pub hovered_entity: Option<String>,
}
pub fn field_input(
ui: &mut egui::Ui,
field: &FormField,
current: &mut Value,
mut probe: Option<&mut HashMap<String, egui::Rect>>,
actions: &mut FieldActions,
) -> (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 mut combo = egui::ComboBox::from_id_salt(("form-enum", field.key()));
if !ui.layout().prefer_right_to_left() {
combo = combo.width(ui.available_width());
}
let combo = combo
.selected_text(&sel)
.show_ui(ui, |ui| {
for v in variants {
let item = ui.selectable_value(&mut sel, v.to_string(), v.as_str());
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 (committed, rect) = scalar_widget(
ui,
("scalar-edit", path.join(".")),
value_at(current, path),
*step,
input_width(ui, 72.0),
);
let changed = committed.is_some();
if let Some(value) = committed {
set_at(current, path, value);
}
(changed, rect)
}
FieldKind::Text { read_only } => {
let mut s = value_at(current, path)
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let width = input_width(ui, ui.spacing().text_edit_width);
if *read_only {
let r = ui.add_enabled(
false,
egui::TextEdit::singleline(&mut s).desired_width(width),
);
(false, r.rect)
} else {
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
if r.changed() {
set_at(current, path, Value::String(s));
}
(r.changed(), r.rect)
}
}
FieldKind::Vec3 { step } => {
let stored = value_at(current, path).cloned();
let mut edited: Option<(usize, Value)> = None;
let mut rect = egui::Rect::NOTHING;
let mut component = |ui: &mut egui::Ui, index: usize, width: f32| {
let (committed, r) = scalar_widget(
ui,
("vec3-edit", path.join("."), index),
vec3_slot(stored.as_ref(), index),
*step,
width,
);
if let Some(value) = committed {
edited = Some((index, value));
}
rect = rect.union(r);
};
if ui.layout().prefer_right_to_left() {
for index in [2usize, 1, 0] {
component(ui, index, 56.0);
}
} else {
let gap = ui.spacing().item_spacing.x;
let each = ((ui.available_width() - 2.0 * gap) / 3.0).max(24.0);
ui.horizontal(|ui| {
for index in 0..3 {
component(ui, index, each);
}
});
}
match edited {
Some((index, value)) => {
set_at(current, path, vec3_with_slot(stored.as_ref(), index, value));
(true, rect)
}
None => (false, rect),
}
}
FieldKind::Button { label } => {
let r = ui.add_sized(
[ui.available_width(), ui.spacing().interact_size.y],
egui::Button::new(label.as_str()),
);
if r.clicked() {
actions.clicked = Some(field.key().to_string());
}
(false, r.rect)
}
FieldKind::Reference { filter, multiple } => {
let names = reference_names(value_at(current, path));
let pkey = path.join(".");
let mut removed: Option<usize> = None;
let mut activate_rect = egui::Rect::NOTHING;
ui.vertical(|ui| {
let width = ui.available_width();
let hint = format!(
"▣ Select {}{}",
filter.join("/"),
if *multiple { " …" } else { "" }
);
let select = crate::icon_text::icon_button(ui, &hint);
let button = ui.add_sized(
[width, ui.spacing().interact_size.y],
select,
);
activate_rect = button.rect;
if button.clicked() {
actions.clicked = Some(field.key().to_string());
}
if let Some(map) = probe.as_deref_mut() {
map.insert(format!("{pkey}#activate"), button.rect);
}
if names.is_empty() {
ui.label(egui::RichText::new("(none)").weak());
return;
}
for (i, name) in names.iter().enumerate() {
ui.horizontal(|ui| {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let remove = crate::icon_text::icon_button_colored(
ui,
"✕",
Some(REMOVE_RED),
)
.stroke(egui::Stroke::new(1.0, REMOVE_RED))
.small();
let x = ui.add(remove);
if let Some(map) = probe.as_deref_mut() {
map.insert(format!("{pkey}#x{i}"), x.rect);
}
if x.clicked() {
removed = Some(i);
}
ui.with_layout(
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let line = ui.add(
egui::Label::new(format!("• {name}"))
.truncate(),
);
if line.hovered() {
actions.hovered_entity = Some(name.clone());
}
if let Some(map) = probe.as_deref_mut() {
map.insert(
format!("{pkey}#line{i}"),
line.rect,
);
}
},
);
},
);
});
}
});
if let Some(i) = removed {
let mut kept = names;
kept.remove(i);
let value = if *multiple {
Value::Array(kept.into_iter().map(Value::String).collect())
} else {
Value::String(kept.first().cloned().unwrap_or_default())
};
set_at(current, path, value);
return (true, activate_rect);
}
(false, activate_rect)
}
}
}
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_widget(
ui: &mut egui::Ui,
id_source: impl std::hash::Hash + std::fmt::Debug,
stored: Option<&Value>,
step: f64,
width: f32,
) -> (Option<Value>, egui::Rect) {
let buf_id = ui.make_persistent_id(id_source);
let stored_text = scalar_display(stored);
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(width),
);
let mut committed = None;
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;
committed = Some(scalar_store(&buf));
}
}
}
if r.lost_focus() {
let trimmed = buf.trim();
if !trimmed.is_empty() && trimmed != stored_text {
committed = Some(scalar_store(&buf));
}
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));
}
(committed, r.rect)
}
fn vec3_slot(stored: Option<&Value>, index: usize) -> Option<&Value> {
stored?.as_array()?.get(index).filter(|v| !v.is_null())
}
fn vec3_with_slot(stored: Option<&Value>, index: usize, value: Value) -> Value {
let mut out: Vec<Value> = (0..3)
.map(|i| vec3_slot(stored, i).cloned().unwrap_or(Value::from(0.0)))
.collect();
out[index] = value;
Value::Array(out)
}
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 {
let raw: f32 = ui.input(|i| {
i.events
.iter()
.filter_map(|event| match event {
egui::Event::MouseWheel { unit, delta, .. } => Some(wheel_delta_to_notches(
*unit,
delta.y,
cfg!(target_arch = "wasm32"),
)),
_ => None,
})
.sum()
});
(raw as f64).round()
}
fn wheel_delta_to_notches(unit: egui::MouseWheelUnit, delta_y: f32, web: bool) -> f32 {
let (lines_per_notch, points_per_notch) = if web { (3.0, 100.0) } else { (1.0, 40.0) };
match unit {
egui::MouseWheelUnit::Line => delta_y / lines_per_notch,
egui::MouseWheelUnit::Point => delta_y / points_per_notch,
egui::MouseWheelUnit::Page => delta_y * 20.0,
}
}
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,
]
}