brep_app/form_view.rs
1//! Schema-driven form layout built on [`form::field_input`].
2//!
3//! The view edits parameter data and returns [`FormViewOut`] actions for the caller
4//! to apply after drawing. It takes no engine or document, so the app and capture
5//! tools can use the same layout without overlapping mutable engine borrows.
6//!
7//! Sections run from references and parameters through schema-ordered groups,
8//! followed by Transform and read-only sections. Transform and Outputs start
9//! collapsed. Reference selections stay visible below their activation button.
10//!
11//! Accordion and arrival-scroll state lives in egui memory keyed by the form's
12//! hit prefix, title, and section.
13
14use crate::form;
15use brep_render::style::{FieldKind, FormField};
16use eframe::egui;
17use serde_json::Value;
18use std::collections::{HashMap, HashSet};
19
20/// Groups that are drawn as a COLLAPSED accordion by default. `Transform` is the
21/// owner's explicit call (R6); the read-only trailing sections join it because
22/// they report, they do not edit. Everything else opens by default — a section
23/// the user must click to see is a section they will miss.
24const COLLAPSED_BY_DEFAULT: &[&str] = &["Transform", "Outputs"];
25
26/// Everything ABOUT the form that the consumer supplies. Borrowed; the form view
27/// holds no state of its own beyond egui memory.
28pub struct FormViewSpec<'a> {
29 /// The form's heading — e.g. `"E3 ⟠ Extrude"`. Also the scope key for this
30 /// form's transient view state and per-field widget ids, so it must be
31 /// STABLE and UNIQUE per subject (a feature id / constraint id qualifies).
32 pub title: &'a str,
33 /// An optional second line under the title (a status, a type name…).
34 pub subtitle: Option<&'a str>,
35 /// The schema fields to render, in schema order.
36 pub fields: &'a [FormField],
37 /// Field keys ([`FormField::key`]) to HIDE this frame — the schema-driven
38 /// field-visibility hook. A field whose key is in this set is dropped BEFORE
39 /// the fields are partitioned into sections, so a group left with nothing
40 /// visible draws no header. `None` (the common case) hides nothing. A feature
41 /// consumer fills this from [`brep_render::features::feature_hidden_params`]
42 /// against the LIVE params each frame, so it reacts to both first display and
43 /// every field change with no extra plumbing.
44 pub hidden: Option<&'a HashSet<String>>,
45 /// A message banner drawn under the title — e.g. the feature's run error.
46 pub banner: Option<(&'a str, egui::Color32)>,
47 /// Read-only sections appended after the fields — e.g. `[("Outputs", …)]`.
48 pub trailing: Option<&'a [(&'a str, Vec<String>)]>,
49 /// ONE consumer-drawn section `(title, drawer)` placed after the schema
50 /// groups and before the trailing sections — for a subject whose editing
51 /// surface the schema cannot express (a spline's anchor list). The drawer
52 /// is `Fn`, so anything it decides goes through interior mutability the
53 /// consumer owns; the form view stays intent-out like everything else here.
54 pub extra: Option<(&'a str, &'a dyn Fn(&mut egui::Ui))>,
55 /// The exit button's label — the button at the RIGHT end of the title row.
56 /// Both of today's consumers say "Return to tree" — the history feature list
57 /// and the constraint list are BOTH drawn by `panels::tree`, so the word is
58 /// literal, not a metaphor (Q12). A consumer whose list is NOT a tree
59 /// supplies its own wording here.
60 pub exit_label: &'a str,
61 /// Whether this consumer's subject lives in a ROLLED history — i.e. whether
62 /// LEAVING the form also means "roll the model back to the tip".
63 ///
64 /// The form view holds no engine and therefore cannot roll anything itself
65 /// (that is [`FormViewOut::roll_to_tip`], which the caller acts on). What
66 /// this flag does is decide, in ONE place, whether an exit *carries* that
67 /// intent — so no consumer has to hard-code "am I the history panel?" at its
68 /// exit arm. The history panel passes `true`; assembly constraints have no
69 /// rollback at all (the owner's call) and pass `false`, which is why their
70 /// panel has no roll branch to get wrong.
71 pub rollback: bool,
72 /// Prefix for every published hit key. `""` for a consumer that shows ONE
73 /// form at a time (history); a consumer that can show several at once MUST
74 /// pass a prefix carrying the subject id or the rects collide silently.
75 pub hits_prefix: &'a str,
76 /// Why the subject may not change (a PLM revision not checked out, or
77 /// released), or `None`. The fields then draw DISABLED under a banner
78 /// saying why, so nothing is typed that the engine would refuse and the
79 /// field's buffer would keep; the exit button stays live.
80 pub read_only: Option<&'a str>,
81 /// The reference field a pick is RUNNING in, when one is — the form draws
82 /// it as the focused selection box ([`form::ActivePick`]): its button
83 /// reads `Done`, its list is the engine's running picks and its `✕`s come
84 /// out as [`FormViewOut::pick_remove`]. `None` draws every reference field
85 /// at rest.
86 pub picking: Option<form::ActivePick<'a>>,
87}
88
89/// A reference field's `Select` was pressed — the caller dispatches this to its
90/// own picker flavour (`begin_ref_select` / `begin_ref_select_for_constraint`).
91#[derive(Debug, Clone, PartialEq)]
92pub struct RefActivate {
93 /// The JSON key chain the picked selection writes back into.
94 pub path: Vec<String>,
95 /// The field's label, for the picker's prompt.
96 pub label: String,
97 /// The schema's entity-kind filter (`["solid"]`, `["face"]`…).
98 pub filter: Vec<String>,
99 /// Whether the field accepts more than one entity.
100 pub multiple: bool,
101 /// The names already chosen — the picker lights them up as the seed.
102 pub seed: Vec<String>,
103}
104
105/// What the user did in one drawn frame of the form.
106#[derive(Debug, Default, Clone, PartialEq)]
107pub struct FormViewOut {
108 /// A field wrote into `params` — the caller commits the whole buffer.
109 pub changed: bool,
110 /// A [`FieldKind::Button`] field was clicked, by its key (`"editSketch"`).
111 pub button_clicked: Option<String>,
112 /// A reference field's `Select` was pressed. With a pick already running
113 /// in ANOTHER field (`spec.picking`), the consumer finishes that one first:
114 /// focus moves from box to box, one pick at a time.
115 pub ref_activate: Option<RefActivate>,
116 /// The focused reference field's `Done` was pressed: commit the running
117 /// pick.
118 pub pick_done: bool,
119 /// An `✕` on the focused reference field: drop the i-th running pick.
120 pub pick_remove: Option<usize>,
121 /// The title row's exit button was pressed — the caller returns to its list.
122 pub exit_clicked: bool,
123 /// The exit ALSO means "roll the model to the tip" — set only when the exit
124 /// button was pressed AND the consumer declared [`FormViewSpec::rollback`].
125 /// Always `false` for a consumer with no rollback, so its panel never needs
126 /// the branch.
127 pub roll_to_tip: bool,
128 /// The pointer is over a line that NAMES a scene entity — a reference line or
129 /// a read-only trailing line (`Outputs`). The caller feeds it to
130 /// `EngineState::hover_entity_by_name` so the entity lights in the 3D view
131 /// exactly as mousing over it there would, and calls
132 /// `EngineState::dialog_hover_end` when it is `None`.
133 pub hovered_entity: Option<String>,
134}
135
136/// Draw ONE complete schema-driven form into `ui`, editing `params` live, and
137/// return what the user did. `hits`, when supplied, receives the widget screen
138/// rects the headed verifier drives:
139///
140/// | key | what |
141/// |---|---|
142/// | `form:feature` | the title label |
143/// | `form:section:{Group}` | an accordion header |
144/// | `form:return` | the exit button, right end of the title row |
145/// | `field:{path}` | a field's input widget |
146/// | `field:{path}#{variant}` | an OPEN enum's items |
147/// | `field:{path}#activate` / `#x{i}` | a reference's Select / remove buttons (the focused field's Select is its Done) |
148/// | `field:{path}#line{i}` | a reference's i-th chosen-entity line |
149/// | `form:trailing:{Section}:{i}` | a read-only section's i-th line (`Outputs`) |
150///
151/// all with `spec.hits_prefix` prepended.
152/// Padding INSIDE the form's container — the gap between the frame edge and the
153/// first/last widget on every side.
154const FORM_MARGIN: i8 = 10;
155
156/// Gap OUTSIDE the container, between it and the pane edge, so the frame's own
157/// stroke is not flush against the dock border.
158const FORM_OUTER_MARGIN: i8 = 6;
159
160pub fn form_view(
161 ui: &mut egui::Ui,
162 spec: &FormViewSpec<'_>,
163 params: &mut Value,
164 mut hits: Option<&mut HashMap<String, egui::Rect>>,
165) -> FormViewOut {
166 let mut out = FormViewOut::default();
167
168 // A form ARRIVES IN VIEW. Every consumer's pane is wrapped in the dock's
169 // `ScrollArea`, whose offset egui remembers PER PANE — so a form opened from
170 // a tree the user had scrolled down inherits that offset and starts part-way
171 // in, with the title row (and the exit button on it) above the viewport. The
172 // enclosing scroll area is asked to bring the CURSOR — which is the form's
173 // TOP EDGE here, before the container's own margins push it down — into
174 // view, ONCE per arrival: keyed on the subject AND on having been drawn on
175 // the previous pass, so re-opening the same subject after a trip back to the
176 // tree resets too, while scrolling INSIDE an open form sticks.
177 //
178 // WHERE this sits matters: inside the container it would align the first
179 // WIDGET, not the form, and leave the pane a margin's worth (measured: 14
180 // pt) past the form's own top edge and border. Out here the cursor IS that
181 // edge. The align is `None` — "only if it is not already visible" — rather
182 // than `Align::TOP`, so a pane already at the top computes a zero delta and
183 // asks for nothing at all, rather than a target that only happens to clamp
184 // back to where it was.
185 //
186 // (`form_view` cannot reach the scroll area itself — it is the consumer's,
187 // two levels out — and asking through the cursor is what keeps the required
188 // inputs at `(ui, spec, &mut params)` for the headless caller.)
189 let arrival = egui::Id::new(("form-view-arrival", spec.hits_prefix));
190 let pass = ui.ctx().cumulative_pass_nr();
191 let arrived = ui.ctx().memory_mut(|m| {
192 let previous: Option<(String, u64)> = m.data.get_temp(arrival);
193 let continuing = previous
194 .is_some_and(|(title, drawn)| title == spec.title && pass.saturating_sub(drawn) <= 1);
195 m.data.insert_temp(arrival, (spec.title.to_string(), pass));
196 !continuing
197 });
198 if arrived {
199 // INSTANT, not animated: the reset must land before the frame the user
200 // sees, and a dialog gliding into place on every open is noise.
201 ui.scroll_to_cursor_animation(None, egui::style::ScrollAnimation::none());
202 }
203
204 // The whole form sits inside ONE padded container, so the fields never run
205 // flush against the pane edge the way the inline tree rendering did. The
206 // margin is the frame's, not per-widget spacing: a single container keeps
207 // the label-above-full-width rhythm intact when the pane is resized, and
208 // gives the scroll region a consistent inset on every side.
209 egui::Frame::group(ui.style())
210 .inner_margin(egui::Margin::same(FORM_MARGIN))
211 .outer_margin(egui::Margin::same(FORM_OUTER_MARGIN))
212 .show(ui, |ui| {
213 form_body(ui, spec, params, &mut hits, &mut out);
214 });
215
216 out
217}
218
219/// The form's contents, drawn INSIDE the padded container opened by
220/// [`form_view`]. Split out so the container owns the margin in one place
221/// rather than every section adding its own edge spacing.
222fn form_body(
223 ui: &mut egui::Ui,
224 spec: &FormViewSpec<'_>,
225 params: &mut Value,
226 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
227 out: &mut FormViewOut,
228) {
229 // Fill the container's width so full-width inputs stay full width.
230 ui.set_width(ui.available_width());
231
232 // --- heading + the ONE exit button, on one row -------------------------
233 // No OK/Cancel pair and no buffer: editing is LIVE (every change commits and
234 // re-runs) and UNDO is the revert mechanism, so there is nothing for a
235 // Cancel to roll back that undo does not already cover.
236 //
237 // The exit is allocated FIRST, from the RIGHT edge, and the title takes what
238 // is left over: a right-to-left row measures the button before the heading
239 // exists, so no title — however long — can push the button off the pane, and
240 // the heading truncates into the remainder instead of running under it. (The
241 // reverse order, title-then-button, is exactly the layout that loses the
242 // button on a long feature name.)
243 ui.horizontal(|ui| {
244 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
245 let exit = ui.button(spec.exit_label);
246 publish(spec, hits, "form:return", exit.rect);
247 out.exit_clicked = exit.clicked();
248 // The exit's rollback half, gated by the consumer's declaration —
249 // see [`FormViewSpec::rollback`].
250 out.roll_to_tip = out.exit_clicked && spec.rollback;
251 // Back to reading order for the title, inside whatever width the
252 // button left behind.
253 ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
254 let head = ui.add(
255 egui::Label::new(egui::RichText::new(spec.title).heading()).truncate(),
256 );
257 publish(spec, hits, "form:feature", head.rect);
258 });
259 });
260 });
261 if let Some(subtitle) = spec.subtitle {
262 ui.label(egui::RichText::new(subtitle).weak());
263 }
264 ui.separator();
265
266 // --- banner: the subject's error, ABOVE the fields ---------------------
267 // (The history tree ALSO keeps this on the feature's row, so a failure is
268 // still visible while scanning the tree — see the panel.)
269 if let Some((text, color)) = spec.banner {
270 egui::Frame::group(ui.style())
271 .stroke(egui::Stroke::new(1.0, color))
272 .fill(color.gamma_multiply(0.12))
273 .show(ui, |ui| {
274 ui.set_width(ui.available_width());
275 ui.add(
276 egui::Label::new(egui::RichText::new(text).color(color))
277 .wrap_mode(egui::TextWrapMode::Wrap),
278 );
279 });
280 ui.add_space(4.0);
281 }
282
283 // --- read-only: say why, and take no edit below this line ------------
284 if let Some(reason) = spec.read_only {
285 egui::Frame::group(ui.style())
286 .stroke(egui::Stroke::new(1.0, ui.visuals().weak_text_color()))
287 .show(ui, |ui| {
288 ui.set_width(ui.available_width());
289 let said = ui.add(egui::Label::new(format!("Read-only: {reason}")).wrap_mode(egui::TextWrapMode::Wrap));
290 publish(spec, hits, "form:read-only", said.rect);
291 });
292 ui.add_space(4.0);
293 ui.disable();
294 }
295
296 // --- partition the schema into direct params + grouped sections --------
297 let mut references: Vec<&FormField> = Vec::new();
298 let mut param_leaves: Vec<&FormField> = Vec::new();
299 let mut groups: Vec<(&str, Vec<&FormField>)> = Vec::new();
300 for f in spec.fields {
301 // The field-visibility hook: a hidden field is dropped here, before the
302 // partition, so it never reaches a section — and an emptied group never
303 // draws a bare header. Evaluated fresh each frame against the live params
304 // by the consumer, this is what makes fields show/hide as the field that
305 // governs them (e.g. a hole's type) changes.
306 if spec.hidden.is_some_and(|h| h.contains(f.key())) {
307 continue;
308 }
309 match f.group.as_str() {
310 "References" => references.push(f),
311 "Parameters" => {
312 if matches!(f.kind, FieldKind::Text { read_only: true }) {
313 continue; // the subject id — already the form's title
314 }
315 param_leaves.push(f);
316 }
317 group => match groups.iter_mut().find(|(name, _)| *name == group) {
318 Some(existing) => existing.1.push(f),
319 None => groups.push((group, vec![f])),
320 },
321 }
322 }
323 // Transform trails the other groups: it is the one the owner named as the
324 // collapsed accordion, so it belongs at the bottom, not between two open
325 // sections.
326 groups.sort_by_key(|(name, _)| usize::from(*name == "Transform"));
327
328 // (1) references, then (2) plain parameters — both un-sectioned, because a
329 // section over the fields the user came here to edit is a click in the way.
330 for f in references.iter().chain(param_leaves.iter()) {
331 draw_field(ui, spec, f, params, hits, out);
332 }
333
334 // (3) the remaining groups, each an accordion.
335 for (name, fields) in &groups {
336 section(ui, spec, name, hits, |ui, hits| {
337 for f in fields {
338 draw_field(ui, spec, f, params, hits, out);
339 }
340 });
341 }
342
343 // (4) the consumer's own section, when it has one.
344 if let Some((name, draw)) = spec.extra {
345 section(ui, spec, name, hits, |ui, _hits| draw(ui));
346 }
347
348 // (5) read-only trailing sections (Outputs…).
349 //
350 // These lines NAME scene entities too (a feature's output solids), so they
351 // report a hover exactly as a reference line does — the section is read-only
352 // in the sense that it edits nothing, not that it is inert.
353 let mut trailing_hover: Option<String> = None;
354 for (name, values) in spec.trailing.unwrap_or(&[]) {
355 section(ui, spec, name, hits, |ui, hits| {
356 if values.is_empty() {
357 ui.label(egui::RichText::new("(none)").weak());
358 }
359 for (i, value) in values.iter().enumerate() {
360 let line = ui.label(format!("• {value}"));
361 if line.hovered() {
362 trailing_hover = Some(value.clone());
363 }
364 publish(spec, hits, &format!("form:trailing:{name}:{i}"), line.rect);
365 }
366 });
367 }
368 if trailing_hover.is_some() {
369 out.hovered_entity = trailing_hover;
370 }
371
372 // Breathing room UNDER the last thing in the form, so the final section's
373 // widgets are not flush against (and, once egui rounds their rects to
374 // physical pixels, a hair past) the pane's clip edge — which is exactly
375 // where a click stops landing.
376 ui.add_space(8.0);
377}
378
379/// Record one widget rect under `spec.hits_prefix`.
380fn publish(
381 spec: &FormViewSpec<'_>,
382 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
383 key: &str,
384 rect: egui::Rect,
385) {
386 if let Some(map) = hits.as_deref_mut() {
387 map.insert(format!("{}{key}", spec.hits_prefix), rect);
388 }
389}
390
391/// One collapsible section: a header row that publishes `form:section:{name}`
392/// and, when open, `body`. Open/collapsed lives in egui memory, keyed to this
393/// form's subject, so two features' Transform sections remember separately and
394/// the caller carries no state.
395fn section(
396 ui: &mut egui::Ui,
397 spec: &FormViewSpec<'_>,
398 name: &str,
399 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
400 body: impl FnOnce(&mut egui::Ui, &mut Option<&mut HashMap<String, egui::Rect>>),
401) {
402 ui.add_space(6.0);
403 let open = !COLLAPSED_BY_DEFAULT.contains(&name);
404 let response = egui::CollapsingHeader::new(egui::RichText::new(name).strong())
405 .id_salt(("form-view-section", spec.hits_prefix, spec.title, name))
406 .default_open(open)
407 .show(ui, |ui| body(ui, hits));
408 publish(
409 spec,
410 hits,
411 &format!("form:section:{name}"),
412 response.header_response.rect,
413 );
414}
415
416/// Draw ONE schema field: its label, then the input at FULL WIDTH beneath it.
417/// A `Reference` is the same shape — [`form::field_input`] draws its activation
418/// button and the chosen entities under this same label.
419fn draw_field(
420 ui: &mut egui::Ui,
421 spec: &FormViewSpec<'_>,
422 field: &FormField,
423 params: &mut Value,
424 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
425 out: &mut FormViewOut,
426) {
427 let mut probe: HashMap<String, egui::Rect> = HashMap::new();
428 let mut rect = egui::Rect::NOTHING;
429 let mut actions = form::FieldActions::default();
430 ui.add_space(4.0);
431 // A `Button` field IS its own label (the schema gives it one), so a label
432 // above it would say the same thing twice.
433 if !matches!(field.kind, FieldKind::Button { .. }) {
434 ui.label(&field.label);
435 }
436 // Scope the widget id-stack to THIS (subject, field) so a Scalar's
437 // per-location egui-memory edit buffer (and its TextEdit focus id) can't
438 // collide when two subjects share a param name — switching between two
439 // extrudes mid-edit must not hand one's half-typed `distance` to the other.
440 // `make_persistent_id` folds in the ui id-stack only.
441 // The FULL path, not the last segment: a nested form (CAM's operation
442 // dialog) has `leadIn.kind` and `ramp.kind` on one subject, which would
443 // otherwise share an id. A single-segment path is its key, as before.
444 ui.push_id((spec.title, field.path.join(".")), |ui| {
445 let (changed, r) =
446 form::field_input_with(ui, field, params, Some(&mut probe), &mut actions, spec.picking);
447 out.changed |= changed;
448 rect = r;
449 });
450 if let Some(map) = hits.as_deref_mut() {
451 let prefix = spec.hits_prefix;
452 map.insert(format!("{prefix}field:{}", field.path.join(".")), rect);
453 for (key, r) in probe {
454 map.insert(format!("{prefix}field:{key}"), r);
455 }
456 }
457 // A hovered reference LINE names an entity; the form's consumer owns lighting
458 // it (that needs the engine). One line is under the pointer at a time, so the
459 // last field to report wins — and only one ever reports.
460 if actions.hovered_entity.is_some() {
461 out.hovered_entity = actions.hovered_entity.take();
462 }
463 // The focused reference field's own two intents.
464 out.pick_done |= actions.pick_done;
465 if actions.pick_remove.is_some() {
466 out.pick_remove = actions.pick_remove;
467 }
468 // `field_input` reports the two ACTION kinds through the same out-param, by
469 // field key; which intent it is comes from the field's own kind.
470 if actions.clicked.is_some() {
471 match &field.kind {
472 FieldKind::Reference { filter, multiple } => {
473 out.ref_activate = Some(RefActivate {
474 path: field.path.clone(),
475 label: field.label.clone(),
476 filter: filter.clone(),
477 multiple: *multiple,
478 seed: form::reference_names(form::value_at(params, &field.path)),
479 });
480 }
481 _ => out.button_clicked = actions.clicked.take(),
482 }
483 }
484}
485