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 pick_done: bool,
pub pick_remove: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ActivePick<'a> {
pub path: &'a [String],
pub names: &'a [String],
}
pub fn field_input(
ui: &mut egui::Ui,
field: &FormField,
current: &mut Value,
probe: Option<&mut HashMap<String, egui::Rect>>,
actions: &mut FieldActions,
) -> (bool, egui::Rect) {
field_input_with(ui, field, current, probe, actions, None)
}
pub use brep_ecad_egui::widgets::enum_dropdown;
pub fn text_input(ui: &mut egui::Ui, text: &mut String, width: f32) -> egui::Response {
ui.add(
egui::TextEdit::singleline(text)
.margin(crate::viewport::touch::text_edit_margin(ui))
.desired_width(width)
.min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)),
)
}
pub fn text_input_width(ui: &egui::Ui) -> f32 {
input_width(ui, ui.spacing().text_edit_width)
}
pub fn field_input_with(
ui: &mut egui::Ui,
field: &FormField,
current: &mut Value,
mut probe: Option<&mut HashMap<String, egui::Rect>>,
actions: &mut FieldActions,
picking: Option<ActivePick<'_>>,
) -> (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 choices: Vec<(String, &str, &str)> = variants.iter().map(|v| (v.to_string(), v.as_str(), v.as_str())).collect();
let width = (!ui.layout().prefer_right_to_left()).then(|| ui.available_width());
let (changed, response) = enum_dropdown(ui, &path.join("."), &choices, &mut sel, width, probe.as_deref_mut());
if changed {
set_at(current, path, Value::String(sel));
}
(changed, 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 } | FieldKind::BoundedScalar { 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(mut value) = committed {
if let (FieldKind::BoundedScalar { min, max, .. }, Some(number)) = (&field.kind, value.as_f64()) {
let bounded = number.clamp(*min, *max);
if bounded != number {
value = serde_json::json!(bounded);
}
}
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).margin(crate::viewport::touch::text_edit_margin(ui)).desired_width(width)
.min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)),
);
(false, r.rect)
} else {
let r = text_input(ui, &mut s, 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 focused = picking.filter(|pick| pick.path == path.as_slice());
let names = match focused {
Some(pick) => pick.names.to_vec(),
None => 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 = if focused.is_some() {
"▣ Done".to_string()
} else {
format!(
"▣ Select {}{}",
filter.join("/"),
if *multiple { " …" } else { "" }
)
};
let select = crate::icon_text::icon_button(ui, &hint).selected(focused.is_some());
let button = ui.add_sized(
[width, ui.spacing().interact_size.y],
select,
);
activate_rect = button.rect;
if button.clicked() {
if focused.is_some() {
actions.pick_done = true;
} else {
actions.clicked = Some(field.key().to_string());
}
}
if let Some(map) = probe.as_deref_mut() {
map.insert(format!("{pkey}#activate"), button.rect);
}
if focused.is_some() {
let accent = ui.visuals().selection.stroke.color;
ui.painter().rect_stroke(
button.rect.expand(2.0),
3.0,
egui::Stroke::new(1.5, accent),
egui::StrokeKind::Outside,
);
let what = if *multiple { "Click in the view to add; Escape restores" } else { "Click in the view to pick; Escape restores" };
ui.label(egui::RichText::new(what).weak().small());
}
if names.is_empty() {
ui.label(egui::RichText::new(if focused.is_some() { "(nothing picked yet)" } else { "(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 {
if focused.is_some() {
actions.pick_remove = Some(i);
return (false, activate_rect);
}
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).margin(crate::viewport::touch::text_edit_margin(ui))
.id(buf_id)
.desired_width(width)
.min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
.vertical_align(if crate::viewport::touch::enabled(ui.ctx()) { egui::Align::Center } else { egui::Align::TOP }),
);
let mut committed = None;
if r.gained_focus() {
buf = stored_text.clone();
if crate::viewport::touch::enabled(ui.ctx()) {
if let Some(mut edit) = egui::TextEdit::load_state(ui.ctx(), r.id) {
edit.cursor.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count()),
)));
edit.store(ui.ctx(), r.id);
}
}
}
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,
]
}