forked from 1b-agent/fieldcraft-upstream
UI: a form's label column fits its longest label, long labels wrap, fields fill the row
The column is measured from the form's labels, at most 45 % of the form's
width; a longer label wraps onto two lines (the column widens only when
two are not enough) and its required and help marks follow it inside the
column. Fixes labels cut at a fixed 112 pt ("Hardening modu…").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
3d8864c313
commit
fc85550534
2 files changed
+224
-42
No files matched your search
@@ -2,7 +2,7 @@
|
||||
//! is a number, a drop-down, a text field. They know nothing of FieldCraft (colours and widths
|
||||
//! come in a [`Style`]), so they can move to the shared Craft UI crate.
|
||||
//!
|
||||
//! The row layout (a label column of fixed width, clamped for long labels, then the field) is
|
||||
//! The row layout (a label column, a long label wrapping inside it, then the field) is
|
||||
//! adapted from SolidCraft's dialog rows (`crates/ui-egui/src/dialogs.rs`, MIT OR Apache-2.0).
|
||||
|
||||
use egui::{Color32, Id, RichText, Ui, vec2};
|
||||
@@ -12,8 +12,6 @@ use egui::{Color32, Id, RichText, Ui, vec2};
|
||||
pub struct Style {
|
||||
/// The label column.
|
||||
pub label_width: f32,
|
||||
/// A field (number, text, drop-down) without its unit.
|
||||
pub field_width: f32,
|
||||
pub dim: Color32,
|
||||
pub error: Color32,
|
||||
/// The mark of a required field.
|
||||
@@ -23,39 +21,79 @@ pub struct Style {
|
||||
/// Row height.
|
||||
pub const ROW_H: f32 = 22.0;
|
||||
|
||||
/// The room the marks after a label take: the required `*` and the help [`help_mark`].
|
||||
pub fn marks_width(required: bool, help: bool) -> f32 {
|
||||
MARK_GAP + if required { STAR_W } else { 0.0 } + if help { HELP_W } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Between a label and its marks.
|
||||
const MARK_GAP: f32 = 2.0;
|
||||
/// The required mark.
|
||||
const STAR_W: f32 = 9.0;
|
||||
/// The help mark and the space before it.
|
||||
const HELP_W: f32 = 13.0;
|
||||
/// A label longer than its column wraps onto this many lines (labels are short phrases).
|
||||
pub const LABEL_LINES: usize = 2;
|
||||
|
||||
/// The font of a row's label.
|
||||
pub fn label_font(ui: &Ui) -> egui::FontId {
|
||||
egui::TextStyle::Body.resolve(ui.style())
|
||||
}
|
||||
|
||||
/// The width of `label` on one line, with its marks: what a label column needs to show it whole.
|
||||
pub fn label_width(ui: &Ui, label: &str, required: bool, help: bool) -> f32 {
|
||||
crate::elide::width(ui, label, &label_font(ui)).ceil() + marks_width(required, help)
|
||||
}
|
||||
|
||||
/// `label` laid out in a column `wrap` wide, on at most [`LABEL_LINES`] lines (elided past them).
|
||||
pub fn label_galley(ui: &Ui, label: &str, color: Color32, wrap: f32) -> std::sync::Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::simple(label.to_string(), label_font(ui), color, wrap.max(0.0));
|
||||
job.wrap.max_rows = LABEL_LINES;
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// label column, then `add`. A label wider than the column wraps onto [`LABEL_LINES`] lines; the
|
||||
/// marks always follow it inside the column. 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;
|
||||
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);
|
||||
}
|
||||
});
|
||||
let galley = label_galley(ui, label, s.dim, s.label_width - marks_width(required, !help.is_empty()));
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(s.label_width, galley.size().y.max(ROW_H)), egui::Sense::hover());
|
||||
let text_rect = egui::Align2::LEFT_CENTER.anchor_size(rect.left_center(), galley.size());
|
||||
let tip = if help.is_empty() { label.to_string() } else { format!("{label}\n{help}") };
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, label));
|
||||
response.on_hover_text(&tip);
|
||||
// The marks sit after the label's last line.
|
||||
let last = galley.rows.last().map_or(egui::Rect::ZERO, |r| r.rect());
|
||||
let mark_y = text_rect.top() + last.center().y;
|
||||
let mut x = text_rect.left() + last.right() + MARK_GAP;
|
||||
ui.painter().galley(text_rect.min, galley, s.dim);
|
||||
if required {
|
||||
let star = egui::Rect::from_min_size(egui::pos2(x, mark_y - 7.0), vec2(STAR_W, 14.0));
|
||||
ui.painter().text(egui::pos2(x, mark_y), egui::Align2::LEFT_CENTER, "*", label_font(ui), s.required);
|
||||
ui.interact(star, ui.id().with("required"), egui::Sense::hover()).on_hover_text("Required");
|
||||
x += STAR_W;
|
||||
}
|
||||
if !help.is_empty() {
|
||||
let mark = egui::Rect::from_center_size(egui::pos2(x + HELP_W / 2.0, mark_y), vec2(11.0, 11.0));
|
||||
paint_help_mark(ui.painter(), mark, s.dim);
|
||||
ui.interact(mark, ui.id().with("help"), egui::Sense::hover()).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();
|
||||
pub fn paint_help_mark(painter: &egui::Painter, rect: egui::Rect, color: Color32) {
|
||||
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
|
||||
}
|
||||
|
||||
/// The width of `unit` after a field ([`unit`]), with the space before it; 0 without one.
|
||||
pub fn unit_width(ui: &Ui, unit: Option<&str>) -> f32 {
|
||||
unit.map_or(0.0, |u| crate::elide::width(ui, u, &egui::TextStyle::Small.resolve(ui.style())).ceil() + ui.spacing().item_spacing.x)
|
||||
}
|
||||
|
||||
/// A message under a field, aligned with the fields.
|
||||
|
||||
+161
-17
@@ -12,10 +12,12 @@ use serde_json::{Map, Value, json};
|
||||
use crate::fields::{self, NumberEdit, Style};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The label column.
|
||||
const LABEL_W: f32 = 112.0;
|
||||
/// A field without its unit.
|
||||
const FIELD_W: f32 = 120.0;
|
||||
/// The label column: as wide as the form's longest label, at least this…
|
||||
const LABEL_MIN: f32 = 72.0;
|
||||
/// …and at most this share of the form's width; longer labels wrap.
|
||||
const LABEL_SHARE: f32 = 0.45;
|
||||
/// The narrowest field.
|
||||
const FIELD_MIN: f32 = 48.0;
|
||||
/// Indentation of a group's fields.
|
||||
const INDENT: f32 = 10.0;
|
||||
|
||||
@@ -58,14 +60,80 @@ struct Form<'a> {
|
||||
out: Outcome,
|
||||
}
|
||||
|
||||
pub fn style(t: &Tokens) -> Style {
|
||||
Style { label_width: LABEL_W, field_width: FIELD_W, dim: t.text_dim, error: t.error, required: t.accent }
|
||||
/// The form's look; `label_width` is the label column ([`label_column`]).
|
||||
pub fn style(t: &Tokens, label_width: f32) -> Style {
|
||||
Style { label_width, dim: t.text_dim, error: t.error, required: t.accent }
|
||||
}
|
||||
|
||||
/// The label column of `fields` in a form `width` wide: its longest label on one line with its
|
||||
/// marks, within [`LABEL_MIN`] and [`LABEL_SHARE`] of the width. Labels longer than that wrap; the
|
||||
/// column widens when one would need more than [`fields::LABEL_LINES`] lines, as long as a field
|
||||
/// keeps [`FIELD_MIN`].
|
||||
pub fn label_column(ui: &Ui, fields: &[Field], units: UnitSystem, width: f32) -> f32 {
|
||||
let share = (width * LABEL_SHARE).max(LABEL_MIN);
|
||||
let most = (width - FIELD_MIN - ui.spacing().item_spacing.x).max(share);
|
||||
let mut column = LABEL_MIN;
|
||||
each_label(fields, units, 0, &mut |label, required, help| {
|
||||
let one_line = fields::label_width(ui, label, required, help);
|
||||
let need = if one_line <= share { one_line } else { wrapped_width(ui, label, required, help, share, one_line.min(most)) };
|
||||
column = column.max(need);
|
||||
});
|
||||
column.min(most)
|
||||
}
|
||||
|
||||
/// The narrowest column in `lo..=hi` that shows `label` whole on [`fields::LABEL_LINES`] lines.
|
||||
fn wrapped_width(ui: &Ui, label: &str, required: bool, help: bool, mut lo: f32, mut hi: f32) -> f32 {
|
||||
let marks = fields::marks_width(required, help);
|
||||
let fits = |column: f32| !fields::label_galley(ui, label, egui::Color32::PLACEHOLDER, column - marks).elided;
|
||||
if fits(lo) {
|
||||
return lo;
|
||||
}
|
||||
// Bisection to a point.
|
||||
for _ in 0..12 {
|
||||
if hi - lo < 1.0 {
|
||||
break;
|
||||
}
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if fits(mid) { hi = mid } else { lo = mid }
|
||||
}
|
||||
hi.ceil()
|
||||
}
|
||||
|
||||
/// A field's width: the rest of its row.
|
||||
fn fill(ui: &Ui) -> f32 {
|
||||
ui.available_width().max(FIELD_MIN)
|
||||
}
|
||||
|
||||
/// Each row label of `fields` (nested too): its text, whether it is required and has help.
|
||||
fn each_label(fields: &[Field], units: UnitSystem, depth: usize, out: &mut dyn FnMut(&str, bool, bool)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for f in fields {
|
||||
let help = !f.help_text(units).is_empty();
|
||||
match f.kind {
|
||||
Kind::Variants { variants, .. } => {
|
||||
// A flattened variant's row is the field's own; a keyed one's is "Type".
|
||||
if f.key.is_empty() {
|
||||
out(f.label, f.required, help);
|
||||
} else {
|
||||
out("Type", true, help);
|
||||
}
|
||||
for v in variants {
|
||||
each_label(v.fields, units, depth + 1, out);
|
||||
}
|
||||
}
|
||||
Kind::Group(inner) | Kind::Rows { fields: inner, .. } => each_label(inner, units, depth + 1, out),
|
||||
_ => out(f.label, f.required, help),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw `fields` editing `params` (an object).
|
||||
pub fn show(ui: &mut Ui, t: &Tokens, cx: &Context, fields: &'static [Field], params: &mut Map<String, Value>) -> Outcome {
|
||||
let problems = cx.problems.iter().cloned().collect();
|
||||
let mut f = Form { cx, style: style(t), problems, names: BTreeMap::new(), out: Outcome::default() };
|
||||
let label_width = label_column(ui, fields, cx.units, ui.available_width());
|
||||
let mut f = Form { cx, style: style(t, label_width), 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);
|
||||
@@ -138,7 +206,7 @@ impl Form<'_> {
|
||||
let labels: Vec<&str> = variants.iter().map(|v| v.label).collect();
|
||||
let id = self.id(path);
|
||||
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));
|
||||
let picked = fields::row(ui, &self.style, f.label, f.required, &help, |ui| fields::combo(ui, id, selected, &labels, fill(ui)));
|
||||
picked.and_then(|i| variants.get(i)).map(|v| v.tag.to_string()).filter(|t| Some(t.as_str()) != current)
|
||||
}
|
||||
|
||||
@@ -173,15 +241,15 @@ impl Form<'_> {
|
||||
match f.kind {
|
||||
Kind::Number { .. } => {
|
||||
let unit = f.unit_label(self.cx.units);
|
||||
let out = self.number(ui, id, value, f.hint, false, FIELD_W);
|
||||
let width = (fill(ui) - fields::unit_width(ui, unit.as_deref())).max(FIELD_MIN);
|
||||
let out = self.number(ui, id, value, f.hint, false, width);
|
||||
fields::unit(ui, &s, unit.as_deref());
|
||||
out
|
||||
}
|
||||
Kind::Integer { .. } => self.number(ui, id, value, f.hint, true, FIELD_W),
|
||||
Kind::Integer { .. } => self.number(ui, id, value, f.hint, true, fill(ui)),
|
||||
Kind::Text | Kind::Path { .. } => {
|
||||
let mut text = value.and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
fields::text(ui, &s, id, &mut text, f.hint, FIELD_W + 40.0)
|
||||
.then(|| if text.is_empty() && !f.required { Value::Null } else { json!(text) })
|
||||
fields::text(ui, &s, id, &mut text, f.hint, fill(ui)).then(|| if text.is_empty() && !f.required { Value::Null } else { json!(text) })
|
||||
}
|
||||
Kind::Bool => {
|
||||
let mut on = value.and_then(Value::as_bool).unwrap_or(false);
|
||||
@@ -202,7 +270,7 @@ impl Form<'_> {
|
||||
if optional {
|
||||
labels.push("(default)");
|
||||
}
|
||||
fields::combo(ui, id, selected, &labels, FIELD_W + 40.0).map(|i| options.get(i).map_or(Value::Null, |(v, _)| json!(v)))
|
||||
fields::combo(ui, id, selected, &labels, fill(ui)).map(|i| options.get(i).map_or(Value::Null, |(v, _)| json!(v)))
|
||||
}
|
||||
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),
|
||||
@@ -218,7 +286,7 @@ impl Form<'_> {
|
||||
if !f.required {
|
||||
labels.push("(none)");
|
||||
}
|
||||
fields::combo(ui, id, selected, &labels, FIELD_W + 40.0).and_then(|i| match options.get(i) {
|
||||
fields::combo(ui, id, selected, &labels, fill(ui)).and_then(|i| match options.get(i) {
|
||||
Some(o) if Some(o.key) == key => None,
|
||||
Some(o) if matches!(o.kind, Kind::Unit) => Some(json!(o.key)),
|
||||
Some(o) => Some(json!({o.key: schema::prefill(&[*o], &json!({})).get(o.key).cloned().unwrap_or(Value::Null)})),
|
||||
@@ -314,7 +382,7 @@ impl Form<'_> {
|
||||
if names.is_empty() {
|
||||
ui.vertical(|ui| {
|
||||
let none = format!("No {} yet", plural(c.label()));
|
||||
ui.add_enabled_ui(false, |ui| fields::combo(ui, id, current.as_deref().unwrap_or(&none), &[], FIELD_W + 40.0));
|
||||
ui.add_enabled_ui(false, |ui| fields::combo(ui, id, current.as_deref().unwrap_or(&none), &[], fill(ui)));
|
||||
self.create_button(ui, c, path);
|
||||
});
|
||||
return None;
|
||||
@@ -325,7 +393,7 @@ impl Form<'_> {
|
||||
if !required {
|
||||
labels.push("(default)");
|
||||
}
|
||||
fields::combo(ui, id, &selected, &labels, FIELD_W + 40.0).map(|i| names.get(i).map_or(Value::Null, |n| json!(n)))
|
||||
fields::combo(ui, id, &selected, &labels, fill(ui)).map(|i| names.get(i).map_or(Value::Null, |n| json!(n)))
|
||||
}
|
||||
|
||||
fn reference_list(&mut self, ui: &mut Ui, path: &str, value: Option<&Value>, c: Collection) -> Option<Value> {
|
||||
@@ -367,7 +435,7 @@ impl Form<'_> {
|
||||
let shown = value.map(Value::to_string).unwrap_or_default();
|
||||
let mut text = ui.data(|d| d.get_temp::<(String, String)>(id)).filter(|(_, over)| *over == shown).map_or_else(|| shown.clone(), |(t, _)| t);
|
||||
let bad = !text.trim().is_empty() && serde_json::from_str::<Value>(&text).is_err();
|
||||
let mut edit = egui::TextEdit::singleline(&mut text).id(id).code_editor().desired_width(FIELD_W + 40.0);
|
||||
let mut edit = egui::TextEdit::singleline(&mut text).id(id).code_editor().desired_width(fill(ui));
|
||||
if bad {
|
||||
edit = edit.text_color(self.style.error);
|
||||
self.out.invalid += 1;
|
||||
@@ -561,6 +629,8 @@ fn preselect_in(fields: &[Field], obj: &mut Map<String, Value>, pick: &dyn Fn(Co
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::Color32;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every command's form draws, with its defaults and with hostile parameters, with and without
|
||||
@@ -606,6 +676,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every form's labels show whole, wrapped at most onto two lines, with their marks inside
|
||||
/// the label column, at the context panel's width and at a narrow one, in a 1280×800 window.
|
||||
#[test]
|
||||
fn no_label_or_mark_is_clipped() {
|
||||
let ctx = egui::Context::default();
|
||||
let input =
|
||||
|| egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), ..Default::default() };
|
||||
let one = |c: Collection| vec![format!("{} with a long name", c.label())];
|
||||
let mut checked = 0;
|
||||
for width in [crate::context::WIDTH - 16.0, 200.0] {
|
||||
for c in fieldcraft_engine::commands() {
|
||||
let Some(form) = c.form else { continue };
|
||||
let mut params = schema::prefill(form, &json!({})).as_object().cloned().unwrap_or_default();
|
||||
let mut labels = BTreeSet::new();
|
||||
each_label(form, UnitSystem::MmTonneS, 0, &mut |l, _, _| {
|
||||
labels.insert(l.to_string());
|
||||
});
|
||||
let (mut left, mut column, mut cut) = (0.0, 0.0, Vec::new());
|
||||
let mut out = ctx.run_ui(input(), |ui| {
|
||||
let touched = BTreeSet::new();
|
||||
let cx = Context {
|
||||
units: UnitSystem::MmTonneS,
|
||||
names: &one,
|
||||
salt: Id::new(c.id),
|
||||
touched: &touched,
|
||||
attempted: false,
|
||||
refused: None,
|
||||
problems: &[],
|
||||
};
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(width);
|
||||
left = ui.min_rect().left();
|
||||
column = label_column(ui, form, cx.units, width);
|
||||
// Every label (also of sections not open), laid out as its row lays it out.
|
||||
each_label(form, cx.units, 0, &mut |label, required, help| {
|
||||
let wrap = column - fields::marks_width(required, help);
|
||||
if fields::label_galley(ui, label, Color32::WHITE, wrap).elided {
|
||||
cut.push(label.to_string());
|
||||
}
|
||||
});
|
||||
show(ui, &Tokens::get(false), &cx, form, &mut params);
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(cut.is_empty(), "{} at {width}: {cut:?} do not fit {column}", c.id);
|
||||
assert!(column <= width - FIELD_MIN, "{}: {column}", c.id);
|
||||
// A row's column starts at its label (nested rows are indented); its marks follow.
|
||||
let mut row_left = left;
|
||||
for shape in &out.shapes {
|
||||
let egui::Shape::Text(t) = &shape.shape else { continue };
|
||||
let text = t.galley.text();
|
||||
let rect = t.galley.rect.translate(t.pos.to_vec2());
|
||||
let end = row_left + column + 0.5;
|
||||
if labels.contains(text) && rect.left() < left + width / 2.0 {
|
||||
row_left = rect.left();
|
||||
assert!(!t.galley.elided, "{} at {width}: `{text}` elided", c.id);
|
||||
assert!(rect.right() <= rect.left() + column + 0.5, "{} at {width}: `{text}` past the column", c.id);
|
||||
checked += 1;
|
||||
} else if (text == "*" || text == "i") && rect.left() < end {
|
||||
assert!(rect.right() <= end, "{} at {width}: a mark past the column: {rect:?}", c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 100, "{checked}");
|
||||
}
|
||||
|
||||
/// Long labels share the column: the Plasticity form's "Hardening modulus" shows whole.
|
||||
#[test]
|
||||
fn hardening_modulus_shows_whole() {
|
||||
let text = drawn("model.material.plasticity", UnitSystem::MmTonneS, json!({"plastic": {"type": "bilinear"}}));
|
||||
assert!(text.lines().any(|l| l == "Hardening modulus"), "{text}");
|
||||
}
|
||||
|
||||
/// In every form, each required field left empty is a problem, so OK waits, and the message
|
||||
/// names the field by its label, never by a JSON key.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user