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
|
//! 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.
|
//! 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).
|
//! adapted from SolidCraft's dialog rows (`crates/ui-egui/src/dialogs.rs`, MIT OR Apache-2.0).
|
||||||
|
|
||||||
use egui::{Color32, Id, RichText, Ui, vec2};
|
use egui::{Color32, Id, RichText, Ui, vec2};
|
||||||
@@ -12,8 +12,6 @@ use egui::{Color32, Id, RichText, Ui, vec2};
|
|||||||
pub struct Style {
|
pub struct Style {
|
||||||
/// The label column.
|
/// The label column.
|
||||||
pub label_width: f32,
|
pub label_width: f32,
|
||||||
/// A field (number, text, drop-down) without its unit.
|
|
||||||
pub field_width: f32,
|
|
||||||
pub dim: Color32,
|
pub dim: Color32,
|
||||||
pub error: Color32,
|
pub error: Color32,
|
||||||
/// The mark of a required field.
|
/// The mark of a required field.
|
||||||
@@ -23,39 +21,79 @@ pub struct Style {
|
|||||||
/// Row height.
|
/// Row height.
|
||||||
pub const ROW_H: f32 = 22.0;
|
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
|
/// 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 {
|
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.horizontal(|ui| {
|
||||||
ui.allocate_ui_with_layout(vec2(s.label_width, ROW_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
let galley = label_galley(ui, label, s.dim, s.label_width - marks_width(required, !help.is_empty()));
|
||||||
ui.set_width(s.label_width);
|
let (rect, response) = ui.allocate_exact_size(vec2(s.label_width, galley.size().y.max(ROW_H)), egui::Sense::hover());
|
||||||
ui.spacing_mut().item_spacing.x = 2.0;
|
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}") };
|
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.
|
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, label));
|
||||||
let marks = if required { 10.0 } else { 0.0 } + if help.is_empty() { 0.0 } else { 13.0 };
|
response.on_hover_text(&tip);
|
||||||
ui.scope(|ui| {
|
// The marks sit after the label's last line.
|
||||||
ui.set_max_width((s.label_width - marks).max(0.0));
|
let last = galley.rows.last().map_or(egui::Rect::ZERO, |r| r.rect());
|
||||||
ui.add(egui::Label::new(RichText::new(label).color(s.dim)).truncate()).on_hover_text(&tip);
|
let mark_y = text_rect.top() + last.center().y;
|
||||||
});
|
let mut x = text_rect.left() + last.right() + MARK_GAP;
|
||||||
if required {
|
ui.painter().galley(text_rect.min, galley, s.dim);
|
||||||
ui.label(RichText::new("*").color(s.required)).on_hover_text("Required");
|
if required {
|
||||||
}
|
let star = egui::Rect::from_min_size(egui::pos2(x, mark_y - 7.0), vec2(STAR_W, 14.0));
|
||||||
if !help.is_empty() {
|
ui.painter().text(egui::pos2(x, mark_y), egui::Align2::LEFT_CENTER, "*", label_font(ui), s.required);
|
||||||
help_mark(ui, s.dim).on_hover_text(&tip);
|
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)
|
add(ui)
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small circled "i" after a label that has help, drawn (the UI font has no such glyph).
|
/// 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 {
|
pub fn paint_help_mark(painter: &egui::Painter, rect: egui::Rect, color: Color32) {
|
||||||
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.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);
|
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.
|
/// 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::fields::{self, NumberEdit, Style};
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
|
|
||||||
/// The label column.
|
/// The label column: as wide as the form's longest label, at least this…
|
||||||
const LABEL_W: f32 = 112.0;
|
const LABEL_MIN: f32 = 72.0;
|
||||||
/// A field without its unit.
|
/// …and at most this share of the form's width; longer labels wrap.
|
||||||
const FIELD_W: f32 = 120.0;
|
const LABEL_SHARE: f32 = 0.45;
|
||||||
|
/// The narrowest field.
|
||||||
|
const FIELD_MIN: f32 = 48.0;
|
||||||
/// Indentation of a group's fields.
|
/// Indentation of a group's fields.
|
||||||
const INDENT: f32 = 10.0;
|
const INDENT: f32 = 10.0;
|
||||||
|
|
||||||
@@ -58,14 +60,80 @@ struct Form<'a> {
|
|||||||
out: Outcome,
|
out: Outcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn style(t: &Tokens) -> Style {
|
/// The form's look; `label_width` is the label column ([`label_column`]).
|
||||||
Style { label_width: LABEL_W, field_width: FIELD_W, dim: t.text_dim, error: t.error, required: t.accent }
|
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).
|
/// 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 {
|
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 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;
|
ui.spacing_mut().item_spacing.y = 3.0;
|
||||||
if cx.units == UnitSystem::Unspecified && has_system_units(fields, 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);
|
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 labels: Vec<&str> = variants.iter().map(|v| v.label).collect();
|
||||||
let id = self.id(path);
|
let id = self.id(path);
|
||||||
let help = f.help_text(self.cx.units);
|
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)
|
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 {
|
match f.kind {
|
||||||
Kind::Number { .. } => {
|
Kind::Number { .. } => {
|
||||||
let unit = f.unit_label(self.cx.units);
|
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());
|
fields::unit(ui, &s, unit.as_deref());
|
||||||
out
|
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 { .. } => {
|
Kind::Text | Kind::Path { .. } => {
|
||||||
let mut text = value.and_then(Value::as_str).unwrap_or_default().to_string();
|
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)
|
fields::text(ui, &s, id, &mut text, f.hint, fill(ui)).then(|| if text.is_empty() && !f.required { Value::Null } else { json!(text) })
|
||||||
.then(|| if text.is_empty() && !f.required { Value::Null } else { json!(text) })
|
|
||||||
}
|
}
|
||||||
Kind::Bool => {
|
Kind::Bool => {
|
||||||
let mut on = value.and_then(Value::as_bool).unwrap_or(false);
|
let mut on = value.and_then(Value::as_bool).unwrap_or(false);
|
||||||
@@ -202,7 +270,7 @@ impl Form<'_> {
|
|||||||
if optional {
|
if optional {
|
||||||
labels.push("(default)");
|
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::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::Ref(c) => self.reference(ui, id, path, value, c, f.hint, f.required),
|
||||||
@@ -218,7 +286,7 @@ impl Form<'_> {
|
|||||||
if !f.required {
|
if !f.required {
|
||||||
labels.push("(none)");
|
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 Some(o.key) == key => None,
|
||||||
Some(o) if matches!(o.kind, Kind::Unit) => Some(json!(o.key)),
|
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)})),
|
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() {
|
if names.is_empty() {
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
let none = format!("No {} yet", plural(c.label()));
|
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);
|
self.create_button(ui, c, path);
|
||||||
});
|
});
|
||||||
return None;
|
return None;
|
||||||
@@ -325,7 +393,7 @@ impl Form<'_> {
|
|||||||
if !required {
|
if !required {
|
||||||
labels.push("(default)");
|
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> {
|
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 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 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 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 {
|
if bad {
|
||||||
edit = edit.text_color(self.style.error);
|
edit = edit.text_color(self.style.error);
|
||||||
self.out.invalid += 1;
|
self.out.invalid += 1;
|
||||||
@@ -561,6 +629,8 @@ fn preselect_in(fields: &[Field], obj: &mut Map<String, Value>, pick: &dyn Fn(Co
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use egui::Color32;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Every command's form draws, with its defaults and with hostile parameters, with and without
|
/// 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
|
/// 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.
|
/// names the field by its label, never by a JSON key.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user