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