Skip to main content

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