UI: field help on hover and an (i) mark, units without a unit system, X/Y/Z captions

A form row's label and its drawn (i) mark show the field's help and unit
on hover. With no unit system a number shows its dimension ("[stress]")
and the form says where to set the system (Properties › Units). Vector
components are captioned (X, Y, Z, or the field's own names such as E1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 21:29:07 -04:00
1 parent 6b9e669c7e
commit 8b12cdd5c7
2 files changed
+101 -20

No files matched your search

+27 -3
View File
@@ -23,22 +23,41 @@ pub struct Style {
/// Row height.
pub const ROW_H: f32 = 22.0;
/// A row: `label` (with a mark when `required`) in the label column, then `add`.
pub fn row<R>(ui: &mut Ui, s: &Style, label: &str, required: bool, add: impl FnOnce(&mut Ui) -> R) -> R {
/// A row: `label` (with a mark when `required`, and [`help_mark`] when there is `help`) in the
/// label column, then `add`. Hovering the label or the mark shows the label and `help`.
pub fn row<R>(ui: &mut Ui, s: &Style, label: &str, required: bool, help: &str, add: impl FnOnce(&mut Ui) -> R) -> R {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(s.label_width, ROW_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(s.label_width);
ui.spacing_mut().item_spacing.x = 2.0;
ui.add(egui::Label::new(RichText::new(label).color(s.dim)).truncate()).on_hover_text(label);
let tip = if help.is_empty() { label.to_string() } else { format!("{label}\n{help}") };
// The marks keep their room: a long label truncates before them.
let marks = if required { 10.0 } else { 0.0 } + if help.is_empty() { 0.0 } else { 13.0 };
ui.scope(|ui| {
ui.set_max_width((s.label_width - marks).max(0.0));
ui.add(egui::Label::new(RichText::new(label).color(s.dim)).truncate()).on_hover_text(&tip);
});
if required {
ui.label(RichText::new("*").color(s.required)).on_hover_text("Required");
}
if !help.is_empty() {
help_mark(ui, s.dim).on_hover_text(&tip);
}
});
add(ui)
})
.inner
}
/// A small circled "i" after a label that has help, drawn (the UI font has no such glyph).
pub fn help_mark(ui: &mut Ui, color: Color32) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(vec2(11.0, 11.0), egui::Sense::hover());
let painter = ui.painter();
painter.circle_stroke(rect.center(), 5.0, egui::Stroke::new(1.0, color));
painter.text(rect.center(), egui::Align2::CENTER_CENTER, "i", egui::FontId::proportional(8.5), color);
response
}
/// A message under a field, aligned with the fields.
pub fn note(ui: &mut Ui, s: &Style, text: &str, color: Color32) {
ui.horizontal(|ui| {
@@ -131,6 +150,11 @@ 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()
}
/// 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));
}
/// A unit after a field.
pub fn unit(ui: &mut Ui, s: &Style, unit: Option<&str>) {
if let Some(u) = unit {
+74 -17
View File
@@ -67,6 +67,10 @@ pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &'static [Field], par
let problems = cx.problems.iter().cloned().collect();
let mut f = Form { cx, style: style(t), problems, names: BTreeMap::new(), out: Outcome::default() };
ui.spacing_mut().item_spacing.y = 3.0;
if cx.units == UnitSystem::Unspecified && has_system_units(fields, 0) {
let note = format!("No unit system: numbers are in the model's own consistent units. {}", schema::UNITS_HINT);
ui.add(egui::Label::new(RichText::new(note).small().color(f.style.dim)).wrap());
}
f.fields(ui, fields, fields, params, "", 0);
f.out
}
@@ -102,10 +106,6 @@ impl Form<'_> {
self.names.entry(c.label()).or_insert_with(|| (self.cx.names)(c))
}
fn unit(&self, dim: schema::Dim) -> Option<String> {
dim.unit(self.cx.units)
}
/// The fields of one object. `common` are the fields beside a flattened variant (kept when it
/// switches).
fn fields(&mut self, ui: &mut Ui, fields: &[Field], common: &[Field], obj: &mut Map<String, Value>, prefix: &str, depth: usize) {
@@ -137,7 +137,8 @@ impl Form<'_> {
let selected = variants.iter().find(|v| Some(v.tag) == current).map_or(current.unwrap_or("—"), |v| v.label);
let labels: Vec<&str> = variants.iter().map(|v| v.label).collect();
let id = self.id(path);
let picked = fields::row(ui, &self.style, f.label, f.required, |ui| fields::combo(ui, id, selected, &labels, FIELD_W + 40.0));
let help = f.help_text(self.cx.units);
let picked = fields::row(ui, &self.style, f.label, f.required, &help, |ui| fields::combo(ui, id, selected, &labels, FIELD_W + 40.0));
picked.and_then(|i| variants.get(i)).map(|v| v.tag.to_string()).filter(|t| Some(t.as_str()) != current)
}
@@ -148,7 +149,8 @@ impl Form<'_> {
_ => {
let value = obj.get(f.key).cloned();
let style = self.style;
let new = fields::row(ui, &style, f.label, f.required, |ui| self.scalar(ui, f, value.as_ref(), &path));
let help = f.help_text(self.cx.units);
let new = fields::row(ui, &style, f.label, f.required, &help, |ui| self.scalar(ui, f, value.as_ref(), &path));
if let Some(new) = new {
match new {
Value::Null => obj.remove(f.key),
@@ -169,8 +171,8 @@ impl Form<'_> {
let id = self.id(path);
let s = self.style;
match f.kind {
Kind::Number { dim, .. } => {
let unit = self.unit(dim);
Kind::Number { .. } => {
let unit = f.unit_label(self.cx.units);
let out = self.number(ui, id, value, f.hint, false, FIELD_W);
fields::unit(ui, &s, unit.as_deref());
out
@@ -202,7 +204,7 @@ impl Form<'_> {
}
fields::combo(ui, id, selected, &labels, FIELD_W + 40.0).map(|i| options.get(i).map_or(Value::Null, |(v, _)| json!(v)))
}
Kind::Vector { dim, labels, nullable } => self.vector(ui, id, value, dim, labels, nullable),
Kind::Vector { labels, nullable, .. } => self.vector(ui, id, value, f, labels, nullable),
Kind::Ref(c) => self.reference(ui, id, path, value, c, f.hint, f.required),
Kind::RefList(c) => self.reference_list(ui, path, value, c),
Kind::Either(options) => {
@@ -235,7 +237,8 @@ impl Form<'_> {
let Some(option) = options.iter().find(|o| o.key == key) else { return };
let sub = join(path, &key);
let style = self.style;
let new = fields::row(ui, &style, "", false, |ui| self.scalar(ui, option, Some(&value), &sub));
let help = option.help_text(self.cx.units);
let new = fields::row(ui, &style, "", false, &help, |ui| self.scalar(ui, option, Some(&value), &sub));
if let Some(new) = new {
obj.insert(f.key.into(), json!({key: new}));
self.touch(path);
@@ -260,7 +263,8 @@ impl Form<'_> {
}
}
fn vector(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, dim: schema::Dim, labels: &[&str], nullable: bool) -> Option<Value> {
/// A vector: one number per component, each after its caption (X, Y, Z or the field's own).
fn vector(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field, labels: &[&str], nullable: bool) -> Option<Value> {
let current: Vec<Value> = match value {
Some(Value::Array(a)) if a.len() == labels.len() => a.clone(),
Some(v) if !v.is_null() => {
@@ -272,14 +276,15 @@ impl Form<'_> {
};
let mut next = current.clone();
let mut changed = false;
let width = if labels.len() > 3 { 34.0 } else { 44.0 };
let unit = self.unit(dim);
let width = if labels.len() > 3 { 30.0 } else { 34.0 };
let unit = f.unit_label(self.cx.units);
ui.vertical(|ui| {
for chunk in (0..labels.len()).collect::<Vec<_>>().chunks(3) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for &i in chunk {
let hint = labels.get(i).copied().unwrap_or("");
fields::caption(ui, &self.style, &caption(hint));
let slot = current.get(i).cloned().unwrap_or(Value::Null);
let shown = (!slot.is_null()).then_some(&slot);
if let Some(v) = self.number(ui, id.with(i), shown, hint, false, width) {
@@ -387,10 +392,10 @@ impl Form<'_> {
let present = obj.get(f.key).is_some_and(|v| !v.is_null());
let mut on = present;
ui.add_space(2.0);
if f.required {
ui.label(RichText::new(f.label).strong());
} else {
ui.checkbox(&mut on, RichText::new(f.label).strong());
let help = f.help_text(self.cx.units);
let heading = if f.required { ui.label(RichText::new(f.label).strong()) } else { ui.checkbox(&mut on, RichText::new(f.label).strong()) };
if !help.is_empty() {
heading.on_hover_text(help);
}
if !f.hint.is_empty() && !present {
fields::note(ui, &Style { label_width: INDENT, ..self.style }, f.hint, self.style.dim);
@@ -468,6 +473,26 @@ impl Form<'_> {
}
}
/// A vector component's caption: axis names upper-cased ("x" → "X"), others as they are ("E1").
fn caption(component: &str) -> String {
if component.len() <= 3 && component.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) {
component.to_uppercase()
} else {
component.to_string()
}
}
/// Whether any of `fields` (nested too) is a number whose unit follows the unit system.
pub fn has_system_units(fields: &[Field], depth: usize) -> bool {
depth <= MAX_DEPTH
&& fields.iter().any(|f| match f.kind {
Kind::Number { dim, .. } | Kind::Vector { dim, .. } => dim.follows_system(),
Kind::Group(inner) | Kind::Rows { fields: inner, .. } | Kind::Either(inner) => has_system_units(inner, depth + 1),
Kind::Variants { variants, .. } => variants.iter().any(|v| has_system_units(v.fields, depth + 1)),
_ => false,
})
}
/// What is missing or wrong in `params`, in the form's order: (field path, message), each
/// phrased with the field's label as the form shows it ([`schema::Problem::for_form`]).
pub fn messages(fields: &'static [Field], params: &Map<String, Value>) -> Vec<(String, String)> {
@@ -661,4 +686,36 @@ mod tests {
let refused = draw(&none, false, Some(("name", "names no material")));
assert!(refused.contains("Material: names no material") && !refused.contains("choose a material"), "{refused}");
}
/// The text a form draws, with `units`.
fn drawn(id: &str, units: UnitSystem, params: Value) -> String {
let form = fieldcraft_engine::find_command(id).and_then(|c| c.form).unwrap();
let ctx = egui::Context::default();
let mut params = params.as_object().cloned().unwrap_or_default();
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: &[] };
show(ui, &Tokens::get(false), &cx, form, &mut params);
});
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|c| if let egui::Shape::Text(t) = &c.shape { Some(t.galley.text().to_string()) } else { None })
.collect::<Vec<_>>()
.join("\n")
}
/// Without a unit system a number shows its dimension and the form says where to set the
/// system; with one, the unit. A vector's components are captioned.
#[test]
fn units_dimensions_and_vector_captions_show() {
let bare = drawn("model.material.plasticity", UnitSystem::Unspecified, json!({"plastic": {"type": "bilinear"}}));
assert!(bare.contains("[stress]") && bare.contains(schema::UNITS_HINT), "{bare}");
let mpa = drawn("model.material.plasticity", UnitSystem::MmTonneS, json!({"plastic": {"type": "bilinear"}}));
assert!(mpa.contains("MPa") && !mpa.contains(schema::UNITS_HINT), "{mpa}");
let load = drawn("model.load.create", UnitSystem::Unspecified, json!({"type": "force"}));
let lines: Vec<&str> = load.lines().collect();
assert!(["X", "Y", "Z", "[force]"].iter().all(|w| lines.contains(w)), "{load}");
}
}