ui: typed form fields for label lists, point lists, multi-choices and number-or-auto (#169)

Four field kinds in the schema, each checked and described in JSON Schema: labels (nodes or
elements, typed as 1-5, 8 with Use selection taking the viewport's picks), points (rows of
x, y, z, added and removed), multi (checkboxes: coupling DOFs, step output) and number or
auto (the deformed scale). Node and element lists, probe and Delaunay points and block
corners use them; the remaining JSON fields hold geometry-specific shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 11:07:46 -04:00
1 parent c0cbcadf3d
commit 1f85a8caa3
6 files changed
+420 -21

No files matched your search

+10
View File
@@ -171,6 +171,15 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
};
});
}
// The nodes and elements picked in the viewport, for the label lists' Use selection.
let mut picked = crate::form::Picked::default();
for item in app.pick.selection.items() {
match item {
selection::Item::Node(n) => picked.nodes.push(u64::from(*n)),
selection::Item::Element(e) => picked.elements.push(u64::from(*e)),
_ => {}
}
}
let mut advanced = d.advanced;
heading(ui, t, Level::Section, |ui| {
ui.label(RichText::new("Parameters").size(12.0));
@@ -221,6 +230,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
attempted: d.attempted,
refused,
problems: &problems,
picked: picked.clone(),
};
let out = crate::form::show(ui, t, &cx, fields, &mut obj);
invalid = out.invalid;
+12
View File
@@ -179,6 +179,18 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String {
_ => crate::context::value_text(v),
},
Kind::Rows { .. } => v.as_array().map_or_else(|| crate::context::value_text(v), |a| plural(a.len(), "row")),
Kind::Labels(_) => match v.as_array().and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>()) {
Some(l) => crate::fields::format_labels(&l),
None => crate::context::value_text(v),
},
Kind::Points { .. } => v.as_array().map_or_else(|| crate::context::value_text(v), |a| plural(a.len(), "point")),
Kind::Multi(options) => {
list(v, |x| options.iter().find(|(o, _)| fieldcraft_engine::schema::multi_value(o) == *x).map(|(_, l)| (*l).to_string()))
}
Kind::NumberOrAuto { .. } => match v {
Value::String(s) => s.clone(),
_ => v.as_f64().map_or_else(|| crate::context::value_text(v), |x| with_unit(x, unit.as_deref())),
},
Kind::Json | Kind::Unit | Kind::Group(_) | Kind::Variants { .. } => crate::context::value_text(v),
}
}
+53
View File
@@ -188,6 +188,46 @@ pub fn text(ui: &mut Ui, s: &Style, id: Id, value: &mut String, hint: &str, widt
ui.add(egui::TextEdit::singleline(value).id(id).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim))).changed()
}
/// Labels as typed: runs of consecutive labels as `a-b`, the rest by commas ("1-5, 8, 10-12").
pub fn format_labels(labels: &[u64]) -> String {
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while let Some(&a) = labels.get(i) {
let mut j = i;
while labels.get(j + 1).is_some_and(|&n| Some(n) == labels.get(j).and_then(|m| m.checked_add(1))) {
j += 1;
}
let b = labels.get(j).copied().unwrap_or(a);
out.push(match j - i {
0 => a.to_string(),
1 => format!("{a}, {b}"),
_ => format!("{a}-{b}"),
});
i = j + 1;
}
out.join(", ")
}
/// Labels typed as numbers and `a-b` ranges, separated by commas or spaces, in the order typed;
/// `None` when something is not a label from 1, or when there would be more than `max`.
pub fn parse_labels(text: &str, max: usize) -> Option<Vec<u64>> {
let mut out = Vec::new();
for part in text.split(|c: char| c == ',' || c.is_whitespace()).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.parse::<u64>().ok()?, b.parse::<u64>().ok()?),
None => {
let n = part.parse::<u64>().ok()?;
(n, n)
}
};
if a == 0 || b < a || out.len() as u64 + (b - a) >= max as u64 {
return None;
}
out.extend(a..=b);
}
Some(out)
}
/// A short label before a field ("X"), dim.
pub fn caption(ui: &mut Ui, s: &Style, text: &str) {
ui.label(RichText::new(text).small().color(s.dim));
@@ -204,6 +244,19 @@ pub fn unit(ui: &mut Ui, s: &Style, unit: Option<&str>) {
mod tests {
use super::*;
#[test]
fn labels_print_and_parse_as_ranges() {
assert_eq!(format_labels(&[1, 2, 3, 4, 5, 8, 10, 11]), "1-5, 8, 10, 11");
assert_eq!(format_labels(&[]), "");
assert_eq!(format_labels(&[7, 3, 4, 5]), "7, 3-5", "the order is kept");
assert_eq!(parse_labels("1-5, 8 10,11", 100), Some(vec![1, 2, 3, 4, 5, 8, 10, 11]));
assert_eq!(parse_labels("", 100), Some(vec![]));
for bad in ["0", "x", "5-3", "1-", "-4", "1-1000"] {
assert_eq!(parse_labels(bad, 100), None, "{bad}");
}
assert_eq!(parse_labels(&format_labels(&[3, 4, 5, 9]), 100), Some(vec![3, 4, 5, 9]));
}
#[test]
fn numbers_print_as_typed() {
assert_eq!(format_number(210000.0), "210000");
+179 -1
View File
@@ -38,6 +38,15 @@ pub struct Context<'a> {
pub refused: Option<(&'a str, &'a str)>,
/// What is missing or wrong in the parameters ([`messages`]).
pub problems: &'a [(String, String)],
/// The nodes and the elements picked in the viewport, for a label list's Use selection.
pub picked: Picked,
}
/// The mesh labels picked in the viewport.
#[derive(Clone, Debug, Default)]
pub struct Picked {
pub nodes: Vec<u64>,
pub elements: Vec<u64>,
}
/// What a frame of the form did.
@@ -320,6 +329,10 @@ impl Form<'_> {
})
}
Kind::Json => self.json(ui, id, value, f.hint),
Kind::Labels(of) => self.labels(ui, id, value, of, f.hint),
Kind::Points { max, .. } => self.points(ui, id, value, f, max),
Kind::Multi(options) => self.multi(ui, value, options),
Kind::NumberOrAuto { .. } => self.number_or_auto(ui, id, value, f),
Kind::Unit | Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } => None,
}
}
@@ -520,6 +533,142 @@ impl Form<'_> {
}
}
/// A list of node or element labels typed as numbers and ranges (`1-5, 8`), kept while it
/// does not parse; Use selection takes the labels picked in the viewport.
fn labels(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, of: schema::LabelOf, hint: &str) -> Option<Value> {
let current: Option<Vec<u64>> = match value {
None | Some(Value::Null) => Some(Vec::new()),
Some(v) => v.as_array().and_then(|a| a.iter().map(Value::as_u64).collect()),
};
let Some(current) = current else {
self.json_only(ui);
return None;
};
let shown = fields::format_labels(&current);
let mut text = ui.data(|d| d.get_temp::<(String, String)>(id)).filter(|(_, over)| *over == shown).map_or_else(|| shown.clone(), |(t, _)| t);
let picked = match of {
schema::LabelOf::Node => self.cx.picked.nodes.clone(),
schema::LabelOf::Element => self.cx.picked.elements.clone(),
};
let mut out = None;
ui.vertical(|ui| {
let parsed = fields::parse_labels(&text, schema::MAX_LABELS);
let mut edit =
egui::TextEdit::singleline(&mut text).id(id).desired_width(fill(ui)).hint_text(RichText::new(hint).italics().color(self.style.dim));
if parsed.is_none() {
edit = edit.text_color(self.style.error);
self.out.invalid += 1;
}
if ui.add(edit).changed()
&& let Some(l) = fields::parse_labels(&text, schema::MAX_LABELS)
{
out = Some(if l.is_empty() { Value::Null } else { json!(l) });
}
let label = format!("Use selection ({} {}{})", picked.len(), of.label(), if picked.len() == 1 { "" } else { "s" });
let tip = format!("The {}s picked in the viewport (Select: {}s)", of.label(), of.label());
if ui
.add_enabled(!picked.is_empty(), egui::Button::new(RichText::new(label).small()))
.on_hover_text(&tip)
.on_disabled_hover_text(&tip)
.clicked()
{
text = fields::format_labels(&picked);
out = Some(json!(picked));
}
});
let over = out.as_ref().map_or(shown, |v| {
v.as_array().map_or_else(String::new, |a| fields::format_labels(&a.iter().filter_map(Value::as_u64).collect::<Vec<_>>()))
});
ui.data_mut(|d| d.insert_temp(id, (text, over)));
out
}
/// A list of points: a row of x, y, z each, removable, and Add point.
fn points(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field, max: usize) -> Option<Value> {
let current: Vec<Value> = match value {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(a)) if a.iter().all(Value::is_array) => a.clone(),
Some(_) => {
self.json_only(ui);
return None;
}
};
if current.len() > MAX_ROWS {
self.json_only(ui);
return None;
}
let mut next = current.clone();
let (mut changed, mut remove) = (false, None);
ui.vertical(|ui| {
for (i, p) in current.iter().enumerate() {
ui.horizontal(|ui| {
if ui.small_button("×").on_hover_text("Remove this point").clicked() {
remove = Some(i);
}
if let Some(v) = self.vector(ui, id.with(i), Some(p), f, &["x", "y", "z"], false)
&& let Some(slot) = next.get_mut(i)
{
*slot = v;
changed = true;
}
});
}
if ui.add_enabled(current.len() < max.min(MAX_ROWS), egui::Button::new(RichText::new("Add point").small())).clicked() {
next.push(json!([0.0, 0.0, 0.0]));
changed = true;
}
});
if let Some(i) = remove {
next.remove(i);
changed = true;
}
changed.then_some(if next.is_empty() { Value::Null } else { Value::Array(next) })
}
/// Any of `options`: a checkbox each; none ticked leaves the key out.
fn multi(&mut self, ui: &mut Ui, value: Option<&Value>, options: &[(&str, &str)]) -> Option<Value> {
let chosen: Vec<Value> = value.and_then(Value::as_array).cloned().unwrap_or_default();
let mut out = None;
ui.horizontal_wrapped(|ui| {
for (o, label) in options {
let v = schema::multi_value(o);
let mut on = chosen.contains(&v);
if ui.checkbox(&mut on, *label).changed() {
// In the options' order.
let next: Vec<Value> =
options.iter().map(|(x, _)| schema::multi_value(x)).filter(|x| if *x == v { on } else { chosen.contains(x) }).collect();
out = Some(if next.is_empty() { Value::Null } else { Value::Array(next) });
}
}
});
out
}
/// A number, or Auto: the checkbox, then the number when it is off.
fn number_or_auto(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field) -> Option<Value> {
let auto = match value {
None | Some(Value::Null) => f.default.is_some_and(|d| d.contains("auto")),
Some(Value::String(s)) => s == "auto",
Some(_) => false,
};
let mut on = auto;
let mut out = None;
ui.horizontal(|ui| {
if ui.checkbox(&mut on, "Auto").changed() {
out = Some(if on { json!("auto") } else { json!(1.0) });
}
if !auto {
let unit = f.unit_label(self.cx.units);
let width = (fill(ui) - fields::unit_width(ui, unit.as_deref())).max(FIELD_MIN);
if let Some(v) = self.number(ui, id, value, f.hint, false, width) {
out = Some(v);
}
fields::unit(ui, &self.style, unit.as_deref());
}
});
out
}
/// A JSON value typed as text: kept while it does not parse.
/// Empty, it shows `hint` (the expected format) greyed.
fn json(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, hint: &str) -> Option<Value> {
@@ -755,6 +904,7 @@ mod tests {
attempted: true,
refused: None,
problems: &messages(form, &params),
picked: Picked::default(),
};
ui.vertical(|ui| {
ui.set_max_width(width);
@@ -799,6 +949,7 @@ mod tests {
attempted: false,
refused: None,
problems: &[],
picked: Picked::default(),
};
ui.vertical(|ui| {
ui.set_max_width(width);
@@ -904,6 +1055,7 @@ mod tests {
attempted,
refused,
problems: &messages(form, &params),
picked: Picked::default(),
};
show(ui, &Tokens::get(false), &cx, form, &mut params);
});
@@ -934,7 +1086,16 @@ mod tests {
let names = |_: Collection| Vec::new();
let touched = BTreeSet::new();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let cx = Context { units, names: &names, salt: Id::new("t"), touched: &touched, attempted: false, refused: None, problems: &[] };
let cx = Context {
units,
names: &names,
salt: Id::new("t"),
touched: &touched,
attempted: false,
refused: None,
problems: &[],
picked: Picked { nodes: vec![4, 5], elements: Vec::new() },
};
show(ui, &Tokens::get(false), &cx, form, &mut params);
});
out.textures_delta.clear();
@@ -957,4 +1118,21 @@ mod tests {
let lines: Vec<&str> = load.lines().collect();
assert!(["X", "Y", "Z", "[force]"].iter().all(|w| lines.contains(w)), "{load}");
}
/// Label lists, point lists, multi-choices and number-or-auto are typed fields (#169): a
/// label list shows its ranges and offers the picked nodes, points show their coordinates,
/// output choices are checkboxes, the scale an Auto box.
#[test]
fn typed_lists_choices_and_auto_show() {
let probe = drawn("solve.probe", UnitSystem::MmTonneS, json!({"nodes": [1, 2, 3, 7], "at": [[1.5, 0, 0]]}));
assert!(
probe.contains("1-3, 7") && probe.contains("Use selection (2 nodes)") && probe.contains("1.5") && probe.contains("Add point"),
"{probe}"
);
assert!(!probe.contains("set in JSON"), "{probe}");
let step = drawn("model.step.create", UnitSystem::MmTonneS, json!({"name": "S", "procedure": {"type": "static"}, "output": ["stress"]}));
assert!(step.contains("Reaction force") && step.contains("Heat flux"), "{step}");
let deformed = drawn("results.deformed", UnitSystem::MmTonneS, json!({"scale": "auto"}));
assert!(deformed.contains("Auto"), "{deformed}");
}
}