GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/container/parameters_bg.rs (196.8K)
1 //! Narrow-trait `ParametersBg` (Phase 5s) — the designer's parameter panel: a scrollable column
2 //! of param rows (sliders, spinboxes, dropdowns, text boxes, toggles, colors, float3s,
3 //! buttons, section borders, and an inline emacs-flavored code editor), each row's widget owned
4 //! by value in parallel `Vec<Option<..>>` fields (most already `Adapted<W>` from earlier
5 //! phases), plus a raw-pointer `children` container list. The designer stores it as
6 //! `Box<dyn WidgetHost>` and drives it through direct `dyn WidgetHost` calls; `window_runner`'s
7 //! `get_child_widget_for_quad` downcasts to the concrete type through `as_any` (which the
8 //! adapter forwards to the inner widget) and reads the pub sub-widget fields — both keep
9 //! working unchanged.
10 //!
11 //! The model caches its laid-out rect via [`Layout::rect_assigned`] (all row geometry derives
12 //! from it — the TextBox pattern), and serves BOTH legacy escape hatches: the plain-quad view
13 //! ([`Paint::serves_legacy_plain_quads`] — the designer renders params through raw
14 //! `extra_quads`, and the panel's own background is drawn by the host from
15 //! [`Paint::color`]/[`Paint::corner_style`], NOT emitted here), and the per-label text view
16 //! ([`Paint::serves_legacy_labels`], new with this migration — each label clips to the
17 //! viewport but the code editor's clip to the code box, in monospace, which the one-font
18 //! one-bounds prim bridge can't express).
19 //!
20 //! The code row is an editor, not a text box (2026-09-24): a line-number gutter, shift+arrow /
21 //! shift+click selection with ctrl+c / ctrl+x / ctrl+v through the toolkit clipboard, tab and
22 //! shift+tab indenting by four, Enter carrying the line's indentation (a level deeper after an
23 //! opening bracket), ctrl+z / ctrl+shift+z on a `History` of editor snapshots that lives as long
24 //! as the editor does, and the emacs chords it always had. Edits go to the BUFFER and reach the
25 //! row's VALUE on ctrl+enter, Escape, or leaving the row — never per keystroke, because a host
26 //! that evaluates a script on every value change would fail it on every half-typed line. The
27 //! border says which state the row is in (amber pending, blue applied, grey at rest), and
28 //! [`ParametersBg::set_code_error_line`] lets the host flag the line its last evaluation
29 //! failed on.
30 //!
31 //! Flagged approximation: the legacy scrollbar-press called `self.focus()` (base flag only —
32 //! nothing reads it: the highlight keys on the ctx focus slot, and the designer tracks its
33 //! focused pane by index); the `on_event` arm drops it.
34
35 use crate::colors;
36 use crate::scene::layout::Rect;
37 use crate::scene::paint::PaintCtx;
38 use crate::widget::display::{Float3, TextLabel};
39 use crate::widget::input::{Button, ColorSelector, Dropdown, Ramp, Slider, Spinbox, TextBox, Toggle};
40 use crate::widget::{
41 Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
42 MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext,
43 };
44
45 /// A text row, in either variant: plain (`"text"`), or with a completion
46 /// picker (`"textpick:a,b,c"` — the Houdini-style attribute/group chooser: a
47 /// TextBox plus a slim menu-button Dropdown at its right edge whose pick
48 /// fills the box; the host supplies the candidates in the type string).
49 fn is_text_row(t: &str) -> bool {
50 t == "text" || t.starts_with("textpick")
51 }
52
53 /// Width of a textpick row's picker button, nested inside the right end of
54 /// the TextBox's recessed well.
55 const PICK_W: f32 = 24.0;
56
57 pub struct ParametersBg {
58 /// A row VALUE changed inside `tick` (a picker stream folded into its
59 /// row) — for the host's sync, which must not run on every tick that
60 /// merely animated (a scroll glide reports change every frame; syncing
61 /// the whole parameter list on each was the choppy params scroll,
62 /// 2026-09-20). Drained by [`Self::take_tick_value_change`].
63 tick_value_changed: bool,
64 /// The label layout the rows were BUILT under (`layout::param_labels_inline`
65 /// at the last rebuild): inline, the pane draws each label in a column
66 /// beside an unlabelled control; stacked, the control carries its own
67 /// label above itself. Read once per rebuild so geometry and widgets agree.
68 inline_labels: bool,
69 rect: Rect,
70 display_params: Vec<(String, String, String)>,
71 dragging_param: Option<usize>,
72 pub focused_param: Option<usize>,
73 pub code_editor: Option<TextEditorState>,
74 /// The code editor's undo history: one entry per edit, typing runs
75 /// coalesced by group. Lives exactly as long as the editor does.
76 code_history: crate::history::History<TextEditorState>,
77 /// A line (0-based) the host has flagged as the site of an error in the
78 /// code row's value — a script that failed to parse. Drawn as a band
79 /// under that line while the value it was reported for still stands.
80 code_error_line: Option<usize>,
81 mouse_pos: Option<(f32, f32)>,
82 pub sliders: Vec<Option<Adapted<Slider>>>,
83 pub float3s: Vec<Option<Adapted<Float3>>>,
84 pub spinboxes: Vec<Option<Adapted<Spinbox>>>,
85 pub buttons: Vec<Option<Adapted<Button>>>,
86 pub choices: Vec<Option<Adapted<Dropdown>>>,
87 pub texts: Vec<Option<Adapted<TextBox>>>,
88 pub toggles: Vec<Option<Adapted<Toggle>>>,
89 pub colors: Vec<Option<crate::widget::Adapted<ColorSelector>>>,
90 /// Ramp-curve rows (`"ramp"` type; value = the ramp spec string). Painted
91 /// scene-path through [`ParametersBg::paint_scene_rows`] — the legacy flat
92 /// views can't carry the curve/key geometry.
93 pub ramps: Vec<Option<Adapted<Ramp>>>,
94 /// Titles of the sections the user has collapsed by clicking their header. Keyed by
95 /// title so it outlives the row rebuild `set_display_params` runs on every node change.
96 collapsed: std::collections::HashSet<String>,
97 visible: bool,
98 pub scroll_y: f32,
99 pub content_h: f32,
100 scrollbar_dragging: bool,
101 drag_offset_y: f32,
102 /// The raise/sink hysteresis (wheel/drag raises, hover sustains, the hold decays in
103 /// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
104 /// this widget so every app's plate-straddling scrollbar behaves the same way.
105 activity: crate::widget::ScrollbarActivity,
106 /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
107 scroll_motion: crate::widget::ScrollMotion,
108 /// One code-editor column's shaped advance (monospace @12, the family/size
109 /// the code rows draw in), recorded by [`Paint::prepare_text`]. The caret
110 /// and click→column math read it; the hardcoded 7.2 px/col they used
111 /// before drifted off the glyphs. 0.0 until the first shape.
112 code_char_advance: f32,
113 }
114
115 /// The channel: the ONLY gap a control keeps from whatever its edge meets — the
116 /// neighboring control, or its section's wall. Controls pack edge-to-edge; the
117 /// reliefs on either side (control bevel, section wall) shade the channel into a
118 /// narrow 3D groove.
119 const CHANNEL: f32 = 3.0;
120
121 /// The section carve's wall width: the DE relief scaled by the section depth
122 /// multiplier (`style.container.section.depth`), capped against the row height
123 /// (safety) and the control channel — the channel cap scales WITH the
124 /// multiplier, so deepening sections is an explicit choice to let the roll
125 /// cross the groove.
126 fn section_carve_depth(h: f32) -> f32 {
127 let sd = crate::layout::section_depth().max(0.0);
128 (crate::layout::bevel_width() * sd).min(h * 0.2).min(CHANNEL * sd)
129 }
130 /// Vertical pitch between consecutive rows. Wider than the horizontal
131 /// channel on purpose: each label+control pair gets its own breathing room,
132 /// so rows read as separate entries rather than one packed stack. Was 8
133 /// until 2026-09-06; a control's flush seam and the next row's label sat
134 /// close enough to read as one block, so the pitch is now near two channels
135 /// past the label's own line height.
136 const ROW_GAP: f32 = 14.0;
137 /// How far a section's title box overhangs its header row upward, and the box's height.
138 const TITLE_BOX_INSET: f32 = 2.0;
139 const TITLE_BOX_H: f32 = 28.0;
140 /// How far a section's content box overhangs the first and last row it wraps —
141 /// one channel, so the rows abut the section's top/bottom walls too.
142 const CONTENT_BOX_PAD: f32 = CHANNEL;
143 /// The section outline's color, stroke width, and its corner radii: convex (outer) corners, and the
144 /// concave (inner) corners where the neck joins the title and content boxes.
145 const SECTION_BORDER_COLOR: [f32; 4] = [0.18, 0.18, 0.27, 1.0];
146 const SECTION_BORDER_T: f32 = 1.0;
147 const SECTION_R: f32 = 13.0;
148 /// The throat — the concave fillet where the tab's right side turns onto the content
149 /// body's top edge (the tab sits flush on the body; there is no connector neck).
150 const SECTION_THROAT_R: f32 = 5.0;
151 /// The relief carve's concave inside-corner radius at the tab throat
152 /// (`section_fillets`) — sized against SECTION_R so inside and outside
153 /// corners read as one family.
154 const SECTION_FILLET_R: f32 = 10.0;
155 /// Narrowest a title box may be: both its corners plus the throat fillet. Titles run
156 /// wider than this in practice; it only keeps the throat clear of the corners.
157 const SECTION_TITLE_MIN_W: f32 = 2.0 * SECTION_R + SECTION_THROAT_R;
158
159 /// The gap between one section's bottom box edge and the next section's title box —
160 /// deliberately much wider than the channel the rows pack on, so sections read as
161 /// separate blocks. Laid out from the previous block's *drawn* bottom edge (rather
162 /// than the uniform row pitch, which the two boxes' overhangs eat into unequally) so the
163 /// gap is exact.
164 const SECTION_GAP: f32 = 16.0;
165
166 /// Horizontal inset of a section's boxes (title tab and content body) from the pane
167 /// plate's sides — deliberately the wider of the two horizontal gaps, so sections
168 /// float clearly inside the plate.
169 const SECTION_MARGIN: f32 = 16.0;
170 /// Horizontal gap between a control row and its parent section's side walls —
171 /// one channel; controls abut the section's edge.
172 const CONTROL_INSET: f32 = CHANNEL;
173 /// A row's inset from the plate: the section margin plus the controls' inset within
174 /// the section, so bare rows above the first section align with wrapped ones.
175 const ROW_X_INSET: f32 = SECTION_MARGIN + CONTROL_INSET;
176
177 impl ParametersBg {
178 /// The code box's line pitch, its text inset below the box top, and the
179 /// gutter that carries line numbers: four columns of digits and a gap.
180 const CODE_LINE_H: f32 = 16.0;
181 const CODE_TOP: f32 = 22.0;
182 const CODE_BOX_TOP: f32 = 18.0;
183 const CODE_GUTTER_COLS: f32 = 4.0;
184 const CODE_INDENT: &'static str = " ";
185
186 /// Where a code row's text starts: past the gutter.
187 fn code_text_x(&self, r: (f32, f32, f32, f32)) -> f32 {
188 r.0 + 12.0 + (Self::CODE_GUTTER_COLS + 1.0) * self.code_col_w()
189 }
190
191 /// Flag a line of the code row as an error site, or clear it. The host
192 /// calls this when the value it evaluated failed with a line number —
193 /// it is the one piece of feedback a script editor cannot do without.
194 pub fn set_code_error_line(&mut self, line: Option<usize>) {
195 self.code_error_line = line;
196 }
197
198 /// Whether a code row is being edited right now.
199 pub fn code_editing(&self) -> bool {
200 self.focused_param.is_some() && self.code_editor.is_some()
201 }
202
203 /// The clipboard, selection and history actions over the code editor:
204 /// what the runner's undo / redo chords and the context menu's rows
205 /// reach through [`Input::context_action`], and what the editor's own
206 /// chords call. Returns whether the editor was open to act on.
207 pub fn code_action(&mut self, action: crate::widget::ContextAction) -> bool {
208 match self.code_editor.as_mut() {
209 Some(editor) => {
210 apply_code_action(editor, &mut self.code_history, action);
211 true
212 }
213 None => false,
214 }
215 }
216
217 /// Whether the focused code row holds edits not yet applied to its value.
218 pub fn code_is_dirty(&self) -> bool {
219 match (self.focused_param, &self.code_editor) {
220 (Some(i), Some(editor)) => self.display_params.get(i).is_some_and(|p| p.1 != editor.buffer),
221 _ => false,
222 }
223 }
224
225 /// One code column's width — the shaped advance when recorded, else the
226 /// legacy 7.2 estimate (only before the first `prepare_text`).
227 fn code_col_w(&self) -> f32 {
228 if self.code_char_advance > 0.0 {
229 self.code_char_advance
230 } else {
231 7.2
232 }
233 }
234
235 pub fn new() -> Adapted<ParametersBg> {
236 Adapted::new(ParametersBg {
237 inline_labels: crate::layout::param_labels_inline(),
238 rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
239 display_params: Vec::new(),
240 dragging_param: None,
241 focused_param: None,
242 code_editor: None,
243 code_history: crate::history::History::with_limit(200),
244 code_error_line: None,
245 mouse_pos: None,
246 sliders: Vec::new(),
247 float3s: Vec::new(),
248 spinboxes: Vec::new(),
249 buttons: Vec::new(),
250 choices: Vec::new(),
251 texts: Vec::new(),
252 toggles: Vec::new(),
253 colors: Vec::new(),
254 ramps: Vec::new(),
255 collapsed: std::collections::HashSet::new(),
256 visible: true,
257 scroll_y: 0.0,
258 content_h: 0.0,
259 code_char_advance: 0.0,
260 scrollbar_dragging: false,
261 drag_offset_y: 0.0,
262 activity: crate::widget::ScrollbarActivity::new(),
263 scroll_motion: crate::widget::ScrollMotion::new(),
264 tick_value_changed: false,
265 })
266 }
267
268 /// Row `i`'s laid-out height, ignoring collapse (the row-type table).
269 /// Whether row `i` is laid out with its label beside the control. Only
270 /// the rows whose control would otherwise carry a label strip: toggles
271 /// and buttons ARE their label, a ramp keeps its label band, sections
272 /// and code rows have layouts of their own.
273 fn inline_row(&self, i: usize) -> bool {
274 if !self.inline_labels {
275 return false;
276 }
277 let t = self.display_params[i].2.as_str();
278 t.starts_with("slider")
279 || t.starts_with("float3")
280 || t.starts_with("spinbox")
281 || t.starts_with("choice")
282 || is_text_row(t)
283 || t.starts_with("color")
284 || t == "rgb"
285 || t == "rgba"
286 }
287
288 /// The label strip an inline row no longer spends: the control was
289 /// built unlabelled, so the row loses exactly the strip's height.
290 fn inline_strip(&self, i: usize) -> f32 {
291 if self.inline_row(i) { crate::layout::control_label_strip() } else { 0.0 }
292 }
293
294 /// Gap between the label column and the control.
295 const LABEL_GAP: f32 = 16.0;
296
297 /// The inline label's font: the pane's own labels draw in the control
298 /// label font (`Paint::widget_font`), so the column is measured in it —
299 /// family AND size — or the widest label runs into its control.
300 fn inline_label_font() -> (String, f32) {
301 crate::layout::control_label_font_parsed()
302 }
303
304 /// Width of the label column under the inline layout: the widest visible
305 /// inline label plus the gap, clamped to a floor (so a pane of one-word
306 /// labels still reads as a column) and to under half the row (so a long
307 /// label truncates rather than squeezing the control out). Zero when
308 /// nothing is inline.
309 fn label_col_w(&self) -> f32 {
310 if !self.inline_labels {
311 return 0.0;
312 }
313 let (family, size) = Self::inline_label_font();
314 let hidden = self.hidden_rows();
315 let widest = self
316 .display_params
317 .iter()
318 .enumerate()
319 .filter(|(i, _)| !hidden[*i] && self.inline_row(*i))
320 .map(|(_, p)| crate::widget::display::measure_text_width(&p.0, &family, size))
321 .fold(0.0f32, f32::max);
322 if widest <= 0.0 {
323 return 0.0;
324 }
325 let row_w = (self.rect.width - 2.0 * ROW_X_INSET).max(0.0);
326 (widest + Self::LABEL_GAP).clamp(72.0f32.min(row_w * 0.45), row_w * 0.45)
327 }
328
329 /// Row `i`'s CONTROL rect: the row rect less the label column when the
330 /// row is inline, the row rect itself otherwise.
331 fn control_rect(&self, r: (f32, f32, f32, f32), i: usize, lw: f32) -> (f32, f32, f32, f32) {
332 if self.inline_row(i) { (r.0 + lw, r.1, (r.2 - lw).max(0.0), r.3) } else { r }
333 }
334
335 fn row_height(&self, i: usize) -> f32 {
336 let p = &self.display_params[i];
337 if p.2 == "code" {
338 let val_text = if self.focused_param == Some(i) {
339 if let Some(ref editor) = self.code_editor {
340 &editor.buffer
341 } else {
342 &p.1
343 }
344 } else {
345 &p.1
346 };
347 let line_count = val_text.split('\n').count();
348 let content_h = Self::CODE_TOP + (line_count as f32 * Self::CODE_LINE_H) + 12.0;
349 content_h.max(200.0)
350 } else if p.2 == "section" {
351 24.0
352 } else if p.2 == "ramp" {
353 // Label band + the ramp's graph and control strip. The control
354 // strip and label band are fixed, so this whole increase grows the
355 // curve plot (Ramp::graph_h = height − strip).
356 260.0
357 } else if p.2.starts_with("float3") {
358 Float3::preferred_height(!self.inline_row(i))
359 } else if p.2.starts_with("slider") {
360 (38.0 - self.inline_strip(i)).max(22.0)
361 } else if is_text_row(&p.2) || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
362 (42.0 - self.inline_strip(i)).max(24.0)
363 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
364 (40.0 - self.inline_strip(i)).max(24.0)
365 } else if p.2 == "button" || p.2 == "toggle" || p.2 == "checkbox" {
366 24.0
367 } else {
368 20.0
369 }
370 }
371
372 /// Per-row "sits inside a collapsed section" flags: a section owns every row between its
373 /// header and the next header. Headers themselves are never hidden — they stay clickable
374 /// so a collapsed section can be reopened. Rows before the first header belong to no
375 /// section and are always shown.
376 fn hidden_rows(&self) -> Vec<bool> {
377 let mut out = Vec::with_capacity(self.display_params.len());
378 let mut hiding = false;
379 for p in &self.display_params {
380 if p.2 == "section" {
381 hiding = self.collapsed.contains(&p.0);
382 out.push(false);
383 } else {
384 out.push(hiding);
385 }
386 }
387 out
388 }
389
390 /// Whether the section titled `title` is collapsed.
391 pub fn section_collapsed(&self, title: &str) -> bool {
392 self.collapsed.contains(title)
393 }
394
395 /// Collapse/expand the section titled `title`, re-laying the rows. Keyed by title, not
396 /// row index, so the state survives the `set_display_params` rebuilds a host runs
397 /// whenever the inspected node changes.
398 pub fn set_section_collapsed(&mut self, title: &str, collapsed: bool) {
399 let changed = if collapsed {
400 self.collapsed.insert(title.to_string())
401 } else {
402 self.collapsed.remove(title)
403 };
404 if changed {
405 self.refresh_scroll_metrics();
406 }
407 }
408
409 /// The box drawn around a section header's title — and, since the title is the collapse
410 /// affordance, that box is also the header's click target.
411 fn section_title_box(&self, hdr: usize, r_hdr: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
412 // The label sits 8px in from the box's left edge; matching that 8px on the right
413 // (box width = text + 16) centers the text in the box. Measure the run in the
414 // label's real family AND size — parsed, not the raw "Family NN" spec string, which
415 // resvg can't resolve (it would fall back to a narrow font and undersize the box).
416 let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
417 let (label_family, label_size) = crate::layout::control_label_font_parsed();
418 let text_w =
419 crate::widget::display::measure_text_width(&self.display_params[hdr].0, &label_family, label_size);
420 let title_w = (text_w + 16.0).max(SECTION_TITLE_MIN_W).min(full_w);
421 // The tab sits FLUSH on the content body: its bottom edge is the body's top
422 // edge, in every style (the relief carve always drew it there; the outline now
423 // fuses to it too). Collapsed, the box stays where the expanded tab sits (one
424 // title-box height above where the body's top edge would be), so collapsing
425 // doesn't jump the tab — and the click-toggle hit zone follows the ink. An
426 // expanded-but-empty section keeps the legacy header-row placement.
427 let y = if self.collapsed.contains(&self.display_params[hdr].0) {
428 r_hdr.1 + r_hdr.3 + ROW_GAP - CONTENT_BOX_PAD - TITLE_BOX_H
429 } else {
430 match self.first_visible_row_top(hdr) {
431 Some(control_top) => control_top - CONTENT_BOX_PAD - TITLE_BOX_H,
432 None => r_hdr.1 - TITLE_BOX_INSET,
433 }
434 };
435 (self.rect.x + SECTION_MARGIN, y, title_w, TITLE_BOX_H)
436 }
437
438 /// The top edge of the first visible row under header `hdr` — the row the section's
439 /// content box (and so its tab) hangs from. `None` for an empty section (a header
440 /// with no rows of its own before the next header).
441 fn first_visible_row_top(&self, hdr: usize) -> Option<f32> {
442 let hidden = self.hidden_rows();
443 let rects = self.get_param_rects();
444 self.display_params
445 .iter()
446 .enumerate()
447 .skip(hdr + 1)
448 .take_while(|(_, q)| q.2 != "section")
449 .find(|(j, _)| !hidden[*j])
450 .map(|(j, _)| rects[j].1)
451 }
452
453 /// Each section as `(header index, its content-row range)` — the rows between a header
454 /// and the next one, `None` for a header with nothing under it.
455 fn sections(&self) -> Vec<(usize, Option<(usize, usize)>)> {
456 let mut out: Vec<(usize, Option<(usize, usize)>)> = Vec::new();
457 for (i, p) in self.display_params.iter().enumerate() {
458 if p.2 == "section" {
459 out.push((i, None));
460 } else if let Some((_, content)) = out.last_mut() {
461 match content {
462 Some((_, end)) => *end = i,
463 None => *content = Some((i, i)),
464 }
465 }
466 }
467 out
468 }
469
470 /// Each section's boxes: the title box, plus the box wrapping its rows (`None` when the
471 /// section is collapsed or has no rows). The shared source for the outline's straight
472 /// runs and its corner fillets, so the two halves can't disagree.
473 /// The row floors (`layout::param_compression`): the pane material
474 /// frosted at the configured compression, filled under every visible
475 /// parameter row (headers excepted) before anything else in the pane
476 /// paints, at the control corner radius — each parameter on its own
477 /// tablet, the way the designer's node bodies get their own compression.
478 /// Nothing when the key is unset. Relief only: the flat style has no
479 /// floor language.
480 fn paint_row_floors(&self, ctx: &mut PaintCtx) {
481 let Some(k) = crate::layout::param_compression() else { return };
482 if !crate::layout::control_relief() {
483 return;
484 }
485 let mut mat = crate::scene::Material::pane();
486 if let crate::scene::Frost::Frosted { compression, .. } = &mut mat.frost {
487 *compression = k;
488 }
489 let r = crate::layout::control_corner_radius();
490 let hidden = self.hidden_rows();
491 for (i, (x, y, w, h)) in self.get_param_rects().into_iter().enumerate() {
492 if hidden[i] || h <= 0.0 || self.display_params[i].2 == "section" {
493 continue;
494 }
495 ctx.fill_material(Rect { x, y, width: w, height: h }, (r, r, r, r), &mat);
496 }
497 }
498
499 fn section_boxes(&self) -> Vec<((f32, f32, f32, f32), Option<(f32, f32, f32, f32)>)> {
500 let rects = self.get_param_rects();
501 let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
502 let mut out = Vec::new();
503 for (hdr, content) in self.sections() {
504 if hdr >= rects.len() {
505 continue;
506 }
507 let title = self.section_title_box(hdr, rects[hdr]);
508 let content_box = if self.collapsed.contains(&self.display_params[hdr].0) {
509 None
510 } else {
511 content.and_then(|(start, end)| {
512 if start <= end && start < rects.len() && end < rects.len() {
513 let by = rects[start].1 - CONTENT_BOX_PAD;
514 let bh = (rects[end].1 + rects[end].3 + CONTENT_BOX_PAD) - by;
515 Some((title.0, by, full_w, bh))
516 } else {
517 None
518 }
519 })
520 };
521 out.push((title, content_box));
522 }
523 out
524 }
525
526 /// One section's outline: a SINGLE continuous border shaped like a folder tab — around
527 /// the title box, whose bottom edge is open onto the content body it sits flush on
528 /// (its right side turning onto the body's top edge through the concave throat fillet,
529 /// its left side running straight down into the body's left edge) — as
530 /// `(straight runs, corner fillets)`.
531 ///
532 /// Runs are `(x, y, w, h)`; fillets are `(cx, cy, radius, start, end)` for an arc stroked
533 /// `SECTION_BORDER_T` inward of `radius` (the renderer's convention). Convex corners take
534 /// the stroke inside the box, so their radius is the outer one; the concave throat corner
535 /// has its centre out in the empty pocket, so its carries the `+ T` that puts the
536 /// ink on the far side. Every run stops a radius short of its corner, and each fillet
537 /// picks it up there — the path closes.
538 fn section_outline(
539 &self,
540 title: (f32, f32, f32, f32),
541 content: Option<(f32, f32, f32, f32)>,
542 ) -> (Vec<(f32, f32, f32, f32)>, Vec<(f32, f32, f32, f32, f32)>) {
543 use std::f32::consts::{PI, TAU};
544 const Q: f32 = std::f32::consts::FRAC_PI_2;
545 let (t, r) = (SECTION_BORDER_T, SECTION_R);
546 let mut quads: Vec<(f32, f32, f32, f32)> = Vec::new();
547 let mut arcs: Vec<(f32, f32, f32, f32, f32)> = Vec::new();
548 fn hrun(out: &mut Vec<(f32, f32, f32, f32)>, x0: f32, x1: f32, y: f32) {
549 if x1 - x0 > 0.01 {
550 out.push((x0, y, x1 - x0, SECTION_BORDER_T));
551 }
552 }
553 fn vrun(out: &mut Vec<(f32, f32, f32, f32)>, y0: f32, y1: f32, x: f32) {
554 if y1 - y0 > 0.01 {
555 out.push((x, y0, SECTION_BORDER_T, y1 - y0));
556 }
557 }
558
559 let (tx, ty, tw, th) = title;
560 let ty_b = ty + th; // the title box's bottom edge
561 arcs.push((tx + r, ty + r, r, PI, PI + Q)); // title top-left
562 arcs.push((tx + tw - r, ty + r, r, PI + Q, TAU)); // title top-right
563 hrun(&mut quads, tx + r, tx + tw - r, ty); // title top
564
565 let Some((cx, cy_t, cw, ch)) = content else {
566 // Collapsed or empty: the title box IS the section, so it closes on itself.
567 arcs.push((tx + tw - r, ty_b - r, r, 0.0, Q)); // title bottom-right
568 arcs.push((tx + r, ty_b - r, r, Q, PI)); // title bottom-left
569 vrun(&mut quads, ty + r, ty_b - r, tx + tw - t); // title right
570 vrun(&mut quads, ty + r, ty_b - r, tx); // title left
571 hrun(&mut quads, tx + r, tx + tw - r, ty_b - t); // title bottom
572 return (quads, arcs);
573 };
574
575 // The tab sits flush on the body (`ty_b == cy_t` — `section_title_box` places it
576 // there): its bottom edge is open. The throat fillet shrinks if the tab runs
577 // close to the body's right corner.
578 let f = SECTION_THROAT_R.min((cx + cw - r - (tx + tw)).max(0.0));
579 vrun(&mut quads, ty + r, cy_t - f, tx + tw - t); // tab right side, down to the throat
580 arcs.push((tx + tw + f, cy_t - f, f + t, Q, PI)); // throat: tab side -> body top
581 hrun(&mut quads, tx + tw + f, cx + cw - r, cy_t); // body top, right of the tab
582
583 arcs.push((cx + cw - r, cy_t + r, r, PI + Q, TAU)); // body top-right
584 arcs.push((cx + cw - r, cy_t + ch - r, r, 0.0, Q)); // body bottom-right
585 arcs.push((cx + r, cy_t + ch - r, r, Q, PI)); // body bottom-left
586 vrun(&mut quads, cy_t + r, cy_t + ch - r, cx + cw - t); // body right
587 hrun(&mut quads, cx + r, cx + cw - r, cy_t + ch - t); // body bottom
588 vrun(&mut quads, ty + r, cy_t + ch - r, tx); // tab + body left, one straight run
589
590 (quads, arcs)
591 }
592
593 /// The section outlines' corner fillets, as the renderer's arc tuples
594 /// `(cx, cy, radius, thickness, start, end, color)`.
595 ///
596 /// A concrete accessor rather than prims out of [`Paint::paint`] on purpose: the host
597 /// draws this panel through the legacy plain-quad hatch, and `append_widget_plate` serves
598 /// a widget's arcs UNCLIPPED — these have to land inside the pane's scroll viewport, so
599 /// the host draws them itself under its own clip (the straight runs ride `plain_quads`,
600 /// which clips them there by hand).
601 pub fn arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> {
602 if !self.visible || crate::layout::control_relief() {
603 return Vec::new();
604 }
605 let color = SECTION_BORDER_COLOR;
606 let mut out = Vec::new();
607 for (title, content) in self.section_boxes() {
608 let (_, arcs) = self.section_outline(title, content);
609 out.extend(
610 arcs.into_iter()
611 .map(|(cx, cy, r, a0, a1)| (cx, cy, r, SECTION_BORDER_T, a0, a1, color)),
612 );
613 }
614 out
615 }
616
617 /// Where the rows start, in absolute y — the origin `get_param_rects` lays out from.
618 fn rows_origin(&self) -> f32 {
619 self.rect.y - self.scroll_y
620 }
621
622 /// The scrollable height of the row column. Derived from [`Self::get_param_rects`] rather
623 /// than re-walking the advance rules, so the two can't drift apart.
624 pub fn get_total_content_height(&self) -> f32 {
625 let hidden = self.hidden_rows();
626 let rects = self.get_param_rects();
627 let bottom = rects
628 .iter()
629 .enumerate()
630 .filter(|(i, _)| !hidden[*i])
631 .map(|(_, r)| r.1 + r.3)
632 .fold(f32::NEG_INFINITY, f32::max);
633 if bottom == f32::NEG_INFINITY {
634 return 30.0 + 10.0; // no rows: the top offset plus the bottom padding
635 }
636 (bottom - self.rows_origin()) + ROW_GAP + 10.0
637 }
638
639 /// One rect per row, in index order — collapsed rows get a zero-height rect at the
640 /// current cursor (and consume no vertical space), so every index-parallel consumer
641 /// keeps working while the row draws and hit-tests as nothing.
642 ///
643 /// Rows advance on a uniform [`ROW_GAP`] pitch, except a section header, which is placed
644 /// [`SECTION_GAP`] below the previous section's drawn bottom edge — see [`SECTION_GAP`].
645 pub fn get_param_rects(&self) -> Vec<(f32, f32, f32, f32)> {
646 let hidden = self.hidden_rows();
647 let mut rects = Vec::new();
648 let mut cur_y = self.rows_origin() + 30.0;
649 // Bottom edge of the last row as DRAWN: a row inside a section is wrapped by the
650 // content box, which overhangs it; a header with no visible rows under it (empty or
651 // collapsed) ends at its own title box. `None` until the first section starts —
652 // rows above it are bare, with no box to measure against.
653 let mut prev_bottom: Option<f32> = None;
654 for i in 0..self.display_params.len() {
655 if hidden[i] {
656 rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, 0.0));
657 continue;
658 }
659 let is_header = self.display_params[i].2 == "section";
660 if is_header {
661 if let Some(bottom) = prev_bottom {
662 cur_y = bottom + SECTION_GAP + TITLE_BOX_INSET;
663 }
664 }
665 let h = self.row_height(i);
666 rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, h));
667 prev_bottom = Some(if is_header {
668 cur_y - TITLE_BOX_INSET + TITLE_BOX_H
669 } else if prev_bottom.is_some() {
670 cur_y + h + CONTENT_BOX_PAD
671 } else {
672 cur_y + h
673 });
674 cur_y += h + ROW_GAP;
675 }
676 rects
677 }
678
679 /// The pane's scrollbar width — the DE `scrollbar_width` widened: the bar
680 /// rides over the section carves and reads too slim at the stock width.
681 fn scrollbar_w(&self) -> f32 {
682 crate::layout::scrollbar_width() * 1.6
683 }
684
685 /// The scrollbar's left x: the bar rides the pane's CENTRE line, as
686 /// every sink-behind bar in the DE does (cce-mail's list and body, the
687 /// designer's dialog list) — over the rows, reserving no lane, in front
688 /// only while raised. It sat a sixth of the width in from the right
689 /// edge before 2026-09-21, which beside a centred bar read as off.
690 fn scrollbar_x(&self) -> f32 {
691 self.rect.x + (self.rect.width - self.scrollbar_w()) * 0.5
692 }
693
694 pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
695 if self.content_h <= self.rect.height {
696 return false;
697 }
698 let sb_w = self.scrollbar_w();
699 let sb_x = self.scrollbar_x();
700 let sb_track_h = self.rect.height - 8.0;
701 let sb_track_y = self.rect.y + 4.0;
702
703 px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0
704 && py >= sb_track_y && py <= sb_track_y + sb_track_h
705 }
706
707 /// Whether the pane holds enough content to need a scrollbar at all.
708 pub fn scrollbar_visible(&self) -> bool {
709 self.content_h > self.rect.height
710 }
711
712 /// Whether the scrollbar is currently raised in front of the pane plate (the latched
713 /// state). While this is false the bar sits behind the plate and is non-interactive.
714 pub fn scrollbar_active(&self) -> bool {
715 self.activity.raised()
716 }
717
718 /// Re-latch the shared hysteresis with this pane's inputs, returning whether it changed.
719 fn recompute_scrollbar_raised(&mut self) -> bool {
720 let visible = self.scrollbar_visible();
721 self.activity.recompute(visible, self.scrollbar_dragging)
722 }
723
724 /// The scrollbar's track + thumb quads (empty when no scrollbar is needed). The host draws
725 /// these either behind or in front of the pane plate per [`Self::scrollbar_active`]; they
726 /// are deliberately kept out of [`Self::plain_quads`] so the host controls their depth.
727 pub fn scrollbar_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
728 if !self.scrollbar_visible() {
729 return Vec::new();
730 }
731 let sb_w = self.scrollbar_w();
732 let sb_x = self.scrollbar_x();
733 let sb_track_h = self.rect.height - 8.0;
734 let sb_track_y = self.rect.y + 4.0;
735
736 let visible_ratio = self.rect.height / self.content_h;
737 let thumb_h = if sb_track_h <= 20.0 {
738 sb_track_h
739 } else {
740 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
741 };
742 let max_scroll = self.content_h - self.rect.height;
743 let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
744 let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
745
746 vec![
747 (sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()),
748 (sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()),
749 ]
750 }
751
752 fn update_slider_rects(&mut self) {
753 // Inline rows hand their control the row less the label column; the
754 // row rects themselves (`get_param_rects`) stay the full row, which is
755 // what the floors, the section boxes and hit routing measure against.
756 let lw = self.label_col_w();
757 let rects: Vec<(f32, f32, f32, f32)> = self
758 .get_param_rects()
759 .into_iter()
760 .enumerate()
761 .map(|(i, r)| self.control_rect(r, i, lw))
762 .collect();
763 let inline: Vec<bool> = (0..self.display_params.len()).map(|i| self.inline_row(i)).collect();
764 for (i, s_opt) in self.sliders.iter_mut().enumerate() {
765 if let Some(s) = s_opt {
766 let r = rects[i];
767 s.set_rect(r.0, r.1, r.2, r.3);
768 }
769 }
770 for (i, f_opt) in self.float3s.iter_mut().enumerate() {
771 if let Some(f) = f_opt {
772 let r = rects[i];
773 f.set_rect(r.0, r.1, r.2, r.3);
774 }
775 }
776 for (i, sb_opt) in self.spinboxes.iter_mut().enumerate() {
777 if let Some(sb) = sb_opt {
778 let r = rects[i];
779 sb.set_rect(r.0, r.1, r.2, r.3);
780 }
781 }
782 for (i, b_opt) in self.buttons.iter_mut().enumerate() {
783 if let Some(b) = b_opt {
784 let r = rects[i];
785 b.set_rect(r.0, r.1, r.2, r.3);
786 }
787 }
788 for (i, d_opt) in self.choices.iter_mut().enumerate() {
789 if let Some(d) = d_opt {
790 let r = rects[i];
791 if self.display_params[i].2.starts_with("textpick") {
792 // The picker button nests INSIDE the text box's recessed
793 // well (the box spans the full row): below the detached
794 // label band, its face reaching exactly to the base of
795 // the well's wall (inset = the carve depth) on the sides
796 // it adjoins — there the WELL'S OWN BEVEL is the seam's
797 // far side, and the trough ([`Self::troughs`]) carves
798 // only the interior left edge. A ring encapsulated
799 // within the bevel doubled the valley on the adjoining
800 // sides.
801 let label_top = if inline[i] { 0.0 } else { crate::layout::control_label_strip() };
802 let band_h = r.3 - label_top;
803 let inset = crate::layout::bevel_width().min(band_h * 0.2);
804 let by = r.1 + label_top + inset;
805 let bh = (band_h - 2.0 * inset).max(8.0);
806 d.set_rect(r.0 + r.2 - PICK_W - inset, by, PICK_W, bh);
807 // The menu hangs off the WHOLE field, not the button
808 // sliver: anchor the popover to the box's well band.
809 d.popover_anchor = Some(Rect {
810 x: r.0,
811 y: r.1 + label_top,
812 width: r.2,
813 height: r.3 - label_top,
814 });
815 } else {
816 d.set_rect(r.0, r.1, r.2, r.3);
817 }
818 }
819 }
820 for (i, tb_opt) in self.texts.iter_mut().enumerate() {
821 if let Some(tb) = tb_opt {
822 let r = rects[i];
823 tb.set_rect(r.0, r.1, r.2, r.3);
824 }
825 }
826 for (i, cb_opt) in self.toggles.iter_mut().enumerate() {
827 if let Some(cb) = cb_opt {
828 let r = rects[i];
829 cb.set_rect(r.0, r.1, r.2, r.3);
830 }
831 }
832 for (i, c_opt) in self.colors.iter_mut().enumerate() {
833 if let Some(c) = c_opt {
834 let r = rects[i];
835 c.set_rect(r.0, r.1, r.2, r.3);
836 }
837 }
838 for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
839 if let Some(rp) = rp_opt {
840 let r = rects[i];
841 // Below the 18px label band own_text_labels draws (the ramp
842 // carries no label of its own).
843 rp.set_rect(r.0, r.1 + 18.0, r.2, r.3 - 18.0);
844 }
845 }
846 }
847
848 /// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
849 /// scroll into it, re-lay the rows.
850 fn refresh_scroll_metrics(&mut self) {
851 self.content_h = self.get_total_content_height();
852 let max_scroll = (self.content_h - self.rect.height).max(0.0);
853 self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
854 self.update_slider_rects();
855 }
856
857 fn own_text_labels(&self) -> Vec<TextLabel> {
858 let rects = self.get_param_rects();
859 let hidden = self.hidden_rows();
860 let mut labels = Vec::new();
861 let lw = self.label_col_w();
862 let (family, size) = Self::inline_label_font();
863 for (i, (name, value, ptype)) in self.display_params.iter().enumerate() {
864 if hidden[i] {
865 continue;
866 }
867 let r = rects[i];
868 if self.inline_row(i) {
869 // The pane's own label, in the column beside an unlabelled
870 // control: vertically centred on the row, tail-truncated to
871 // the column.
872 let em = crate::widget::display::measure_text_width("M", &family, size).max(1.0);
873 let cols = ((lw - Self::LABEL_GAP) / em).floor().max(0.0) as usize;
874 labels.push(TextLabel {
875 text: crate::widget::display::truncate_tail(name, cols),
876 x: r.0,
877 y: r.1 + (r.3 - size) * 0.5,
878 font_size: size,
879 color: [0xaa, 0xaa, 0xbb],
880 });
881 }
882 if ptype.starts_with("slider") {
883 if let Some(s) = &self.sliders[i] {
884 labels.extend(s.own_text_labels());
885 }
886 } else if ptype.starts_with("float3") {
887 if let Some(f) = &self.float3s[i] {
888 labels.extend(f.own_text_labels());
889 }
890 } else if ptype == "section" {
891 // Centered within the tab itself — `section_title_box` is the one
892 // source for where the tab sits (flush on the body; collapsed and
893 // empty sections carry their own placements there).
894 let font_size = 13.0;
895 let (bx, by, _, _) = self.section_title_box(i, r);
896 labels.push(TextLabel {
897 text: name.clone(),
898 // 8px in from the title box's left edge — the inset
899 // `section_title_box`'s width math centers against.
900 x: bx + 8.0,
901 y: by + (TITLE_BOX_H - font_size) / 2.0,
902 font_size,
903 color: [0xee, 0xee, 0xf0],
904 });
905 } else if ptype == "code" {
906 labels.push(TextLabel {
907 text: format!("{}:", name),
908 x: self.rect.x + 12.0,
909 y: r.1,
910 font_size: 12.0,
911 color: [0xaa, 0xaa, 0xbb],
912 });
913 let val_text = if self.focused_param == Some(i) {
914 if let Some(ref editor) = self.code_editor {
915 editor.buffer.clone()
916 } else {
917 value.clone()
918 }
919 } else {
920 value.clone()
921 };
922 // One label PER LINE, at the same pitch the cursor math uses
923 // (`plain_quads`' cursor_y) — a single multi-line label would depend on
924 // the consumer's buffer line-height matching that pitch, and never
925 // exactly did. A line number sits in the gutter before each.
926 let text_x = self.code_text_x(r);
927 for (line_i, line) in val_text.split('\n').enumerate() {
928 let y = r.1 + Self::CODE_TOP + line_i as f32 * Self::CODE_LINE_H;
929 let number = format!("{:>3}", line_i + 1);
930 labels.push(TextLabel {
931 text: number,
932 x: r.0 + 12.0,
933 y,
934 font_size: 12.0,
935 color: if self.code_error_line == Some(line_i) { [0xff, 0x80, 0x70] } else { [0x66, 0x66, 0x78] },
936 });
937 if line.is_empty() {
938 continue;
939 }
940 labels.push(TextLabel {
941 text: line.to_string(),
942 x: text_x,
943 y,
944 font_size: 12.0,
945 color: [0xee, 0xee, 0xf0],
946 });
947 }
948 if self.focused_param == Some(i) && self.code_is_dirty() {
949 labels.push(TextLabel {
950 text: "ctrl+enter applies".to_string(),
951 x: r.0 + r.2 - 12.0 - 18.0 * self.code_col_w(),
952 y: r.1 + r.3 - 15.0,
953 font_size: 12.0,
954 color: [0xd8, 0xa0, 0x50],
955 });
956 }
957 } else if ptype.starts_with("spinbox") {
958 if let Some(sb) = &self.spinboxes[i] {
959 labels.extend(sb.own_text_labels());
960 }
961 } else if is_text_row(ptype) {
962 if let Some(tb) = &self.texts[i] {
963 labels.extend(tb.own_text_labels());
964 }
965 if let Some(d) = &self.choices[i] {
966 labels.extend(d.own_text_labels());
967 }
968 } else if ptype.starts_with("choice") {
969 if let Some(d) = &self.choices[i] {
970 labels.extend(d.own_text_labels());
971 }
972 } else if ptype == "button" {
973 if let Some(b) = &self.buttons[i] {
974 labels.extend(b.own_text_labels());
975 }
976 } else if ptype == "toggle" || ptype == "checkbox" {
977 if let Some(cb) = &self.toggles[i] {
978 labels.extend(cb.own_text_labels());
979 }
980 } else if ptype.starts_with("color") || ptype == "rgb" || ptype == "rgba" {
981 if let Some(c) = &self.colors[i] {
982 labels.extend(c.own_text_labels());
983 }
984 } else if ptype == "ramp" {
985 // The name label only — the ramp's own control labels ride its
986 // scene-path paint (paint_scene_rows).
987 labels.push(TextLabel {
988 text: name.clone(),
989 x: r.0,
990 y: r.1,
991 font_size: 12.0,
992 color: [0xaa, 0xaa, 0xbb],
993 });
994 } else {
995 labels.push(TextLabel {
996 text: format!("{}: {}", name, value),
997 x: self.rect.x + ROW_X_INSET,
998 y: r.1,
999 font_size: 12.0,
1000 color: [0xaa, 0xaa, 0xbb],
1001 });
1002 }
1003 }
1004 labels
1005 }
1006
1007 /// The dropdown rows' popover, if one is open — the widget's OWN popover surface
1008 /// ([`Paint::popover`]); the raw `children`'s popovers are the adapter's recursion.
1009 /// The ramp rows' field dropdowns count too.
1010 fn choices_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1011 for d_opt in &self.choices {
1012 if let Some(d) = d_opt {
1013 if let Some(r) = d.popover_rect() {
1014 return Some(r);
1015 }
1016 }
1017 }
1018 for rp_opt in &self.ramps {
1019 if let Some(rp) = rp_opt {
1020 let ramp = rp.inner();
1021 if let Some(r) = ramp
1022 .preset_dropdown
1023 .popover_rect()
1024 .or_else(|| ramp.line_type_dropdown.popover_rect())
1025 {
1026 return Some(r);
1027 }
1028 }
1029 }
1030 None
1031 }
1032
1033 /// The full legacy popover reach (choices, then children) — hit-testing extends to it.
1034 fn own_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1035 if let Some(r) = self.choices_popover_rect() {
1036 return Some(r);
1037 }
1038 None
1039 }
1040
1041 /// The state half of the legacy `unfocus`: commit the focused row's in-flight value back
1042 /// into `display_params`, drop the code editor, and unfocus the raw children.
1043 fn commit_and_unfocus(&mut self) {
1044 if let Some(idx) = self.focused_param {
1045 if idx < self.display_params.len() {
1046 let p = &mut self.display_params[idx];
1047 if p.2.starts_with("spinbox") {
1048 if let Some(sb) = &mut self.spinboxes[idx] {
1049 sb.unfocus();
1050 p.1 = sb.value.to_string();
1051 }
1052 } else if p.2.starts_with("slider") {
1053 if let Some(s) = &mut self.sliders[idx] {
1054 s.unfocus();
1055 let (min, max) = parse_slider_range(&p.2);
1056 let new_val = min + s.value * (max - min);
1057 p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
1058 }
1059 } else if p.2.starts_with("float3") {
1060 if let Some(f) = &mut self.float3s[idx] {
1061 f.unfocus();
1062 p.1 = f.value_string();
1063 }
1064 } else if is_text_row(&p.2) {
1065 if let Some(tb) = &mut self.texts[idx] {
1066 tb.unfocus();
1067 p.1 = tb.text.clone();
1068 }
1069 if let Some(d) = &mut self.choices[idx] {
1070 d.unfocus();
1071 }
1072 } else if p.2.starts_with("choice") {
1073 if let Some(d) = &mut self.choices[idx] {
1074 d.unfocus();
1075 if let Some(val) = d.get_value_string() {
1076 p.1 = val;
1077 }
1078 }
1079 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1080 if let Some(c) = &mut self.colors[idx] {
1081 c.unfocus();
1082 if let Some(val) = c.get_value_string() {
1083 p.1 = val;
1084 }
1085 }
1086 } else if p.2 == "code" {
1087 if let Some(ref editor) = self.code_editor {
1088 p.1 = editor.buffer.clone();
1089 }
1090 self.code_editor = None;
1091 self.code_history.clear();
1092 }
1093 }
1094 }
1095 self.focused_param = None;
1096 }
1097
1098 /// The legacy `extra_quads` body: section border boxes, every row's chrome (slider/spinbox
1099 /// backgrounds read via `rect()`+`color()`, the code editor's box/border/cursor), the raw
1100 /// children via [`collect_child_quads`], all clipped to the viewport — plus the unclipped
1101 /// scrollbar. Served verbatim through [`Paint::legacy_plain_quads`].
1102 fn plain_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1103 if !self.visible {
1104 return Vec::new();
1105 }
1106 let mut quads = Vec::new();
1107 let rects = self.get_param_rects();
1108 let view_min = self.rect.y + 4.0;
1109 let view_max = self.rect.y + self.rect.height - 4.0;
1110
1111 let clip_quad = |q: (f32, f32, f32, f32, [f32; 4])| -> Option<(f32, f32, f32, f32, [f32; 4])> {
1112 let (qx, qy, qw, qh, qc) = q;
1113 let y1 = qy.max(view_min);
1114 let y2 = (qy + qh).min(view_max);
1115 if y1 < y2 {
1116 Some((qx, y1, qw, y2 - y1, qc))
1117 } else {
1118 None
1119 }
1120 };
1121
1122 let mut param_quads = Vec::new();
1123
1124 // Each section is drawn as ONE continuous outline — around the title, down the
1125 // neck, around the content rows — whose straight runs are these quads; its corner
1126 // fillets ride `arcs()`, which the host draws under the same clip. Under
1127 // `control_relief` the outline is replaced by the inset carves served
1128 // through `reliefs` (title tab + content body recessed).
1129 if !crate::layout::control_relief() {
1130 for (title, content) in self.section_boxes() {
1131 let (runs, _) = self.section_outline(title, content);
1132 param_quads.extend(runs.into_iter().map(|(x, y, w, h)| (x, y, w, h, SECTION_BORDER_COLOR)));
1133 }
1134 }
1135
1136 let hidden = self.hidden_rows();
1137 for (i, p) in self.display_params.iter().enumerate() {
1138 if hidden[i] {
1139 continue;
1140 }
1141 let r = rects[i];
1142 if p.2.starts_with("slider") {
1143 if let Some(s) = &self.sliders[i] {
1144 let (sx, sy, sw, sh) = s.rect();
1145 param_quads.push((sx, sy, sw, sh, s.color()));
1146 param_quads.extend(s.extra_quads());
1147 }
1148 } else if p.2 == "section" {
1149 // Section header line is handled by the border box top border now
1150 } else if p.2.starts_with("float3") {
1151 if let Some(f) = &self.float3s[i] {
1152 param_quads.extend(f.extra_quads());
1153 }
1154 } else if p.2 == "code" {
1155 let (bx, by, bw, bh) = (r.0, r.1 + Self::CODE_BOX_TOP, r.2, r.3 - Self::CODE_BOX_TOP);
1156 param_quads.push((bx, by, bw, bh, [0.08, 0.08, 0.10, 1.0]));
1157 let focused = self.focused_param == Some(i);
1158 // Amber while edits are pending, blue while focused and
1159 // applied, grey at rest: the border says whether what the
1160 // node runs is what the box shows.
1161 let border_color = if focused && self.code_is_dirty() {
1162 [0.85, 0.60, 0.25, 1.0]
1163 } else if focused {
1164 [0.25, 0.45, 0.85, 1.0]
1165 } else {
1166 [0.20, 0.20, 0.25, 1.0]
1167 };
1168 let text_x = self.code_text_x(r);
1169 let col_w = self.code_col_w();
1170 let line_y = |l: usize| by + (Self::CODE_TOP - Self::CODE_BOX_TOP) + l as f32 * Self::CODE_LINE_H;
1171 // The gutter's edge.
1172 param_quads.push((text_x - col_w * 0.5, by + 1.0, 1.0, bh - 2.0, [0.16, 0.16, 0.20, 1.0]));
1173 // The error band, under the flagged line.
1174 if let Some(err_line) = self.code_error_line {
1175 let y = line_y(err_line);
1176 if y >= by && y + Self::CODE_LINE_H <= by + bh {
1177 param_quads.push((bx + 1.0, y, bw - 2.0, Self::CODE_LINE_H, [0.45, 0.12, 0.10, 1.0]));
1178 }
1179 }
1180 if focused {
1181 if let Some(ref editor) = self.code_editor {
1182 // Selection: one band per line it covers.
1183 if let Some((start, end)) = editor.selected_range() {
1184 let (sl, sc) = get_cursor_line_col(&editor.buffer, start);
1185 let (el, ec) = get_cursor_line_col(&editor.buffer, end);
1186 let line_len = |l: usize| editor.buffer.split('\n').nth(l).map_or(0, |s| s.chars().count());
1187 for l in sl..=el {
1188 let c0 = if l == sl { sc } else { 0 };
1189 let c1 = if l == el { ec } else { line_len(l) + 1 };
1190 let y = line_y(l);
1191 if y >= by && y + Self::CODE_LINE_H <= by + bh && c1 > c0 {
1192 param_quads.push((
1193 text_x + c0 as f32 * col_w,
1194 y,
1195 (c1 - c0) as f32 * col_w,
1196 Self::CODE_LINE_H,
1197 [0.22, 0.32, 0.55, 1.0],
1198 ));
1199 }
1200 }
1201 }
1202 let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
1203 let cursor_x = text_x + (cursor_c as f32 * col_w);
1204 let cursor_y = line_y(cursor_l) + (Self::CODE_LINE_H - 13.0) / 2.0;
1205 if cursor_y >= by && cursor_y + 13.0 <= by + bh {
1206 param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
1207 }
1208 }
1209 }
1210 param_quads.push((bx, by, bw, 1.0, border_color));
1211 param_quads.push((bx, by + bh - 1.0, bw, 1.0, border_color));
1212 param_quads.push((bx, by, 1.0, bh, border_color));
1213 param_quads.push((bx + bw - 1.0, by, 1.0, bh, border_color));
1214 } else if is_text_row(&p.2) {
1215 if let Some(tb) = &self.texts[i] {
1216 param_quads.extend(tb.extra_quads());
1217 }
1218 if let Some(d) = &self.choices[i] {
1219 param_quads.extend(d.extra_quads());
1220 }
1221 } else if p.2.starts_with("choice") {
1222 if let Some(d) = &self.choices[i] {
1223 param_quads.extend(d.extra_quads());
1224 }
1225 } else if p.2 == "button" {
1226 if let Some(b) = &self.buttons[i] {
1227 param_quads.extend(b.extra_quads());
1228 }
1229 } else if p.2.starts_with("spinbox") {
1230 if let Some(sb) = &self.spinboxes[i] {
1231 { let (bx, by, bw, bh) = sb.rect(); param_quads.push((bx, by, bw, bh, sb.color())); }
1232 param_quads.extend(sb.extra_quads());
1233 }
1234 } else if p.2 == "toggle" || p.2 == "checkbox" {
1235 if let Some(cb) = &self.toggles[i] {
1236 param_quads.extend(cb.extra_quads());
1237 }
1238 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1239 if let Some(c) = &self.colors[i] {
1240 param_quads.extend(c.extra_quads());
1241 }
1242 }
1243 }
1244
1245 // Clip all parameter quads vertically
1246 for q in param_quads {
1247 if let Some(clipped) = clip_quad(q) {
1248 quads.push(clipped);
1249 }
1250 }
1251
1252 // The scrollbar is NOT emitted here: the host draws it via `scrollbar_quads`, above
1253 // or below the pane plate depending on `scrollbar_active`.
1254
1255 quads
1256 }
1257
1258 /// The rounded companion to [`Self::plain_quads`]: the row controls whose boxes are
1259 /// `Prim::RoundedRect` (textbox, dropdown, button, toggle, color selector). Those
1260 /// backgrounds never reach the plain view — `own_plain_quads` keeps `Prim::Quad` only —
1261 /// so a host that renders this panel through the legacy plain-quad hatch must read this
1262 /// getter too or the controls draw as bare text. Returned unclipped; the host clips to
1263 /// the pane's scroll viewport when it pushes vertices. Tuple layout matches
1264 /// `all_rounded_quads`: (x, y, w, h, radius, color, (tl, tr, br, bl)).
1265 pub fn rounded_quads(
1266 &self,
1267 ctx: &UiContext,
1268 ) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
1269 if !self.visible {
1270 return Vec::new();
1271 }
1272 let mut out = Vec::new();
1273 let hidden = self.hidden_rows();
1274 for (i, p) in self.display_params.iter().enumerate() {
1275 if hidden[i] {
1276 continue;
1277 }
1278 if is_text_row(&p.2) {
1279 if let Some(tb) = &self.texts[i] {
1280 out.extend(tb.all_rounded_quads(ctx));
1281 }
1282 if let Some(d) = &self.choices[i] {
1283 out.extend(d.all_rounded_quads(ctx));
1284 }
1285 } else if p.2.starts_with("slider") {
1286 // Track (square style only — the recessed style has no track
1287 // background), value fill, and readout box; the thumb knob is a
1288 // `Prim::Sphere` and rides `spheres()` instead.
1289 if let Some(s) = &self.sliders[i] {
1290 out.extend(s.all_rounded_quads(ctx));
1291 }
1292 } else if p.2.starts_with("float3") {
1293 // Three slider rows: the same set per row (readout boxes,
1294 // square-style tracks and fills), through the group's own paint.
1295 if let Some(f) = &self.float3s[i] {
1296 out.extend(f.all_rounded_quads(ctx));
1297 }
1298 } else if p.2.starts_with("choice") {
1299 if let Some(d) = &self.choices[i] {
1300 out.extend(d.all_rounded_quads(ctx));
1301 }
1302 } else if p.2.starts_with("spinbox") {
1303 // The spinbox's whole chrome (frame, display well, +/- button
1304 // wells) is modern rounded-rect paint — its legacy color() is
1305 // transparent and it has no extra_quads, so skipping it here
1306 // renders the row as bare text.
1307 if let Some(sb) = &self.spinboxes[i] {
1308 out.extend(sb.all_rounded_quads(ctx));
1309 }
1310 } else if p.2 == "button" {
1311 if let Some(b) = &self.buttons[i] {
1312 out.extend(b.all_rounded_quads(ctx));
1313 }
1314 } else if p.2 == "toggle" || p.2 == "checkbox" {
1315 if let Some(cb) = &self.toggles[i] {
1316 out.extend(cb.all_rounded_quads(ctx));
1317 }
1318 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1319 if let Some(c) = &self.colors[i] {
1320 out.extend(c.all_rounded_quads(ctx));
1321 }
1322 }
1323 }
1324 out
1325 }
1326
1327 /// The relief companion to [`Self::rounded_quads`]: the row controls'
1328 /// raised/recessed step prims (boss rims, recess wells), which the flat
1329 /// views cannot carry — a host rendering this panel through the legacy
1330 /// views must read this getter too or the `control_relief` styling is lost
1331 /// entirely (with the DE's transparent control backgrounds the controls all
1332 /// but vanish). Edges-only variants throughout: the flat views own the
1333 /// faces, exactly the widgets' own transparent-fill bevel→boss degradation.
1334 /// Returned unclipped; the host clips to the pane's scroll viewport and
1335 /// draws these AFTER the flat quads, so the walls' shading modulates the
1336 /// fills they cross (the order the widgets' own paints use). Tuple:
1337 /// (x, y, w, h, per-corner radii, depth, raised, walls) — raised maps to
1338 /// `PaintCtx::boss_edges`, flat to `recess_edges`; a toggle's well and
1339 /// the plate standing in it are why radii/walls are per-entry.
1340 #[allow(clippy::type_complexity)]
1341 pub fn reliefs(
1342 &self,
1343 ) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, bool, (bool, bool, bool, bool))> {
1344 if !self.visible || !crate::layout::control_relief() {
1345 return Vec::new();
1346 }
1347 let mut out = Vec::new();
1348
1349 // Sections as inset panels (the flat outline+fillet path is the
1350 // non-relief style): ONE union-shaped recess per section — a
1351 // title-text-width tab strip flush with the body's left edge, opening
1352 // into the full-width body below. Composed from three edge-suppressed
1353 // pieces (the tab with its bottom open, the body with its top open,
1354 // and the top-wall run right of the tab's throat) so no wall crosses
1355 // the union's interior and the whole section reads as a single well.
1356 // Collapsed sections keep the title-box carve.
1357 let all = (true, true, true, true);
1358 let r4 = |r: f32| (r, r, r, r);
1359 let r = SECTION_R;
1360 for (title, content) in self.section_boxes() {
1361 let (tx, ty, tw, th) = title;
1362 if let Some((cx, cy, cw, ch)) = content {
1363 // Capped at the channel: the well's wall must roll off inside the
1364 // groove between it and the controls packed one CHANNEL inside,
1365 // not shade across their faces. `section_depth` scales wall and
1366 // channel cap together — past 1.0 the roll crosses the groove
1367 // by choice.
1368 let depth = section_carve_depth(ch);
1369 // Tab strip: the title box itself, sitting flush on the body's top
1370 // edge (the header row above it stays plain plate), bottom open.
1371 // A wall FADES OUT over the carve width approaching a suppressed
1372 // edge (the tessellator's host fade — meant for carves flush with
1373 // a real plate edge), so every piece extends past its interior
1374 // seam by `depth`: its fade-out then crossfades with the
1375 // neighbor's fade-in instead of both dying AT the seam (which
1376 // notched the walls there; found the hard way).
1377 let throat_r = tx + tw;
1378 let rho = SECTION_FILLET_R;
1379 // Right of the throat, ONE piece owns the whole right run —
1380 // top wall, top-right arc, right wall, bottom-right arc — so
1381 // both right corners are real turns. (The old top-run +
1382 // full-body split put the top wall and the right wall in
1383 // different pieces; each faded out at the seam and the
1384 // top-right corner rendered square.) A left piece carries the
1385 // left wall and the bottom-left arc; the two bottom runs
1386 // crossfade under the seam at the right piece's left edge.
1387 let body_lr = |x_run: f32, out: &mut Vec<_>| {
1388 out.push((x_run, cy, cx + cw - x_run, ch, (0.0, r, r, 0.0), depth, false, (true, true, true, false)));
1389 out.push((cx, cy, x_run + depth - cx, ch, (0.0, 0.0, 0.0, r), depth, false, (false, false, true, true)));
1390 };
1391 if cx + cw > throat_r + 2.0 * rho {
1392 // Filleted throat ([`Self::section_fillets`]): the tab's
1393 // right wall must END at the fillet's vertical tangent
1394 // (crossfading out under the arc) or its straight run
1395 // ghosts through the curve — so the tab piece stops there,
1396 // and a left-only bridge carries the left wall across the
1397 // fillet span down to the body's own fade-in.
1398 out.push((tx, ty, tw, (cy - rho) - ty + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1399 out.push((tx, cy - rho, tw, rho + depth, (0.0, 0.0, 0.0, 0.0), depth, false, (false, false, false, true)));
1400 body_lr(throat_r + rho - depth, &mut out);
1401 } else {
1402 // Too narrow for the fillet: the plain square throat.
1403 out.push((tx, ty, tw, th + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1404 if cx + cw > throat_r + 0.5 {
1405 body_lr(throat_r - depth, &mut out);
1406 } else {
1407 // The tab spans the body: no top wall at all.
1408 out.push((cx, cy, cw, ch, (0.0, 0.0, r, r), depth, false, (false, true, true, true)));
1409 }
1410 }
1411 } else {
1412 let depth = section_carve_depth(th);
1413 out.push((tx, ty, tw, th, r4(SECTION_R), depth, false, all));
1414 }
1415 }
1416
1417 let hidden = self.hidden_rows();
1418 for (i, p) in self.display_params.iter().enumerate() {
1419 if hidden[i] {
1420 continue;
1421 }
1422 // (control, its configured corner radius, raised vs recessed)
1423 let ctl: Option<(&dyn WidgetHost, f32, bool)> = if is_text_row(&p.2) {
1424 // The textpick picker button is NOT in this list: it is a
1425 // flush inset control (face level with the well floor, a
1426 // valley seam around it — the dropdown trigger's trough
1427 // language, not a boss), and troughs travel through
1428 // [`Self::picker_troughs`]. A boss here read as a raised
1429 // island, which no other inset control in the DE does.
1430 self.texts[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::textbox_corner_radius(), false))
1431 } else if p.2.starts_with("choice") {
1432 // The dropdown trigger is a FLUSH inset control: the widget's
1433 // own raised paint is one `inset_plate` (face level with the
1434 // plate, a valley seam around it), so its ring travels through
1435 // [`Self::troughs`]. A boss here read as a raised island the
1436 // widget itself never draws.
1437 None
1438 } else if p.2 == "button" {
1439 self.buttons[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::button_corner_radius(), true))
1440 } else if p.2 == "toggle" || p.2 == "checkbox" {
1441 // A toggle paints no fill at all, so these carves — the
1442 // widget's own (`Toggle::flat_carves`, exactly what its paint
1443 // emits, in the order it emits them) — ARE the control: the
1444 // track's well (a recess) and the plate gliding on its floor
1445 // (a boss). Neither is flush, so nothing here rides
1446 // [`Self::troughs`].
1447 if let Some(t) = &self.toggles[i] {
1448 let (x, y, w, h) = t.rect();
1449 if w > 0.0 && h > 0.0 {
1450 let ty = t.label_strip();
1451 let rect = Rect { x, y: y + ty, width: w, height: h - ty };
1452 for c in t.inner().flat_carves(rect) {
1453 let raised = match c.kind {
1454 crate::layout::CarveKind::Boss { .. } => true,
1455 crate::layout::CarveKind::Recess { .. } => false,
1456 crate::layout::CarveKind::Trough => continue,
1457 };
1458 out.push((c.x, c.y, c.w, c.h, c.radii, c.depth, raised, c.edges));
1459 }
1460 }
1461 }
1462 None
1463 } else if p.2.starts_with("spinbox") {
1464 // The well recess only — the -/+ run's trough and its seam
1465 // travel through [`Self::troughs`] / [`Self::grooves`] (this
1466 // tuple speaks boss/recess). The generic push below matches
1467 // the widget's own `relief_parts` well exactly: same side-
1468 // label inset, same content band, same depth cap.
1469 self.spinboxes[i]
1470 .as_ref()
1471 .map(|w| (w as &dyn WidgetHost, crate::layout::spinbox_corner_radius(), false))
1472 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1473 // The control's one well (`ColorSelector::field_relief`, the
1474 // same geometry its paint carves); the swatch is a fill on its
1475 // floor and the seam a groove, both on the widget's paint.
1476 if let Some(c) = &self.colors[i] {
1477 let (x, y, w, h) = c.rect();
1478 let ty = c.label_strip();
1479 if let Some((rx, ry, rw, rh, rr, rd)) =
1480 c.inner().field_relief(Rect { x, y: y + ty, width: w, height: h - ty })
1481 {
1482 out.push((rx, ry, rw, rh, r4(rr), rd, false, all));
1483 }
1484 }
1485 None
1486 } else {
1487 // Sliders (and Float3's three rows) are bands: their well is
1488 // hand-shaded quads that follow the band's contour, which reach
1489 // a flat host through the plain-quad view — no rect carve.
1490 None
1491 };
1492 if let Some((w, radius, raised)) = ctl {
1493 let (x, y, ww, h) = w.rect();
1494 if ww <= 0.0 || h <= 0.0 {
1495 continue;
1496 }
1497 // The top-label band stays outside the relief like every other host.
1498 let ty = w.label_strip();
1499 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1500 out.push((x, y + ty, ww, h - ty, r4(radius), depth, raised, all));
1501 }
1502 }
1503 out
1504 }
1505
1506 /// The textpick picker buttons' trough rings — `(x, y, w, h, radii, depth)`
1507 /// for [`crate::scene::paint::PaintCtx::trough_edges`], drawn by the host
1508 /// AFTER [`Self::reliefs`]. These are the rows' FLUSH inset controls — the
1509 /// textpick picker button, the spinbox's -/+ run, the dropdown trigger
1510 /// (the widget's own `inset_plate`): faces
1511 /// level with the surface they sit in, so the control reads as part of
1512 /// the plate, marked off by its seam alone. On the sides a control adjoins its well, the WELL'S
1513 /// OWN WALL is the seam's far side (the face reaches the wall's base and
1514 /// that trough edge is suppressed — a lip of its own there doubles the
1515 /// valley); only edges facing open floor carve their own wall. They
1516 /// cannot ride in [`Self::reliefs`], whose tuple only speaks boss/recess.
1517 /// Radii are the well radius's parallel curve at each control's inset;
1518 /// depths match the well's carve, so seam and wall read as one family.
1519 #[allow(clippy::type_complexity)]
1520 pub fn troughs(
1521 &self,
1522 ) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, (bool, bool, bool, bool))> {
1523 if !self.visible || !crate::layout::control_relief() {
1524 return Vec::new();
1525 }
1526 let mut out = Vec::new();
1527 let hidden = self.hidden_rows();
1528 for (i, p) in self.display_params.iter().enumerate() {
1529 if hidden[i] {
1530 continue;
1531 }
1532 if p.2.starts_with("textpick") {
1533 if let (Some(d), Some(tb)) = (&self.choices[i], &self.texts[i]) {
1534 let (bx, by, bw, bh) = d.rect();
1535 let (_, _, _, th) = tb.rect();
1536 let ty = tb.label_strip();
1537 if bw > 0.0 && bh > 0.0 {
1538 let depth = crate::layout::bevel_width().min((th - ty) * 0.2);
1539 let r = (crate::layout::textbox_corner_radius() - depth).max(2.0);
1540 // Left edge only: top, right and bottom adjoin the well.
1541 out.push((bx, by, bw, bh, (r, r, r, r), depth, (false, false, false, true)));
1542 }
1543 }
1544 } else if p.2.starts_with("spinbox") {
1545 if let Some(sb) = &self.spinboxes[i] {
1546 let (x, y, w, h) = sb.rect();
1547 let ty = sb.label_strip();
1548 let band = Rect { x, y: y + ty, width: w, height: h - ty };
1549 if let Some((_, Some(((run, radii, rd, edges), _)))) =
1550 sb.inner().relief_parts(band)
1551 {
1552 out.push((run.x, run.y, run.width, run.height, radii, rd, edges));
1553 }
1554 }
1555 } else if p.2.starts_with("choice") {
1556 // The dropdown trigger: the widget's own raised paint is one
1557 // `inset_plate` on its content band — the same ring here, on
1558 // the same band (top-label band excluded),
1559 // same radius, same depth cap. The face stays the plate: the
1560 // pane carries no dropdown fill (a `Border` face never reaches
1561 // the rounded-quad view), so the trigger is flush and bare.
1562 if let Some(d) = &self.choices[i] {
1563 let (x, y, w, h) = d.rect();
1564 if w > 0.0 && h > 0.0 {
1565 let ty = d.label_strip();
1566 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1567 let r = crate::layout::dropdown_corner_radius();
1568 out.push((x, y + ty, w, h - ty, (r, r, r, r), depth, (true, true, true, true)));
1569 }
1570 }
1571 }
1572 // A toggle contributes nothing here: its well and its glider plate
1573 // are a recess and a boss, and both ride [`Self::reliefs`].
1574 }
1575 out
1576 }
1577
1578 /// The engraved seams companion — `(a, b, width, depth, host)` for
1579 /// [`crate::scene::paint::PaintCtx::groove`], drawn AFTER [`Self::troughs`]
1580 /// (a groove engraves the surface the trough's control face provides).
1581 /// Today: the seam dividing a spinbox's -/+ run into its two buttons —
1582 /// the breadcrumb's segment-seam language at miniature scale.
1583 #[allow(clippy::type_complexity)]
1584 pub fn grooves(&self) -> Vec<((f32, f32), (f32, f32), f32, f32, Rect)> {
1585 if !self.visible || !crate::layout::control_relief() {
1586 return Vec::new();
1587 }
1588 let mut out = Vec::new();
1589 let hidden = self.hidden_rows();
1590 for (i, p) in self.display_params.iter().enumerate() {
1591 if hidden[i] || !p.2.starts_with("spinbox") {
1592 continue;
1593 }
1594 if let Some(sb) = &self.spinboxes[i] {
1595 let (x, y, w, h) = sb.rect();
1596 let ty = sb.label_strip();
1597 let band = Rect { x, y: y + ty, width: w, height: h - ty };
1598 if let Some((_, Some(((_, _, rd, _), (sa, sb2, sw, host))))) =
1599 sb.inner().relief_parts(band)
1600 {
1601 out.push((sa, sb2, sw, rd, host));
1602 }
1603 }
1604 }
1605 out
1606 }
1607
1608 /// The knobs a flat host draws after [`Self::reliefs`] — none: the sliders
1609 /// are bands (their swell is part of the band's own quads), so this is kept
1610 /// only for the hosts that still call it.
1611 pub fn spheres(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
1612 Vec::new()
1613 }
1614
1615 /// The section carves' concave inside-corner fillets — `(cx, cy, radius,
1616 /// depth, start angle)` for [`crate::scene::paint::PaintCtx::concave_fillet`]
1617 /// (recessed), drawn by the host AFTER [`Self::reliefs`]. The box reliefs
1618 /// can only round convex corners; this rounds the throat where a tab's
1619 /// right wall turns onto its body's top edge.
1620 pub fn section_fillets(&self) -> Vec<(f32, f32, f32, f32, f32)> {
1621 if !self.visible || !crate::layout::control_relief() {
1622 return Vec::new();
1623 }
1624 let mut out = Vec::new();
1625 for (title, content) in self.section_boxes() {
1626 let (tx, _ty, tw, _th) = title;
1627 if let Some((cx, cy, cw, ch)) = content {
1628 let depth = section_carve_depth(ch);
1629 let throat_r = tx + tw;
1630 if cx + cw > throat_r + 2.0 * SECTION_FILLET_R {
1631 out.push((
1632 throat_r + SECTION_FILLET_R,
1633 cy - SECTION_FILLET_R,
1634 SECTION_FILLET_R,
1635 depth,
1636 std::f32::consts::FRAC_PI_2,
1637 ));
1638 }
1639 }
1640 }
1641 out
1642 }
1643
1644 /// The scene-path companion to the legacy views: rows whose widgets paint
1645 /// prims NO flat tuple view can carry (the ramp rows' curve fill, key
1646 /// circles, and field controls). A host rendering this panel through the
1647 /// legacy hatches calls this with its own `PaintCtx` inside the pane's
1648 /// scroll clip, after the flat chrome — or the rows draw as bare labels.
1649 /// Whether a row value changed inside `tick` since the last call
1650 /// (see `tick_value_changed`); clears the flag.
1651 pub fn take_tick_value_change(&mut self) -> bool {
1652 std::mem::take(&mut self.tick_value_changed)
1653 }
1654
1655 pub fn paint_scene_rows(&self, pc: &mut PaintCtx) {
1656 if !self.visible {
1657 return;
1658 }
1659 let hidden = self.hidden_rows();
1660 let dummy = UiContext::new();
1661 for (i, p) in self.display_params.iter().enumerate() {
1662 if hidden[i] || p.2 != "ramp" {
1663 continue;
1664 }
1665 if let Some(rp) = &self.ramps[i] {
1666 rp.paint_self(&dummy, pc);
1667 }
1668 }
1669 }
1670
1671 }
1672
1673 impl Layout for ParametersBg {
1674 /// Ungated rect landing (the legacy `set_rect` head, before its visibility gate): cache the
1675 /// rect all row geometry derives from, then re-derive content height/scroll/row rects.
1676 fn rect_assigned(&mut self, rect: Rect) {
1677 self.rect = rect;
1678 self.refresh_scroll_metrics();
1679 }
1680
1681 }
1682
1683 impl Paint for ParametersBg {
1684 /// Shape the hosted controls (their carets read per-glyph advances nothing
1685 /// else records for children of a container) and one code column's advance
1686 /// from the same monospace@12 path the code rows draw through.
1687 fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
1688 for tb in self.texts.iter_mut().flatten() {
1689 tb.prepare_text(fs);
1690 }
1691 for sb in self.spinboxes.iter_mut().flatten() {
1692 sb.prepare_text(fs);
1693 }
1694 for c in self.colors.iter_mut().flatten() {
1695 c.prepare_text(fs);
1696 }
1697 let clusters = crate::backend::window_runner::shaped_cluster_offsets(
1698 fs,
1699 "MMMMMMMM",
1700 12.0,
1701 Some("monospace"),
1702 );
1703 if let Some(&(_, total)) = clusters.last() {
1704 if total > 0.0 {
1705 self.code_char_advance = total / 8.0;
1706 }
1707 }
1708 }
1709
1710 /// The panel IS its own background plate (the host draws it from `color()` + the corner
1711 /// style via `push_widget_vertices`) — there is no separate plate widget behind it, so
1712 /// this carries the full plate treatment: `PARAM_BG` scaled by the global plate opacity,
1713 /// with the alpha negated as the scenefx blur marker when plate blur is on. Transparent
1714 /// while hidden. It must not ALSO be emitted as a quad anywhere or it would double-blend.
1715 fn color(&self) -> [f32; 4] {
1716 if !self.visible {
1717 return [0.0, 0.0, 0.0, 0.0];
1718 }
1719 colors::param_plate_fill()
1720 }
1721
1722 /// The shared plate corner radius (rounded on all four corners when non-zero).
1723 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
1724 let r = crate::layout::plate_corner_radius();
1725 let on = r > 0.0;
1726 Some((r, (on, on, on, on)))
1727 }
1728
1729 /// The shared plate border (folded in from the retired backing plate widget).
1730 fn solid_border(&self) -> Option<([f32; 4], f32)> {
1731 colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
1732 }
1733
1734 fn widget_font(&self) -> Option<String> {
1735 Some(crate::layout::control_label_font())
1736 }
1737
1738 /// The COMPLETE row chrome — everything the legacy hatches carry, in the hosts' canonical
1739 /// draw order: the controls' rounded wells, the flat-style section outline fillets, the
1740 /// relief steps (after the fills so the walls shade what they cross), the section carves'
1741 /// concave throat fillets, the slider-thumb spheres, the scene-path rows, then the flat
1742 /// plain-quad chrome. What stays OUT, deliberately: the background plate (see
1743 /// [`color`](Paint::color)), the scrollbar (hosts place its depth — the designer straddles
1744 /// it around the pane plate), and text (`paint_self`'s own-labels bridge carries the
1745 /// per-row fonts and code-box bounds). Hosts clip this to their pane viewport — a rect
1746 /// clip pushed here would not survive `paint_self`'s replay.
1747 fn paint_ui(&self, ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
1748 if !self.visible {
1749 return;
1750 }
1751 self.paint_row_floors(ctx);
1752 for (qx, qy, qw, qh, qr, qc, corners) in self.rounded_quads(ui) {
1753 ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
1754 }
1755 for (acx, acy, ar, at, a0, a1, ac) in self.arcs() {
1756 ctx.arc(acx, acy, ar, at, a0, a1, ac);
1757 }
1758 for (rx, ry, rw, rh, radii, rd, raised, edges) in self.reliefs() {
1759 if raised {
1760 ctx.boss_edges(Rect { x: rx, y: ry, width: rw, height: rh }, radii, rd, edges);
1761 } else {
1762 ctx.recess_edges(Rect { x: rx, y: ry, width: rw, height: rh }, radii, rd, edges);
1763 }
1764 }
1765 for (tx2, ty2, tw2, th2, radii, td, tedges) in self.troughs() {
1766 ctx.trough_edges(Rect { x: tx2, y: ty2, width: tw2, height: th2 }, radii, td, tedges);
1767 }
1768 for (ga, gb, gw, gd, ghost) in self.grooves() {
1769 ctx.groove(ga, gb, gw, gd, ghost);
1770 }
1771 for (fcx, fcy, fr, fd, fs) in self.section_fillets() {
1772 ctx.concave_fillet(fcx, fcy, fr, fd, fs, false);
1773 }
1774 for (scx, scy, sr, sc) in self.spheres() {
1775 ctx.sphere(scx, scy, sr, &crate::scene::material::Material::from_fill(sc));
1776 }
1777 self.paint_scene_rows(ctx);
1778 for (qx, qy, qw, qh, qc) in self.plain_quads() {
1779 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1780 }
1781 }
1782
1783 /// Ui-less emission (the [`paint_ui`](Paint::paint_ui) override above is what `paint_self`
1784 /// runs): the flat subset plus the scrollbar, kept for direct callers only. The background
1785 /// plate stays out — see [`color`](Paint::color).
1786 fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
1787 for (qx, qy, qw, qh, qc) in self.plain_quads() {
1788 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1789 }
1790 self.paint_scene_rows(ctx);
1791 // Scene-path hosts get the scrollbar on top (the designer instead straddles it around
1792 // the pane plate through `scrollbar_quads`).
1793 for (qx, qy, qw, qh, qc) in self.scrollbar_quads() {
1794 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1795 }
1796 let view_min = self.rect.y + 4.0;
1797 let view_max = self.rect.y + self.rect.height - 4.0;
1798 // The y test above culls the scrolled-away rows; it is the pane's
1799 // WIDTH that nothing enforced, so a long parameter name ran out of the
1800 // pane sideways.
1801 let pane = Some([
1802 self.rect.x,
1803 self.rect.y,
1804 self.rect.x + self.rect.width,
1805 self.rect.y + self.rect.height,
1806 ]);
1807 for l in self.own_text_labels() {
1808 if l.y >= view_min - 20.0 && l.y <= view_max + 20.0 {
1809 ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, pane);
1810 }
1811 }
1812 }
1813
1814 fn serves_legacy_plain_quads(&self) -> bool {
1815 true
1816 }
1817
1818 fn legacy_plain_quads(&self, _rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1819 self.plain_quads()
1820 }
1821
1822 fn serves_legacy_labels(&self) -> bool {
1823 true
1824 }
1825
1826 /// The legacy `text_labels_with_font_and_bounds` body: every label clipped to the panel
1827 /// viewport in the control-label font, except labels inside a code row — those clip to the
1828 /// code box (or hide when it's scrolled out) and render monospace.
1829 fn legacy_labels_with_font_and_bounds(&self, _rect: Rect, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
1830 let view_min = self.rect.y + 4.0;
1831 let view_max = self.rect.y + self.rect.height - 4.0;
1832 let mut result = Vec::new();
1833 let font = Paint::widget_font(self);
1834 let rects = self.get_param_rects();
1835 for l in self.own_text_labels() {
1836 if l.y < view_min - 20.0 || l.y > view_max + 20.0 {
1837 continue;
1838 }
1839 let mut bounds = Some([self.rect.x + 4.0, view_min, self.rect.x + self.rect.width - 4.0, view_max]);
1840 let mut label_font = font.clone();
1841 for (i, p) in self.display_params.iter().enumerate() {
1842 if p.2 == "code" {
1843 let r = rects[i];
1844 if l.y >= r.1 + 18.0 && l.y <= r.1 + r.3 {
1845 let code_min = (r.1 + 19.0).max(view_min);
1846 let code_max = (r.1 + r.3 - 1.0).min(view_max);
1847 if code_min < code_max {
1848 bounds = Some([r.0 + 1.0, code_min, r.0 + r.2 - 1.0, code_max]);
1849 } else {
1850 bounds = Some([0.0, 0.0, 0.0, 0.0]); // hidden
1851 }
1852 label_font = Some("monospace".to_string());
1853 break;
1854 }
1855 }
1856 }
1857 result.push((l, label_font, bounds));
1858 }
1859 result
1860 }
1861
1862 fn popover(&self, _rect: Rect) -> Option<(f32, f32, f32, f32)> {
1863 self.choices_popover_rect()
1864 }
1865
1866 /// The dropdown rows' popovers; the raw children's are the adapter's recursion.
1867 fn draw_popover(&self, _rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
1868 for d_opt in &self.choices {
1869 if let Some(d) = d_opt {
1870 d.render_popover(pc);
1871 }
1872 }
1873 for rp_opt in &self.ramps {
1874 if let Some(rp) = rp_opt {
1875 rp.inner().preset_dropdown.render_popover(pc);
1876 rp.inner().line_type_dropdown.render_popover(pc);
1877 }
1878 }
1879 }
1880 }
1881
1882 impl Input for ParametersBg {
1883 /// While a code row is being edited, the clipboard, selection and
1884 /// history actions are the editor's — the runner routes the undo / redo
1885 /// chords here before the app's own history gets them.
1886 fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
1887 if self.code_editing() {
1888 return self.code_action(action);
1889 }
1890 false
1891 }
1892
1893 fn scrollable(&self) -> bool {
1894 true
1895 }
1896
1897 /// Legacy `mouse_input` saw every press (and consumes every left press — the designer
1898 /// relies on the panel swallowing clicks anywhere while it's the dispatch target).
1899 fn gates_presses(&self) -> bool {
1900 false
1901 }
1902
1903 /// Legacy hit reach: the panel rect, or an open popover (a dropdown row's list extends
1904 /// below the panel).
1905 fn hit(&self, rect: Rect, px: f32, py: f32) -> bool {
1906 if px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height {
1907 return true;
1908 }
1909 if let Some((pop_x, pop_y, pop_w, pop_h)) = self.own_popover_rect() {
1910 if px >= pop_x && px <= pop_x + pop_w && py >= pop_y && py <= pop_y + pop_h {
1911 return true;
1912 }
1913 }
1914 false
1915 }
1916
1917
1918 // --- The host-driven drag surface (the designer routes pointer drags here directly). ---
1919
1920 fn draggable(&self, _rect: Rect) -> bool {
1921 self.scrollbar_dragging
1922 || self.dragging_param.is_some()
1923 || self.display_params.iter().any(|p| p.2.starts_with("slider") || p.2.starts_with("float3"))
1924 }
1925
1926 fn is_dragging(&self) -> bool {
1927 self.scrollbar_dragging || self.dragging_param.is_some()
1928 }
1929
1930 fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
1931 if self.scrollbar_dragging {
1932 return;
1933 }
1934 let rects = self.get_param_rects();
1935 for (i, p) in self.display_params.iter().enumerate() {
1936 if p.2.starts_with("slider") {
1937 let r = rects[i];
1938 if let Some(s) = &mut self.sliders[i] {
1939 let top = s.label_strip();
1940 if py >= r.1 + top && py <= r.1 + r.3 {
1941 s.drag_begin(px, py);
1942 self.dragging_param = Some(i);
1943 break;
1944 }
1945 }
1946 } else if p.2.starts_with("float3") {
1947 let r = rects[i];
1948 if py >= r.1 && py <= r.1 + r.3 {
1949 if let Some(f) = &mut self.float3s[i] {
1950 let mut dummy = crate::context::UiContext::new();
1951 if f.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut dummy) {
1952 self.dragging_param = Some(i);
1953 break;
1954 }
1955 }
1956 }
1957 }
1958 }
1959 }
1960
1961 fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
1962 if self.scrollbar_dragging {
1963 let sb_track_h = self.rect.height - 8.0;
1964 let sb_track_y = self.rect.y + 4.0;
1965 let visible_ratio = self.rect.height / self.content_h;
1966 let thumb_h = if sb_track_h <= 20.0 {
1967 sb_track_h
1968 } else {
1969 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
1970 };
1971 let max_scroll = (self.content_h - self.rect.height).max(0.0);
1972
1973 let target_thumb_y = py - self.drag_offset_y;
1974 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
1975 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
1976 } else {
1977 0.0
1978 };
1979
1980 let old_scroll = self.scroll_y;
1981 self.scroll_y = new_scroll_ratio * max_scroll;
1982 if (self.scroll_y - old_scroll).abs() > 0.01 {
1983 self.update_slider_rects();
1984 return true;
1985 }
1986 return false;
1987 }
1988
1989 if let Some(i) = self.dragging_param {
1990 if let Some(s) = &mut self.sliders[i] {
1991 if s.drag_update(px, py) {
1992 let (min, max) = parse_slider_range(&self.display_params[i].2);
1993 let new_val = min + s.value * (max - min);
1994 let old_val = &self.display_params[i].1;
1995 let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
1996 if *old_val != new_val_str {
1997 self.display_params[i].1 = new_val_str;
1998 return true;
1999 }
2000 }
2001 } else if let Some(f) = &mut self.float3s[i] {
2002 if f.drag_update(px, py) {
2003 let new_val_str = f.value_string();
2004 if self.display_params[i].1 != new_val_str {
2005 self.display_params[i].1 = new_val_str;
2006 return true;
2007 }
2008 }
2009 }
2010 }
2011 false
2012 }
2013
2014 fn drag_end(&mut self) {
2015 if self.scrollbar_dragging {
2016 self.scrollbar_dragging = false;
2017 self.activity.bump();
2018 return;
2019 }
2020 if let Some(i) = self.dragging_param.take() {
2021 if let Some(s) = &mut self.sliders[i] {
2022 s.drag_end();
2023 } else if let Some(f) = &mut self.float3s[i] {
2024 f.drag_end();
2025 }
2026 }
2027 }
2028
2029 /// Legacy `tick` forwarded to the raw children (the adapter's recursion now) and the
2030 /// checkbox rows. The checkbox tick never touches the ctx (a leaf `Adapted` tick is
2031 /// ctx-free), so the in-file dummy-ctx convention (`drag_begin`, `collect_child_quads`)
2032 /// applies.
2033 fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
2034 if !self.visible {
2035 return false;
2036 }
2037 let mut changed = false;
2038 let mut dummy = crate::context::UiContext::new();
2039 // Choice rows tick their Dropdowns' open/close animation. This was the
2040 // ONLY path that can advance a pane dropdown's anim_snap (the widget's
2041 // tick-receiver registration points at an id pane internals never put
2042 // in the host tree), and without it every params-pane dropdown opened
2043 // at zero drawn extent: logically open, invisible, reporting a sliver
2044 // popover rect — and the next click toggled it closed again.
2045 for c_opt in &mut self.choices {
2046 if let Some(d) = c_opt {
2047 if d.tick(dt, &mut dummy) {
2048 changed = true;
2049 }
2050 }
2051 }
2052 for cb_opt in &mut self.toggles {
2053 if let Some(cb) = cb_opt {
2054 if cb.tick(dt, &mut dummy) {
2055 changed = true;
2056 }
2057 }
2058 }
2059 // Slider rows tick their wheel-glide inertia — fold a coasting value
2060 // back into the row string so hosts syncing off display_params apply
2061 // it, exactly like a live wheel event would.
2062 for i in 0..self.sliders.len() {
2063 if let Some(s) = &mut self.sliders[i] {
2064 if s.tick(dt, &mut dummy) {
2065 let (min, max) = parse_slider_range(&self.display_params[i].2);
2066 let new_val = min + s.value * (max - min);
2067 let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
2068 if self.display_params[i].1 != new_val_str {
2069 self.display_params[i].1 = new_val_str;
2070 }
2071 changed = true;
2072 }
2073 }
2074 }
2075 // Float3 rows are three slider rows: same glide, same fold-back.
2076 for i in 0..self.float3s.len() {
2077 if let Some(f) = &mut self.float3s[i] {
2078 if f.tick(dt, &mut dummy) {
2079 let new_val_str = f.value_string();
2080 if self.display_params[i].1 != new_val_str {
2081 self.display_params[i].1 = new_val_str;
2082 }
2083 changed = true;
2084 }
2085 }
2086 }
2087 // Ramp rows tick their field widgets (preset application, slider→key
2088 // sync) and drain their change flag — fold the curve back into the row
2089 // value when it moved.
2090 for i in 0..self.ramps.len() {
2091 if let Some(rp) = &mut self.ramps[i] {
2092 if rp.tick(dt, &mut dummy) {
2093 self.display_params[i].1 = rp.inner().spec_string();
2094 changed = true;
2095 }
2096 }
2097 }
2098 // Color rows tick their picker-stream poll (`cce-color-editor --stream`
2099 // lines applying live) — fold a changed value back into the row so
2100 // hosts syncing off display_params see it while the picker is open.
2101 for i in 0..self.colors.len() {
2102 if let Some(c) = &mut self.colors[i] {
2103 if c.tick(dt, &mut dummy) {
2104 if let Some(val) = c.get_value_string() {
2105 if self.display_params[i].1 != val {
2106 self.display_params[i].1 = val;
2107 self.tick_value_changed = true;
2108 }
2109 }
2110 changed = true;
2111 }
2112 }
2113 }
2114 // The pane's own wheel glide / trackpad coast: adopt any host write to
2115 // `scroll_y`, advance, and re-seat the rows when the offset moved.
2116 self.scroll_motion.reconcile(0.0, self.scroll_y);
2117 let pane_max = (self.content_h - self.rect.height).max(0.0);
2118 if self.scroll_motion.tick(dt, crate::widget::Bounds::max(0.0), crate::widget::Bounds::max(pane_max)) {
2119 self.scroll_y = self.scroll_motion.y.pos();
2120 self.update_slider_rects();
2121 changed = true;
2122 }
2123 if self.scroll_motion.is_animating() {
2124 changed = true;
2125 }
2126 // Decay the "recently scrolled" window; keep frames coming until it expires so the
2127 // scrollbar's sink behind the plate actually renders.
2128 if self.activity.holding() {
2129 changed = true;
2130 }
2131 // Re-latch the raised state (e.g. sink once the scroll window lapses).
2132 let visible = self.scrollbar_visible();
2133 if self.activity.tick(dt, visible, self.scrollbar_dragging) {
2134 changed = true;
2135 }
2136 changed
2137 }
2138
2139 fn visibility_changed(&mut self, visible: bool) {
2140 self.visible = visible;
2141 }
2142
2143 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
2144 // Copied before `ectx.ui` is borrowed: the wheel arm's occlusion check keys on the
2145 // adapter's address (the pointer hosts register/popover-track).
2146 let self_id = ectx.id;
2147 match event {
2148 // Hosts call `unfocus()` directly (the designer's pane switches): commit the
2149 // focused row and unfocus the children. Needs no ctx, so the direct path's
2150 // ui-less synthesis works too.
2151 Event::FocusOut => {
2152 self.commit_and_unfocus();
2153 true
2154 }
2155 Event::PointerMove { x: px, y: py, .. } => {
2156 let (px, py) = (*px, *py);
2157 self.mouse_pos = Some((px, py));
2158 // Track scrollbar hover, then re-latch: hover only sustains an already-raised
2159 // bar (a sunk one is behind the plate, so the pointer never reaches it), so
2160 // the only visible change here is the raised state — redraw on that transition.
2161 let hover = self.hit_test_scrollbar(px, py);
2162 self.activity.set_hover(hover);
2163 let raised_changed = self.recompute_scrollbar_raised();
2164 let Some(ui) = ectx.ui.as_deref_mut() else {
2165 return raised_changed;
2166 };
2167 let mut changed = raised_changed;
2168
2169 if self.scrollbar_dragging {
2170 let sb_track_h = self.rect.height - 8.0;
2171 let sb_track_y = self.rect.y + 4.0;
2172 let visible_ratio = self.rect.height / self.content_h;
2173 let thumb_h = if sb_track_h <= 20.0 {
2174 sb_track_h
2175 } else {
2176 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2177 };
2178 let max_scroll = (self.content_h - self.rect.height).max(0.0);
2179
2180 let target_thumb_y = py - self.drag_offset_y;
2181 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2182 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2183 } else {
2184 0.0
2185 };
2186
2187 let old_scroll = self.scroll_y;
2188 self.scroll_y = new_scroll_ratio * max_scroll;
2189 if (self.scroll_y - old_scroll).abs() > 0.01 {
2190 self.update_slider_rects();
2191 changed = true;
2192 }
2193 }
2194
2195 for sb_opt in &mut self.spinboxes {
2196 if let Some(sb) = sb_opt {
2197 if sb.on_cursor_moved(px, py, ui) {
2198 changed = true;
2199 }
2200 }
2201 }
2202 for f_opt in &mut self.float3s {
2203 if let Some(f) = f_opt {
2204 if f.on_cursor_moved(px, py, ui) {
2205 changed = true;
2206 }
2207 }
2208 }
2209 for b_opt in &mut self.buttons {
2210 if let Some(b) = b_opt {
2211 if b.on_cursor_moved(px, py, ui) {
2212 changed = true;
2213 }
2214 }
2215 }
2216 for d_opt in &mut self.choices {
2217 if let Some(d) = d_opt {
2218 if d.on_cursor_moved(px, py, ui) {
2219 changed = true;
2220 }
2221 }
2222 }
2223 for tb_opt in &mut self.texts {
2224 if let Some(tb) = tb_opt {
2225 if tb.on_cursor_moved(px, py, ui) {
2226 changed = true;
2227 }
2228 }
2229 }
2230 for cb_opt in &mut self.toggles {
2231 if let Some(cb) = cb_opt {
2232 if cb.on_cursor_moved(px, py, ui) {
2233 changed = true;
2234 }
2235 }
2236 }
2237 for c_opt in &mut self.colors {
2238 if let Some(c) = c_opt {
2239 if c.on_cursor_moved(px, py, ui) {
2240 changed = true;
2241 }
2242 }
2243 }
2244 // Ramp rows: a move can drag a key — re-serialize the curve
2245 // into the row value so hosts polling `node_params` see it.
2246 for i in 0..self.ramps.len() {
2247 if let Some(rp) = &mut self.ramps[i] {
2248 if rp.on_cursor_moved(px, py, ui) {
2249 self.display_params[i].1 = rp.inner().spec_string();
2250 changed = true;
2251 }
2252 }
2253 }
2254
2255 changed
2256 }
2257 Event::MouseButton { button, state, x: px, y: py, .. } => {
2258 if !self.visible {
2259 return false;
2260 }
2261 let (button, state, px, py) = (*button, *state, *px, *py);
2262 let Some(ui) = ectx.ui.as_deref_mut() else {
2263 return false;
2264 };
2265
2266 if button == MouseButton::Left {
2267 if state == ElementState::Pressed {
2268 // Only a raised bar can be grabbed — a sunk one is behind the plate,
2269 // so the press falls through to the pane content underneath it.
2270 if self.activity.raised() && self.hit_test_scrollbar(px, py) {
2271 // Legacy called `self.focus()` here — base flag only, which
2272 // nothing reads (see module docs).
2273 self.scrollbar_dragging = true;
2274
2275 let sb_track_h = self.rect.height - 8.0;
2276 let sb_track_y = self.rect.y + 4.0;
2277 let visible_ratio = self.rect.height / self.content_h;
2278 let thumb_h = if sb_track_h <= 20.0 {
2279 sb_track_h
2280 } else {
2281 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2282 };
2283 let max_scroll = (self.content_h - self.rect.height).max(0.0);
2284 let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
2285 let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
2286
2287 let click_offset = py - thumb_y;
2288 if click_offset >= 0.0 && click_offset <= thumb_h {
2289 self.drag_offset_y = click_offset;
2290 } else {
2291 // Clicked outside the thumb: jump thumb center to py
2292 self.drag_offset_y = thumb_h / 2.0;
2293 let target_thumb_y = py - self.drag_offset_y;
2294 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2295 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2296 } else {
2297 0.0
2298 };
2299 self.scroll_y = new_scroll_ratio * max_scroll;
2300 self.update_slider_rects();
2301 }
2302 return true;
2303 }
2304 } else if state == ElementState::Released {
2305 if self.scrollbar_dragging {
2306 self.scrollbar_dragging = false;
2307 self.activity.bump();
2308 return true;
2309 }
2310 }
2311 }
2312
2313 // A press on a section's title box collapses/expands it. Checked before the
2314 // rows so a header can never be shadowed by a control under it, and only on
2315 // the press — the matching release lands on whatever the relayout moved
2316 // under the pointer, which must not toggle it straight back.
2317 if button == MouseButton::Left && state == ElementState::Pressed {
2318 let rects = self.get_param_rects();
2319 let hit = self.display_params.iter().enumerate().position(|(i, p)| {
2320 if p.2 != "section" {
2321 return false;
2322 }
2323 let (bx, by, bw, bh) = self.section_title_box(i, rects[i]);
2324 px >= bx && px <= bx + bw && py >= by && py <= by + bh
2325 });
2326 if let Some(i) = hit {
2327 let title = self.display_params[i].0.clone();
2328 let collapsed = self.collapsed.contains(&title);
2329 // Collapsing out from under a focused row would strand the editor.
2330 self.commit_and_unfocus();
2331 self.set_section_collapsed(&title, !collapsed);
2332 return true;
2333 }
2334 }
2335
2336 let hidden = self.hidden_rows();
2337
2338 // 1. Check open dropdown popovers first (since they are drawn on top)
2339 for (i, d_opt) in self.choices.iter_mut().enumerate() {
2340 if hidden[i] {
2341 continue;
2342 }
2343 if let Some(d) = d_opt {
2344 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2345 eprintln!("[pdbg] press ({px:.0},{py:.0}) choice[{i}] open-priority: popover_rect={:?}", d.popover_rect());
2346 }
2347 if let Some((ox, oy, ow, oh)) = d.popover_rect() {
2348 // Textpick pickers are press-driven end to end
2349 // (selection fires on the option PRESS): a release
2350 // over the open surface is swallowed, never
2351 // dispatched — mid-animation it can read as an
2352 // outside press and close the menu it just opened.
2353 if state != ElementState::Pressed
2354 && self.display_params[i].2.starts_with("textpick")
2355 {
2356 if px >= ox && px <= ox + ow && py >= oy && py <= oy + oh {
2357 return true;
2358 }
2359 continue;
2360 }
2361 let consumed = d.mouse_input(button, state, px, py, ui);
2362 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2363 eprintln!("[pdbg] -> open dropdown consumed={consumed}");
2364 }
2365 if consumed {
2366 if d.take_change() {
2367 if let Some(val) = d.get_value_string() {
2368 // A textpick row's pick fills its
2369 // TextBox — the box IS the value.
2370 if self.display_params[i].2.starts_with("textpick") {
2371 if let Some(tb) = &mut self.texts[i] {
2372 tb.text = val.clone();
2373 tb.edit_buffer = val.clone();
2374 }
2375 }
2376 self.display_params[i].1 = val;
2377 }
2378 }
2379 return true;
2380 }
2381 }
2382 }
2383 }
2384 // The ramp rows' field dropdowns can pop over neighboring rows too.
2385 for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
2386 if hidden[i] {
2387 continue;
2388 }
2389 if let Some(rp) = rp_opt {
2390 let ramp = rp.inner();
2391 if ramp.preset_dropdown.popover_rect().is_some()
2392 || ramp.line_type_dropdown.popover_rect().is_some()
2393 {
2394 if rp.mouse_input(button, state, px, py, ui) {
2395 self.display_params[i].1 = rp.inner().spec_string();
2396 return true;
2397 }
2398 }
2399 }
2400 }
2401
2402 // 2. Propagate to our widgets
2403 for (i, p) in self.display_params.iter_mut().enumerate() {
2404 if hidden[i] {
2405 continue;
2406 }
2407 if p.2.starts_with("choice") {
2408 if let Some(d) = &mut self.choices[i] {
2409 if d.mouse_input(button, state, px, py, ui) {
2410 // Claim the param focus while the dropdown is
2411 // open — the KeyInput arm above is gated on
2412 // `focused_param`, and without this the choice
2413 // row was the ONE row type that never set it,
2414 // so Escape/arrows/Enter could not reach an
2415 // open params dropdown (found via cce-designer).
2416 if d.open {
2417 self.focused_param = Some(i);
2418 } else if self.focused_param == Some(i) {
2419 self.focused_param = None;
2420 }
2421 if d.take_change() {
2422 if let Some(val) = d.get_value_string() {
2423 p.1 = val;
2424 }
2425 }
2426 return true;
2427 }
2428 }
2429 } else if p.2 == "button" {
2430 if let Some(b) = &mut self.buttons[i] {
2431 if b.mouse_input(button, state, px, py, ui) {
2432 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2433 eprintln!("[pdbg] press ({px:.0},{py:.0}) BUTTON[{i}] '{}' consumed", p.0);
2434 }
2435 if b.take_click() {
2436 p.1 = "clicked".to_string();
2437 }
2438 return true;
2439 }
2440 }
2441 } else if is_text_row(&p.2) {
2442 if let Some(d) = &mut self.choices[i] {
2443 // The picker acts on PRESSES only; the release
2444 // over the button is swallowed. Releases used to
2445 // reach the dropdown, and one arriving before the
2446 // open animation's first frame (a fast or
2447 // injected click) read as an outside press and
2448 // closed the menu it had just opened.
2449 let (bx, by, bw, bh) = d.rect();
2450 let on_button =
2451 px >= bx && px <= bx + bw && py >= by && py <= by + bh;
2452 if state == ElementState::Pressed {
2453 if d.mouse_input(button, state, px, py, ui) {
2454 if d.take_change() {
2455 if let Some(val) = d.get_value_string() {
2456 if let Some(tb) = &mut self.texts[i] {
2457 tb.text = val.clone();
2458 tb.edit_buffer = val.clone();
2459 }
2460 p.1 = val;
2461 }
2462 }
2463 return true;
2464 }
2465 } else if on_button {
2466 return true;
2467 }
2468 }
2469 if let Some(tb) = &mut self.texts[i] {
2470 if tb.mouse_input(button, state, px, py, ui) {
2471 if tb.editing {
2472 self.focused_param = Some(i);
2473 } else {
2474 if self.focused_param == Some(i) {
2475 self.focused_param = None;
2476 }
2477 }
2478 if tb.take_change() {
2479 if let Some(val) = tb.get_value_string() {
2480 p.1 = val;
2481 }
2482 }
2483 return true;
2484 }
2485 }
2486 } else if p.2.starts_with("spinbox") {
2487 if let Some(sb) = &mut self.spinboxes[i] {
2488 if sb.mouse_input(button, state, px, py, ui) {
2489 p.1 = sb.value.to_string();
2490 if sb.editing {
2491 self.focused_param = Some(i);
2492 } else {
2493 if self.focused_param == Some(i) {
2494 self.focused_param = None;
2495 }
2496 }
2497 return true;
2498 }
2499 }
2500 } else if p.2 == "toggle" || p.2 == "checkbox" {
2501 if let Some(cb) = &mut self.toggles[i] {
2502 if cb.mouse_input(button, state, px, py, ui) {
2503 if cb.take_change() {
2504 if let Some(val) = cb.get_value_string() {
2505 p.1 = val;
2506 }
2507 }
2508 return true;
2509 }
2510 }
2511 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
2512 if let Some(c) = &mut self.colors[i] {
2513 if c.mouse_input(button, state, px, py, ui) {
2514 if let Some(val) = c.get_value_string() {
2515 p.1 = val;
2516 }
2517 if c.editing {
2518 self.focused_param = Some(i);
2519 } else {
2520 if self.focused_param == Some(i) {
2521 self.focused_param = None;
2522 }
2523 }
2524 return true;
2525 }
2526 }
2527 } else if p.2 == "ramp" {
2528 if let Some(rp) = &mut self.ramps[i] {
2529 if rp.mouse_input(button, state, px, py, ui) {
2530 p.1 = rp.inner().spec_string();
2531 return true;
2532 }
2533 }
2534 }
2535 }
2536
2537 if button == MouseButton::Left && state == ElementState::Pressed {
2538 let rects = self.get_param_rects();
2539 let mut clicked_any_focusable = false;
2540 for (i, p) in self.display_params.iter_mut().enumerate() {
2541 if hidden[i] {
2542 continue;
2543 }
2544 if p.2 == "code" {
2545 let r = rects[i];
2546 if px >= r.0 && px <= r.0 + r.2 && py >= r.1 + Self::CODE_BOX_TOP && py <= r.1 + r.3 {
2547 // Clicking inside the box already being edited moves the
2548 // caret and keeps the buffer (and its history); a click
2549 // into a different row's box starts a fresh editor.
2550 let mut editor = match (self.focused_param == Some(i), self.code_editor.take()) {
2551 (true, Some(e)) => e,
2552 (_, other) => {
2553 drop(other);
2554 self.code_history.clear();
2555 TextEditorState::new(p.1.clone())
2556 }
2557 };
2558 self.focused_param = Some(i);
2559 let click_x = px - self.code_text_x(r);
2560 let click_y = py - (r.1 + Self::CODE_TOP);
2561 let line = (click_y / Self::CODE_LINE_H).floor().max(0.0) as usize;
2562 let col = (click_x / self.code_col_w() + 0.5).floor().max(0.0) as usize;
2563 let at = map_2d_to_1d(&editor.buffer, line, col);
2564 if ui.shift_pressed {
2565 if editor.select_anchor.is_none() {
2566 editor.select_anchor = Some(editor.cursor_idx);
2567 }
2568 } else {
2569 editor.clear_selection();
2570 }
2571 editor.cursor_idx = at;
2572 self.code_editor = Some(editor);
2573 clicked_any_focusable = true;
2574 break;
2575 }
2576 } else if p.2.starts_with("slider") {
2577 let r = rects[i];
2578 if py >= r.1 && py <= r.1 + r.3 {
2579 if let Some(s) = &mut self.sliders[i] {
2580 if s.mouse_input(button, state, px, py, ui) {
2581 if s.editing {
2582 self.focused_param = Some(i);
2583 clicked_any_focusable = true;
2584 }
2585 break;
2586 }
2587 }
2588 }
2589 } else if p.2.starts_with("float3") {
2590 let r = rects[i];
2591 if py >= r.1 && py <= r.1 + r.3 {
2592 if let Some(f) = &mut self.float3s[i] {
2593 if f.mouse_input(button, state, px, py, ui) {
2594 if f.editing_idx().is_some() {
2595 self.focused_param = Some(i);
2596 clicked_any_focusable = true;
2597 }
2598 break;
2599 }
2600 }
2601 }
2602 }
2603 }
2604 if !clicked_any_focusable {
2605 self.commit_and_unfocus();
2606 }
2607 return true;
2608 }
2609 false
2610 }
2611 Event::KeyInput(event) => {
2612 if !self.visible {
2613 return false;
2614 }
2615 let Some(ui) = ectx.ui.as_deref_mut() else {
2616 return false;
2617 };
2618 if let Some(idx) = self.focused_param {
2619 if event.state == ElementState::Pressed {
2620 let p = &mut self.display_params[idx];
2621 if p.2 == "code" {
2622 if let Some(mut editor) = self.code_editor.take() {
2623 // Edits go to the BUFFER, not the value: a script
2624 // re-evaluates the node on every value change, and
2625 // a half-typed line would fail on every keystroke.
2626 // ctrl+enter applies, and so does leaving the row
2627 // (Escape, a click elsewhere, `unfocus`).
2628 let before = editor.clone();
2629 let mut handled = true;
2630 let mut edited = false;
2631 let mut apply = false;
2632 let mut should_unfocus = false;
2633 let shift = event.shift;
2634 let mut move_vertical = |editor: &mut TextEditorState, delta: i32| {
2635 if shift && editor.select_anchor.is_none() {
2636 editor.select_anchor = Some(editor.cursor_idx);
2637 } else if !shift {
2638 editor.clear_selection();
2639 }
2640 let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
2641 let total = editor.buffer.split('\n').count() as i32;
2642 let target = (line as i32 + delta).clamp(0, total - 1) as usize;
2643 editor.cursor_idx = map_2d_to_1d(&editor.buffer, target, col);
2644 };
2645 match &event.logical_key {
2646 Key::Named(NamedKey::Backspace) => {
2647 edited = editor.delete_backwards();
2648 }
2649 Key::Named(NamedKey::Delete) => {
2650 edited = editor.delete_forwards();
2651 }
2652 Key::Named(NamedKey::Enter) if event.ctrl => {
2653 apply = true;
2654 }
2655 Key::Named(NamedKey::Enter) => {
2656 // Auto-indent: the line's own leading whitespace,
2657 // one level deeper after an opening brace.
2658 let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
2659 let line: String = editor.buffer.chars().skip(line_start).take(editor.cursor_idx - line_start).collect();
2660 let mut indent: String = line.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
2661 if line.trim_end().ends_with('{') || line.trim_end().ends_with('(') || line.trim_end().ends_with('[') {
2662 indent.push_str(Self::CODE_INDENT);
2663 }
2664 editor.insert_text(&format!("\n{indent}"));
2665 edited = true;
2666 }
2667 Key::Named(NamedKey::Tab) if event.shift => {
2668 // Dedent the current line by one level, or what it has.
2669 let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
2670 let leading = editor.buffer.chars().skip(line_start).take_while(|c| *c == ' ').count().min(Self::CODE_INDENT.len());
2671 if leading > 0 {
2672 let chars: Vec<char> = editor.buffer.chars().collect();
2673 editor.buffer = chars[..line_start].iter().chain(&chars[line_start + leading..]).collect();
2674 editor.cursor_idx = editor.cursor_idx.saturating_sub(leading).max(line_start);
2675 editor.clear_selection();
2676 edited = true;
2677 }
2678 }
2679 Key::Named(NamedKey::Tab) => {
2680 editor.insert_text(Self::CODE_INDENT);
2681 edited = true;
2682 }
2683 Key::Named(NamedKey::Escape) => {
2684 apply = true;
2685 should_unfocus = true;
2686 }
2687 Key::Named(NamedKey::ArrowLeft) => {
2688 editor.move_cursor_left(shift);
2689 }
2690 Key::Named(NamedKey::ArrowRight) => {
2691 editor.move_cursor_right(shift);
2692 }
2693 Key::Named(NamedKey::ArrowUp) => move_vertical(&mut editor, -1),
2694 Key::Named(NamedKey::ArrowDown) => move_vertical(&mut editor, 1),
2695 Key::Named(NamedKey::Home) => {
2696 if shift && editor.select_anchor.is_none() {
2697 editor.select_anchor = Some(editor.cursor_idx);
2698 } else if !shift {
2699 editor.clear_selection();
2700 }
2701 editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
2702 }
2703 Key::Named(NamedKey::End) => {
2704 if shift && editor.select_anchor.is_none() {
2705 editor.select_anchor = Some(editor.cursor_idx);
2706 } else if !shift {
2707 editor.clear_selection();
2708 }
2709 editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
2710 }
2711 Key::Character(s) => {
2712 if event.ctrl {
2713 match s.to_lowercase().as_str() {
2714 "f" => {
2715 editor.move_cursor_right(false);
2716 }
2717 "b" => {
2718 editor.move_cursor_left(false);
2719 }
2720 "p" => move_vertical(&mut editor, -1),
2721 "n" => move_vertical(&mut editor, 1),
2722 "a" if event.shift => {
2723 editor.select_all();
2724 }
2725 "a" => {
2726 editor.clear_selection();
2727 editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
2728 }
2729 "e" => {
2730 editor.clear_selection();
2731 editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
2732 }
2733 "d" => {
2734 edited = editor.delete_forwards();
2735 }
2736 "h" => {
2737 edited = editor.delete_backwards();
2738 }
2739 "k" => {
2740 let current_idx = editor.cursor_idx;
2741 let end_idx = get_line_end(&editor.buffer, current_idx);
2742 let chars: Vec<char> = editor.buffer.chars().collect();
2743 if current_idx < chars.len() {
2744 let delete_end = if chars[current_idx] == '\n' { current_idx + 1 } else { end_idx };
2745 editor.buffer = chars[..current_idx].iter().chain(&chars[delete_end..]).collect();
2746 editor.clear_selection();
2747 edited = true;
2748 }
2749 }
2750 // The clipboard and history chords are the
2751 // context actions, so the chord, the runner's
2752 // routing and the menu row do one thing.
2753 "c" | "x" | "v" | "z" => {
2754 let action = match (s.to_lowercase().as_str(), event.shift) {
2755 ("c", _) => crate::widget::ContextAction::Copy,
2756 ("x", _) => crate::widget::ContextAction::Cut,
2757 ("v", _) => crate::widget::ContextAction::Paste,
2758 ("z", true) => crate::widget::ContextAction::Redo,
2759 _ => crate::widget::ContextAction::Undo,
2760 };
2761 apply_code_action(&mut editor, &mut self.code_history, action);
2762 }
2763 _ => {
2764 handled = false;
2765 }
2766 }
2767 } else {
2768 editor.insert_text(s);
2769 edited = true;
2770 }
2771 }
2772 _ => {
2773 handled = false;
2774 }
2775 }
2776 if edited {
2777 // Typing runs coalesce into one undo step; anything
2778 // structural (a newline, a paste, a deletion of a
2779 // selection) starts a new one.
2780 let plain_char = matches!(&event.logical_key, Key::Character(c) if !event.ctrl && c.chars().count() == 1);
2781 if plain_char {
2782 self.code_history.record_grouped(before, 1);
2783 } else {
2784 self.code_history.record(before);
2785 }
2786 }
2787 if apply {
2788 p.1 = editor.buffer.clone();
2789 }
2790 if should_unfocus {
2791 self.focused_param = None;
2792 self.code_editor = None;
2793 self.code_history.clear();
2794 } else {
2795 self.code_editor = Some(editor);
2796 }
2797 if handled {
2798 return true;
2799 }
2800 }
2801 } else if is_text_row(&p.2) {
2802 if let Some(d) = &mut self.choices[idx] {
2803 if d.open && d.keyboard_input(event, ui) {
2804 if d.take_change() {
2805 if let Some(val) = d.get_value_string() {
2806 if let Some(tb) = &mut self.texts[idx] {
2807 tb.text = val.clone();
2808 tb.edit_buffer = val.clone();
2809 }
2810 p.1 = val;
2811 }
2812 }
2813 return true;
2814 }
2815 }
2816 if let Some(tb) = &mut self.texts[idx] {
2817 if tb.keyboard_input(event, ui) {
2818 if !tb.editing {
2819 p.1 = tb.text.clone();
2820 self.focused_param = None;
2821 } else {
2822 p.1 = tb.edit_buffer.clone();
2823 }
2824 return true;
2825 }
2826 }
2827 } else if p.2.starts_with("choice") {
2828 if let Some(d) = &mut self.choices[idx] {
2829 if d.keyboard_input(event, ui) {
2830 if !d.open {
2831 if let Some(val) = d.get_value_string() {
2832 p.1 = val;
2833 }
2834 self.focused_param = None;
2835 }
2836 return true;
2837 }
2838 }
2839 } else if p.2.starts_with("spinbox") {
2840 if let Some(sb) = &mut self.spinboxes[idx] {
2841 if sb.keyboard_input(event, ui) {
2842 if !sb.editing {
2843 p.1 = sb.value.to_string();
2844 self.focused_param = None;
2845 } else {
2846 p.1 = sb.edit_buffer.clone();
2847 }
2848 return true;
2849 }
2850 }
2851 } else if p.2.starts_with("slider") {
2852 if let Some(s) = &mut self.sliders[idx] {
2853 if s.keyboard_input(event, ui) {
2854 let (min, max) = parse_slider_range(&p.2);
2855 let new_val = min + s.value * (max - min);
2856 p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
2857 if !s.editing {
2858 self.focused_param = None;
2859 }
2860 return true;
2861 }
2862 }
2863 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
2864 if let Some(c) = &mut self.colors[idx] {
2865 if c.keyboard_input(event, ui) {
2866 if let Some(val) = c.get_value_string() {
2867 p.1 = val;
2868 }
2869 if !c.editing {
2870 self.focused_param = None;
2871 }
2872 return true;
2873 }
2874 }
2875 } else if p.2.starts_with("float3") {
2876 if let Some(f) = &mut self.float3s[idx] {
2877 if f.keyboard_input(event, ui) {
2878 p.1 = f.value_string();
2879 if f.editing_idx().is_none() {
2880 self.focused_param = None;
2881 }
2882 return true;
2883 }
2884 }
2885 }
2886 }
2887 }
2888 false
2889 }
2890 Event::MouseWheel { delta, x: px, y: py, .. } => {
2891 if !self.visible {
2892 return false;
2893 }
2894 let (px, py) = (*px, *py);
2895 let Some(ui) = ectx.ui.as_deref_mut() else {
2896 return false;
2897 };
2898 let mut changed = false;
2899 // A value row that took the wheel (slider/float3/spinbox),
2900 // whether or not its 2-decimal string ticked over. Gating the
2901 // pane's viewport-scroll fallback on the STRING (`changed`)
2902 // let every sub-tick trackpad event scroll the pane instead —
2903 // a slider-vs-pane tug-of-war that shifted the rows under the
2904 // pointer mid-adjust.
2905 let mut wheel_taken = false;
2906 // Gesture ownership. A gesture the PANE acquired — its first
2907 // event fell on no control and scrolled the rows — stays the
2908 // pane's until the gesture ends (`scroll_gesture_new`), however
2909 // the rows travel under the pointer meanwhile. Without this a
2910 // list scroll ran until a slider's halo or a spinbox row slid
2911 // under the pointer, which then took every remaining event of
2912 // the same gesture and adjusted a value the user never aimed
2913 // at (the Alt+D settings tab, 2026-09-20). A gesture that
2914 // began ON a control is that control's, as before, and one
2915 // nobody claimed (dead space, a control at its limit) is still
2916 // open to spatial acquisition — the band slider's feel.
2917 let pane_owns = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(self_id);
2918 // A trackpad gesture is the PANE's, from anywhere — a
2919 // two-finger swipe is a scroll everywhere on the desktop, and
2920 // a pane that is mostly controls (the Alt+D Settings list)
2921 // was otherwise scrollable only from a label. A wheel notch
2922 // still adjusts the control under the pointer. Unconditional
2923 // since 2026-09-21: the first cut kept hover-adjust for finger
2924 // gestures in a pane whose content fits, which made the main
2925 // params pane feel different from the Settings list depending
2926 // on how many parameters the node had. Finger-end frames (no
2927 // delta) take the same path so the pane's coast starts.
2928 let finger = matches!(delta, MouseScrollDelta::PixelDelta(_))
2929 && matches!(
2930 crate::widget::scroll_motion::current_scroll_phase(),
2931 crate::widget::ScrollPhase::Finger | crate::widget::ScrollPhase::FingerEnd
2932 );
2933 let pane_takes = pane_owns || finger;
2934 let rects = self.get_param_rects();
2935 for (i, p) in self.display_params.iter_mut().enumerate() {
2936 if pane_takes {
2937 break;
2938 }
2939 if p.2.starts_with("slider") {
2940 // The capture zone is the slider's own shape halo
2941 // (`Slider::scroll_hit` — the band plus the traveling
2942 // swell, inset), so scrolls off the shape fall through
2943 // to the pane's viewport scroll below.
2944 let in_zone = self.sliders[i].as_ref().map_or(false, |s| {
2945 // The same gesture latch the slider's own wheel
2946 // test applies: mid-gesture the slider that
2947 // acquired the scroll keeps it (its halo travels
2948 // away from the pointer as the value moves).
2949 let latched = !ui.scroll_gesture_new
2950 && ui.scroll_initiate_widget_id == Some(s.base().id());
2951 let (sx, sy, sw, sh) = s.rect();
2952 let ty = s.label_strip();
2953 latched
2954 || s.inner().scroll_hit(
2955 Rect { x: sx, y: sy + ty, width: sw, height: sh - ty },
2956 px,
2957 py,
2958 )
2959 });
2960 if in_zone {
2961 if let Some(s) = &mut self.sliders[i] {
2962 let was_scroll = s.scroll_enabled;
2963 s.set_scroll(true);
2964 // Ungated: the in_zone halo above already gated
2965 // spatially, and the adapter's rect gate would
2966 // clip the halo's fringe outside the row rect.
2967 if s.mouse_wheel_ungated(delta, px, py, ui) {
2968 wheel_taken = true;
2969 let (min, max) = parse_slider_range(&p.2);
2970 let new_val = min + s.value * (max - min);
2971 let old_val = &p.1;
2972 let new_val_str = format!("{:.*}", slider_decimals(&p.2), new_val);
2973 if *old_val != new_val_str {
2974 p.1 = new_val_str;
2975 changed = true;
2976 }
2977 }
2978 s.set_scroll(was_scroll);
2979 }
2980 }
2981 } else if p.2.starts_with("float3") {
2982 let r = rects[i];
2983 if py >= r.1 - 2.0 && py <= r.1 + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
2984 // The group's rows apply the slider-row contract
2985 // themselves (band halo / gesture latch, else the
2986 // row strip; ungated forward).
2987 if let Some(f) = &mut self.float3s[i] {
2988 if f.wheel(delta, px, py, ui) {
2989 wheel_taken = true;
2990 let new_val_str = f.value_string();
2991 if p.1 != new_val_str {
2992 p.1 = new_val_str;
2993 changed = true;
2994 }
2995 }
2996 }
2997 }
2998 } else if p.2.starts_with("spinbox") {
2999 let r = rects[i];
3000 let row_y = r.1;
3001 if py >= row_y && py <= row_y + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
3002 if let Some(sb) = &mut self.spinboxes[i] {
3003 wheel_taken = true;
3004 let scroll_amount = match delta {
3005 MouseScrollDelta::LineDelta(_x, y) => *y as i32,
3006 MouseScrollDelta::PixelDelta(pos) => {
3007 let dy = pos.y;
3008 if dy > 0.0 { 1 } else if dy < 0.0 { -1 } else { 0 }
3009 }
3010 };
3011 let new_val = (sb.value + scroll_amount * sb.step).clamp(sb.min, sb.max);
3012 if sb.value != new_val {
3013 sb.value = new_val;
3014 p.1 = new_val.to_string();
3015 changed = true;
3016 }
3017 }
3018 }
3019 }
3020 }
3021
3022 // The legacy tail's `self.hit_test(px, py, ctx)`: occlusion via the adapter's
3023 // address, then rect-or-popover containment.
3024 let mut swallowed = changed || wheel_taken;
3025 if !ui.is_coordinate_covered(self_id, px, py) {
3026 let in_rect = px >= self.rect.x
3027 && px <= self.rect.x + self.rect.width
3028 && py >= self.rect.y
3029 && py <= self.rect.y + self.rect.height;
3030 let in_popover = self.own_popover_rect().map_or(false, |(rx, ry, rw, rh)| {
3031 px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
3032 });
3033 if in_rect || in_popover {
3034 if !changed && !wheel_taken {
3035 if crate::scroll_debug() {
3036 eprintln!("[scroll] params: PANE-SCROLL fallback at ({px:.0},{py:.0})");
3037 }
3038 // The pane takes the gesture (see `pane_owns`).
3039 ui.scroll_initiate_widget_id = Some(self_id);
3040 let max_scroll = (self.content_h - self.rect.height).max(0.0);
3041 self.scroll_motion.reconcile(0.0, self.scroll_y);
3042 let moved = self.scroll_motion.apply(
3043 delta,
3044 (crate::widget::LINE_PX, crate::widget::LINE_PX),
3045 crate::widget::Bounds::max(0.0),
3046 crate::widget::Bounds::max(max_scroll),
3047 );
3048 self.scroll_y = self.scroll_motion.y.pos();
3049 if moved {
3050 self.update_slider_rects();
3051 self.activity.bump();
3052 self.recompute_scrollbar_raised();
3053 }
3054 }
3055 // An opaque pane swallows EVERY wheel over it, whether
3056 // anything moved or not: returning false would hand the
3057 // event to whatever lies BEHIND the plate — the designer
3058 // routes unhandled wheels to the 3D viewport, whose rect
3059 // is the whole window in the floating layout, so a
3060 // near-miss on a slider would orbit the camera through
3061 // the pane.
3062 swallowed = true;
3063 }
3064 }
3065
3066 swallowed
3067 }
3068 _ => false,
3069 }
3070 }
3071 }
3072
3073 /// Display precision for a slider row from the type string's optional 4th
3074 /// segment (`slider:min:max:decimals`); 2 when absent — the pane-wide
3075 /// historical default.
3076 fn slider_decimals(ptype: &str) -> usize {
3077 ptype.split(':').nth(3).and_then(|s| s.parse().ok()).unwrap_or(2)
3078 }
3079
3080 fn parse_slider_range(ptype: &str) -> (f32, f32) {
3081 if ptype.starts_with("slider:") || ptype.starts_with("float3:") {
3082 let parts: Vec<&str> = ptype.split(':').collect();
3083 if parts.len() >= 3 {
3084 if let (Ok(min), Ok(max)) = (parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
3085 return (min, max);
3086 }
3087 }
3088 }
3089 (0.0, 2.0)
3090 }
3091
3092 fn parse_hex_to_rgb(s: &str) -> Option<[u8; 3]> {
3093 crate::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
3094 }
3095
3096 fn parse_hex_to_rgba(s: &str) -> Option<[u8; 4]> {
3097 crate::color::parse_hex_bytes(s)
3098 }
3099
3100 fn parse_spinbox_range(ptype: &str) -> (i32, i32, i32) {
3101 if ptype.starts_with("spinbox:") {
3102 let parts: Vec<&str> = ptype.split(':').collect();
3103 if parts.len() >= 4 {
3104 if let (Ok(min), Ok(max), Ok(step)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>(), parts[3].parse::<i32>()) {
3105 return (min, max, step);
3106 }
3107 } else if parts.len() == 3 {
3108 if let (Ok(min), Ok(max)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>()) {
3109 return (min, max, 1);
3110 }
3111 }
3112 }
3113 (0, 10000, 1)
3114 }
3115
3116 fn parse_float3_value(val_str: &str, min: f32, max: f32) -> [f32; 3] {
3117 let mut out = [0.5, 0.5, 0.5];
3118 let parts: Vec<&str> = val_str
3119 .split(|c| c == ':' || c == ',' || c == ' ')
3120 .filter(|s| !s.is_empty())
3121 .collect();
3122 for i in 0..3 {
3123 if i < parts.len() {
3124 if let Ok(v) = parts[i].parse::<f32>() {
3125 let range = max - min;
3126 if range != 0.0 {
3127 out[i] = ((v - min) / range).clamp(0.0, 1.0);
3128 } else {
3129 out[i] = 0.0;
3130 }
3131 }
3132 }
3133 }
3134 out
3135 }
3136
3137 impl ParamController for ParametersBg {
3138 fn node_params(&self) -> Vec<(String, String, String)> {
3139 self.display_params.clone()
3140 }
3141
3142 fn set_display_params(&mut self, params: &[(String, String, String)]) {
3143 let mut layout_changed = self.display_params.len() != params.len();
3144 if !layout_changed {
3145 for (p_old, p_new) in self.display_params.iter().zip(params.iter()) {
3146 if p_old.0 != p_new.0 || p_old.2 != p_new.2 {
3147 layout_changed = true;
3148 break;
3149 }
3150 }
3151 }
3152
3153 if layout_changed {
3154 self.scroll_y = 0.0;
3155 self.display_params = params.to_vec();
3156 self.focused_param = None;
3157 // Re-read at every rebuild, so a reloaded style takes effect the
3158 // next time the rows are built, and geometry and widgets agree.
3159 self.inline_labels = crate::layout::param_labels_inline();
3160 let inline = self.inline_labels;
3161 self.sliders = self.display_params.iter().map(|p| {
3162 if p.2.starts_with("slider") {
3163 let val = p.1.parse::<f32>().unwrap_or(0.0);
3164 let (min, max) = parse_slider_range(&p.2);
3165 let t = if max - min != 0.0 {
3166 ((val - min) / (max - min)).clamp(0.0, 1.0)
3167 } else {
3168 0.0
3169 };
3170 let s = Slider::new().with_value(t).with_range(min, max).with_readout(true).with_decimals(slider_decimals(&p.2));
3171 Some(if inline { s } else { s.with_label(&p.0) })
3172 } else {
3173 None
3174 }
3175 }).collect();
3176 self.float3s = self.display_params.iter().map(|p| {
3177 if p.2.starts_with("float3") {
3178 let (min, max) = parse_slider_range(&p.2);
3179 let vals = parse_float3_value(&p.1, min, max);
3180 let f = Float3::new().with_values(vals).with_range(min, max);
3181 Some(if inline { f } else { f.with_label(&p.0) })
3182 } else {
3183 None
3184 }
3185 }).collect();
3186 self.spinboxes = self.display_params.iter().map(|p| {
3187 if p.2.starts_with("spinbox") {
3188 let (min, max, step) = parse_spinbox_range(&p.2);
3189 let val = p.1.parse::<i32>().unwrap_or(min);
3190 let sb = Spinbox::new(val, min, max, step);
3191 Some(if inline { sb } else { sb.with_label(&p.0) })
3192 } else {
3193 None
3194 }
3195 }).collect();
3196 self.buttons = self.display_params.iter().map(|p| {
3197 if p.2 == "button" {
3198 // Left-aligned, like the toggles below: the rows form one column, and
3199 // centered labels made each row's text start at a different x.
3200 Some(Button::new(0.0, 0.0, 0.0, 0.0).with_label(&p.0).with_left_align(true))
3201 } else {
3202 None
3203 }
3204 }).collect();
3205 self.choices = self.display_params.iter().map(|p| {
3206 if p.2.starts_with("choice:") {
3207 let options_str = p.2.strip_prefix("choice:").unwrap_or("");
3208 let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3209 let selected = options.iter().position(|o| o == &p.1).unwrap_or(0);
3210 let d = Dropdown::new(options, selected);
3211 Some(if inline { d } else { d.with_label(&p.0) })
3212 } else if p.2.starts_with("textpick:") {
3213 // The text row's completion picker: a menu-button Dropdown
3214 // (fixed glyph, re-fires on repeat picks) beside the box.
3215 let options: Vec<String> = p.2.strip_prefix("textpick:").unwrap_or("")
3216 .split(',')
3217 .filter(|s| !s.is_empty())
3218 .map(|s| s.to_string())
3219 .collect();
3220 if options.is_empty() {
3221 None
3222 } else {
3223 // A raised face: the picker reads as a BUTTON sitting
3224 // in the box's recess, not a bare glyph beside it.
3225 Some(
3226 Dropdown::new(options, 0)
3227 .with_custom_display_text("\u{25be}")
3228 .with_raised(true),
3229 )
3230 }
3231 } else {
3232 None
3233 }
3234 }).collect();
3235 self.texts = self.display_params.iter().map(|p| {
3236 if is_text_row(&p.2) {
3237 let tb = TextBox::new(p.1.clone());
3238 Some(if inline { tb } else { tb.with_label(&p.0) })
3239 } else {
3240 None
3241 }
3242 }).collect();
3243 self.toggles = self.display_params.iter().map(|p| {
3244 if p.2 == "toggle" || p.2 == "checkbox" {
3245 let on = p.1.trim().to_lowercase() == "true";
3246 let mut t = Toggle::new().with_label(&p.0).with_left_align(true);
3247 t.set_toggled(on);
3248 Some(t)
3249 } else {
3250 None
3251 }
3252 }).collect();
3253 self.colors = self.display_params.iter().map(|p| {
3254 if p.2 == "rgba" {
3255 // Alpha-carrying param: the full picker, 8-digit hex.
3256 let col = parse_hex_to_rgba(&p.1).unwrap_or([255, 255, 255, 255]);
3257 let c = ColorSelector::new_rgba(col);
3258 Some(if inline { c } else { c.with_label(&p.0) })
3259 } else if p.2.starts_with("color") || p.2 == "rgb" {
3260 let col = parse_hex_to_rgb(&p.1).unwrap_or([255, 255, 255]);
3261 let c = ColorSelector::new(col);
3262 Some(if inline { c } else { c.with_label(&p.0) })
3263 } else {
3264 None
3265 }
3266 }).collect();
3267 self.ramps = self.display_params.iter().map(|p| {
3268 if p.2 == "ramp" {
3269 let mut rp = Ramp::new();
3270 rp.inner_mut().set_spec(&p.1);
3271 Some(rp)
3272 } else {
3273 None
3274 }
3275 }).collect();
3276 } else {
3277 for (i, p_new) in params.iter().enumerate() {
3278 if Some(i) != self.focused_param && Some(i) != self.dragging_param {
3279 self.display_params[i].1 = p_new.1.clone();
3280 if let Some(ref mut s) = self.sliders[i] {
3281 let (min, max) = parse_slider_range(&p_new.2);
3282 // Idempotence guard: hosts push params straight back
3283 // after every sync, and re-seeding from the 2-decimal
3284 // string quantizes away the slider's sub-tick state —
3285 // mid-scroll that snaps the value BACKWARD between
3286 // wheel events/glide ticks (visible as jitter). Only
3287 // re-seed when the incoming string says something the
3288 // current value doesn't (a genuinely external change).
3289 let cur_str = format!("{:.*}", slider_decimals(&p_new.2), min + s.value * (max - min));
3290 if cur_str != p_new.1 {
3291 let val = p_new.1.parse::<f32>().unwrap_or(0.0);
3292 let t = if max - min != 0.0 {
3293 ((val - min) / (max - min)).clamp(0.0, 1.0)
3294 } else {
3295 0.0
3296 };
3297 s.set_value(t);
3298 }
3299 } else if let Some(ref mut f) = self.float3s[i] {
3300 let (min, max) = parse_slider_range(&p_new.2);
3301 // Same round-trip guard as the slider row.
3302 let cur_str = f.value_string();
3303 if cur_str != p_new.1 {
3304 let vals = parse_float3_value(&p_new.1, min, max);
3305 f.set_values(vals);
3306 }
3307 } else if let Some(ref mut sb) = self.spinboxes[i] {
3308 if !sb.editing {
3309 let (min, _max, _step) = parse_spinbox_range(&p_new.2);
3310 let val = p_new.1.parse::<i32>().unwrap_or(min);
3311 sb.value = val;
3312 }
3313 } else if let Some(ref mut d) = self.choices[i] {
3314 if !d.open {
3315 if let Some(options_str) = p_new.2.strip_prefix("choice:") {
3316 let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3317 if d.options != options {
3318 d.options = options.clone();
3319 }
3320 if let Some(idx) = options.iter().position(|o| o == &p_new.1) {
3321 d.selected = idx;
3322 }
3323 }
3324 }
3325 } else if let Some(ref mut tb) = self.texts[i] {
3326 if !tb.editing {
3327 tb.set_value_string(&p_new.1);
3328 }
3329 } else if let Some(ref mut t) = self.toggles[i] {
3330 let on = p_new.1.trim().to_lowercase() == "true";
3331 t.set_toggled(on);
3332 } else if let Some(ref mut c) = self.colors[i] {
3333 if !c.editing {
3334 c.set_value_string(&p_new.1);
3335 }
3336 } else if let Some(ref mut rp) = self.ramps[i] {
3337 if !rp.inner().is_dragging_key {
3338 rp.set_value_string(&p_new.1);
3339 }
3340 }
3341 }
3342 }
3343 }
3344 self.refresh_scroll_metrics();
3345 }
3346 }
3347
3348 /// One clipboard, selection or history action over a code editor and its
3349 /// history — the body [`ParametersBg::code_action`] and the editor's own
3350 /// chords share, free of `self` so a caller holding a row borrow can use it.
3351 fn apply_code_action(
3352 editor: &mut TextEditorState,
3353 history: &mut crate::history::History<TextEditorState>,
3354 action: crate::widget::ContextAction,
3355 ) {
3356 use crate::widget::ContextAction;
3357 let before = editor.clone();
3358 let mut edited = false;
3359 match action {
3360 ContextAction::Undo => {
3361 if let Some(prev) = history.undo(editor.clone()) {
3362 *editor = prev;
3363 }
3364 }
3365 ContextAction::Redo => {
3366 if let Some(next) = history.redo(editor.clone()) {
3367 *editor = next;
3368 }
3369 }
3370 ContextAction::SelectAll => editor.select_all(),
3371 ContextAction::Copy => {
3372 if let Some(text) = editor.selected_text() {
3373 crate::widget::clipboard::copy_to_clipboard(&text);
3374 }
3375 }
3376 ContextAction::Cut => {
3377 if let Some(text) = editor.selected_text() {
3378 crate::widget::clipboard::copy_to_clipboard(&text);
3379 edited = editor.delete_backwards();
3380 }
3381 }
3382 ContextAction::Paste => {
3383 if let Some(text) = crate::widget::clipboard::read_from_clipboard() {
3384 editor.insert_text(&text);
3385 edited = true;
3386 }
3387 }
3388 _ => {}
3389 }
3390 if edited {
3391 history.record(before);
3392 }
3393 }
3394
3395 fn get_cursor_line_col(buffer: &str, cursor_idx: usize) -> (usize, usize) {
3396 let mut cur_line = 0;
3397 let mut cur_col = 0;
3398 let mut count = 0;
3399 for c in buffer.chars() {
3400 if count == cursor_idx {
3401 return (cur_line, cur_col);
3402 }
3403 if c == '\n' {
3404 cur_line += 1;
3405 cur_col = 0;
3406 } else {
3407 cur_col += 1;
3408 }
3409 count += 1;
3410 }
3411 (cur_line, cur_col)
3412 }
3413
3414 fn map_2d_to_1d(buffer: &str, line: usize, col: usize) -> usize {
3415 let mut target_line = line;
3416 let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3417 if lines.is_empty() {
3418 return 0;
3419 }
3420 if target_line >= lines.len() {
3421 target_line = lines.len() - 1;
3422 }
3423 let mut target_col = col;
3424 if target_col > lines[target_line].len() {
3425 target_col = lines[target_line].len();
3426 }
3427 let mut index = 0;
3428 for i in 0..target_line {
3429 index += lines[i].len() + 1; // +1 for the '\n'
3430 }
3431 index += target_col;
3432 index
3433 }
3434
3435 fn get_line_start(buffer: &str, cursor_idx: usize) -> usize {
3436 let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3437 map_2d_to_1d(buffer, line, 0)
3438 }
3439
3440 fn get_line_end(buffer: &str, cursor_idx: usize) -> usize {
3441 let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3442 let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3443 if lines.is_empty() {
3444 return 0;
3445 }
3446 let line_len = if line < lines.len() {
3447 lines[line].len()
3448 } else {
3449 lines[lines.len() - 1].len()
3450 };
3451 map_2d_to_1d(buffer, line, line_len)
3452 }
3453
3454 #[cfg(test)]
3455 mod tests {
3456 use super::*;
3457 use crate::context::UiContext;
3458
3459 fn panel_with(params: &[(&str, &str, &str)]) -> Adapted<ParametersBg> {
3460 let mut p = ParametersBg::new();
3461 let params: Vec<(String, String, String)> = params
3462 .iter()
3463 .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
3464 .collect();
3465 ParamController::set_display_params(&mut *p, ¶ms);
3466 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
3467 p
3468 }
3469
3470 /// The textpick text-row variant: a TextBox AND a menu-button Dropdown
3471 /// share the row — the picker takes a right-edge sliver, its options come
3472 /// from the type string, and a plain text row builds no picker.
3473 #[test]
3474 fn textpick_rows_carry_a_picker() {
3475 let p = panel_with(&[
3476 ("Attribute Name", "mass", "textpick:Norm,UV,Pos,Col"),
3477 ("Plain", "x", "text"),
3478 ("Empty", "y", "textpick:"),
3479 ]);
3480 assert!(p.texts[0].is_some(), "textpick keeps its TextBox");
3481 let d = p.choices[0].as_ref().expect("textpick builds the picker");
3482 assert_eq!(d.options, ["Norm", "UV", "Pos", "Col"]);
3483 assert!(d.custom_display_text.is_some(), "menu-button mode");
3484 assert!(p.choices[1].is_none(), "plain text has no picker");
3485 assert!(p.texts[2].is_some() && p.choices[2].is_none(), "no options, no picker");
3486
3487 // Layout: the box spans the full row; the picker button nests
3488 // inside it (within the box's right end).
3489 let (tx, ty, tw, th) = p.texts[0].as_ref().unwrap().rect();
3490 let (dx, dy, dw, dh) = p.choices[0].as_ref().unwrap().rect();
3491 assert_eq!(dw, PICK_W);
3492 assert!(dx > tx && dx + dw < tx + tw, "button inside the box horizontally");
3493 assert!(dy > ty && dy + dh <= ty + th, "button inside the box vertically");
3494
3495 // Both text variants lay out at the same row height.
3496 assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
3497
3498 // The menu anchors off the WHOLE field: the popover spans at least
3499 // the box width and hangs below it, not off the button sliver.
3500 let anchor = p.choices[0].as_ref().unwrap().popover_anchor.expect("anchor set");
3501 assert_eq!(anchor.x, tx);
3502 assert_eq!(anchor.width, tw);
3503 let d = p.choices[0].as_ref().unwrap();
3504 let (px_, py_, pw, _ph) = d.popover_geom(crate::scene::layout::Rect {
3505 x: dx, y: dy, width: dw, height: dh,
3506 });
3507 assert_eq!(px_, tx, "menu left-aligns with the box");
3508 assert!(pw >= tw, "menu at least as wide as the box");
3509 assert!(py_ >= ty + th - 1.0, "menu hangs below the box");
3510 }
3511
3512 #[test]
3513 fn param_controller_roundtrip_and_row_widgets() {
3514 let p = panel_with(&[
3515 ("Size", "1.00", "slider:0:2"),
3516 ("Mode", "b", "choice:a,b,c"),
3517 ("On", "true", "checkbox"),
3518 ]);
3519 assert_eq!(ParamController::node_params(&*p).len(), 3);
3520 assert!(p.sliders[0].is_some() && p.choices[1].is_some() && p.toggles[2].is_some());
3521 // Rows were laid out from the cached rect: the slider's control rect
3522 // is the row less the label column when the labels are inline (the
3523 // default), the whole row when they are stacked.
3524 let lw = p.inner().label_col_w();
3525 if p.inner().inline_labels {
3526 assert!(lw > 0.0, "inline labels reserve a column");
3527 let labels = p.inner().own_text_labels();
3528 let size = labels.iter().find(|l| l.text == "Size").expect("the pane draws the inline label");
3529 assert_eq!(size.x, ROW_X_INSET, "the label sits at the row's left edge");
3530 } else {
3531 assert_eq!(lw, 0.0);
3532 }
3533 let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
3534 assert_eq!(
3535 (sx, sw),
3536 (ROW_X_INSET + lw, 300.0 - 2.0 * ROW_X_INSET - lw),
3537 "control rect derives from the assigned rect and the label column"
3538 );
3539 }
3540
3541 #[test]
3542 fn ramp_row_builds_from_spec_and_edits_serialize_back() {
3543 let mut ctx = UiContext::new();
3544 let mut p = panel_with(&[("Bevel Profile", "smooth;0.000:0.000,1.000:1.000", "ramp")]);
3545 let rp = p.ramps[0].as_ref().expect("ramp row builds a Ramp");
3546 assert_eq!(rp.inner().keys.len(), 2);
3547 assert!(rp.inner().smooth());
3548
3549 // A press inside the curve area adds a key, and the row value carries
3550 // the re-serialized spec (what hosts poll and persist).
3551 let (rx, ry, rw, _) = rp.rect();
3552 p.mouse_input(MouseButton::Left, ElementState::Pressed, rx + rw * 0.5, ry + 40.0, &mut ctx);
3553 assert_eq!(p.ramps[0].as_ref().unwrap().inner().keys.len(), 3);
3554 let val = &ParamController::node_params(&*p)[0].1;
3555 assert_eq!(val.split(',').count(), 3, "spec re-serialized: {val}");
3556 assert!(val.starts_with("smooth;"));
3557 }
3558
3559 #[test]
3560 fn toggle_click_commits_value_and_unfocus_commits_editor() {
3561 let mut ctx = UiContext::new();
3562 let mut p = panel_with(&[("On", "false", "checkbox")]);
3563 let (cx, cy, _, ch) = p.toggles[0].as_ref().unwrap().rect();
3564 // Click the toggle row (presses are ungated for this widget; the panel consumes
3565 // every left press, so the return is true either way — assert the value flip).
3566 p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
3567 p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
3568 assert_eq!(ParamController::node_params(&*p)[0].1, "true");
3569
3570 // Code editor: focus it via a click, type, then unfocus commits the buffer.
3571 let mut p = panel_with(&[("Src", "let x = 1;", "code")]);
3572 let rects = p.get_param_rects();
3573 let r = rects[0];
3574 p.mouse_input(MouseButton::Left, ElementState::Pressed, r.0 + 20.0, r.1 + 30.0, &mut ctx);
3575 assert_eq!(p.focused_param, Some(0), "code row focused");
3576 assert!(p.code_editor.is_some());
3577 p.code_editor.as_mut().unwrap().insert_text("y");
3578 WidgetHost::unfocus(&mut p);
3579 assert_eq!(p.focused_param, None);
3580 assert!(p.code_editor.is_none());
3581 assert!(ParamController::node_params(&*p)[0].1.contains('y'), "editor buffer committed on unfocus");
3582 }
3583
3584 fn key(k: Key, ctrl: bool, shift: bool) -> Event {
3585 Event::KeyInput(crate::widget::KeyEvent { state: ElementState::Pressed, logical_key: k, text: None, repeat: false, ctrl, shift, alt: false })
3586 }
3587
3588 fn chr(c: &str) -> Event {
3589 key(Key::Character(c.into()), false, false)
3590 }
3591
3592 fn ctrl(c: &str) -> Event {
3593 key(Key::Character(c.into()), true, false)
3594 }
3595
3596 fn named(k: NamedKey) -> Event {
3597 key(Key::Named(k), false, false)
3598 }
3599
3600 fn shift_named(k: NamedKey) -> Event {
3601 key(Key::Named(k), false, true)
3602 }
3603
3604 /// A code row focused by a click at its first character.
3605 fn code_panel(src: &str) -> (Adapted<ParametersBg>, UiContext) {
3606 let mut ctx = UiContext::new();
3607 let mut p = panel_with(&[("Src", src, "code")]);
3608 let r = p.get_param_rects()[0];
3609 let x = p.code_text_x(r);
3610 p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0, &mut ctx);
3611 assert_eq!(p.focused_param, Some(0));
3612 (p, ctx)
3613 }
3614
3615 fn send(p: &mut Adapted<ParametersBg>, ctx: &mut UiContext, ev: Event) -> bool {
3616 WidgetHost::handle_event(p, &ev, ctx)
3617 }
3618
3619 /// Typing edits the buffer and leaves the VALUE alone until ctrl+enter
3620 /// applies it — a script would otherwise re-run on every keystroke — and
3621 /// the row says so: dirty while they differ, clean once applied.
3622 #[test]
3623 fn code_edits_apply_on_ctrl_enter_not_per_keystroke() {
3624 let (mut p, mut ctx) = code_panel("let x = 1;");
3625 assert!(!p.code_is_dirty());
3626 send(&mut p, &mut ctx, chr("/"));
3627 send(&mut p, &mut ctx, chr("/"));
3628 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "//let x = 1;");
3629 assert_eq!(ParamController::node_params(&*p)[0].1, "let x = 1;", "the value waits");
3630 assert!(p.code_is_dirty());
3631 send(&mut p, &mut ctx, key(Key::Named(NamedKey::Enter), true, false));
3632 assert_eq!(ParamController::node_params(&*p)[0].1, "//let x = 1;", "ctrl+enter applies");
3633 assert!(!p.code_is_dirty());
3634 assert_eq!(p.focused_param, Some(0), "and keeps editing");
3635 // Escape applies too, and leaves the row.
3636 send(&mut p, &mut ctx, chr("!"));
3637 send(&mut p, &mut ctx, named(NamedKey::Escape));
3638 assert_eq!(ParamController::node_params(&*p)[0].1, "//!let x = 1;");
3639 assert_eq!(p.focused_param, None);
3640 }
3641
3642 /// Tab indents, shift+tab dedents, and Enter carries the indentation —
3643 /// one level deeper after an opening brace.
3644 #[test]
3645 fn code_editor_indents_like_an_editor() {
3646 let (mut p, mut ctx) = code_panel("");
3647 send(&mut p, &mut ctx, chr("i"));
3648 send(&mut p, &mut ctx, chr("f"));
3649 send(&mut p, &mut ctx, chr(" "));
3650 send(&mut p, &mut ctx, chr("{"));
3651 send(&mut p, &mut ctx, named(NamedKey::Enter));
3652 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n ", "a brace opens a level");
3653 send(&mut p, &mut ctx, chr("x"));
3654 send(&mut p, &mut ctx, named(NamedKey::Enter));
3655 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n x\n ", "the level carries");
3656 send(&mut p, &mut ctx, shift_named(NamedKey::Tab));
3657 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n x\n", "shift+tab dedents the line");
3658 send(&mut p, &mut ctx, chr("}"));
3659 send(&mut p, &mut ctx, named(NamedKey::Tab));
3660 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n x\n} ", "tab inserts a level");
3661 assert!(send(&mut p, &mut ctx, named(NamedKey::Tab)), "tab is the editor's: it does not fall through to the host");
3662 }
3663
3664 /// shift+arrows select, typing replaces the selection, ctrl+z undoes a
3665 /// typing run as one step and ctrl+shift+z redoes it.
3666 #[test]
3667 fn code_editor_selects_and_undoes() {
3668 let (mut p, mut ctx) = code_panel("abc\ndef");
3669 send(&mut p, &mut ctx, named(NamedKey::End));
3670 send(&mut p, &mut ctx, shift_named(NamedKey::ArrowDown));
3671 let e = p.code_editor.as_ref().unwrap();
3672 assert_eq!(e.selected_text().as_deref(), Some("\ndef"), "shift+down from the end of line 1 selects to the same column of line 2");
3673 send(&mut p, &mut ctx, chr("Z"));
3674 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZ", "typing replaces the selection");
3675 send(&mut p, &mut ctx, chr("Y"));
3676 send(&mut p, &mut ctx, chr("X"));
3677 send(&mut p, &mut ctx, ctrl("z"));
3678 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc\ndef", "one typing run, one undo");
3679 send(&mut p, &mut ctx, key(Key::Character("z".into()), true, true));
3680 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZYX", "and one redo");
3681 send(&mut p, &mut ctx, key(Key::Character("a".into()), true, true));
3682 assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcZYX"), "ctrl+shift+a selects all");
3683 assert_eq!(ParamController::node_params(&*p)[0].1, "abc\ndef", "none of it applied yet");
3684 }
3685
3686 /// The host flags an error line and the gutter number turns red for it;
3687 /// a click lands the caret past the gutter, on the column it named.
3688 #[test]
3689 fn code_row_flags_an_error_line_and_clicks_land_past_the_gutter() {
3690 let (mut p, mut ctx) = code_panel("one\ntwo\nthree");
3691 p.set_code_error_line(Some(1));
3692 let labels = p.own_text_labels();
3693 let two = labels.iter().find(|l| l.text == " 2").expect("a gutter number for line 2");
3694 assert_eq!(two.color, [0xff, 0x80, 0x70]);
3695 let one = labels.iter().find(|l| l.text == " 1").unwrap();
3696 assert_eq!(one.color, [0x66, 0x66, 0x78]);
3697 let r = p.get_param_rects()[0];
3698 let x = p.code_text_x(r) + 2.0 * p.code_col_w();
3699 p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0 * ParametersBg::CODE_LINE_H + 2.0, &mut ctx);
3700 let e = p.code_editor.as_ref().unwrap();
3701 assert_eq!(get_cursor_line_col(&e.buffer, e.cursor_idx), (2, 2), "line 3, column 2");
3702 p.set_code_error_line(None);
3703 assert!(p.own_text_labels().iter().all(|l| l.color != [0xff, 0x80, 0x70]));
3704 }
3705
3706 /// The context actions reach the editor: undo through the runner's
3707 /// routing is the editor's own history, and select-all selects the
3708 /// buffer. Outside an edit the pane declines them.
3709 #[test]
3710 fn code_editor_answers_context_actions_while_editing() {
3711 use crate::widget::ContextAction;
3712 let (mut p, mut ctx) = code_panel("abc");
3713 send(&mut p, &mut ctx, named(NamedKey::End));
3714 send(&mut p, &mut ctx, chr("d"));
3715 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
3716 assert!(Input::context_action(&mut *p, ContextAction::Undo));
3717 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc", "undo through the context action");
3718 assert!(Input::context_action(&mut *p, ContextAction::Redo));
3719 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
3720 assert!(Input::context_action(&mut *p, ContextAction::SelectAll));
3721 assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcd"));
3722 WidgetHost::unfocus(&mut p);
3723 assert!(!Input::context_action(&mut *p, ContextAction::Undo), "nothing to act on once the editor is closed");
3724 }
3725
3726 #[test]
3727 fn clicking_a_section_title_collapses_its_rows() {
3728 let mut ctx = UiContext::new();
3729 let mut p = panel_with(&[
3730 ("Transform", "", "section"),
3731 ("Size", "1.00", "slider:0:2"),
3732 ("Shading", "", "section"),
3733 ("On", "true", "checkbox"),
3734 ]);
3735 let expanded_h = p.get_total_content_height();
3736 let below_before = p.get_param_rects()[2].1;
3737
3738 // Press the first section's title box.
3739 let r_hdr = p.get_param_rects()[0];
3740 let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
3741 p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
3742
3743 assert!(p.section_collapsed("Transform"));
3744 assert_eq!(p.get_param_rects()[1].3, 0.0, "the collapsed section's row has no height");
3745 assert!(p.get_param_rects()[2].1 < below_before, "the next section moves up");
3746 assert!(p.get_total_content_height() < expanded_h);
3747 // The row's chrome and label are gone; the header's stay.
3748 assert!(!p.own_text_labels().iter().any(|l| l.text.contains("Size")));
3749 assert!(p.own_text_labels().iter().any(|l| l.text.contains("Transform")));
3750
3751 // Clicking it again restores the section.
3752 let r_hdr = p.get_param_rects()[0];
3753 let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
3754 p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
3755 assert!(!p.section_collapsed("Transform"));
3756 assert_eq!(p.get_total_content_height(), expanded_h);
3757 }
3758
3759 /// Both ends of a straight run, and of an arc, as points on the path.
3760 fn run_ends(r: &(f32, f32, f32, f32)) -> [(f32, f32); 2] {
3761 let (x, y, w, h) = *r;
3762 if w > h {
3763 [(x, y), (x + w, y)]
3764 } else {
3765 [(x, y), (x, y + h)]
3766 }
3767 }
3768 fn arc_ends(a: &(f32, f32, f32, f32, f32)) -> [(f32, f32); 2] {
3769 let (cx, cy, r, a0, a1) = *a;
3770 [
3771 (cx + r * a0.cos(), cy + r * a0.sin()),
3772 (cx + r * a1.cos(), cy + r * a1.sin()),
3773 ]
3774 }
3775
3776 #[test]
3777 fn section_outline_is_one_continuous_path() {
3778 let p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
3779 let (title, content) = p.section_boxes().into_iter().next().expect("one section");
3780 let content = content.expect("the section has a content box");
3781 let (runs, arcs) = p.section_outline(title, content.into());
3782
3783 // The tab sits flush on the body — its bottom edge is the body's top edge.
3784 assert_eq!(title.1 + title.3, content.1, "the tab fuses to the body");
3785 // Folder-tab shape: the tab's two top corners, the concave throat where its right
3786 // side turns onto the body's top edge, and the body's three remaining corners
3787 // (its top-LEFT is the tab's left side running straight through).
3788 assert_eq!(arcs.len(), 6);
3789 let (tab_right, body_top) = (title.0 + title.2, content.1);
3790 let throats = arcs
3791 .iter()
3792 .filter(|(cx, cy, ..)| *cx > tab_right - 0.01 && *cy < body_top)
3793 .count();
3794 assert_eq!(throats, 1, "the throat — centred out in the pocket right of the tab");
3795
3796 // Every corner hands off to a straight run — no arc dangles. (Within a stroke width:
3797 // runs and arcs are anchored on opposite ink sides at the concave corner.)
3798 let ends: Vec<(f32, f32)> = runs.iter().flat_map(|r| run_ends(r)).collect();
3799 for arc in &arcs {
3800 for (ax, ay) in arc_ends(arc) {
3801 let nearest = ends
3802 .iter()
3803 .map(|(x, y)| ((x - ax).powi(2) + (y - ay).powi(2)).sqrt())
3804 .fold(f32::INFINITY, f32::min);
3805 assert!(nearest <= SECTION_BORDER_T + 0.01, "corner at ({ax}, {ay}) dangles: {nearest}");
3806 }
3807 }
3808
3809 // The tab's bottom edge is open (no run along it), and the body's top edge runs
3810 // only right of the throat.
3811 let tab_bottom = title.1 + title.3 - SECTION_BORDER_T;
3812 let bottom_runs = runs
3813 .iter()
3814 .filter(|(_, y, w, _)| (*y - tab_bottom).abs() < 0.01 && *w > SECTION_BORDER_T)
3815 .count();
3816 assert_eq!(bottom_runs, 0, "the tab opens onto the body");
3817 let top_runs: Vec<&(f32, f32, f32, f32)> = runs
3818 .iter()
3819 .filter(|(_, y, w, _)| (*y - body_top).abs() < 0.01 && *w > SECTION_BORDER_T)
3820 .collect();
3821 assert_eq!(top_runs.len(), 1, "the body's top edge starts past the tab");
3822 assert!(top_runs[0].0 >= tab_right, "…right of the throat");
3823
3824 // The left edge is ONE straight run from the tab's top corner to the body's
3825 // bottom corner.
3826 let left_runs: Vec<&(f32, f32, f32, f32)> = runs
3827 .iter()
3828 .filter(|(x, _, _, h)| (*x - title.0).abs() < 0.01 && *h > 0.0)
3829 .collect();
3830 assert_eq!(left_runs.len(), 1, "tab + body share one left side");
3831 assert!((left_runs[0].1 - (title.1 + SECTION_R)).abs() < 0.01);
3832 assert!((left_runs[0].1 + left_runs[0].3 - (content.1 + content.3 - SECTION_R)).abs() < 0.01);
3833 }
3834
3835 #[test]
3836 fn a_collapsed_section_outline_closes_on_itself() {
3837 let mut p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
3838 p.set_section_collapsed("Transform", true);
3839 let (title, content) = p.section_boxes().into_iter().next().expect("one section");
3840 assert!(content.is_none(), "nothing to wrap below a collapsed header");
3841 let (runs, arcs) = p.section_outline(title, None);
3842 assert_eq!(arcs.len(), 4, "a plain rounded rect");
3843 assert_eq!(runs.len(), 4);
3844 }
3845
3846 #[test]
3847 fn rows_pack_on_the_channel_and_sections_separate_wider() {
3848 let p = panel_with(&[
3849 ("Transform", "", "section"),
3850 ("Size", "1.00", "slider:0:2"),
3851 ("Shading", "", "section"),
3852 ("On", "true", "checkbox"),
3853 ]);
3854 let rects = p.get_param_rects();
3855 let content_bottom = |i: usize| rects[i].1 + rects[i].3 + CONTENT_BOX_PAD;
3856 let title_top = |i: usize| rects[i].1 - TITLE_BOX_INSET;
3857
3858 // Inside a section everything packs on the channel: each tab sits flush on its
3859 // content box, and the rows keep one channel from the box's walls.
3860 for (title, content) in p.section_boxes() {
3861 let content = content.expect("both sections have content");
3862 assert_eq!(title.1 + title.3, content.1, "tab flush on its body");
3863 }
3864 let (_, content0) = p.section_boxes()[0];
3865 let content0 = content0.unwrap();
3866 assert_eq!(rects[1].1 - content0.1, CHANNEL, "row -> its box's top wall");
3867 assert_eq!(rects[1].0 - content0.0, CHANNEL, "row -> its box's side wall");
3868
3869 // Section to section stays far wider, so the blocks still read apart.
3870 assert_eq!(title_top(2) - content_bottom(1), SECTION_GAP, "section -> next section");
3871 assert!(SECTION_GAP > 2.0 * CHANNEL, "sections separate wider than any channel");
3872 }
3873
3874 #[test]
3875 fn a_collapsed_section_keeps_the_same_gap_to_the_next_one() {
3876 // With no content box under it, the collapsed section's bottom edge is its own title
3877 // box — a fixed row-pitch bump would leave a double gap here.
3878 let mut p = panel_with(&[
3879 ("Transform", "", "section"),
3880 ("Size", "1.00", "slider:0:2"),
3881 ("Shading", "", "section"),
3882 ("On", "true", "checkbox"),
3883 ]);
3884 p.set_section_collapsed("Transform", true);
3885 let rects = p.get_param_rects();
3886 let collapsed_bottom = rects[0].1 - TITLE_BOX_INSET + TITLE_BOX_H;
3887 let next_title_top = rects[2].1 - TITLE_BOX_INSET;
3888 assert_eq!(next_title_top - collapsed_bottom, SECTION_GAP);
3889 }
3890
3891 #[test]
3892 fn collapse_survives_a_param_rebuild_and_swallows_row_clicks() {
3893 let mut ctx = UiContext::new();
3894 let mut p = panel_with(&[("Shading", "", "section"), ("On", "false", "checkbox")]);
3895 p.set_section_collapsed("Shading", true);
3896
3897 // A click where the toggle used to sit must not reach it.
3898 let (cx, cy, _, ch) = p.toggles[1].as_ref().unwrap().rect();
3899 p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
3900 p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
3901 assert_eq!(ParamController::node_params(&*p)[1].1, "false", "hidden row ignores clicks");
3902
3903 // The host re-syncs the panel (node change): collapse is keyed by title, so it holds.
3904 let params: Vec<(String, String, String)> = [("Shading", "", "section"), ("On", "false", "checkbox"), ("Extra", "1", "int")]
3905 .iter()
3906 .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
3907 .collect();
3908 ParamController::set_display_params(&mut *p, ¶ms);
3909 assert!(p.section_collapsed("Shading"));
3910 assert_eq!(p.get_param_rects()[1].3, 0.0);
3911 }
3912
3913 #[test]
3914 fn plain_view_serves_row_chrome_and_all_quads_stays_empty() {
3915 let ctx = UiContext::new();
3916 let p = panel_with(&[("Size", "1.00", "slider:0:2")]);
3917 // The designer's plain path: extra_quads carries the row chrome (clipped), including
3918 // the slider background it reads via rect()+color()...
3919 let extra = WidgetHost::extra_quads(&p);
3920 assert!(!extra.is_empty(), "row chrome served through extra_quads");
3921 // ...but NOT the panel's own PARAM_BG plate (the host draws that from color()).
3922 let (x, y, w, h) = WidgetHost::rect(&p);
3923 assert!(
3924 !extra.iter().any(|q| (q.0, q.1, q.2, q.3) == (x, y, w, h)),
3925 "panel bg plate is the host's, not extra_quads'"
3926 );
3927 // The no-double-draw contract of the plain-quad hatch.
3928 assert!(WidgetHost::all_quads(&p, &ctx).is_empty());
3929 // Per-label hatch: the walk's text prims carry the widget font and viewport bounds.
3930 let mut scratch = crate::scene::paint::PaintCtx::new();
3931 crate::scene::painter::append_widget_text(&ctx, &p, &mut scratch);
3932 let labels: Vec<_> = scratch
3933 .finish()
3934 .items
3935 .into_iter()
3936 .filter_map(|item| match item.prim {
3937 crate::scene::paint::Prim::Text { font, bounds, .. } => Some((font, bounds)),
3938 _ => None,
3939 })
3940 .collect();
3941 assert!(!labels.is_empty());
3942 assert!(labels.iter().all(|(font, bounds)| font.is_some() && bounds.is_some()));
3943 }
3944
3945 #[test]
3946 fn scroll_wheel_scrolls_when_content_overflows() {
3947 let mut ctx = UiContext::new();
3948 let rows: Vec<(String, String, String)> = (0..30)
3949 .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
3950 .collect();
3951 let mut p = ParametersBg::new();
3952 ParamController::set_display_params(&mut *p, &rows);
3953 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
3954 assert!(p.content_h > 200.0);
3955 assert!(WidgetHost::is_scrollable(&p));
3956 // Wheel over the panel body but off every slider row's x-span is impossible (rows are
3957 // full-width), so scroll via the region below the last visible row: use a y between
3958 // rows (the 2px slack above a row) — simplest is the bottom padding strip.
3959 let before = p.scroll_y;
3960 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 150.0, 199.0, &mut ctx);
3961 // Either a slider consumed it (value change) or the panel scrolled; both mark change.
3962 // The panel-scroll path must work when no slider is under the pointer:
3963 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
3964 assert!(p.scroll_y >= before, "scroll never decreases on a downward wheel");
3965 }
3966
3967 /// A gesture the pane acquired stays the pane's: rows travelling under
3968 /// the pointer mid-gesture must not hand the wheel to the slider that
3969 /// arrives there (the Alt+D settings-tab leak, 2026-09-20). A NEW gesture
3970 /// over the same slider still adjusts it.
3971 #[test]
3972 fn pane_owned_scroll_gesture_is_not_captured_by_a_slider_sliding_under_the_pointer() {
3973 let rows: Vec<(String, String, String)> = (0..30)
3974 .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
3975 .collect();
3976 let fresh = || {
3977 let mut p = ParametersBg::new();
3978 ParamController::set_display_params(&mut *p, &rows);
3979 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
3980 p
3981 };
3982 // A point on a slider's band: row 1's rect, below the label strip.
3983 let band_point = |p: &Adapted<ParametersBg>| {
3984 let r = p.get_param_rects()[1];
3985 (r.0 + r.2 * 0.5, r.1 + r.3 * 0.7)
3986 };
3987 let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
3988
3989 // Control: a new gesture ON the band adjusts the slider (the point is a real hit).
3990 let mut ctx = UiContext::new();
3991 let mut p = fresh();
3992 let (bx, by) = band_point(&p);
3993 ctx.scroll_gesture_new = true;
3994 ctx.scroll_initiate_widget_id = None;
3995 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
3996 assert_ne!(values(&p)[1], "1.00", "a new gesture on the band adjusts the slider");
3997
3998 // The case: the gesture starts on the pane (dead space), the pane owns it...
3999 let mut ctx = UiContext::new();
4000 let mut p = fresh();
4001 ctx.scroll_gesture_new = true;
4002 ctx.scroll_initiate_widget_id = None;
4003 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
4004 // (The scroll itself is animated by scroll_motion over later ticks, so
4005 // ownership — set only on the pane-scroll path — is the witness.)
4006 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane owns the gesture");
4007 // ...and the same gesture continuing over a band adjusts nothing.
4008 let (bx, by) = band_point(&p);
4009 ctx.scroll_gesture_new = false;
4010 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4011 assert!(values(&p).iter().all(|v| v == "1.00"), "no slider took the pane's gesture: {:?}", values(&p));
4012 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane still owns it");
4013 }
4014
4015 /// A trackpad gesture is the pane's from anywhere — even beginning on a
4016 /// slider band, and even in a pane whose content fits — while a wheel
4017 /// notch on the band still adjusts the slider.
4018 #[test]
4019 fn finger_gesture_goes_to_the_pane_even_from_a_control() {
4020 use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
4021 let rows = |n: usize| -> Vec<(String, String, String)> {
4022 (0..n).map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string())).collect()
4023 };
4024 let panel = |n: usize, h: f32| {
4025 let mut p = ParametersBg::new();
4026 ParamController::set_display_params(&mut *p, &rows(n));
4027 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
4028 p
4029 };
4030 let band_point = |p: &Adapted<ParametersBg>| {
4031 let r = p.get_param_rects()[1];
4032 (r.0 + r.2 * 0.5, r.1 + r.3 * 0.7)
4033 };
4034 let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
4035
4036 // Overflowing pane, finger gesture starting ON the band: the pane scrolls, the slider holds.
4037 let mut ctx = UiContext::new();
4038 let mut p = panel(30, 200.0);
4039 assert!(p.content_h > 200.0);
4040 let (bx, by) = band_point(&p);
4041 ctx.scroll_gesture_new = true;
4042 set_scroll_phase(ScrollPhase::Finger);
4043 p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), bx, by, &mut ctx);
4044 assert!(values(&p).iter().all(|v| v == "1.00"), "finger over a band did not adjust: {:?}", values(&p));
4045 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
4046 assert!(p.scroll_y > 0.0, "the pane scrolled (finger tracks 1:1): {}", p.scroll_y);
4047
4048 // Same pane, a wheel notch on the band: the slider adjusts.
4049 let mut ctx = UiContext::new();
4050 let mut p = panel(30, 200.0);
4051 let (bx, by) = band_point(&p);
4052 ctx.scroll_gesture_new = true;
4053 set_scroll_phase(ScrollPhase::Wheel);
4054 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4055 assert_ne!(values(&p)[1], "1.00", "a wheel notch on the band adjusts the slider");
4056
4057 // A pane whose content fits: the finger gesture is still the pane's (nothing to
4058 // scroll, nothing adjusted) — the same rule whatever the node's parameter count.
4059 let mut ctx = UiContext::new();
4060 let mut p = panel(3, 600.0);
4061 assert!(p.content_h <= 600.0);
4062 let (bx, by) = band_point(&p);
4063 ctx.scroll_gesture_new = true;
4064 set_scroll_phase(ScrollPhase::Finger);
4065 p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), bx, by, &mut ctx);
4066 assert!(values(&p).iter().all(|v| v == "1.00"), "a finger gesture never adjusts: {:?}", values(&p));
4067 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took it");
4068 set_scroll_phase(ScrollPhase::Wheel);
4069 }
4070 }