brep_app/form_view.rs
1//! The SHARED schema-driven FORM VIEW — one function that draws a COMPLETE form
2//! for a schema-described subject (a history feature, an assembly constraint, a
3//! future PMI annotation) and returns what the user did.
4//!
5//! [`form::field_input`] is the shared per-FIELD renderer; this is the shared
6//! per-FORM renderer above it. Before it existed, four call sites hand-rolled
7//! their own chrome, grouping and layout around the same widget dispatcher — and
8//! the docs screenshot rig's layout had already silently drifted away from the
9//! app's. One form view means the layout is a ONE-place change.
10//!
11//! # The layout
12//!
13//! ```text
14//! E3 ⟠ Extrude ← title
15//! ─────────────────────────
16//! ⚠ <error message> ← banner (only when the subject failed)
17//!
18//! Profile ← a reference field: label, full-width
19//! [ ▣ Select sketch ] activation button, and the chosen entities
20//! • Sketch1 [✕] listed BENEATH it — never behind a `[+]`
21//!
22//! Distance ← every other field: label ABOVE a
23//! [ 25 ] full-width input
24//!
25//! ▼ Boolean ← accordion, open by default
26//! ▶ Transform ← accordion, COLLAPSED by default
27//! ▶ Outputs ← read-only, collapsed by default
28//!
29//! [ Return to tree ] ← the ONE bottom button
30//! ```
31//!
32//! Section order is References → Parameters → the remaining groups in schema
33//! order → Transform → the read-only trailing sections. References lead because
34//! they are what the user picks and they are the tallest thing on the form;
35//! Transform trails because it is the one the owner asked to collapse.
36//!
37//! # It renders; it does not mutate any engine
38//!
39//! The inputs are exactly `(ui, spec, &mut params)` — **no `EngineState`, no
40//! runner, no document**. That is deliberate and load-bearing: the headless docs
41//! generator (`examples/capture_dialogs.rs`) has none of those, so requiring one
42//! would make "the docs image generator uses the same code as the UI"
43//! impossible. Everything that needs an engine — activating the reference
44//! picker, acting on a schema `button`, committing the edited params, leaving
45//! the form — comes back OUT as a value in [`FormViewOut`] that the calling
46//! panel acts on AFTER the draw. (A commit callback would have to capture
47//! `&mut EngineState` while `params` is itself read out of that engine and the
48//! panel already holds it for the frame — three overlapping mutable borrows.)
49//!
50//! # Transient view state
51//!
52//! Which accordions are open is pure view state, so it lives in egui MEMORY
53//! keyed by (`hits_prefix`, `title`, section) rather than in a caller-owned set
54//! — that is what keeps the required inputs at `(ui, spec, &mut params)` for the
55//! headless caller.
56
57use crate::form;
58use brep_render::style::{FieldKind, FormField};
59use eframe::egui;
60use serde_json::Value;
61use std::collections::HashMap;
62
63/// Groups that are drawn as a COLLAPSED accordion by default. `Transform` is the
64/// owner's explicit call (R6); the read-only trailing sections join it because
65/// they report, they do not edit. Everything else opens by default — a section
66/// the user must click to see is a section they will miss.
67const COLLAPSED_BY_DEFAULT: &[&str] = &["Transform", "Outputs"];
68
69/// Everything ABOUT the form that the consumer supplies. Borrowed; the form view
70/// holds no state of its own beyond egui memory.
71pub struct FormViewSpec<'a> {
72 /// The form's heading — e.g. `"E3 ⟠ Extrude"`. Also the scope key for this
73 /// form's transient view state and per-field widget ids, so it must be
74 /// STABLE and UNIQUE per subject (a feature id / constraint id qualifies).
75 pub title: &'a str,
76 /// An optional second line under the title (a status, a type name…).
77 pub subtitle: Option<&'a str>,
78 /// The schema fields to render, in schema order.
79 pub fields: &'a [FormField],
80 /// A message banner drawn under the title — e.g. the feature's run error.
81 pub banner: Option<(&'a str, egui::Color32)>,
82 /// Read-only sections appended after the fields — e.g. `[("Outputs", …)]`.
83 pub trailing: Option<&'a [(&'a str, Vec<String>)]>,
84 /// The bottom button's label. Both of today's consumers say "Return to
85 /// tree" — the history feature list and the constraint list are BOTH drawn
86 /// by `panels::tree`, so the word is literal, not a metaphor (Q12). A
87 /// consumer whose list is NOT a tree supplies its own wording here.
88 pub exit_label: &'a str,
89 /// Whether this consumer's subject lives in a ROLLED history — i.e. whether
90 /// LEAVING the form also means "roll the model back to the tip".
91 ///
92 /// The form view holds no engine and therefore cannot roll anything itself
93 /// (that is [`FormViewOut::roll_to_tip`], which the caller acts on). What
94 /// this flag does is decide, in ONE place, whether an exit *carries* that
95 /// intent — so no consumer has to hard-code "am I the history panel?" at its
96 /// exit arm. The history panel passes `true`; assembly constraints have no
97 /// rollback at all (the owner's call) and pass `false`, which is why their
98 /// panel has no roll branch to get wrong.
99 pub rollback: bool,
100 /// Prefix for every published hit key. `""` for a consumer that shows ONE
101 /// form at a time (history); a consumer that can show several at once MUST
102 /// pass a prefix carrying the subject id or the rects collide silently.
103 pub hits_prefix: &'a str,
104}
105
106/// A reference field's `Select` was pressed — the caller dispatches this to its
107/// own picker flavour (`begin_ref_select` / `begin_ref_select_for_constraint`).
108#[derive(Debug, Clone, PartialEq)]
109pub struct RefActivate {
110 /// The JSON key chain the picked selection writes back into.
111 pub path: Vec<String>,
112 /// The field's label, for the picker's prompt.
113 pub label: String,
114 /// The schema's entity-kind filter (`["solid"]`, `["face"]`…).
115 pub filter: Vec<String>,
116 /// Whether the field accepts more than one entity.
117 pub multiple: bool,
118 /// The names already chosen — the picker lights them up as the seed.
119 pub seed: Vec<String>,
120}
121
122/// What the user did in one drawn frame of the form.
123#[derive(Debug, Default, Clone, PartialEq)]
124pub struct FormViewOut {
125 /// A field wrote into `params` — the caller commits the whole buffer.
126 pub changed: bool,
127 /// A [`FieldKind::Button`] field was clicked, by its key (`"editSketch"`).
128 pub button_clicked: Option<String>,
129 /// A reference field's `Select` was pressed.
130 pub ref_activate: Option<RefActivate>,
131 /// The bottom exit button was pressed — the caller returns to its list.
132 pub exit_clicked: bool,
133 /// The exit ALSO means "roll the model to the tip" — set only when the exit
134 /// button was pressed AND the consumer declared [`FormViewSpec::rollback`].
135 /// Always `false` for a consumer with no rollback, so its panel never needs
136 /// the branch.
137 pub roll_to_tip: bool,
138}
139
140/// Draw ONE complete schema-driven form into `ui`, editing `params` live, and
141/// return what the user did. `hits`, when supplied, receives the widget screen
142/// rects the headed verifier drives:
143///
144/// | key | what |
145/// |---|---|
146/// | `form:feature` | the title row |
147/// | `form:section:{Group}` | an accordion header |
148/// | `form:return` | the bottom exit button |
149/// | `field:{path}` | a field's input widget |
150/// | `field:{path}#{variant}` | an OPEN enum's items |
151/// | `field:{path}#activate` / `#x{i}` | a reference's Select / remove buttons |
152///
153/// all with `spec.hits_prefix` prepended.
154/// Padding INSIDE the form's container — the gap between the frame edge and the
155/// first/last widget on every side.
156const FORM_MARGIN: i8 = 10;
157
158/// Gap OUTSIDE the container, between it and the pane edge, so the frame's own
159/// stroke is not flush against the dock border.
160const FORM_OUTER_MARGIN: i8 = 6;
161
162pub fn form_view(
163 ui: &mut egui::Ui,
164 spec: &FormViewSpec<'_>,
165 params: &mut Value,
166 mut hits: Option<&mut HashMap<String, egui::Rect>>,
167) -> FormViewOut {
168 let mut out = FormViewOut::default();
169
170 // The whole form sits inside ONE padded container, so the fields never run
171 // flush against the pane edge the way the inline tree rendering did. The
172 // margin is the frame's, not per-widget spacing: a single container keeps
173 // the label-above-full-width rhythm intact when the pane is resized, and
174 // gives the scroll region a consistent inset on every side.
175 egui::Frame::group(ui.style())
176 .inner_margin(egui::Margin::same(FORM_MARGIN))
177 .outer_margin(egui::Margin::same(FORM_OUTER_MARGIN))
178 .show(ui, |ui| {
179 form_body(ui, spec, params, &mut hits, &mut out);
180 });
181
182 out
183}
184
185/// The form's contents, drawn INSIDE the padded container opened by
186/// [`form_view`]. Split out so the container owns the margin in one place
187/// rather than every section adding its own edge spacing.
188fn form_body(
189 ui: &mut egui::Ui,
190 spec: &FormViewSpec<'_>,
191 params: &mut Value,
192 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
193 out: &mut FormViewOut,
194) {
195 // Fill the container's width so full-width inputs stay full width.
196 ui.set_width(ui.available_width());
197
198 // --- heading ----------------------------------------------------------
199 let head = ui.heading(spec.title);
200 publish(spec, hits, "form:feature", head.rect);
201 if let Some(subtitle) = spec.subtitle {
202 ui.label(egui::RichText::new(subtitle).weak());
203 }
204 ui.separator();
205
206 // --- banner: the subject's error, ABOVE the fields ---------------------
207 // (The history tree ALSO keeps this on the feature's row, so a failure is
208 // still visible while scanning the tree — see the panel.)
209 if let Some((text, color)) = spec.banner {
210 egui::Frame::group(ui.style())
211 .stroke(egui::Stroke::new(1.0, color))
212 .fill(color.gamma_multiply(0.12))
213 .show(ui, |ui| {
214 ui.set_width(ui.available_width());
215 ui.add(
216 egui::Label::new(egui::RichText::new(text).color(color))
217 .wrap_mode(egui::TextWrapMode::Wrap),
218 );
219 });
220 ui.add_space(4.0);
221 }
222
223 // --- partition the schema into direct params + grouped sections --------
224 let mut references: Vec<&FormField> = Vec::new();
225 let mut param_leaves: Vec<&FormField> = Vec::new();
226 let mut groups: Vec<(&str, Vec<&FormField>)> = Vec::new();
227 for f in spec.fields {
228 match f.group.as_str() {
229 "References" => references.push(f),
230 "Parameters" => {
231 if matches!(f.kind, FieldKind::Text { read_only: true }) {
232 continue; // the subject id — already the form's title
233 }
234 param_leaves.push(f);
235 }
236 group => match groups.iter_mut().find(|(name, _)| *name == group) {
237 Some(existing) => existing.1.push(f),
238 None => groups.push((group, vec![f])),
239 },
240 }
241 }
242 // Transform trails the other groups: it is the one the owner named as the
243 // collapsed accordion, so it belongs at the bottom, not between two open
244 // sections.
245 groups.sort_by_key(|(name, _)| usize::from(*name == "Transform"));
246
247 // (1) references, then (2) plain parameters — both un-sectioned, because a
248 // section over the fields the user came here to edit is a click in the way.
249 for f in references.iter().chain(param_leaves.iter()) {
250 draw_field(ui, spec, f, params, hits, out);
251 }
252
253 // (3) the remaining groups, each an accordion.
254 for (name, fields) in &groups {
255 section(ui, spec, name, hits, |ui, hits| {
256 for f in fields {
257 draw_field(ui, spec, f, params, hits, out);
258 }
259 });
260 }
261
262 // (4) read-only trailing sections (Outputs…).
263 for (name, values) in spec.trailing.unwrap_or(&[]) {
264 section(ui, spec, name, hits, |ui, _hits| {
265 if values.is_empty() {
266 ui.label(egui::RichText::new("(none)").weak());
267 }
268 for value in values {
269 ui.label(format!("• {value}"));
270 }
271 });
272 }
273
274 // --- the ONE bottom button: return to the list -------------------------
275 // No OK/Cancel pair and no buffer: editing is LIVE (every change commits and
276 // re-runs) and UNDO is the revert mechanism, so there is nothing for a
277 // Cancel to roll back that undo does not already cover.
278 ui.add_space(10.0);
279 let exit = ui.add_sized(
280 [ui.available_width(), 26.0],
281 egui::Button::new(spec.exit_label),
282 );
283 publish(spec, hits, "form:return", exit.rect);
284 out.exit_clicked = exit.clicked();
285 // The exit's rollback half, gated by the consumer's declaration — see
286 // [`FormViewSpec::rollback`].
287 out.roll_to_tip = out.exit_clicked && spec.rollback;
288 // Breathing room UNDER the last thing in the form. Not decoration: a form
289 // taller than its pane scrolls, and the scroll stops when the content bottom
290 // meets the viewport bottom — so without this the exit button ends up flush
291 // against (and, once egui rounds its rect to physical pixels, a hair past)
292 // the pane's clip edge, which is exactly where a click stops landing.
293 ui.add_space(8.0);
294}
295
296/// Record one widget rect under `spec.hits_prefix`.
297fn publish(
298 spec: &FormViewSpec<'_>,
299 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
300 key: &str,
301 rect: egui::Rect,
302) {
303 if let Some(map) = hits.as_deref_mut() {
304 map.insert(format!("{}{key}", spec.hits_prefix), rect);
305 }
306}
307
308/// One collapsible section: a header row that publishes `form:section:{name}`
309/// and, when open, `body`. Open/collapsed lives in egui memory, keyed to this
310/// form's subject, so two features' Transform sections remember separately and
311/// the caller carries no state.
312fn section(
313 ui: &mut egui::Ui,
314 spec: &FormViewSpec<'_>,
315 name: &str,
316 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
317 body: impl FnOnce(&mut egui::Ui, &mut Option<&mut HashMap<String, egui::Rect>>),
318) {
319 ui.add_space(6.0);
320 let open = !COLLAPSED_BY_DEFAULT.contains(&name);
321 let response = egui::CollapsingHeader::new(egui::RichText::new(name).strong())
322 .id_salt(("form-view-section", spec.hits_prefix, spec.title, name))
323 .default_open(open)
324 .show(ui, |ui| body(ui, hits));
325 publish(
326 spec,
327 hits,
328 &format!("form:section:{name}"),
329 response.header_response.rect,
330 );
331}
332
333/// Draw ONE schema field: its label, then the input at FULL WIDTH beneath it.
334/// A `Reference` is the same shape — [`form::field_input`] draws its activation
335/// button and the chosen entities under this same label.
336fn draw_field(
337 ui: &mut egui::Ui,
338 spec: &FormViewSpec<'_>,
339 field: &FormField,
340 params: &mut Value,
341 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
342 out: &mut FormViewOut,
343) {
344 let mut probe: HashMap<String, egui::Rect> = HashMap::new();
345 let mut rect = egui::Rect::NOTHING;
346 let mut clicked: Option<String> = None;
347 ui.add_space(4.0);
348 // A `Button` field IS its own label (the schema gives it one), so a label
349 // above it would say the same thing twice.
350 if !matches!(field.kind, FieldKind::Button { .. }) {
351 ui.label(&field.label);
352 }
353 // Scope the widget id-stack to THIS (subject, field) so a Scalar's
354 // per-location egui-memory edit buffer (and its TextEdit focus id) can't
355 // collide when two subjects share a param name — switching between two
356 // extrudes mid-edit must not hand one's half-typed `distance` to the other.
357 // `make_persistent_id` folds in the ui id-stack only.
358 ui.push_id((spec.title, field.key()), |ui| {
359 let (changed, r) = form::field_input(ui, field, params, Some(&mut probe), &mut clicked);
360 out.changed |= changed;
361 rect = r;
362 });
363 if let Some(map) = hits.as_deref_mut() {
364 let prefix = spec.hits_prefix;
365 map.insert(format!("{prefix}field:{}", field.path.join(".")), rect);
366 for (key, r) in probe {
367 map.insert(format!("{prefix}field:{key}"), r);
368 }
369 }
370 // `field_input` reports the two ACTION kinds through the same out-param, by
371 // field key; which intent it is comes from the field's own kind.
372 if clicked.is_some() {
373 match &field.kind {
374 FieldKind::Reference { filter, multiple } => {
375 out.ref_activate = Some(RefActivate {
376 path: field.path.clone(),
377 label: field.label.clone(),
378 filter: filter.clone(),
379 multiple: *multiple,
380 seed: form::reference_names(form::value_at(params, &field.path)),
381 });
382 }
383 _ => out.button_clicked = clicked,
384 }
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use brep_render::style::FieldKind;
392 use serde_json::json;
393
394 fn field(path: &str, label: &str, group: &str, kind: FieldKind) -> FormField {
395 FormField {
396 path: path.split('.').map(str::to_string).collect(),
397 label: label.into(),
398 group: group.into(),
399 kind,
400 }
401 }
402
403 /// One extrude-shaped schema: a reference, two plain params, a Boolean group
404 /// and a Transform group — enough to exercise ordering and both accordion
405 /// defaults.
406 fn fields() -> Vec<FormField> {
407 vec![
408 field("id", "Id", "Parameters", FieldKind::Text { read_only: true }),
409 field("distance", "Distance", "Parameters", FieldKind::Scalar { step: 0.5 }),
410 field(
411 "transform.position",
412 "Position",
413 "Transform",
414 FieldKind::Vec3 { step: 0.1 },
415 ),
416 field(
417 "profile",
418 "Profile",
419 "References",
420 FieldKind::Reference { filter: vec!["sketch".into()], multiple: false },
421 ),
422 field(
423 "boolean.operation",
424 "Operation",
425 "Boolean",
426 FieldKind::Enum { variants: vec!["NONE".into(), "UNION".into()] },
427 ),
428 field("editSketch", "Edit Sketch", "Parameters", FieldKind::Button {
429 label: "Edit Sketch".into(),
430 }),
431 ]
432 }
433
434 /// Draw ONE headless frame of the form and return `(out, hits)`.
435 fn run(
436 ctx: &egui::Context,
437 params: &mut Value,
438 events: Vec<egui::Event>,
439 ) -> (FormViewOut, HashMap<String, egui::Rect>) {
440 run_with(ctx, params, events, true)
441 }
442
443 /// The same, with the consumer's [`FormViewSpec::rollback`] declaration.
444 fn run_with(
445 ctx: &egui::Context,
446 params: &mut Value,
447 events: Vec<egui::Event>,
448 rollback: bool,
449 ) -> (FormViewOut, HashMap<String, egui::Rect>) {
450 let fields = fields();
451 let outputs = vec!["Extrude1_solid".to_string()];
452 let trailing = [("Outputs", outputs)];
453 let spec = FormViewSpec {
454 title: "E3 Extrude",
455 subtitle: None,
456 fields: &fields,
457 banner: None,
458 trailing: Some(&trailing),
459 exit_label: "Return to tree",
460 rollback,
461 hits_prefix: "",
462 };
463 let raw = egui::RawInput {
464 screen_rect: Some(egui::Rect::from_min_size(
465 egui::pos2(0.0, 0.0),
466 egui::vec2(320.0, 700.0),
467 )),
468 events,
469 ..Default::default()
470 };
471 let mut hits = HashMap::new();
472 let mut out = FormViewOut::default();
473 let _ = ctx.run_ui(raw, |ui| {
474 out = form_view(ui, &spec, params, Some(&mut hits));
475 });
476 (out, hits)
477 }
478
479 fn click_at(pos: egui::Pos2) -> Vec<egui::Event> {
480 vec![
481 egui::Event::PointerMoved(pos),
482 egui::Event::PointerButton {
483 pos,
484 button: egui::PointerButton::Primary,
485 pressed: true,
486 modifiers: egui::Modifiers::default(),
487 },
488 egui::Event::PointerButton {
489 pos,
490 button: egui::PointerButton::Primary,
491 pressed: false,
492 modifiers: egui::Modifiers::default(),
493 },
494 ]
495 }
496
497 /// R6 + Q8: `Transform` is a COLLAPSED accordion (its fields are not drawn
498 /// until it is opened) while `Boolean` is open, and the read-only `Outputs`
499 /// section is collapsed too. The read-only `id` never appears — it is the
500 /// form's title.
501 #[test]
502 fn transform_collapses_by_default_and_boolean_does_not() {
503 let ctx = egui::Context::default();
504 let mut params = json!({ "id": "E3", "distance": 25.0 });
505 let (_, hits) = run(&ctx, &mut params, vec![]);
506
507 for key in ["form:section:Transform", "form:section:Boolean", "form:section:Outputs"] {
508 assert!(hits.contains_key(key), "{key} header: {:?}", hits.keys());
509 }
510 assert!(
511 !hits.contains_key("field:transform.position"),
512 "Transform's fields stay behind its collapsed accordion"
513 );
514 assert!(
515 hits.contains_key("field:boolean.operation"),
516 "Boolean's operation is visible without a click: {:?}",
517 hits.keys()
518 );
519 assert!(hits.contains_key("field:distance"), "plain params are un-sectioned");
520 assert!(!hits.contains_key("field:id"), "the read-only id is the title, not a field");
521 }
522
523 /// The form publishes its own chrome so a verifier can drive it: the title,
524 /// the ONE exit button, and the reference activation.
525 #[test]
526 fn the_form_publishes_its_chrome() {
527 let ctx = egui::Context::default();
528 let mut params = json!({ "id": "E3" });
529 let (_, hits) = run(&ctx, &mut params, vec![]);
530 for key in ["form:feature", "form:return", "field:profile#activate"] {
531 assert!(hits.contains_key(key), "{key}: {:?}", hits.keys());
532 }
533 }
534
535 /// A reference `Select` comes OUT as an intent carrying everything the picker
536 /// needs — the form view never enters picking mode itself, because it holds
537 /// no engine.
538 #[test]
539 fn pressing_select_returns_a_ref_activate_intent() {
540 let ctx = egui::Context::default();
541 let mut params = json!({ "id": "E3", "profile": "Sketch1" });
542 let (_, hits) = run(&ctx, &mut params, vec![]);
543 let at = hits["field:profile#activate"].center();
544
545 let (out, _) = run(&ctx, &mut params, click_at(at));
546 let activate = out.ref_activate.expect("Select surfaced an activation");
547 assert_eq!(activate.path, vec!["profile".to_string()]);
548 assert_eq!(activate.label, "Profile");
549 assert_eq!(activate.filter, vec!["sketch".to_string()]);
550 assert!(!activate.multiple);
551 assert_eq!(activate.seed, vec!["Sketch1".to_string()], "the picker seeds from the value");
552 assert_eq!(out.button_clicked, None, "a Select is not a schema button");
553 assert!(!out.changed, "activating the picker edits nothing");
554 }
555
556 /// A schema `button` comes out by key on the SAME channel, and the caller
557 /// tells the two apart by the field's kind — one out-param, two intents.
558 #[test]
559 fn pressing_a_schema_button_returns_its_key() {
560 let ctx = egui::Context::default();
561 let mut params = json!({ "id": "E3" });
562 let (_, hits) = run(&ctx, &mut params, vec![]);
563 let at = hits["field:editSketch"].center();
564
565 let (out, _) = run(&ctx, &mut params, click_at(at));
566 assert_eq!(out.button_clicked.as_deref(), Some("editSketch"));
567 assert_eq!(out.ref_activate, None);
568 }
569
570 /// The ONE bottom button reports itself and nothing else — there is no
571 /// Cancel to tell it apart from.
572 #[test]
573 fn the_exit_button_reports_itself() {
574 let ctx = egui::Context::default();
575 let mut params = json!({ "id": "E3" });
576 let (_, hits) = run(&ctx, &mut params, vec![]);
577 let at = hits["form:return"].center();
578
579 let (out, _) = run(&ctx, &mut params, click_at(at));
580 assert!(out.exit_clicked);
581 assert!(!out.changed);
582 assert_eq!(out.button_clicked, None);
583 }
584
585 /// The ROLLBACK declaration gates the exit's second half. A consumer whose
586 /// subject lives in a rolled history (the feature tree) gets `roll_to_tip`
587 /// with its exit; one that has no rollback at all (assembly constraints)
588 /// gets the SAME exit and never the roll — so its panel carries no branch.
589 #[test]
590 fn rollback_gates_the_roll_to_tip_intent() {
591 let ctx = egui::Context::default();
592 let mut params = json!({ "id": "E3" });
593 let (_, hits) = run_with(&ctx, &mut params, vec![], false);
594 let at = hits["form:return"].center();
595
596 let (rolling, _) = run_with(&ctx, &mut params, click_at(at), true);
597 assert!(rolling.exit_clicked && rolling.roll_to_tip, "a rollback consumer rolls on exit");
598
599 let (flat, _) = run_with(&ctx, &mut params, click_at(at), false);
600 assert!(flat.exit_clicked, "the exit itself is unconditional");
601 assert!(!flat.roll_to_tip, "a consumer with no rollback never carries the roll");
602 }
603
604 /// …and no roll is carried on a frame where the exit was NOT pressed, even
605 /// for a rollback consumer.
606 #[test]
607 fn no_exit_means_no_roll() {
608 let ctx = egui::Context::default();
609 let mut params = json!({ "id": "E3" });
610 let (out, _) = run_with(&ctx, &mut params, vec![], true);
611 assert!(!out.exit_clicked && !out.roll_to_tip);
612 }
613
614 /// R5: the label sits ABOVE its input, and the input fills the form's width.
615 /// Asserted geometrically off the published rect (the label is not a widget
616 /// the form publishes, so this checks the field spans the panel).
617 #[test]
618 fn inputs_fill_the_form_width() {
619 let ctx = egui::Context::default();
620 let mut params = json!({ "id": "E3", "distance": 25.0 });
621 let (_, hits) = run(&ctx, &mut params, vec![]);
622 let distance = hits["field:distance"];
623 assert!(
624 distance.width() > 200.0,
625 "a 320 pt panel gives a full-width field, not a 72 pt one: {}",
626 distance.width()
627 );
628 }
629}