GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/container/parameters_bg.rs (253.2K)
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). The panel's own background is drawn by the host from
13 //! [`Paint::color`]/[`Paint::corner_style`], NOT emitted here, and its text rides the
14 //! per-label view ([`Paint::serves_legacy_labels`] — each label clips to the viewport but
15 //! the code editor's clip to the code box, in monospace, which the one-font one-bounds prim
16 //! bridge can't express).
17 //!
18 //! The code row is an editor, not a text box (2026-09-24): a line-number gutter, shift+arrow /
19 //! shift+click selection with ctrl+c / ctrl+x / ctrl+v through the toolkit clipboard, tab and
20 //! shift+tab indenting by four, Enter carrying the line's indentation (a level deeper after an
21 //! opening bracket), ctrl+z / ctrl+shift+z on a `History` of editor snapshots that lives as long
22 //! as the editor does, and the emacs chords it always had. Edits go to the BUFFER and reach the
23 //! row's VALUE on ctrl+enter, Escape, or leaving the row — never per keystroke, because a host
24 //! that evaluates a script on every value change would fail it on every half-typed line. The
25 //! border says which state the row is in (amber pending, blue applied, grey at rest), and
26 //! [`ParametersBg::set_code_error_line`] lets the host flag the line its last evaluation
27 //! failed on.
28 //!
29 //! Flagged approximation: the legacy scrollbar-press called `self.focus()` (base flag only —
30 //! nothing reads it: the highlight keys on the ctx focus slot, and the designer tracks its
31 //! focused pane by index); the `on_event` arm drops it.
32
33 use crate::colors;
34 use crate::scene::layout::Rect;
35 use crate::scene::paint::PaintCtx;
36 use crate::widget::display::{Float3, TextLabel};
37 use crate::scene::paint::Field;
38 use crate::widget::input::{Button, ColorSelector, Dropdown, Ramp, Slider, Spinbox, TextBox, Toggle};
39 use crate::widget::{Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
40 MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext, WidgetHostExt};
41
42 /// A text row, in either variant: plain (`"text"`), or with a completion
43 /// picker (`"textpick:a,b,c"` — the Houdini-style attribute/group chooser: a
44 /// TextBox plus a slim menu-button Dropdown at its right edge whose pick
45 /// fills the box; the host supplies the candidates in the type string).
46 fn is_text_row(t: &str) -> bool {
47 t == "text" || t.starts_with("textpick")
48 }
49
50 /// Width of a textpick row's picker button: the right end of the field,
51 /// a flush control plate reaching the field's outer edge as a dropdown
52 /// trigger's does, the text box's well ending at the seam beside it.
53 const PICK_W: f32 = crate::widget::input::dropdown::ARROW_SLOT;
54
55 pub struct ParametersBg {
56 /// A row VALUE changed inside `tick` (a picker stream folded into its
57 /// row) — for the host's sync, which must not run on every tick that
58 /// merely animated (a scroll glide reports change every frame; syncing
59 /// the whole parameter list on each was the choppy params scroll,
60 /// 2026-09-20). Drained by [`Self::take_tick_value_change`].
61 tick_value_changed: bool,
62 /// The view every trackball in the pane is seen from
63 /// ([`Self::set_trackball_view`]); kept here so rows built later start
64 /// from it.
65 trackball_view: [[f32; 3]; 3],
66 /// The configured preference (`layout::param_labels_inline`, the
67 /// `param_label_layout` style key), re-read at every rebuild.
68 inline_pref: bool,
69 /// The label layout the rows are laid out under NOW: inline, the pane
70 /// draws each label in a column beside an unlabelled control; stacked,
71 /// the control carries its own label above itself. The preference,
72 /// unless the pane is too narrow for a column: [`Self::decide_inline`]
73 /// re-decides on every rect assignment and [`Self::relabel_rows`]
74 /// moves the labels when it flips, so geometry and widgets agree.
75 inline_labels: bool,
76 rect: Rect,
77 display_params: Vec<(String, String, String)>,
78 dragging_param: Option<usize>,
79 pub focused_param: Option<usize>,
80 pub code_editor: Option<TextEditorState>,
81 /// The code editor's undo history: one entry per edit, typing runs
82 /// coalesced by group. Lives exactly as long as the editor does.
83 code_history: crate::history::History<TextEditorState>,
84 /// A line (0-based) the host has flagged as the site of an error in the
85 /// code row's value — a script that failed to parse. Drawn as a band
86 /// under that line while the value it was reported for still stands.
87 code_error_line: Option<usize>,
88 mouse_pos: Option<(f32, f32)>,
89 /// The parameter row under the pointer: its LABEL lifts
90 /// (`own_text_labels`) and its own carves light (`paint_ui`, the tint
91 /// channel) — the pane's ONE hover rule, over every control kind. The controls keep their own hovers (a spinbox's +/- buttons, a
92 /// slider's band), but under the DE relief style most of them draw none
93 /// at all — the spinbox's row border, the well frames and the dropdown
94 /// border are flat-style chrome the relief branches never paint — so
95 /// which rows answered the pointer depended on the control and the
96 /// style (2026-09-28: a slider lit, the spinbox beside it did not). Set
97 /// with the controls' hover in `hover_controls`, so a scroll moves it
98 /// too.
99 ///
100 /// A label, deliberately, and not a wash over the row. A row wash was
101 /// tried the same day and failed twice, both times through the relief:
102 /// painted before the wells it closed the pane plate's carve-grouping
103 /// window and every well in the pane flipped to the overlay shading on
104 /// any hover; painted after them, its 5% white composited in LINEAR
105 /// light, which lifts a well's dark shade line from 18 to ~67 while
106 /// the pane face moves 46 to 76 — the hovered well's outline paled and
107 /// read as the well lighting up. Text stands clear of the relief, and
108 /// the tinted carve is the relief's own way of lighting ONE rim.
109 hover_row: Option<usize>,
110 pub sliders: Vec<Option<Adapted<Slider>>>,
111 pub float3s: Vec<Option<Adapted<Float3>>>,
112 pub spinboxes: Vec<Option<Adapted<Spinbox>>>,
113 pub buttons: Vec<Option<Adapted<Button>>>,
114 pub choices: Vec<Option<Adapted<Dropdown>>>,
115 pub texts: Vec<Option<Adapted<TextBox>>>,
116 pub toggles: Vec<Option<Adapted<Toggle>>>,
117 pub colors: Vec<Option<crate::widget::Adapted<ColorSelector>>>,
118 /// Ramp-curve rows (`"ramp"` type; value = the ramp spec string). Painted
119 /// scene-path through [`ParametersBg::paint_scene_rows`] — the legacy flat
120 /// views can't carry the curve/key geometry.
121 pub ramps: Vec<Option<Adapted<Ramp>>>,
122 /// Titles of the sections the user has collapsed by clicking their header. Keyed by
123 /// title so it outlives the row rebuild `set_display_params` runs on every node change.
124 collapsed: std::collections::HashSet<String>,
125 visible: bool,
126 pub scroll_y: f32,
127 pub content_h: f32,
128 scrollbar_dragging: bool,
129 drag_offset_y: f32,
130 /// The raise/sink hysteresis (wheel/drag raises, hover sustains, the hold decays in
131 /// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
132 /// this widget so every app's plate-straddling scrollbar behaves the same way.
133 activity: crate::widget::ScrollbarActivity,
134 /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
135 scroll_motion: crate::widget::ScrollMotion,
136 /// One code-editor column's shaped advance (monospace @12, the family/size
137 /// the code rows draw in), recorded by [`Paint::prepare_text`]. The caret
138 /// and click→column math read it; the hardcoded 7.2 px/col they used
139 /// before drifted off the glyphs. 0.0 until the first shape.
140 code_char_advance: f32,
141 }
142
143 /// The channel: the ONLY gap a control keeps from whatever its edge meets — the
144 /// neighboring control, or its section's wall. Controls pack edge-to-edge; the
145 /// reliefs on either side (control bevel, section wall) shade the channel into a
146 /// narrow 3D groove.
147 const CHANNEL: f32 = 3.0;
148
149 /// The section carve's wall width: the DE relief scaled by the section depth
150 /// multiplier (`style.container.section.depth`), capped against the row height
151 /// (safety) and the control channel — the channel cap scales WITH the
152 /// multiplier, so deepening sections is an explicit choice to let the roll
153 /// cross the groove.
154 fn section_carve_depth(h: f32) -> f32 {
155 let sd = crate::layout::section_depth().max(0.0);
156 (crate::layout::bevel_width() * sd).min(h * 0.2).min(CHANNEL * sd)
157 }
158 /// Vertical pitch between consecutive rows. Wider than the horizontal
159 /// channel on purpose: each label+control pair gets its own breathing room,
160 /// so rows read as separate entries rather than one packed stack. Was 8
161 /// until 2026-09-06; a control's flush seam and the next row's label sat
162 /// close enough to read as one block, so the pitch is now near two channels
163 /// past the label's own line height.
164 const ROW_GAP: f32 = 14.0;
165 /// How far a section's title box overhangs its header row upward, and the box's height.
166 const TITLE_BOX_INSET: f32 = 2.0;
167 const TITLE_BOX_H: f32 = 28.0;
168 /// How far a section's content box overhangs the first and last row it wraps —
169 /// one channel, so the rows abut the section's top/bottom walls too.
170 const CONTENT_BOX_PAD: f32 = CHANNEL;
171 /// The section outline's color, stroke width, and its corner radii: convex (outer) corners, and the
172 /// concave (inner) corners where the neck joins the title and content boxes.
173 const SECTION_BORDER_COLOR: [f32; 4] = [0.18, 0.18, 0.27, 1.0];
174 const SECTION_BORDER_T: f32 = 1.0;
175 const SECTION_R: f32 = 13.0;
176 /// The throat — the concave fillet where the tab's right side turns onto the content
177 /// body's top edge (the tab sits flush on the body; there is no connector neck).
178 const SECTION_THROAT_R: f32 = 5.0;
179 /// The relief carve's concave inside-corner radius at the tab throat
180 /// (`section_fillets`) — sized against SECTION_R so inside and outside
181 /// corners read as one family.
182 const SECTION_FILLET_R: f32 = 10.0;
183 /// Narrowest a title box may be: both its corners plus the throat fillet. Titles run
184 /// wider than this in practice; it only keeps the throat clear of the corners.
185 const SECTION_TITLE_MIN_W: f32 = 2.0 * SECTION_R + SECTION_THROAT_R;
186
187 /// The gap between one section's bottom box edge and the next section's title box —
188 /// deliberately much wider than the channel the rows pack on, so sections read as
189 /// separate blocks. Laid out from the previous block's *drawn* bottom edge (rather
190 /// than the uniform row pitch, which the two boxes' overhangs eat into unequally) so the
191 /// gap is exact.
192 const SECTION_GAP: f32 = 16.0;
193
194 /// Horizontal inset of a section's boxes (title tab and content body) from the pane
195 /// plate's sides — deliberately the wider of the two horizontal gaps, so sections
196 /// float clearly inside the plate.
197 const SECTION_MARGIN: f32 = 16.0;
198 /// Horizontal gap between a control row and its parent section's side walls —
199 /// one channel; controls abut the section's edge.
200 const CONTROL_INSET: f32 = CHANNEL;
201 /// A row's inset from the plate: the section margin plus the controls' inset within
202 /// the section, so bare rows above the first section align with wrapped ones.
203 const ROW_X_INSET: f32 = SECTION_MARGIN + CONTROL_INSET;
204
205 impl ParametersBg {
206 /// The code box's line pitch, its text inset below the box top, and the
207 /// gutter that carries line numbers: four columns of digits and a gap.
208 const CODE_LINE_H: f32 = 16.0;
209 const CODE_TOP: f32 = 22.0;
210 const CODE_BOX_TOP: f32 = 18.0;
211 const CODE_GUTTER_COLS: f32 = 4.0;
212 const CODE_INDENT: &'static str = " ";
213
214 /// Where a code row's text starts: past the gutter.
215 fn code_text_x(&self, r: (f32, f32, f32, f32)) -> f32 {
216 r.0 + 12.0 + (Self::CODE_GUTTER_COLS + 1.0) * self.code_col_w()
217 }
218
219 /// Flag a line of the code row as an error site, or clear it. The host
220 /// calls this when the value it evaluated failed with a line number —
221 /// it is the one piece of feedback a script editor cannot do without.
222 pub fn set_code_error_line(&mut self, line: Option<usize>) {
223 self.code_error_line = line;
224 }
225
226 /// Whether a code row is being edited right now.
227 pub fn code_editing(&self) -> bool {
228 self.focused_param.is_some() && self.code_editor.is_some()
229 }
230
231 /// The row open for typing, if any: a code row, or a hosted text box,
232 /// spinbox, slider readout, colour or vector field that is editing. (A
233 /// choice row holds `focused_param` while its list is open; that is not
234 /// typing.)
235 fn typing_row(&self) -> Option<usize> {
236 let i = self.focused_param?;
237 if self.code_editor.is_some() {
238 return Some(i);
239 }
240 fn at<T>(v: &[Option<T>], i: usize) -> Option<&T> {
241 v.get(i).and_then(Option::as_ref)
242 }
243 let typing = at(&self.texts, i).is_some_and(|t| t.editing)
244 || at(&self.spinboxes, i).is_some_and(|t| t.editing)
245 || at(&self.sliders, i).is_some_and(|t| t.editing)
246 || at(&self.colors, i).is_some_and(|t| t.editing)
247 || at(&self.float3s, i).is_some_and(|t| t.editing_idx().is_some());
248 typing.then_some(i)
249 }
250
251 /// Say a field is open for typing (`crate::text_input`): at its row, or
252 /// the pane when the row is not laid out. The hosted fields are drawn
253 /// from this pane's aggregates, never through their own paint, so their
254 /// own claims never run — the pane makes it for them.
255 fn claim_typing(&self, ctx: &PaintCtx) {
256 if let Some(i) = self.typing_row() {
257 let (x, y, w, h) = self
258 .get_param_rects()
259 .get(i)
260 .copied()
261 .filter(|r| r.3 > 0.0)
262 .unwrap_or((self.rect.x, self.rect.y, self.rect.width, self.rect.height));
263 let (ox, oy) = ctx.offset();
264 crate::text_input::claim(x + ox, y + oy, w, h);
265 }
266 }
267
268 /// The clipboard, selection and history actions over the code editor:
269 /// what the runner's undo / redo chords and the context menu's rows
270 /// reach through [`Input::context_action`], and what the editor's own
271 /// chords call. Returns whether the editor was open to act on.
272 pub fn code_action(&mut self, action: crate::widget::ContextAction) -> bool {
273 match self.code_editor.as_mut() {
274 Some(editor) => {
275 apply_code_action(editor, &mut self.code_history, action);
276 true
277 }
278 None => false,
279 }
280 }
281
282 /// Whether the focused code row holds edits not yet applied to its value.
283 pub fn code_is_dirty(&self) -> bool {
284 match (self.focused_param, &self.code_editor) {
285 (Some(i), Some(editor)) => self.display_params.get(i).is_some_and(|p| p.1 != editor.buffer),
286 _ => false,
287 }
288 }
289
290 /// One code column's width — the shaped advance when recorded, else the
291 /// legacy 7.2 estimate (only before the first `prepare_text`).
292 fn code_col_w(&self) -> f32 {
293 if self.code_char_advance > 0.0 {
294 self.code_char_advance
295 } else {
296 7.2
297 }
298 }
299
300 pub fn new() -> Adapted<ParametersBg> {
301 Adapted::new(ParametersBg {
302 trackball_view: crate::widget::display::float3::IDENTITY_VIEW,
303 inline_pref: crate::layout::param_labels_inline(),
304 inline_labels: crate::layout::param_labels_inline(),
305 rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
306 display_params: Vec::new(),
307 dragging_param: None,
308 focused_param: None,
309 code_editor: None,
310 code_history: crate::history::History::with_limit(200),
311 code_error_line: None,
312 mouse_pos: None,
313 hover_row: None,
314 sliders: Vec::new(),
315 float3s: Vec::new(),
316 spinboxes: Vec::new(),
317 buttons: Vec::new(),
318 choices: Vec::new(),
319 texts: Vec::new(),
320 toggles: Vec::new(),
321 colors: Vec::new(),
322 ramps: Vec::new(),
323 collapsed: std::collections::HashSet::new(),
324 visible: true,
325 scroll_y: 0.0,
326 content_h: 0.0,
327 code_char_advance: 0.0,
328 scrollbar_dragging: false,
329 drag_offset_y: 0.0,
330 activity: crate::widget::ScrollbarActivity::new(),
331 scroll_motion: crate::widget::ScrollMotion::new(),
332 tick_value_changed: false,
333 })
334 }
335
336 /// Row `i`'s laid-out height, ignoring collapse (the row-type table).
337 /// The shortest TRACK an inline row may leave its slider. Below it the
338 /// pane stacks every label above its control instead. Measured on the
339 /// track — what the slider is, to the eye and the hand — not on the
340 /// control's rect, which also holds the readout and its gap (and a
341 /// float3's axis column): until 2026-09-28 the rect was what was
342 /// measured, so a slider's track shrank to 52 px, and a float3's to
343 /// 36, before the labels moved. A control with no track (spinbox, text
344 /// box, dropdown, colour) is measured whole.
345 pub const MIN_INLINE_TRACK_W: f32 = 120.0;
346
347 /// See every trackball in the pane from a host's camera
348 /// (`Float3::set_view`): the view's right, up and toward-the-viewer
349 /// axes, in the vectors' space. Returns whether anything changed, so a
350 /// host knows to draw again. Kept for the rows built after it.
351 pub fn set_trackball_view(&mut self, view: [[f32; 3]; 3]) -> bool {
352 let mut probe = Float3::new();
353 if !probe.set_view(view) {
354 return false;
355 }
356 let view = probe.view();
357 let moved = (0..3).any(|i| (0..3).any(|k| (view[i][k] - self.trackball_view[i][k]).abs() > 1e-5));
358 if moved {
359 self.trackball_view = view;
360 for f in self.float3s.iter_mut().flatten() {
361 f.set_view(view);
362 }
363 }
364 moved
365 }
366
367 /// Whether a float3 row's type asks for the trackball: a fourth segment,
368 /// `float3:lo:hi:trackball`.
369 fn has_trackball(t: &str) -> bool {
370 t.starts_with("float3:") && t.split(':').nth(3) == Some("trackball")
371 }
372
373 /// What a row of type `t` spends of its control rect on everything but
374 /// its track: a slider's readout and gap, plus a float3's axis column.
375 /// Zero for the controls that have no track.
376 fn control_chrome(t: &str) -> f32 {
377 if t.starts_with("slider") {
378 crate::widget::input::slider::Slider::readout_chrome()
379 } else if is_vec_row(t) {
380 let ball = if Self::has_trackball(t) { Float3::trackball_chrome() } else { 0.0 };
381 ball + crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
382 } else {
383 0.0
384 }
385 }
386
387 /// Whether a row of type `t` takes the inline layout at all. Only the
388 /// rows whose control would otherwise carry a label strip: toggles and
389 /// buttons ARE their label, a ramp keeps its label band, sections and
390 /// code rows have layouts of their own.
391 fn inline_kind(t: &str) -> bool {
392 t.starts_with("slider")
393 || is_vec_row(t)
394 || t.starts_with("spinbox")
395 || t.starts_with("choice")
396 || is_text_row(t)
397 || t.starts_with("color")
398 || t == "rgb"
399 || t == "rgba"
400 }
401
402 /// Whether row `i` is laid out with its label beside the control.
403 fn inline_row(&self, i: usize) -> bool {
404 self.inline_labels && Self::inline_kind(&self.display_params[i].2)
405 }
406
407 /// The layout the rows allow: the preference, unless a label column
408 /// would leave any visible row's track shorter than
409 /// [`Self::MIN_INLINE_TRACK_W`], in which case the labels stack. One
410 /// decision for the pane — a mix of inline and stacked rows reads as
411 /// ragged — taken by its SHORTEST track, so a pane of spinboxes keeps
412 /// its column at a width where a pane with a float3 has let it go. An
413 /// unlaid pane (no rect yet) follows the preference; the first rect
414 /// assignment decides.
415 fn decide_inline(&self) -> bool {
416 if !self.inline_pref {
417 return false;
418 }
419 if self.rect.width <= 0.0 {
420 return true;
421 }
422 let row_w = (self.rect.width - 2.0 * ROW_X_INSET).max(0.0);
423 let control_w = row_w - self.label_col_w_if(true);
424 let hidden = self.hidden_rows();
425 self.display_params
426 .iter()
427 .enumerate()
428 .filter(|(i, p)| !hidden[*i] && Self::inline_kind(&p.2))
429 .all(|(_, p)| control_w - Self::control_chrome(&p.2) >= Self::MIN_INLINE_TRACK_W)
430 }
431
432 /// Re-decide the layout for the current rect and, when it flips, move
433 /// every label between the column and the controls. The widgets were
434 /// built with or without a label, and the two layouts have different
435 /// row heights, so a flip is a relabel and a re-layout, not a flag.
436 fn apply_label_layout(&mut self) {
437 let want = self.decide_inline();
438 if want != self.inline_labels {
439 self.inline_labels = want;
440 self.relabel_rows();
441 }
442 }
443
444 /// Put each inline-kind row's label where the current layout wants it:
445 /// on the control when stacked, off it (the pane draws the column) when
446 /// inline. Toggles, buttons and ramps carry their label either way.
447 fn relabel_rows(&mut self) {
448 let inline = self.inline_labels;
449 for i in 0..self.display_params.len() {
450 let (name, ty) = (self.display_params[i].0.clone(), self.display_params[i].2.clone());
451 if !Self::inline_kind(&ty) {
452 continue;
453 }
454 macro_rules! relabel {
455 ($w:expr) => {
456 if let Some(w) = $w {
457 if inline { w.clear_label() } else { w.set_label(&name) }
458 }
459 };
460 }
461 relabel!(&mut self.sliders[i]);
462 relabel!(&mut self.float3s[i]);
463 relabel!(&mut self.spinboxes[i]);
464 relabel!(&mut self.texts[i]);
465 relabel!(&mut self.colors[i]);
466 // Only a choice row's dropdown carries the row label; a text
467 // row's completion picker is a button inside the box.
468 if ty.starts_with("choice:") {
469 relabel!(&mut self.choices[i]);
470 }
471 }
472 }
473
474 /// The label strip an inline row no longer spends: the control was
475 /// built unlabelled, so the row loses exactly the strip's height.
476 fn inline_strip(&self, i: usize) -> f32 {
477 if self.inline_row(i) { crate::layout::control_label_strip() } else { 0.0 }
478 }
479
480 /// Gap between the label column and the control.
481 const LABEL_GAP: f32 = 16.0;
482
483 /// The inline label's font: the pane's own labels draw in the control
484 /// label font (`Paint::widget_font`), so the column is measured in it —
485 /// family AND size — or the widest label runs into its control.
486 fn inline_label_font() -> (String, f32) {
487 crate::layout::control_label_font_parsed()
488 }
489
490 /// Width of the label column under the inline layout: the widest visible
491 /// inline label plus the gap, clamped to a floor (so a pane of one-word
492 /// labels still reads as a column) and to under half the row (so a long
493 /// label truncates rather than squeezing the control out). Zero when
494 /// nothing is inline.
495 fn label_col_w(&self) -> f32 {
496 self.label_col_w_if(self.inline_labels)
497 }
498
499 /// [`Self::label_col_w`] under a given layout — what the column WOULD
500 /// take, which is what [`Self::decide_inline`] asks before committing.
501 fn label_col_w_if(&self, inline: bool) -> f32 {
502 if !inline {
503 return 0.0;
504 }
505 let (family, size) = Self::inline_label_font();
506 let hidden = self.hidden_rows();
507 let widest = self
508 .display_params
509 .iter()
510 .enumerate()
511 .filter(|(i, p)| !hidden[*i] && Self::inline_kind(&p.2))
512 .map(|(_, p)| crate::widget::display::measure_text_width(&p.0, &family, size))
513 .fold(0.0f32, f32::max);
514 if widest <= 0.0 {
515 return 0.0;
516 }
517 let row_w = (self.rect.width - 2.0 * ROW_X_INSET).max(0.0);
518 (widest + Self::LABEL_GAP).clamp(72.0f32.min(row_w * 0.45), row_w * 0.45)
519 }
520
521 /// Row `i`'s CONTROL rect: the row rect less the label column when the
522 /// row is inline, the row rect itself otherwise.
523 fn control_rect(&self, r: (f32, f32, f32, f32), i: usize, lw: f32) -> (f32, f32, f32, f32) {
524 if self.inline_row(i) { (r.0 + lw, r.1, (r.2 - lw).max(0.0), r.3) } else { r }
525 }
526
527 fn row_height(&self, i: usize) -> f32 {
528 let p = &self.display_params[i];
529 if p.2 == "code" {
530 let val_text = if self.focused_param == Some(i) {
531 if let Some(ref editor) = self.code_editor {
532 &editor.buffer
533 } else {
534 &p.1
535 }
536 } else {
537 &p.1
538 };
539 let line_count = val_text.split('\n').count();
540 let content_h = Self::CODE_TOP + (line_count as f32 * Self::CODE_LINE_H) + 12.0;
541 content_h.max(200.0)
542 } else if p.2 == "section" {
543 24.0
544 } else if p.2 == SEPARATOR {
545 // A rule, its own pixel: the row gaps either side are the air.
546 1.0
547 } else if p.2 == "ramp" {
548 // Label band + the ramp's graph and control strip. The control
549 // strip and label band are fixed, so this whole increase grows the
550 // curve plot (Ramp::graph_h = height − strip).
551 260.0
552 } else if is_vec_row(&p.2) {
553 Float3::preferred_height_for(!self.inline_row(i), vec_row_n(&p.2))
554 } else if p.2.starts_with("slider") {
555 (38.0 - self.inline_strip(i)).max(22.0)
556 } else if is_text_row(&p.2) || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
557 (42.0 - self.inline_strip(i)).max(24.0)
558 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
559 (40.0 - self.inline_strip(i)).max(24.0)
560 } else if p.2 == "button" || p.2 == "toggle" || p.2 == "checkbox" {
561 24.0
562 } else {
563 20.0
564 }
565 }
566
567 /// Per-row "sits inside a collapsed section" flags: a section owns every row between its
568 /// header and the next header. Headers themselves are never hidden — they stay clickable
569 /// so a collapsed section can be reopened. Rows before the first header belong to no
570 /// section and are always shown.
571 fn hidden_rows(&self) -> Vec<bool> {
572 let mut out = Vec::with_capacity(self.display_params.len());
573 let mut hiding = false;
574 for p in &self.display_params {
575 if p.2 == "section" {
576 hiding = self.collapsed.contains(&p.0);
577 out.push(false);
578 } else {
579 out.push(hiding);
580 }
581 }
582 out
583 }
584
585 /// Whether the section titled `title` is collapsed.
586 pub fn section_collapsed(&self, title: &str) -> bool {
587 self.collapsed.contains(title)
588 }
589
590 /// Collapse/expand the section titled `title`, re-laying the rows. Keyed by title, not
591 /// row index, so the state survives the `set_display_params` rebuilds a host runs
592 /// whenever the inspected node changes.
593 pub fn set_section_collapsed(&mut self, title: &str, collapsed: bool) {
594 let changed = if collapsed {
595 self.collapsed.insert(title.to_string())
596 } else {
597 self.collapsed.remove(title)
598 };
599 if changed {
600 self.refresh_scroll_metrics();
601 }
602 }
603
604 /// The box drawn around a section header's title — and, since the title is the collapse
605 /// affordance, that box is also the header's click target.
606 fn section_title_box(&self, hdr: usize, r_hdr: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
607 // The label sits 8px in from the box's left edge; matching that 8px on the right
608 // (box width = text + 16) centers the text in the box. Measure the run in the
609 // label's real family AND size — parsed, not the raw "Family NN" spec string, which
610 // resvg can't resolve (it would fall back to a narrow font and undersize the box).
611 let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
612 let (label_family, label_size) = crate::layout::control_label_font_parsed();
613 let text_w =
614 crate::widget::display::measure_text_width(&self.display_params[hdr].0, &label_family, label_size);
615 let title_w = (text_w + 16.0).max(SECTION_TITLE_MIN_W).min(full_w);
616 // The tab sits FLUSH on the content body: its bottom edge is the body's top
617 // edge, in every style (the relief carve always drew it there; the outline now
618 // fuses to it too). Collapsed, the box stays where the expanded tab sits (one
619 // title-box height above where the body's top edge would be), so collapsing
620 // doesn't jump the tab — and the click-toggle hit zone follows the ink. An
621 // expanded-but-empty section keeps the legacy header-row placement.
622 let y = if self.collapsed.contains(&self.display_params[hdr].0) {
623 r_hdr.1 + r_hdr.3 + ROW_GAP - CONTENT_BOX_PAD - TITLE_BOX_H
624 } else {
625 match self.first_visible_row_top(hdr) {
626 Some(control_top) => control_top - CONTENT_BOX_PAD - TITLE_BOX_H,
627 None => r_hdr.1 - TITLE_BOX_INSET,
628 }
629 };
630 (self.rect.x + SECTION_MARGIN, y, title_w, TITLE_BOX_H)
631 }
632
633 /// The top edge of the first visible row under header `hdr` — the row the section's
634 /// content box (and so its tab) hangs from. `None` for an empty section (a header
635 /// with no rows of its own before the next header).
636 fn first_visible_row_top(&self, hdr: usize) -> Option<f32> {
637 let hidden = self.hidden_rows();
638 let rects = self.get_param_rects();
639 self.display_params
640 .iter()
641 .enumerate()
642 .skip(hdr + 1)
643 .take_while(|(_, q)| q.2 != "section")
644 .find(|(j, _)| !hidden[*j])
645 .map(|(j, _)| rects[j].1)
646 }
647
648 /// Each section as `(header index, its content-row range)` — the rows between a header
649 /// and the next one, `None` for a header with nothing under it.
650 fn sections(&self) -> Vec<(usize, Option<(usize, usize)>)> {
651 let mut out: Vec<(usize, Option<(usize, usize)>)> = Vec::new();
652 for (i, p) in self.display_params.iter().enumerate() {
653 if p.2 == "section" {
654 out.push((i, None));
655 } else if let Some((_, content)) = out.last_mut() {
656 match content {
657 Some((_, end)) => *end = i,
658 None => *content = Some((i, i)),
659 }
660 }
661 }
662 out
663 }
664
665 /// Each section's boxes: the title box, plus the box wrapping its rows (`None` when the
666 /// section is collapsed or has no rows). The shared source for the outline's straight
667 /// runs and its corner fillets, so the two halves can't disagree.
668 /// The row floors (`layout::param_compression`): the pane material
669 /// frosted at the configured compression, filled under every visible
670 /// parameter row (headers excepted) before anything else in the pane
671 /// paints, at the control corner radius — each parameter on its own
672 /// tablet, the way the designer's node bodies get their own compression.
673 /// Nothing when the key is unset. Relief only: the flat style has no
674 /// floor language.
675 fn paint_row_floors(&self, ctx: &mut PaintCtx) {
676 let Some(k) = crate::layout::param_compression() else { return };
677 if !crate::layout::control_relief() {
678 return;
679 }
680 let mut mat = crate::scene::Material::pane();
681 if let crate::scene::Frost::Frosted { compression, .. } = &mut mat.frost {
682 *compression = k;
683 }
684 let r = crate::layout::control_corner_radius();
685 let hidden = self.hidden_rows();
686 for (i, (x, y, w, h)) in self.get_param_rects().into_iter().enumerate() {
687 if hidden[i] || h <= 0.0 || self.display_params[i].2 == "section" || self.display_params[i].2 == SEPARATOR {
688 continue;
689 }
690 ctx.fill_material(Rect { x, y, width: w, height: h }, (r, r, r, r), &mat);
691 }
692 }
693
694 fn section_boxes(&self) -> Vec<((f32, f32, f32, f32), Option<(f32, f32, f32, f32)>)> {
695 let rects = self.get_param_rects();
696 let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
697 let mut out = Vec::new();
698 for (hdr, content) in self.sections() {
699 if hdr >= rects.len() {
700 continue;
701 }
702 let title = self.section_title_box(hdr, rects[hdr]);
703 let content_box = if self.collapsed.contains(&self.display_params[hdr].0) {
704 None
705 } else {
706 content.and_then(|(start, end)| {
707 if start <= end && start < rects.len() && end < rects.len() {
708 let by = rects[start].1 - CONTENT_BOX_PAD;
709 let bh = (rects[end].1 + rects[end].3 + CONTENT_BOX_PAD) - by;
710 Some((title.0, by, full_w, bh))
711 } else {
712 None
713 }
714 })
715 };
716 out.push((title, content_box));
717 }
718 out
719 }
720
721 /// One section's outline: a SINGLE continuous border shaped like a folder tab — around
722 /// the title box, whose bottom edge is open onto the content body it sits flush on
723 /// (its right side turning onto the body's top edge through the concave throat fillet,
724 /// its left side running straight down into the body's left edge) — as
725 /// `(straight runs, corner fillets)`.
726 ///
727 /// Runs are `(x, y, w, h)`; fillets are `(cx, cy, radius, start, end)` for an arc stroked
728 /// `SECTION_BORDER_T` inward of `radius` (the renderer's convention). Convex corners take
729 /// the stroke inside the box, so their radius is the outer one; the concave throat corner
730 /// has its centre out in the empty pocket, so its carries the `+ T` that puts the
731 /// ink on the far side. Every run stops a radius short of its corner, and each fillet
732 /// picks it up there — the path closes.
733 fn section_outline(
734 &self,
735 title: (f32, f32, f32, f32),
736 content: Option<(f32, f32, f32, f32)>,
737 ) -> (Vec<(f32, f32, f32, f32)>, Vec<(f32, f32, f32, f32, f32)>) {
738 use std::f32::consts::{PI, TAU};
739 const Q: f32 = std::f32::consts::FRAC_PI_2;
740 let (t, r) = (SECTION_BORDER_T, SECTION_R);
741 let mut quads: Vec<(f32, f32, f32, f32)> = Vec::new();
742 let mut arcs: Vec<(f32, f32, f32, f32, f32)> = Vec::new();
743 fn hrun(out: &mut Vec<(f32, f32, f32, f32)>, x0: f32, x1: f32, y: f32) {
744 if x1 - x0 > 0.01 {
745 out.push((x0, y, x1 - x0, SECTION_BORDER_T));
746 }
747 }
748 fn vrun(out: &mut Vec<(f32, f32, f32, f32)>, y0: f32, y1: f32, x: f32) {
749 if y1 - y0 > 0.01 {
750 out.push((x, y0, SECTION_BORDER_T, y1 - y0));
751 }
752 }
753
754 let (tx, ty, tw, th) = title;
755 let ty_b = ty + th; // the title box's bottom edge
756 arcs.push((tx + r, ty + r, r, PI, PI + Q)); // title top-left
757 arcs.push((tx + tw - r, ty + r, r, PI + Q, TAU)); // title top-right
758 hrun(&mut quads, tx + r, tx + tw - r, ty); // title top
759
760 let Some((cx, cy_t, cw, ch)) = content else {
761 // Collapsed or empty: the title box IS the section, so it closes on itself.
762 arcs.push((tx + tw - r, ty_b - r, r, 0.0, Q)); // title bottom-right
763 arcs.push((tx + r, ty_b - r, r, Q, PI)); // title bottom-left
764 vrun(&mut quads, ty + r, ty_b - r, tx + tw - t); // title right
765 vrun(&mut quads, ty + r, ty_b - r, tx); // title left
766 hrun(&mut quads, tx + r, tx + tw - r, ty_b - t); // title bottom
767 return (quads, arcs);
768 };
769
770 // The tab sits flush on the body (`ty_b == cy_t` — `section_title_box` places it
771 // there): its bottom edge is open. The throat fillet shrinks if the tab runs
772 // close to the body's right corner.
773 let f = SECTION_THROAT_R.min((cx + cw - r - (tx + tw)).max(0.0));
774 vrun(&mut quads, ty + r, cy_t - f, tx + tw - t); // tab right side, down to the throat
775 arcs.push((tx + tw + f, cy_t - f, f + t, Q, PI)); // throat: tab side -> body top
776 hrun(&mut quads, tx + tw + f, cx + cw - r, cy_t); // body top, right of the tab
777
778 arcs.push((cx + cw - r, cy_t + r, r, PI + Q, TAU)); // body top-right
779 arcs.push((cx + cw - r, cy_t + ch - r, r, 0.0, Q)); // body bottom-right
780 arcs.push((cx + r, cy_t + ch - r, r, Q, PI)); // body bottom-left
781 vrun(&mut quads, cy_t + r, cy_t + ch - r, cx + cw - t); // body right
782 hrun(&mut quads, cx + r, cx + cw - r, cy_t + ch - t); // body bottom
783 vrun(&mut quads, ty + r, cy_t + ch - r, tx); // tab + body left, one straight run
784
785 (quads, arcs)
786 }
787
788 /// The section outlines' corner fillets, as the renderer's arc tuples
789 /// `(cx, cy, radius, thickness, start, end, color)`.
790 ///
791 /// A concrete accessor rather than prims out of [`Paint::paint`] on purpose: the host
792 /// draws this panel through the legacy plain-quad hatch, and `append_widget_plate` serves
793 /// a widget's arcs UNCLIPPED — these have to land inside the pane's scroll viewport, so
794 /// the host draws them itself under its own clip (the straight runs ride `plain_quads`,
795 /// which clips them there by hand).
796 pub fn arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> {
797 if !self.visible || crate::layout::control_relief() {
798 return Vec::new();
799 }
800 let color = SECTION_BORDER_COLOR;
801 let mut out = Vec::new();
802 for (title, content) in self.section_boxes() {
803 let (_, arcs) = self.section_outline(title, content);
804 out.extend(
805 arcs.into_iter()
806 .map(|(cx, cy, r, a0, a1)| (cx, cy, r, SECTION_BORDER_T, a0, a1, color)),
807 );
808 }
809 out
810 }
811
812 /// Where the rows start, in absolute y — the origin `get_param_rects` lays out from.
813 fn rows_origin(&self) -> f32 {
814 self.rect.y - self.scroll_y
815 }
816
817 /// The scrollable height of the row column. Derived from [`Self::get_param_rects`] rather
818 /// than re-walking the advance rules, so the two can't drift apart.
819 pub fn get_total_content_height(&self) -> f32 {
820 let hidden = self.hidden_rows();
821 let rects = self.get_param_rects();
822 let bottom = rects
823 .iter()
824 .enumerate()
825 .filter(|(i, _)| !hidden[*i])
826 .map(|(_, r)| r.1 + r.3)
827 .fold(f32::NEG_INFINITY, f32::max);
828 if bottom == f32::NEG_INFINITY {
829 return 30.0 + 10.0; // no rows: the top offset plus the bottom padding
830 }
831 (bottom - self.rows_origin()) + ROW_GAP + 10.0
832 }
833
834 /// One rect per row, in index order — collapsed rows get a zero-height rect at the
835 /// current cursor (and consume no vertical space), so every index-parallel consumer
836 /// keeps working while the row draws and hit-tests as nothing.
837 ///
838 /// Rows advance on a uniform [`ROW_GAP`] pitch, except a section header, which is placed
839 /// [`SECTION_GAP`] below the previous section's drawn bottom edge — see [`SECTION_GAP`].
840 pub fn get_param_rects(&self) -> Vec<(f32, f32, f32, f32)> {
841 let hidden = self.hidden_rows();
842 let mut rects = Vec::new();
843 let mut cur_y = self.rows_origin() + 30.0;
844 // Bottom edge of the last row as DRAWN: a row inside a section is wrapped by the
845 // content box, which overhangs it; a header with no visible rows under it (empty or
846 // collapsed) ends at its own title box. `None` until the first section starts —
847 // rows above it are bare, with no box to measure against.
848 let mut prev_bottom: Option<f32> = None;
849 for i in 0..self.display_params.len() {
850 if hidden[i] {
851 rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, 0.0));
852 continue;
853 }
854 let is_header = self.display_params[i].2 == "section";
855 if is_header {
856 if let Some(bottom) = prev_bottom {
857 cur_y = bottom + SECTION_GAP + TITLE_BOX_INSET;
858 }
859 }
860 let h = self.row_height(i);
861 rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, h));
862 prev_bottom = Some(if is_header {
863 cur_y - TITLE_BOX_INSET + TITLE_BOX_H
864 } else if prev_bottom.is_some() {
865 cur_y + h + CONTENT_BOX_PAD
866 } else {
867 cur_y + h
868 });
869 cur_y += h + ROW_GAP;
870 }
871 rects
872 }
873
874 /// The pane's scrollbar width — the DE `scrollbar_width` widened: the bar
875 /// rides over the section carves and reads too slim at the stock width.
876 fn scrollbar_w(&self) -> f32 {
877 crate::layout::scrollbar_width() * 1.6
878 }
879
880 /// The scrollbar's left x: the bar rides the pane's CENTRE line, as
881 /// every sink-behind bar in the DE does (cce-mail's list and body, the
882 /// designer's dialog list) — over the rows, reserving no lane, in front
883 /// only while raised. It sat a sixth of the width in from the right
884 /// edge before 2026-09-21, which beside a centred bar read as off.
885 fn scrollbar_x(&self) -> f32 {
886 self.rect.x + (self.rect.width - self.scrollbar_w()) * 0.5
887 }
888
889 pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
890 if self.content_h <= self.rect.height {
891 return false;
892 }
893 let sb_w = self.scrollbar_w();
894 let sb_x = self.scrollbar_x();
895 let sb_track_h = self.rect.height - 8.0;
896 let sb_track_y = self.rect.y + 4.0;
897
898 px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0
899 && py >= sb_track_y && py <= sb_track_y + sb_track_h
900 }
901
902 /// Whether the pane holds enough content to need a scrollbar at all.
903 pub fn scrollbar_visible(&self) -> bool {
904 self.content_h > self.rect.height
905 }
906
907 /// Whether the scrollbar is currently raised in front of the pane plate (the latched
908 /// state). While this is false the bar sits behind the plate and is non-interactive.
909 pub fn scrollbar_active(&self) -> bool {
910 self.activity.raised()
911 }
912
913 /// How far the bar's FORE copy has faded in, 0..=1: the host draws the
914 /// idle copy before the plate every frame and the fore copy after the
915 /// rows at this alpha, so the raise and the sink are a fade, not a flip.
916 pub fn scrollbar_fade(&self) -> f32 {
917 self.activity.fade()
918 }
919
920 /// Re-latch the shared hysteresis with this pane's inputs, returning whether it changed.
921 fn recompute_scrollbar_raised(&mut self) -> bool {
922 let visible = self.scrollbar_visible();
923 self.activity.recompute(visible, self.scrollbar_dragging)
924 }
925
926 /// The scrollbar's track + thumb quads (empty when no scrollbar is needed). The host draws
927 /// these either behind or in front of the pane plate per [`Self::scrollbar_active`]; they
928 /// are deliberately kept out of [`Self::plain_quads`] so the host controls their depth.
929 pub fn scrollbar_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
930 if !self.scrollbar_visible() {
931 return Vec::new();
932 }
933 let sb_w = self.scrollbar_w();
934 let sb_x = self.scrollbar_x();
935 let sb_track_h = self.rect.height - 8.0;
936 let sb_track_y = self.rect.y + 4.0;
937
938 let visible_ratio = self.rect.height / self.content_h;
939 let thumb_h = if sb_track_h <= 20.0 {
940 sb_track_h
941 } else {
942 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
943 };
944 let max_scroll = self.content_h - self.rect.height;
945 let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
946 let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
947
948 vec![
949 (sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()),
950 (sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()),
951 ]
952 }
953
954 /// Hand a pointer position to every hosted control, so each re-reads
955 /// its own hover (and a ramp row its key drag). Called for every pointer
956 /// move, and again from the last known position (`mouse_pos`) whenever a
957 /// SCROLL moves the rows under a still pointer — the wheel arm and the
958 /// tick's coast — because a control's hover is recomputed only when it
959 /// is told where the pointer is, and until 2026-09-28 nothing told it on
960 /// a scroll: a control that scrolled under the pointer stayed dark and
961 /// one that scrolled away stayed lit until the next motion. Sliders are
962 /// in the roster since the same day, having had no hover before.
963 fn hover_controls(&mut self, px: f32, py: f32, ui: &mut UiContext) -> bool {
964 let mut changed = false;
965 for s in self.sliders.iter_mut().flatten() {
966 if s.on_cursor_moved(px, py, ui) {
967 changed = true;
968 }
969 }
970 for sb in self.spinboxes.iter_mut().flatten() {
971 if sb.on_cursor_moved(px, py, ui) {
972 changed = true;
973 }
974 }
975 for f in self.float3s.iter_mut().flatten() {
976 if f.on_cursor_moved(px, py, ui) {
977 changed = true;
978 }
979 }
980 for b in self.buttons.iter_mut().flatten() {
981 if b.on_cursor_moved(px, py, ui) {
982 changed = true;
983 }
984 }
985 for d in self.choices.iter_mut().flatten() {
986 if d.on_cursor_moved(px, py, ui) {
987 changed = true;
988 }
989 }
990 for tb in self.texts.iter_mut().flatten() {
991 if tb.on_cursor_moved(px, py, ui) {
992 changed = true;
993 }
994 }
995 for cb in self.toggles.iter_mut().flatten() {
996 if cb.on_cursor_moved(px, py, ui) {
997 changed = true;
998 }
999 }
1000 for c in self.colors.iter_mut().flatten() {
1001 if c.on_cursor_moved(px, py, ui) {
1002 changed = true;
1003 }
1004 }
1005 // Ramp rows: a move can drag a key — re-serialize the curve
1006 // into the row value so hosts polling `node_params` see it.
1007 for i in 0..self.ramps.len() {
1008 if let Some(rp) = &mut self.ramps[i] {
1009 if rp.on_cursor_moved(px, py, ui) {
1010 self.display_params[i].1 = rp.inner().spec_string();
1011 changed = true;
1012 }
1013 }
1014 }
1015 let row = self.hoverable_row_at(px, py);
1016 if row != self.hover_row {
1017 self.hover_row = row;
1018 changed = true;
1019 }
1020 changed
1021 }
1022
1023 /// The parameter row under `(px, py)`: a visible, non-header row whose
1024 /// rect holds the point, and only while the point is inside the pane's
1025 /// own viewport (a row scrolled out of it is not under anything).
1026 fn hoverable_row_at(&self, px: f32, py: f32) -> Option<usize> {
1027 if !self.visible
1028 || px < self.rect.x
1029 || px > self.rect.x + self.rect.width
1030 || py < self.rect.y
1031 || py > self.rect.y + self.rect.height
1032 {
1033 return None;
1034 }
1035 let hidden = self.hidden_rows();
1036 self.get_param_rects().into_iter().enumerate().find_map(|(i, (x, y, w, h))| {
1037 let hoverable = !hidden[i] && h > 0.0 && self.display_params[i].2 != "section" && self.display_params[i].2 != SEPARATOR;
1038 (hoverable && px >= x && px <= x + w && py >= y && py <= y + h).then_some(i)
1039 })
1040 }
1041
1042 /// The rows moved under a still pointer: re-hover from where it was.
1043 fn rehover_after_scroll(&mut self, ui: &mut UiContext) -> bool {
1044 match self.mouse_pos {
1045 Some((px, py)) => self.hover_controls(px, py, ui),
1046 None => false,
1047 }
1048 }
1049
1050 /// The pane's own row label, and the same label under the pointer: the
1051 /// dialog's row and selected-row label colours, so a hovered parameter
1052 /// reads as the dialog's hovered command does.
1053 const LABEL: [u8; 3] = [0xaa, 0xaa, 0xbb];
1054 const LABEL_HOVER: [u8; 3] = [0xf4, 0xf4, 0xfa];
1055 /// The hovered row's carve tint: the lifted label's own colour, so the
1056 /// lit rim and the label read as one cue. Through the tint channel the
1057 /// shader composites the rim's light in this colour at its focus gain
1058 /// and the shadow in a quarter of it — the carve lights, it is not
1059 /// washed over (a wash over a carve pales its dark shade line in linear
1060 /// blending; see `hover_row`).
1061 const HOVER_TINT: [f32; 3] = [0xf4 as f32 / 255.0, 0xf4 as f32 / 255.0, 0xfa as f32 / 255.0];
1062
1063 fn update_slider_rects(&mut self) {
1064 // Inline rows hand their control the row less the label column; the
1065 // row rects themselves (`get_param_rects`) stay the full row, which is
1066 // what the floors, the section boxes and hit routing measure against.
1067 let lw = self.label_col_w();
1068 let rects: Vec<(f32, f32, f32, f32)> = self
1069 .get_param_rects()
1070 .into_iter()
1071 .enumerate()
1072 .map(|(i, r)| self.control_rect(r, i, lw))
1073 .collect();
1074 let inline: Vec<bool> = (0..self.display_params.len()).map(|i| self.inline_row(i)).collect();
1075 for (i, s_opt) in self.sliders.iter_mut().enumerate() {
1076 if let Some(s) = s_opt {
1077 let r = rects[i];
1078 s.set_rect(r.0, r.1, r.2, r.3);
1079 }
1080 }
1081 for (i, f_opt) in self.float3s.iter_mut().enumerate() {
1082 if let Some(f) = f_opt {
1083 let r = rects[i];
1084 f.set_rect(r.0, r.1, r.2, r.3);
1085 }
1086 }
1087 for (i, sb_opt) in self.spinboxes.iter_mut().enumerate() {
1088 if let Some(sb) = sb_opt {
1089 let r = rects[i];
1090 sb.set_rect(r.0, r.1, r.2, r.3);
1091 }
1092 }
1093 for (i, b_opt) in self.buttons.iter_mut().enumerate() {
1094 if let Some(b) = b_opt {
1095 let r = rects[i];
1096 b.set_rect(r.0, r.1, r.2, r.3);
1097 }
1098 }
1099 for (i, d_opt) in self.choices.iter_mut().enumerate() {
1100 if let Some(d) = d_opt {
1101 let r = rects[i];
1102 if self.display_params[i].2.starts_with("textpick") {
1103 // The picker is the field's RIGHT END: a flush control
1104 // plate over the whole band below the detached label,
1105 // out to the field's outer edge on the top, right and
1106 // bottom as a dropdown trigger's plate is, square where
1107 // it meets the text box (whose well stops at the seam —
1108 // `TextBox::joined_right`) and the box's radius on the
1109 // outside, so the two read as one field. Until
1110 // 2026-10-01 it was a button nested INSIDE the box's
1111 // well, its face stopping at the base of the well's
1112 // wall, so it never reached the edge a dropdown's does.
1113 let label_top = if inline[i] { 0.0 } else { crate::layout::control_label_strip() };
1114 let band_h = r.3 - label_top;
1115 let rr = crate::layout::textbox_corner_radius();
1116 // The picker is a trigger's arrow slot: PICK_W and a
1117 // wall, the wall being the lip its face rises out of at
1118 // the seam. Its arrow in its middle is then where every
1119 // other trigger's arrow is, in its slot.
1120 let pick_w = crate::widget::input::dropdown::arrow_slot(band_h);
1121 d.set_rect(r.0 + r.2 - pick_w, r.1 + label_top, pick_w, band_h);
1122 d.inner_mut().set_radii(Some((0.0, rr, rr, 0.0)));
1123 d.inner_mut().center_arrow = true;
1124 // The menu hangs off the WHOLE field, not the button
1125 // sliver: anchor the popover to the box's well band.
1126 d.popover_anchor = Some(Rect {
1127 x: r.0,
1128 y: r.1 + label_top,
1129 width: r.2,
1130 height: r.3 - label_top,
1131 });
1132 } else {
1133 d.set_rect(r.0, r.1, r.2, r.3);
1134 }
1135 }
1136 }
1137 for (i, tb_opt) in self.texts.iter_mut().enumerate() {
1138 if let Some(tb) = tb_opt {
1139 let r = rects[i];
1140 // A textpick row's box ends where its picker begins.
1141 let joined = self.display_params[i].2.starts_with("textpick") && self.choices[i].is_some();
1142 tb.inner_mut().joined_right = joined;
1143 tb.set_rect(r.0, r.1, r.2, r.3);
1144 if joined {
1145 // The box ends where the picker begins, at the seam.
1146 let depth = crate::layout::bevel_width().min((r.3 - tb.label_strip()) * 0.2);
1147 tb.set_rect(r.0, r.1, (r.2 - PICK_W - depth).max(0.0), r.3);
1148 }
1149 }
1150 }
1151 for (i, cb_opt) in self.toggles.iter_mut().enumerate() {
1152 if let Some(cb) = cb_opt {
1153 let r = rects[i];
1154 cb.set_rect(r.0, r.1, r.2, r.3);
1155 }
1156 }
1157 for (i, c_opt) in self.colors.iter_mut().enumerate() {
1158 if let Some(c) = c_opt {
1159 let r = rects[i];
1160 c.set_rect(r.0, r.1, r.2, r.3);
1161 }
1162 }
1163 for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
1164 if let Some(rp) = rp_opt {
1165 let r = rects[i];
1166 // Below the 18px label band own_text_labels draws (the ramp
1167 // carries no label of its own).
1168 rp.set_rect(r.0, r.1 + 18.0, r.2, r.3 - 18.0);
1169 }
1170 }
1171 }
1172
1173 /// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
1174 /// scroll into it, re-lay the rows.
1175 fn refresh_scroll_metrics(&mut self) {
1176 // The label layout (inline column or stacked) is decided first, by
1177 // the tracks the rows would get: every geometry below depends on
1178 // it. Here rather than on rect assignment alone, because a section
1179 // collapsing or opening changes which rows are visible, and so
1180 // which is the shortest track and how wide the column is.
1181 self.apply_label_layout();
1182 self.content_h = self.get_total_content_height();
1183 let max_scroll = (self.content_h - self.rect.height).max(0.0);
1184 self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
1185 self.update_slider_rects();
1186 }
1187
1188 fn own_text_labels(&self) -> Vec<TextLabel> {
1189 let rects = self.get_param_rects();
1190 let hidden = self.hidden_rows();
1191 let mut labels = Vec::new();
1192 let lw = self.label_col_w();
1193 let (family, size) = Self::inline_label_font();
1194 for (i, (name, value, ptype)) in self.display_params.iter().enumerate() {
1195 if hidden[i] {
1196 continue;
1197 }
1198 let r = rects[i];
1199 if self.inline_row(i) {
1200 // The pane's own label, in the column beside an unlabelled
1201 // control: vertically centred on the row, tail-truncated to
1202 // the column — by the measure the column is sized with, so
1203 // a label it was sized to hold is never cut (it was cut to
1204 // as many chars as the column holds M's).
1205 labels.push(TextLabel {
1206 text: fit_tail(name, lw - Self::LABEL_GAP, &family, size),
1207 x: r.0,
1208 y: r.1 + (r.3 - size) * 0.5,
1209 font_size: size,
1210 color: if self.hover_row == Some(i) { Self::LABEL_HOVER } else { Self::LABEL },
1211 });
1212 }
1213 if ptype.starts_with("slider") {
1214 if let Some(s) = &self.sliders[i] {
1215 labels.extend(s.own_text_labels());
1216 }
1217 } else if is_vec_row(ptype) {
1218 if let Some(f) = &self.float3s[i] {
1219 labels.extend(f.own_text_labels());
1220 }
1221 } else if ptype == "section" {
1222 // Centered within the tab itself — `section_title_box` is the one
1223 // source for where the tab sits (flush on the body; collapsed and
1224 // empty sections carry their own placements there).
1225 let font_size = 13.0;
1226 let (bx, by, _, _) = self.section_title_box(i, r);
1227 labels.push(TextLabel {
1228 text: name.clone(),
1229 // 8px in from the title box's left edge — the inset
1230 // `section_title_box`'s width math centers against.
1231 x: bx + 8.0,
1232 y: by + (TITLE_BOX_H - font_size) / 2.0,
1233 font_size,
1234 color: [0xee, 0xee, 0xf0],
1235 });
1236 } else if ptype == "code" {
1237 labels.push(TextLabel {
1238 text: format!("{}:", name),
1239 x: self.rect.x + 12.0,
1240 y: r.1,
1241 font_size: 12.0,
1242 color: [0xaa, 0xaa, 0xbb],
1243 });
1244 let val_text = if self.focused_param == Some(i) {
1245 if let Some(ref editor) = self.code_editor {
1246 editor.buffer.clone()
1247 } else {
1248 value.clone()
1249 }
1250 } else {
1251 value.clone()
1252 };
1253 // One label PER LINE, at the same pitch the cursor math uses
1254 // (`plain_quads`' cursor_y) — a single multi-line label would depend on
1255 // the consumer's buffer line-height matching that pitch, and never
1256 // exactly did. A line number sits in the gutter before each.
1257 let text_x = self.code_text_x(r);
1258 for (line_i, line) in val_text.split('\n').enumerate() {
1259 let y = r.1 + Self::CODE_TOP + line_i as f32 * Self::CODE_LINE_H;
1260 let number = format!("{:>3}", line_i + 1);
1261 labels.push(TextLabel {
1262 text: number,
1263 x: r.0 + 12.0,
1264 y,
1265 font_size: 12.0,
1266 color: if self.code_error_line == Some(line_i) { [0xff, 0x80, 0x70] } else { [0x66, 0x66, 0x78] },
1267 });
1268 if line.is_empty() {
1269 continue;
1270 }
1271 labels.push(TextLabel {
1272 text: line.to_string(),
1273 x: text_x,
1274 y,
1275 font_size: 12.0,
1276 color: [0xee, 0xee, 0xf0],
1277 });
1278 }
1279 if self.focused_param == Some(i) && self.code_is_dirty() {
1280 labels.push(TextLabel {
1281 text: "ctrl+enter applies".to_string(),
1282 x: r.0 + r.2 - 12.0 - 18.0 * self.code_col_w(),
1283 y: r.1 + r.3 - 15.0,
1284 font_size: 12.0,
1285 color: [0xd8, 0xa0, 0x50],
1286 });
1287 }
1288 } else if ptype.starts_with("spinbox") {
1289 if let Some(sb) = &self.spinboxes[i] {
1290 labels.extend(sb.own_text_labels());
1291 }
1292 } else if is_text_row(ptype) {
1293 if let Some(tb) = &self.texts[i] {
1294 labels.extend(tb.own_text_labels());
1295 }
1296 if let Some(d) = &self.choices[i] {
1297 labels.extend(d.own_text_labels());
1298 }
1299 } else if ptype.starts_with("choice") {
1300 if let Some(d) = &self.choices[i] {
1301 labels.extend(d.own_text_labels());
1302 }
1303 } else if ptype == "button" {
1304 if let Some(b) = &self.buttons[i] {
1305 labels.extend(b.own_text_labels());
1306 }
1307 } else if ptype == "toggle" || ptype == "checkbox" {
1308 if let Some(cb) = &self.toggles[i] {
1309 labels.extend(cb.own_text_labels());
1310 }
1311 } else if ptype.starts_with("color") || ptype == "rgb" || ptype == "rgba" {
1312 if let Some(c) = &self.colors[i] {
1313 labels.extend(c.own_text_labels());
1314 }
1315 } else if ptype == SEPARATOR {
1316 // A rule says nothing.
1317 } else if ptype == "ramp" {
1318 // The name label only — the ramp's own control labels ride its
1319 // scene-path paint (paint_scene_rows).
1320 labels.push(TextLabel {
1321 text: name.clone(),
1322 x: r.0,
1323 y: r.1,
1324 font_size: 12.0,
1325 color: [0xaa, 0xaa, 0xbb],
1326 });
1327 } else {
1328 labels.push(TextLabel {
1329 text: format!("{}: {}", name, value),
1330 x: self.rect.x + ROW_X_INSET,
1331 y: r.1,
1332 font_size: 12.0,
1333 color: [0xaa, 0xaa, 0xbb],
1334 });
1335 }
1336 }
1337 labels
1338 }
1339
1340 /// The hosted controls' GLYPHS — a dropdown's or a picker's arrow, a
1341 /// spinbox's −/+ — as `(image, rect, alpha)`, row by row as
1342 /// [`own_text_labels`](Self::own_text_labels) collects their text. The
1343 /// pane paints its controls' chrome itself and never runs their
1344 /// `Paint::paint` into the frame, so a symbol a control draws reaches
1345 /// the pane only through here. Ramps are not asked: they paint whole
1346 /// through `paint_scene_rows`.
1347 fn child_glyphs(&self) -> Vec<(u32, Rect, f32)> {
1348 let hidden = self.hidden_rows();
1349 let mut glyphs = Vec::new();
1350 for (i, (_, _, ptype)) in self.display_params.iter().enumerate() {
1351 if hidden[i] {
1352 continue;
1353 }
1354 if ptype.starts_with("slider") {
1355 if let Some(s) = &self.sliders[i] {
1356 glyphs.extend(s.own_glyphs());
1357 }
1358 } else if is_vec_row(ptype) {
1359 if let Some(f) = &self.float3s[i] {
1360 glyphs.extend(f.own_glyphs());
1361 }
1362 } else if ptype.starts_with("spinbox") {
1363 if let Some(sb) = &self.spinboxes[i] {
1364 glyphs.extend(sb.own_glyphs());
1365 }
1366 } else if is_text_row(ptype) {
1367 if let Some(tb) = &self.texts[i] {
1368 glyphs.extend(tb.own_glyphs());
1369 }
1370 if let Some(d) = &self.choices[i] {
1371 glyphs.extend(d.own_glyphs());
1372 }
1373 } else if ptype.starts_with("choice") {
1374 if let Some(d) = &self.choices[i] {
1375 glyphs.extend(d.own_glyphs());
1376 }
1377 } else if ptype == "button" {
1378 if let Some(b) = &self.buttons[i] {
1379 glyphs.extend(b.own_glyphs());
1380 }
1381 } else if ptype == "toggle" || ptype == "checkbox" {
1382 if let Some(cb) = &self.toggles[i] {
1383 glyphs.extend(cb.own_glyphs());
1384 }
1385 } else if ptype.starts_with("color") || ptype == "rgb" || ptype == "rgba" {
1386 if let Some(c) = &self.colors[i] {
1387 glyphs.extend(c.own_glyphs());
1388 }
1389 }
1390 }
1391 glyphs
1392 }
1393
1394 /// Paint [`child_glyphs`](Self::child_glyphs), over the chrome.
1395 fn paint_child_glyphs(&self, ctx: &mut PaintCtx) {
1396 for (image, rect, alpha) in self.child_glyphs() {
1397 ctx.image(image, rect, alpha);
1398 }
1399 }
1400
1401 /// The dropdown rows' popover, if one is open — the widget's OWN popover surface
1402 /// ([`Paint::popover`]); the raw `children`'s popovers are the adapter's recursion.
1403 /// The ramp rows' field dropdowns count too.
1404 fn choices_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1405 for d in self.choices.iter().flatten() {
1406 if let Some(r) = d.popover_rect() {
1407 return Some(r);
1408 }
1409 }
1410 for rp in self.ramps.iter().flatten() {
1411 let ramp = rp.inner();
1412 if let Some(r) = ramp
1413 .preset_dropdown
1414 .popover_rect()
1415 .or_else(|| ramp.line_type_dropdown.popover_rect())
1416 {
1417 return Some(r);
1418 }
1419 }
1420 None
1421 }
1422
1423 /// The full legacy popover reach (choices, then children) — hit-testing extends to it.
1424 fn own_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1425 if let Some(r) = self.choices_popover_rect() {
1426 return Some(r);
1427 }
1428 None
1429 }
1430
1431 /// The state half of the legacy `unfocus`: commit the focused row's in-flight value back
1432 /// into `display_params`, drop the code editor, and unfocus the raw children.
1433 fn commit_and_unfocus(&mut self) {
1434 if let Some(idx) = self.focused_param {
1435 if idx < self.display_params.len() {
1436 let p = &mut self.display_params[idx];
1437 if p.2.starts_with("spinbox") {
1438 if let Some(sb) = &mut self.spinboxes[idx] {
1439 sb.unfocus();
1440 p.1 = sb.value.to_string();
1441 }
1442 } else if p.2.starts_with("slider") {
1443 if let Some(s) = &mut self.sliders[idx] {
1444 s.unfocus();
1445 let new_val = s.get_scaled_value();
1446 p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
1447 }
1448 } else if is_vec_row(&p.2) {
1449 if let Some(f) = &mut self.float3s[idx] {
1450 f.unfocus();
1451 p.1 = f.value_string();
1452 }
1453 } else if is_text_row(&p.2) {
1454 if let Some(tb) = &mut self.texts[idx] {
1455 tb.unfocus();
1456 p.1 = tb.text.clone();
1457 }
1458 if let Some(d) = &mut self.choices[idx] {
1459 d.unfocus();
1460 }
1461 } else if p.2.starts_with("choice") {
1462 if let Some(d) = &mut self.choices[idx] {
1463 d.unfocus();
1464 if let Some(val) = d.get_value_string() {
1465 p.1 = val;
1466 }
1467 }
1468 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1469 if let Some(c) = &mut self.colors[idx] {
1470 c.unfocus();
1471 if let Some(val) = c.get_value_string() {
1472 p.1 = val;
1473 }
1474 }
1475 } else if p.2 == "code" {
1476 if let Some(ref editor) = self.code_editor {
1477 p.1 = editor.buffer.clone();
1478 }
1479 self.code_editor = None;
1480 self.code_history.clear();
1481 }
1482 }
1483 }
1484 self.focused_param = None;
1485 }
1486
1487 /// The pane's plain quads: section border boxes, every row's chrome (slider/spinbox
1488 /// backgrounds read via `rect()`+`color()`, the code editor's box/border/cursor), the
1489 /// controls' own plain quads, all clipped to the viewport — plus the unclipped scrollbar.
1490 fn plain_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1491 if !self.visible {
1492 return Vec::new();
1493 }
1494 let mut quads = Vec::new();
1495 let rects = self.get_param_rects();
1496 let view_min = self.rect.y + 4.0;
1497 let view_max = self.rect.y + self.rect.height - 4.0;
1498
1499 let clip_quad = |q: (f32, f32, f32, f32, [f32; 4])| -> Option<(f32, f32, f32, f32, [f32; 4])> {
1500 let (qx, qy, qw, qh, qc) = q;
1501 let y1 = qy.max(view_min);
1502 let y2 = (qy + qh).min(view_max);
1503 if y1 < y2 {
1504 Some((qx, y1, qw, y2 - y1, qc))
1505 } else {
1506 None
1507 }
1508 };
1509
1510 let mut param_quads = Vec::new();
1511
1512 // Each section is drawn as ONE continuous outline — around the title, down the
1513 // neck, around the content rows — whose straight runs are these quads; its corner
1514 // fillets ride `arcs()`, which the host draws under the same clip. Under
1515 // `control_relief` the outline is replaced by the inset carves served
1516 // through `reliefs` (title tab + content body recessed).
1517 if !crate::layout::control_relief() {
1518 for (title, content) in self.section_boxes() {
1519 let (runs, _) = self.section_outline(title, content);
1520 param_quads.extend(runs.into_iter().map(|(x, y, w, h)| (x, y, w, h, SECTION_BORDER_COLOR)));
1521 }
1522 }
1523
1524 let hidden = self.hidden_rows();
1525 for (i, p) in self.display_params.iter().enumerate() {
1526 if hidden[i] {
1527 continue;
1528 }
1529 let r = rects[i];
1530 if p.2.starts_with("slider") {
1531 if let Some(s) = &self.sliders[i] {
1532 let (sx, sy, sw, sh) = s.rect();
1533 param_quads.push((sx, sy, sw, sh, s.color()));
1534 param_quads.extend(crate::widget::shown_quads(s));
1535 }
1536 } else if p.2 == "section" {
1537 // Section header line is handled by the border box top border now
1538 } else if p.2 == SEPARATOR {
1539 // A hairline across the row, inset from the pane's edges.
1540 let c = crate::colors::active_theme().surface_border;
1541 param_quads.push((r.0 + 6.0, r.1, (r.2 - 12.0).max(0.0), 1.0, [c[0], c[1], c[2], c[3] * 0.8]));
1542 } else if is_vec_row(&p.2) {
1543 if let Some(f) = &self.float3s[i] {
1544 param_quads.extend(crate::widget::shown_quads(f));
1545 }
1546 } else if p.2 == "code" {
1547 let (bx, by, bw, bh) = (r.0, r.1 + Self::CODE_BOX_TOP, r.2, r.3 - Self::CODE_BOX_TOP);
1548 param_quads.push((bx, by, bw, bh, [0.08, 0.08, 0.10, 1.0]));
1549 let focused = self.focused_param == Some(i);
1550 // Amber while edits are pending, blue while focused and
1551 // applied, grey at rest: the border says whether what the
1552 // node runs is what the box shows.
1553 let border_color = if focused && self.code_is_dirty() {
1554 [0.85, 0.60, 0.25, 1.0]
1555 } else if focused {
1556 [0.25, 0.45, 0.85, 1.0]
1557 } else {
1558 [0.20, 0.20, 0.25, 1.0]
1559 };
1560 let text_x = self.code_text_x(r);
1561 let col_w = self.code_col_w();
1562 let line_y = |l: usize| by + (Self::CODE_TOP - Self::CODE_BOX_TOP) + l as f32 * Self::CODE_LINE_H;
1563 // The gutter's edge.
1564 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]));
1565 // The error band, under the flagged line.
1566 if let Some(err_line) = self.code_error_line {
1567 let y = line_y(err_line);
1568 if y >= by && y + Self::CODE_LINE_H <= by + bh {
1569 param_quads.push((bx + 1.0, y, bw - 2.0, Self::CODE_LINE_H, [0.45, 0.12, 0.10, 1.0]));
1570 }
1571 }
1572 if focused {
1573 if let Some(ref editor) = self.code_editor {
1574 // Selection: one band per line it covers.
1575 if let Some((start, end)) = editor.selected_range() {
1576 let (sl, sc) = get_cursor_line_col(&editor.buffer, start);
1577 let (el, ec) = get_cursor_line_col(&editor.buffer, end);
1578 let line_len = |l: usize| editor.buffer.split('\n').nth(l).map_or(0, |s| s.chars().count());
1579 for l in sl..=el {
1580 let c0 = if l == sl { sc } else { 0 };
1581 let c1 = if l == el { ec } else { line_len(l) + 1 };
1582 let y = line_y(l);
1583 if y >= by && y + Self::CODE_LINE_H <= by + bh && c1 > c0 {
1584 param_quads.push((
1585 text_x + c0 as f32 * col_w,
1586 y,
1587 (c1 - c0) as f32 * col_w,
1588 Self::CODE_LINE_H,
1589 [0.22, 0.32, 0.55, 1.0],
1590 ));
1591 }
1592 }
1593 }
1594 let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
1595 let cursor_x = text_x + (cursor_c as f32 * col_w);
1596 let cursor_y = line_y(cursor_l) + (Self::CODE_LINE_H - 13.0) / 2.0;
1597 if cursor_y >= by && cursor_y + 13.0 <= by + bh {
1598 param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
1599 }
1600 }
1601 }
1602 param_quads.push((bx, by, bw, 1.0, border_color));
1603 param_quads.push((bx, by + bh - 1.0, bw, 1.0, border_color));
1604 param_quads.push((bx, by, 1.0, bh, border_color));
1605 param_quads.push((bx + bw - 1.0, by, 1.0, bh, border_color));
1606 } else if is_text_row(&p.2) {
1607 if let Some(tb) = &self.texts[i] {
1608 param_quads.extend(crate::widget::shown_quads(tb));
1609 }
1610 if let Some(d) = &self.choices[i] {
1611 param_quads.extend(crate::widget::shown_quads(d));
1612 }
1613 } else if p.2.starts_with("choice") {
1614 if let Some(d) = &self.choices[i] {
1615 param_quads.extend(crate::widget::shown_quads(d));
1616 }
1617 } else if p.2 == "button" {
1618 if let Some(b) = &self.buttons[i] {
1619 param_quads.extend(crate::widget::shown_quads(b));
1620 }
1621 } else if p.2.starts_with("spinbox") {
1622 if let Some(sb) = &self.spinboxes[i] {
1623 { let (bx, by, bw, bh) = sb.rect(); param_quads.push((bx, by, bw, bh, sb.color())); }
1624 param_quads.extend(crate::widget::shown_quads(sb));
1625 }
1626 } else if p.2 == "toggle" || p.2 == "checkbox" {
1627 if let Some(cb) = &self.toggles[i] {
1628 param_quads.extend(crate::widget::shown_quads(cb));
1629 }
1630 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1631 if let Some(c) = &self.colors[i] {
1632 param_quads.extend(crate::widget::shown_quads(c));
1633 }
1634 }
1635 }
1636
1637 // Clip all parameter quads vertically
1638 for q in param_quads {
1639 if let Some(clipped) = clip_quad(q) {
1640 quads.push(clipped);
1641 }
1642 }
1643
1644 // The scrollbar is NOT emitted here: the host draws it via `scrollbar_quads`, above
1645 // or below the pane plate depending on `scrollbar_active`.
1646
1647 quads
1648 }
1649
1650 /// The rounded companion to [`Self::plain_quads`]: the row controls whose boxes are
1651 /// `Prim::RoundedRect` (textbox, dropdown, button, toggle, color selector), which the
1652 /// plain list does not carry. Returned unclipped; the pane's paint clips them to its
1653 /// scroll viewport. (x, y, w, h, radius, color, (tl, tr, br, bl)).
1654 pub fn rounded_quads(
1655 &self,
1656 ) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
1657 if !self.visible {
1658 return Vec::new();
1659 }
1660 let mut out = Vec::new();
1661 let hidden = self.hidden_rows();
1662 for (i, p) in self.display_params.iter().enumerate() {
1663 if hidden[i] {
1664 continue;
1665 }
1666 if is_text_row(&p.2) {
1667 if let Some(tb) = &self.texts[i] {
1668 out.extend(crate::widget::shown_rounded_quads(tb));
1669 }
1670 if let Some(d) = &self.choices[i] {
1671 out.extend(crate::widget::shown_rounded_quads(d));
1672 }
1673 } else if p.2.starts_with("slider") {
1674 // Track (square style only — the recessed style has no track
1675 // background), value fill, and readout box; the thumb knob is a
1676 // `Prim::Sphere` and rides `spheres()` instead.
1677 if let Some(s) = &self.sliders[i] {
1678 out.extend(crate::widget::shown_rounded_quads(s));
1679 }
1680 } else if is_vec_row(&p.2) {
1681 // Three slider rows: the same set per row (readout boxes,
1682 // square-style tracks and fills), through the group's own paint.
1683 if let Some(f) = &self.float3s[i] {
1684 out.extend(crate::widget::shown_rounded_quads(f));
1685 }
1686 } else if p.2.starts_with("choice") {
1687 if let Some(d) = &self.choices[i] {
1688 out.extend(crate::widget::shown_rounded_quads(d));
1689 }
1690 } else if p.2.starts_with("spinbox") {
1691 // The spinbox's whole chrome (frame, display well, +/- button
1692 // wells) is modern rounded-rect paint — its legacy color() is
1693 // transparent and it has no extra_quads, so skipping it here
1694 // renders the row as bare text.
1695 if let Some(sb) = &self.spinboxes[i] {
1696 out.extend(crate::widget::shown_rounded_quads(sb));
1697 }
1698 } else if p.2 == "button" {
1699 if let Some(b) = &self.buttons[i] {
1700 out.extend(crate::widget::shown_rounded_quads(b));
1701 }
1702 } else if p.2 == "toggle" || p.2 == "checkbox" {
1703 if let Some(cb) = &self.toggles[i] {
1704 out.extend(crate::widget::shown_rounded_quads(cb));
1705 }
1706 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1707 if let Some(c) = &self.colors[i] {
1708 out.extend(crate::widget::shown_rounded_quads(c));
1709 }
1710 }
1711 }
1712 out
1713 }
1714
1715 /// The relief companion to [`Self::rounded_quads`]: the row controls'
1716 /// raised/recessed step prims (boss rims, recess wells), which the flat
1717 /// views cannot carry — a host rendering this panel through the legacy
1718 /// views must read this getter too or the `control_relief` styling is lost
1719 /// entirely (with the DE's transparent control backgrounds the controls all
1720 /// but vanish). Edges-only variants throughout: the flat views own the
1721 /// faces, exactly the widgets' own transparent-fill bevel→boss degradation.
1722 /// Returned unclipped; the host clips to the pane's scroll viewport and
1723 /// draws these AFTER the flat quads, so the walls' shading modulates the
1724 /// fills they cross (the order the widgets' own paints use). Tuple:
1725 /// (x, y, w, h, per-corner radii, depth, raised, walls) — raised maps to
1726 /// `PaintCtx::boss_edges`, flat to `recess_edges`; a toggle's well and
1727 /// the plate standing in it are why radii/walls are per-entry.
1728 #[allow(clippy::type_complexity)]
1729 pub fn reliefs(
1730 &self,
1731 ) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, bool, (bool, bool, bool, bool))> {
1732 if !self.visible || !crate::layout::control_relief() {
1733 return Vec::new();
1734 }
1735 let mut out = Vec::new();
1736
1737 // Sections as inset panels (the flat outline+fillet path is the
1738 // non-relief style): ONE union-shaped recess per section — a
1739 // title-text-width tab strip flush with the body's left edge, opening
1740 // into the full-width body below. Composed from three edge-suppressed
1741 // pieces (the tab with its bottom open, the body with its top open,
1742 // and the top-wall run right of the tab's throat) so no wall crosses
1743 // the union's interior and the whole section reads as a single well.
1744 // Collapsed sections keep the title-box carve.
1745 let all = (true, true, true, true);
1746 let r4 = |r: f32| (r, r, r, r);
1747 let r = SECTION_R;
1748 for (title, content) in self.section_boxes() {
1749 let (tx, ty, tw, th) = title;
1750 if let Some((cx, cy, cw, ch)) = content {
1751 // Capped at the channel: the well's wall must roll off inside the
1752 // groove between it and the controls packed one CHANNEL inside,
1753 // not shade across their faces. `section_depth` scales wall and
1754 // channel cap together — past 1.0 the roll crosses the groove
1755 // by choice.
1756 let depth = section_carve_depth(ch);
1757 // Tab strip: the title box itself, sitting flush on the body's top
1758 // edge (the header row above it stays plain plate), bottom open.
1759 // A wall FADES OUT over the carve width approaching a suppressed
1760 // edge (the tessellator's host fade — meant for carves flush with
1761 // a real plate edge), so every piece extends past its interior
1762 // seam by `depth`: its fade-out then crossfades with the
1763 // neighbor's fade-in instead of both dying AT the seam (which
1764 // notched the walls there; found the hard way).
1765 let throat_r = tx + tw;
1766 let rho = SECTION_FILLET_R;
1767 // Right of the throat, ONE piece owns the whole right run —
1768 // top wall, top-right arc, right wall, bottom-right arc — so
1769 // both right corners are real turns. (The old top-run +
1770 // full-body split put the top wall and the right wall in
1771 // different pieces; each faded out at the seam and the
1772 // top-right corner rendered square.) A left piece carries the
1773 // left wall and the bottom-left arc; the two bottom runs
1774 // crossfade under the seam at the right piece's left edge.
1775 let body_lr = |x_run: f32, out: &mut Vec<_>| {
1776 out.push((x_run, cy, cx + cw - x_run, ch, (0.0, r, r, 0.0), depth, false, (true, true, true, false)));
1777 out.push((cx, cy, x_run + depth - cx, ch, (0.0, 0.0, 0.0, r), depth, false, (false, false, true, true)));
1778 };
1779 if cx + cw > throat_r + 2.0 * rho {
1780 // Filleted throat ([`Self::section_fillets`]): the tab's
1781 // right wall must END at the fillet's vertical tangent
1782 // (crossfading out under the arc) or its straight run
1783 // ghosts through the curve — so the tab piece stops there,
1784 // and a left-only bridge carries the left wall across the
1785 // fillet span down to the body's own fade-in.
1786 out.push((tx, ty, tw, (cy - rho) - ty + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1787 out.push((tx, cy - rho, tw, rho + depth, (0.0, 0.0, 0.0, 0.0), depth, false, (false, false, false, true)));
1788 body_lr(throat_r + rho - depth, &mut out);
1789 } else {
1790 // Too narrow for the fillet: the plain square throat.
1791 out.push((tx, ty, tw, th + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1792 if cx + cw > throat_r + 0.5 {
1793 body_lr(throat_r - depth, &mut out);
1794 } else {
1795 // The tab spans the body: no top wall at all.
1796 out.push((cx, cy, cw, ch, (0.0, 0.0, r, r), depth, false, (false, true, true, true)));
1797 }
1798 }
1799 } else {
1800 let depth = section_carve_depth(th);
1801 out.push((tx, ty, tw, th, r4(SECTION_R), depth, false, all));
1802 }
1803 }
1804
1805 let hidden = self.hidden_rows();
1806 for (i, p) in self.display_params.iter().enumerate() {
1807 if hidden[i] {
1808 continue;
1809 }
1810 // (control, its configured corner radius, raised vs recessed)
1811 let ctl: Option<(&dyn WidgetHost, f32, bool)> = if is_text_row(&p.2) {
1812 // The textpick picker is NOT in this list: with it the row is
1813 // a field ([`Self::fields`]) — a well ending in a flush run.
1814 self.texts[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::textbox_corner_radius(), false))
1815 } else if p.2.starts_with("choice") {
1816 // The dropdown trigger is a FLUSH control, a field that is all
1817 // run ([`Self::fields`]). A boss here read as a raised island
1818 // the widget itself never draws.
1819 None
1820 } else if p.2 == "button" {
1821 // A flush control with a field run's edge, as its own paint
1822 // draws it ([`Self::fields`]) — it was a boss here, a raised
1823 // island the button itself never drew.
1824 None
1825 } else if p.2 == "toggle" || p.2 == "checkbox" {
1826 // A toggle is a field ([`Self::fields`]): its well and the
1827 // flush run gliding in it, one outline round both.
1828 None
1829 } else if p.2.starts_with("spinbox") {
1830 // A plain well only when there is no -/+ run; with one the
1831 // control is a field ([`Self::fields`]) and its -/+ seam a
1832 // groove ([`Self::grooves`]). Same side-label inset, same
1833 // content band, same depth cap as its paint.
1834 if let Some(sb) = &self.spinboxes[i] {
1835 let (x, y, w, h) = sb.rect();
1836 let ty = sb.label_strip();
1837 let band = Rect { x, y: y + ty, width: w, height: h - ty };
1838 if w > 0.0 && h > 0.0 {
1839 let r = crate::layout::spinbox_corner_radius();
1840 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1841 // With its -/+ run the control is a field
1842 // ([`Self::fields`]); without, a plain well.
1843 if let Some(rel) = sb.inner().relief_parts(band) {
1844 if rel.run.is_none() {
1845 out.push((x, y + ty, w, h - ty, r4(r), depth, false, all));
1846 }
1847 }
1848 }
1849 }
1850 None
1851 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1852 // The control's one well (`ColorSelector::field_relief`, the
1853 // same geometry its paint carves); the swatch is a fill on its
1854 // floor and the seam a groove, both on the widget's paint.
1855 if let Some(c) = &self.colors[i] {
1856 let (x, y, w, h) = c.rect();
1857 let ty = c.label_strip();
1858 if let Some((rx, ry, rw, rh, rr, rd)) =
1859 c.inner().field_relief(Rect { x, y: y + ty, width: w, height: h - ty })
1860 {
1861 out.push((rx, ry, rw, rh, r4(rr), rd, false, all));
1862 }
1863 }
1864 None
1865 } else {
1866 // Sliders (and Float3's three rows) are bands: their well is
1867 // hand-shaded quads that follow the band's contour, which reach
1868 // a flat host through the plain-quad view — no rect carve.
1869 None
1870 };
1871 if let Some((w, radius, raised)) = ctl {
1872 let (x, y, ww, h) = w.rect();
1873 if ww <= 0.0 || h <= 0.0 {
1874 continue;
1875 }
1876 // The top-label band stays outside the relief like every other host.
1877 let ty = w.label_strip();
1878 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1879 // A text box joined to its picker is half of a field
1880 // ([`Self::fields`]), drawn there.
1881 if is_text_row(&p.2) && self.texts[i].as_ref().is_some_and(|t| t.inner().joined_right) {
1882 continue;
1883 }
1884 out.push((x, y + ty, ww, h - ty, r4(radius), depth, raised, all));
1885 }
1886 }
1887 out
1888 }
1889
1890 /// The rows that are ONE field with a run ([`Field`]: a sunken well
1891 /// holding a flush run, one outline round both), drawn AFTER
1892 /// [`Self::reliefs`]. A textpick row is its text box's well ending in its
1893 /// picker, a spinbox its value's well ending in its -/+ run
1894 /// ([`Field::ending_in_run`]); a dropdown or button row, a field that is
1895 /// all run ([`Field::run`]); a toggle, its own sliding field
1896 /// (`Toggle::field`). So every flush control in the pane has the same
1897 /// edge. The plain wells are not here — they are [`Self::reliefs`], which
1898 /// group into the pane's plate. They replaced
1899 /// `troughs()` on 2026-10-01. On the pane's own rule for wells and
1900 /// troughs: the content band as the outline, the wall straddling it.
1901 #[allow(clippy::type_complexity)]
1902 pub fn fields(&self) -> Vec<Field> {
1903 if !self.visible || !crate::layout::control_relief() {
1904 return Vec::new();
1905 }
1906 let mut out = Vec::new();
1907 let hidden = self.hidden_rows();
1908 for (i, p) in self.display_params.iter().enumerate() {
1909 if hidden[i] {
1910 continue;
1911 }
1912 if p.2.starts_with("textpick") {
1913 if let (Some(tb), Some(d)) = (&self.texts[i], &self.choices[i]) {
1914 if !tb.inner().joined_right {
1915 continue;
1916 }
1917 let (x, y, w, h) = tb.rect();
1918 let (dx, _, dw, _) = d.rect();
1919 let ty = tb.label_strip();
1920 if w > 0.0 && h - ty > 0.0 && dw > 0.0 {
1921 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1922 let r = crate::layout::textbox_corner_radius();
1923 let outline = Rect { x, y: y + ty, width: dx + dw - x, height: h - ty };
1924 out.push(Field::ending_in_run(outline, (r, r, r, r), depth, dx));
1925 }
1926 }
1927 } else if p.2 == "button" {
1928 // A button: a field that is all run, as its own flush plate
1929 // (`Button::plate`, `PaintCtx::inset_plate`) on its
1930 // band, at the button radius.
1931 if let Some(b) = &self.buttons[i] {
1932 let (x, y, w, h) = b.rect();
1933 let ty = b.label_strip();
1934 if w > 0.0 && h - ty > 0.0 {
1935 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1936 let r = crate::layout::button_corner_radius();
1937 out.push(Field::run(Rect { x, y: y + ty, width: w, height: h - ty }, (r, r, r, r), depth));
1938 }
1939 }
1940 } else if p.2.starts_with("choice") {
1941 // The dropdown trigger: a field that is all run — the
1942 // widget's own raised paint (`PaintCtx::inset_plate`)
1943 // on the same band, radius and depth cap. Until 2026-10-01
1944 // a trough, whose edge differed from the run's at the end of
1945 // a text row's field.
1946 if let Some(d) = &self.choices[i] {
1947 let (x, y, w, h) = d.rect();
1948 let ty = d.label_strip();
1949 if w > 0.0 && h - ty > 0.0 {
1950 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1951 let r = crate::layout::dropdown_corner_radius();
1952 out.push(Field::run(Rect { x, y: y + ty, width: w, height: h - ty }, (r, r, r, r), depth));
1953 }
1954 }
1955 } else if p.2.starts_with("spinbox") {
1956 if let Some(sb) = &self.spinboxes[i] {
1957 let (x, y, w, h) = sb.rect();
1958 let ty = sb.label_strip();
1959 let band = Rect { x, y: y + ty, width: w, height: h - ty };
1960 if let Some(rel) = sb.inner().relief_parts(band) {
1961 if let Some((split, _)) = rel.run {
1962 let r = rel.radius;
1963 out.push(Field::ending_in_run(band, (r, r, r, r), rel.depth, split));
1964 }
1965 }
1966 }
1967 } else if p.2 == "toggle" || p.2 == "checkbox" {
1968 // The toggle's own field (`Toggle::field`) —
1969 // exactly what its paint draws, on its band. It paints no
1970 // fill, so this IS the control.
1971 if let Some(t) = &self.toggles[i] {
1972 let (x, y, w, h) = t.rect();
1973 let ty = t.label_strip();
1974 if w > 0.0 && h - ty > 0.0 {
1975 // The pane's hover is the tint here, as on every field.
1976 let band = Rect { x, y: y + ty, width: w, height: h - ty };
1977 out.push(t.inner().field(band).with_tint(None));
1978 }
1979 }
1980 }
1981 }
1982 out
1983 }
1984
1985 /// The engraved seams companion — `(a, b, width, depth, host)` for
1986 /// [`crate::scene::paint::PaintCtx::groove`], drawn AFTER [`Self::fields`]
1987 /// (a groove engraves the surface the trough's control face provides).
1988 /// Today: the seam dividing a spinbox's -/+ run into its two buttons —
1989 /// the breadcrumb's segment-seam language at miniature scale.
1990 #[allow(clippy::type_complexity)]
1991 pub fn grooves(&self) -> Vec<((f32, f32), (f32, f32), f32, f32, Rect)> {
1992 if !self.visible || !crate::layout::control_relief() {
1993 return Vec::new();
1994 }
1995 let mut out = Vec::new();
1996 let hidden = self.hidden_rows();
1997 for (i, p) in self.display_params.iter().enumerate() {
1998 if hidden[i] || !p.2.starts_with("spinbox") {
1999 continue;
2000 }
2001 if let Some(sb) = &self.spinboxes[i] {
2002 let (x, y, w, h) = sb.rect();
2003 let ty = sb.label_strip();
2004 let band = Rect { x, y: y + ty, width: w, height: h - ty };
2005 if let Some(rel) = sb.inner().relief_parts(band) {
2006 if let Some((_, (sa, sb2, sw, host))) = rel.run {
2007 out.push((sa, sb2, sw, rel.depth, host));
2008 }
2009 }
2010 }
2011 }
2012 out
2013 }
2014
2015 /// The knobs a flat host draws after [`Self::reliefs`] — none: the sliders
2016 /// are bands (their swell is part of the band's own quads), so this is kept
2017 /// only for the hosts that still call it.
2018 pub fn spheres(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
2019 Vec::new()
2020 }
2021
2022 /// The section carves' concave inside-corner fillets — `(cx, cy, radius,
2023 /// depth, start angle)` for [`crate::scene::paint::PaintCtx::concave_fillet`]
2024 /// (recessed), drawn by the host AFTER [`Self::reliefs`]. The box reliefs
2025 /// can only round convex corners; this rounds the throat where a tab's
2026 /// right wall turns onto its body's top edge.
2027 pub fn section_fillets(&self) -> Vec<(f32, f32, f32, f32, f32)> {
2028 if !self.visible || !crate::layout::control_relief() {
2029 return Vec::new();
2030 }
2031 let mut out = Vec::new();
2032 for (title, content) in self.section_boxes() {
2033 let (tx, _ty, tw, _th) = title;
2034 if let Some((cx, cy, cw, ch)) = content {
2035 let depth = section_carve_depth(ch);
2036 let throat_r = tx + tw;
2037 if cx + cw > throat_r + 2.0 * SECTION_FILLET_R {
2038 out.push((
2039 throat_r + SECTION_FILLET_R,
2040 cy - SECTION_FILLET_R,
2041 SECTION_FILLET_R,
2042 depth,
2043 std::f32::consts::FRAC_PI_2,
2044 ));
2045 }
2046 }
2047 }
2048 out
2049 }
2050
2051 /// The scene-path companion to the legacy views: rows whose widgets paint
2052 /// prims NO flat tuple view can carry (the ramp rows' curve fill, key
2053 /// circles, and field controls). A host rendering this panel through the
2054 /// legacy hatches calls this with its own `PaintCtx` inside the pane's
2055 /// scroll clip, after the flat chrome — or the rows draw as bare labels.
2056 /// Whether a row value changed inside `tick` since the last call
2057 /// (see `tick_value_changed`); clears the flag.
2058 pub fn take_tick_value_change(&mut self) -> bool {
2059 std::mem::take(&mut self.tick_value_changed)
2060 }
2061
2062 pub fn paint_scene_rows(&self, pc: &mut PaintCtx) {
2063 if !self.visible {
2064 return;
2065 }
2066 let hidden = self.hidden_rows();
2067 let dummy = UiContext::new();
2068 for (i, p) in self.display_params.iter().enumerate() {
2069 if hidden[i] {
2070 continue;
2071 }
2072 if p.2 == "ramp" {
2073 if let Some(rp) = &self.ramps[i] {
2074 rp.paint_self(&dummy, pc);
2075 }
2076 } else if let Some(f) = self.float3s[i].as_ref().filter(|f| f.has_trackball()) {
2077 // A float3's trackball: a sphere is not a prim the flat
2078 // views carry, so it rides the scene path like the ramp.
2079 f.paint_ball(pc);
2080 }
2081 }
2082 }
2083
2084 }
2085
2086 impl Layout for ParametersBg {
2087 /// Ungated rect landing (the legacy `set_rect` head, before its visibility gate): cache the
2088 /// rect all row geometry derives from, then re-derive content height/scroll/row rects.
2089 fn rect_assigned(&mut self, rect: Rect) {
2090 self.rect = rect;
2091 self.refresh_scroll_metrics();
2092 }
2093
2094 }
2095
2096 impl Paint for ParametersBg {
2097 /// Shape the hosted controls (their carets read per-glyph advances nothing
2098 /// else records for children of a container) and one code column's advance
2099 /// from the same monospace@12 path the code rows draw through.
2100 fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
2101 for tb in self.texts.iter_mut().flatten() {
2102 tb.prepare_text(fs);
2103 }
2104 for sb in self.spinboxes.iter_mut().flatten() {
2105 sb.prepare_text(fs);
2106 }
2107 for c in self.colors.iter_mut().flatten() {
2108 c.prepare_text(fs);
2109 }
2110 let clusters = crate::backend::text::shaped_cluster_offsets(
2111 fs,
2112 "MMMMMMMM",
2113 12.0,
2114 Some("monospace"),
2115 );
2116 if let Some(&(_, total)) = clusters.last() {
2117 if total > 0.0 {
2118 self.code_char_advance = total / 8.0;
2119 }
2120 }
2121 }
2122
2123 /// The panel IS its own background plate (the host draws it from `color()` + the corner
2124 /// style via `append_widget_plate`) — there is no separate plate widget behind it, so
2125 /// this carries the full plate treatment: `PARAM_BG` scaled by the global plate opacity,
2126 /// with the alpha negated as the scenefx blur marker when plate blur is on. Transparent
2127 /// while hidden. It must not ALSO be emitted as a quad anywhere or it would double-blend.
2128 fn color(&self) -> [f32; 4] {
2129 if !self.visible {
2130 return [0.0, 0.0, 0.0, 0.0];
2131 }
2132 colors::param_plate_fill()
2133 }
2134
2135 /// The shared plate corner radius (rounded on all four corners when non-zero).
2136 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
2137 let r = crate::layout::plate_corner_radius();
2138 let on = r > 0.0;
2139 Some((r, (on, on, on, on)))
2140 }
2141
2142 /// The shared plate border (folded in from the retired backing plate widget).
2143 fn solid_border(&self) -> Option<([f32; 4], f32)> {
2144 colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
2145 }
2146
2147 fn widget_font(&self) -> Option<String> {
2148 Some(crate::layout::control_label_font())
2149 }
2150
2151 /// The COMPLETE row chrome — everything the legacy hatches carry, in the hosts' canonical
2152 /// draw order: the controls' rounded wells, the flat-style section outline fillets, the
2153 /// relief steps (after the fills so the walls shade what they cross), the section carves'
2154 /// concave throat fillets, the slider-thumb spheres, the scene-path rows, the flat
2155 /// plain-quad chrome, then the hosted controls' glyphs (arrows, −/+) over it. What stays OUT, deliberately: the background plate (see
2156 /// [`color`](Paint::color)), the scrollbar (hosts place its depth — the designer straddles
2157 /// it around the pane plate), and text (`paint_self`'s own-labels bridge carries the
2158 /// per-row fonts and code-box bounds). Hosts clip this to their pane viewport — a rect
2159 /// clip pushed here would not survive `paint_self`'s replay.
2160 fn paint_ui(&self, _ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
2161 if !self.visible {
2162 return;
2163 }
2164 self.claim_typing(ctx);
2165 self.paint_row_floors(ctx);
2166 for (qx, qy, qw, qh, qr, qc, corners) in self.rounded_quads() {
2167 ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
2168 }
2169 for (acx, acy, ar, at, a0, a1, ac) in self.arcs() {
2170 ctx.arc(acx, acy, ar, at, a0, a1, ac);
2171 }
2172 // The hovered row's carves are TINTED — the focus treatment's channel
2173 // with a neutral light instead of the accent: the rim's own light and
2174 // shadow, recoloured and gained, so the control under the pointer
2175 // reads lit while every other carve keeps the plate's grouped shading.
2176 // A tinted carve shades through the overlay path by design, and a
2177 // section's well spans many rows, so it never qualifies.
2178 let hover_rect = self.hover_row.and_then(|i| self.get_param_rects().get(i).copied()).filter(|r| r.3 > 0.0);
2179 let hovered = |x: f32, y: f32, w: f32, h: f32| {
2180 hover_rect.is_some_and(|(rx, ry, rw, rh)| {
2181 x >= rx - 0.5 && y >= ry - 0.5 && x + w <= rx + rw + 0.5 && y + h <= ry + rh + 0.5
2182 })
2183 };
2184 for (rx, ry, rw, rh, radii, rd, raised, edges) in self.reliefs() {
2185 let rect = Rect { x: rx, y: ry, width: rw, height: rh };
2186 match (raised, hovered(rx, ry, rw, rh)) {
2187 (true, true) => ctx.boss_edges_tinted(rect, radii, rd, edges, Self::HOVER_TINT),
2188 (true, false) => ctx.boss_edges(rect, radii, rd, edges),
2189 (false, true) => ctx.recess_edges_tinted(rect, radii, rd, edges, Self::HOVER_TINT),
2190 (false, false) => ctx.recess_edges(rect, radii, rd, edges),
2191 }
2192 }
2193 for field in self.fields() {
2194 let r = field.rect;
2195 let tint = hovered(r.x, r.y, r.width, r.height).then_some(Self::HOVER_TINT);
2196 ctx.field(&field.with_tint(tint));
2197 }
2198 for (ga, gb, gw, gd, ghost) in self.grooves() {
2199 ctx.groove(ga, gb, gw, gd, ghost);
2200 }
2201 for (fcx, fcy, fr, fd, fs) in self.section_fillets() {
2202 ctx.concave_fillet(fcx, fcy, fr, fd, fs, false);
2203 }
2204 for (scx, scy, sr, sc) in self.spheres() {
2205 ctx.sphere(scx, scy, sr, &crate::scene::material::Material::from_fill(sc));
2206 }
2207 self.paint_scene_rows(ctx);
2208 for (qx, qy, qw, qh, qc) in self.plain_quads() {
2209 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
2210 }
2211 self.paint_child_glyphs(ctx);
2212 }
2213
2214 /// Ui-less emission (the [`paint_ui`](Paint::paint_ui) override above is what `paint_self`
2215 /// runs): the flat subset plus the scrollbar, kept for direct callers only. The background
2216 /// plate stays out — see [`color`](Paint::color).
2217 fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
2218 self.claim_typing(ctx);
2219 for (qx, qy, qw, qh, qc) in self.plain_quads() {
2220 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
2221 }
2222 self.paint_scene_rows(ctx);
2223 self.paint_child_glyphs(ctx);
2224 // Scene-path hosts get the scrollbar on top (the designer instead straddles it around
2225 // the pane plate through `scrollbar_quads`).
2226 for (qx, qy, qw, qh, qc) in self.scrollbar_quads() {
2227 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
2228 }
2229 let view_min = self.rect.y + 4.0;
2230 let view_max = self.rect.y + self.rect.height - 4.0;
2231 // The y test above culls the scrolled-away rows; it is the pane's
2232 // WIDTH that nothing enforced, so a long parameter name ran out of the
2233 // pane sideways.
2234 let pane = Some([
2235 self.rect.x,
2236 self.rect.y,
2237 self.rect.x + self.rect.width,
2238 self.rect.y + self.rect.height,
2239 ]);
2240 for l in self.own_text_labels() {
2241 if l.y >= view_min - 20.0 && l.y <= view_max + 20.0 {
2242 ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, pane);
2243 }
2244 }
2245 }
2246
2247 fn serves_legacy_labels(&self) -> bool {
2248 true
2249 }
2250
2251 /// The legacy `text_labels_with_font_and_bounds` body: every label clipped to the panel
2252 /// viewport in the control-label font, except labels inside a code row — those clip to the
2253 /// code box (or hide when it's scrolled out) and render monospace.
2254 fn legacy_labels_with_font_and_bounds(&self, _rect: Rect, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
2255 let view_min = self.rect.y + 4.0;
2256 let view_max = self.rect.y + self.rect.height - 4.0;
2257 let mut result = Vec::new();
2258 let font = Paint::widget_font(self);
2259 let rects = self.get_param_rects();
2260 for l in self.own_text_labels() {
2261 if l.y < view_min - 20.0 || l.y > view_max + 20.0 {
2262 continue;
2263 }
2264 let mut bounds = Some([self.rect.x + 4.0, view_min, self.rect.x + self.rect.width - 4.0, view_max]);
2265 let mut label_font = font.clone();
2266 for (i, p) in self.display_params.iter().enumerate() {
2267 if p.2 == "code" {
2268 let r = rects[i];
2269 if l.y >= r.1 + 18.0 && l.y <= r.1 + r.3 {
2270 let code_min = (r.1 + 19.0).max(view_min);
2271 let code_max = (r.1 + r.3 - 1.0).min(view_max);
2272 if code_min < code_max {
2273 bounds = Some([r.0 + 1.0, code_min, r.0 + r.2 - 1.0, code_max]);
2274 } else {
2275 bounds = Some([0.0, 0.0, 0.0, 0.0]); // hidden
2276 }
2277 label_font = Some("monospace".to_string());
2278 break;
2279 }
2280 }
2281 }
2282 result.push((l, label_font, bounds));
2283 }
2284 result
2285 }
2286
2287 fn popover(&self, _rect: Rect) -> Option<(f32, f32, f32, f32)> {
2288 self.choices_popover_rect()
2289 }
2290
2291 /// The dropdown rows' popovers; the raw children's are the adapter's recursion.
2292 fn draw_popover(&self, _rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
2293 for d in self.choices.iter().flatten() {
2294 d.render_popover(pc);
2295 }
2296 for rp in self.ramps.iter().flatten() {
2297 rp.inner().preset_dropdown.render_popover(pc);
2298 rp.inner().line_type_dropdown.render_popover(pc);
2299 }
2300 }
2301 }
2302
2303 impl Input for ParametersBg {
2304 /// While a code row is being edited, the clipboard, selection and
2305 /// history actions are the editor's — the runner routes the undo / redo
2306 /// chords here before the app's own history gets them.
2307 fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
2308 if self.code_editing() {
2309 return self.code_action(action);
2310 }
2311 false
2312 }
2313
2314 fn scrollable(&self) -> bool {
2315 true
2316 }
2317
2318 /// Legacy `mouse_input` saw every press (and consumes every left press — the designer
2319 /// relies on the panel swallowing clicks anywhere while it's the dispatch target).
2320 fn gates_presses(&self) -> bool {
2321 false
2322 }
2323
2324 /// Legacy hit reach: the panel rect, or an open popover (a dropdown row's list extends
2325 /// below the panel).
2326 fn hit(&self, rect: Rect, px: f32, py: f32) -> bool {
2327 if px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height {
2328 return true;
2329 }
2330 if let Some((pop_x, pop_y, pop_w, pop_h)) = self.own_popover_rect() {
2331 if px >= pop_x && px <= pop_x + pop_w && py >= pop_y && py <= pop_y + pop_h {
2332 return true;
2333 }
2334 }
2335 false
2336 }
2337
2338
2339 // --- The host-driven drag surface (the designer routes pointer drags here directly). ---
2340
2341 fn draggable(&self, _rect: Rect) -> bool {
2342 self.scrollbar_dragging
2343 || self.dragging_param.is_some()
2344 || self.display_params.iter().any(|p| p.2.starts_with("slider") || is_vec_row(&p.2))
2345 }
2346
2347 fn is_dragging(&self) -> bool {
2348 self.scrollbar_dragging || self.dragging_param.is_some()
2349 }
2350
2351 fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
2352 if self.scrollbar_dragging {
2353 return;
2354 }
2355 let rects = self.get_param_rects();
2356 for (i, p) in self.display_params.iter().enumerate() {
2357 if p.2.starts_with("slider") {
2358 let r = rects[i];
2359 if let Some(s) = &mut self.sliders[i] {
2360 let top = s.label_strip();
2361 if py >= r.1 + top && py <= r.1 + r.3 {
2362 s.drag_begin(px, py);
2363 self.dragging_param = Some(i);
2364 break;
2365 }
2366 }
2367 } else if is_vec_row(&p.2) {
2368 let r = rects[i];
2369 if py >= r.1 && py <= r.1 + r.3 {
2370 if let Some(f) = &mut self.float3s[i] {
2371 let mut dummy = crate::context::UiContext::new();
2372 if f.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut dummy) {
2373 self.dragging_param = Some(i);
2374 break;
2375 }
2376 }
2377 }
2378 }
2379 }
2380 }
2381
2382 fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
2383 if self.scrollbar_dragging {
2384 let sb_track_h = self.rect.height - 8.0;
2385 let sb_track_y = self.rect.y + 4.0;
2386 let visible_ratio = self.rect.height / self.content_h;
2387 let thumb_h = if sb_track_h <= 20.0 {
2388 sb_track_h
2389 } else {
2390 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2391 };
2392 let max_scroll = (self.content_h - self.rect.height).max(0.0);
2393
2394 let target_thumb_y = py - self.drag_offset_y;
2395 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2396 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2397 } else {
2398 0.0
2399 };
2400
2401 let old_scroll = self.scroll_y;
2402 self.scroll_y = new_scroll_ratio * max_scroll;
2403 if (self.scroll_y - old_scroll).abs() > 0.01 {
2404 self.update_slider_rects();
2405 return true;
2406 }
2407 return false;
2408 }
2409
2410 if let Some(i) = self.dragging_param {
2411 if let Some(s) = &mut self.sliders[i] {
2412 if s.drag_update(px, py) {
2413 let new_val = s.get_scaled_value();
2414 let old_val = &self.display_params[i].1;
2415 let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
2416 if *old_val != new_val_str {
2417 self.display_params[i].1 = new_val_str;
2418 return true;
2419 }
2420 }
2421 } else if let Some(f) = &mut self.float3s[i] {
2422 if f.drag_update(px, py) {
2423 let new_val_str = f.value_string();
2424 if self.display_params[i].1 != new_val_str {
2425 self.display_params[i].1 = new_val_str;
2426 return true;
2427 }
2428 }
2429 }
2430 }
2431 false
2432 }
2433
2434 fn drag_end(&mut self) {
2435 if self.scrollbar_dragging {
2436 self.scrollbar_dragging = false;
2437 self.activity.bump();
2438 return;
2439 }
2440 if let Some(i) = self.dragging_param.take() {
2441 if let Some(s) = &mut self.sliders[i] {
2442 s.drag_end();
2443 } else if let Some(f) = &mut self.float3s[i] {
2444 f.drag_end();
2445 }
2446 }
2447 }
2448
2449 /// Legacy `tick` forwarded to the raw children (the adapter's recursion now) and the
2450 /// checkbox rows. The checkbox tick never touches the ctx (a leaf `Adapted` tick is
2451 /// ctx-free), so the in-file dummy-ctx convention (`drag_begin`, `collect_child_quads`)
2452 /// applies.
2453 fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
2454 if !self.visible {
2455 return false;
2456 }
2457 let mut changed = false;
2458 let mut dummy = crate::context::UiContext::new();
2459 // Choice rows tick their Dropdowns' open/close animation. This was the
2460 // ONLY path that can advance a pane dropdown's anim_snap (the widget's
2461 // tick-receiver registration points at an id pane internals never put
2462 // in the host tree), and without it every params-pane dropdown opened
2463 // at zero drawn extent: logically open, invisible, reporting a sliver
2464 // popover rect — and the next click toggled it closed again.
2465 for d in self.choices.iter_mut().flatten() {
2466 if d.tick(dt, &mut dummy) {
2467 changed = true;
2468 }
2469 }
2470 for cb in self.toggles.iter_mut().flatten() {
2471 if cb.tick(dt, &mut dummy) {
2472 changed = true;
2473 }
2474 }
2475 // Slider rows tick their wheel-glide inertia — fold a coasting value
2476 // back into the row string so hosts syncing off display_params apply
2477 // it, exactly like a live wheel event would.
2478 for i in 0..self.sliders.len() {
2479 if let Some(s) = &mut self.sliders[i] {
2480 if s.tick(dt, &mut dummy) {
2481 let new_val = s.get_scaled_value();
2482 let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
2483 if self.display_params[i].1 != new_val_str {
2484 self.display_params[i].1 = new_val_str;
2485 }
2486 changed = true;
2487 }
2488 }
2489 }
2490 // Float3 rows are three slider rows: same glide, same fold-back.
2491 for i in 0..self.float3s.len() {
2492 if let Some(f) = &mut self.float3s[i] {
2493 if f.tick(dt, &mut dummy) {
2494 let new_val_str = f.value_string();
2495 if self.display_params[i].1 != new_val_str {
2496 self.display_params[i].1 = new_val_str;
2497 }
2498 changed = true;
2499 }
2500 }
2501 }
2502 // Ramp rows tick their field widgets (preset application, slider→key
2503 // sync) and drain their change flag — fold the curve back into the row
2504 // value when it moved.
2505 for i in 0..self.ramps.len() {
2506 if let Some(rp) = &mut self.ramps[i] {
2507 if rp.tick(dt, &mut dummy) {
2508 self.display_params[i].1 = rp.inner().spec_string();
2509 changed = true;
2510 }
2511 }
2512 }
2513 // Color rows tick their picker-stream poll (`cce-color-editor --stream`
2514 // lines applying live) — fold a changed value back into the row so
2515 // hosts syncing off display_params see it while the picker is open.
2516 for i in 0..self.colors.len() {
2517 if let Some(c) = &mut self.colors[i] {
2518 if c.tick(dt, &mut dummy) {
2519 if let Some(val) = c.get_value_string() {
2520 if self.display_params[i].1 != val {
2521 self.display_params[i].1 = val;
2522 self.tick_value_changed = true;
2523 }
2524 }
2525 changed = true;
2526 }
2527 }
2528 }
2529 // The pane's own wheel glide / trackpad coast: adopt any host write to
2530 // `scroll_y`, advance, and re-seat the rows when the offset moved.
2531 self.scroll_motion.reconcile(0.0, self.scroll_y);
2532 let pane_max = (self.content_h - self.rect.height).max(0.0);
2533 if self.scroll_motion.tick(dt, crate::widget::Bounds::max(0.0), crate::widget::Bounds::max(pane_max)) {
2534 self.scroll_y = self.scroll_motion.y.pos();
2535 self.update_slider_rects();
2536 self.rehover_after_scroll(&mut dummy);
2537 changed = true;
2538 }
2539 if self.scroll_motion.is_animating() {
2540 changed = true;
2541 }
2542 // Decay the "recently scrolled" window; keep frames coming until it expires so the
2543 // scrollbar's sink behind the plate actually renders.
2544 if self.activity.holding() {
2545 changed = true;
2546 }
2547 // Re-latch the raised state (e.g. sink once the scroll window lapses).
2548 let visible = self.scrollbar_visible();
2549 if self.activity.tick(dt, visible, self.scrollbar_dragging) {
2550 changed = true;
2551 }
2552 changed
2553 }
2554
2555 fn visibility_changed(&mut self, visible: bool) {
2556 self.visible = visible;
2557 }
2558
2559 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
2560 // Copied before `ectx.ui` is borrowed: the wheel arm's occlusion check keys on the
2561 // adapter's address (the pointer hosts register/popover-track).
2562 let self_id = ectx.id;
2563 match event {
2564 // Hosts call `unfocus()` directly (the designer's pane switches): commit the
2565 // focused row and unfocus the children. Needs no ctx, so the direct path's
2566 // ui-less synthesis works too.
2567 Event::FocusOut => {
2568 self.commit_and_unfocus();
2569 true
2570 }
2571 Event::PointerMove { x: px, y: py, .. } => {
2572 let (px, py) = (*px, *py);
2573 self.mouse_pos = Some((px, py));
2574 // Track scrollbar hover, then re-latch: hover only sustains an already-raised
2575 // bar (a sunk one is behind the plate, so the pointer never reaches it), so
2576 // the only visible change here is the raised state — redraw on that transition.
2577 let hover = self.hit_test_scrollbar(px, py);
2578 self.activity.set_hover(hover);
2579 let raised_changed = self.recompute_scrollbar_raised();
2580 let Some(ui) = ectx.ui.as_deref_mut() else {
2581 return raised_changed;
2582 };
2583 let mut changed = raised_changed;
2584
2585 if self.scrollbar_dragging {
2586 let sb_track_h = self.rect.height - 8.0;
2587 let sb_track_y = self.rect.y + 4.0;
2588 let visible_ratio = self.rect.height / self.content_h;
2589 let thumb_h = if sb_track_h <= 20.0 {
2590 sb_track_h
2591 } else {
2592 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2593 };
2594 let max_scroll = (self.content_h - self.rect.height).max(0.0);
2595
2596 let target_thumb_y = py - self.drag_offset_y;
2597 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2598 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2599 } else {
2600 0.0
2601 };
2602
2603 let old_scroll = self.scroll_y;
2604 self.scroll_y = new_scroll_ratio * max_scroll;
2605 if (self.scroll_y - old_scroll).abs() > 0.01 {
2606 self.update_slider_rects();
2607 changed = true;
2608 }
2609 }
2610
2611 if self.hover_controls(px, py, ui) {
2612 changed = true;
2613 }
2614
2615 changed
2616 }
2617 Event::MouseButton { button, state, x: px, y: py, .. } => {
2618 if !self.visible {
2619 return false;
2620 }
2621 let (button, state, px, py) = (*button, *state, *px, *py);
2622 let Some(ui) = ectx.ui.as_deref_mut() else {
2623 return false;
2624 };
2625
2626 if button == MouseButton::Left {
2627 if state == ElementState::Pressed {
2628 // Only a raised bar can be grabbed — a sunk one is behind the plate,
2629 // so the press falls through to the pane content underneath it.
2630 if self.activity.raised() && self.hit_test_scrollbar(px, py) {
2631 // Legacy called `self.focus()` here — base flag only, which
2632 // nothing reads (see module docs).
2633 self.scrollbar_dragging = true;
2634
2635 let sb_track_h = self.rect.height - 8.0;
2636 let sb_track_y = self.rect.y + 4.0;
2637 let visible_ratio = self.rect.height / self.content_h;
2638 let thumb_h = if sb_track_h <= 20.0 {
2639 sb_track_h
2640 } else {
2641 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2642 };
2643 let max_scroll = (self.content_h - self.rect.height).max(0.0);
2644 let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
2645 let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
2646
2647 let click_offset = py - thumb_y;
2648 if click_offset >= 0.0 && click_offset <= thumb_h {
2649 self.drag_offset_y = click_offset;
2650 } else {
2651 // Clicked outside the thumb: jump thumb center to py
2652 self.drag_offset_y = thumb_h / 2.0;
2653 let target_thumb_y = py - self.drag_offset_y;
2654 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2655 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2656 } else {
2657 0.0
2658 };
2659 self.scroll_y = new_scroll_ratio * max_scroll;
2660 self.update_slider_rects();
2661 }
2662 return true;
2663 }
2664 } else if state == ElementState::Released
2665 && self.scrollbar_dragging {
2666 self.scrollbar_dragging = false;
2667 self.activity.bump();
2668 return true;
2669 }
2670 }
2671
2672 // A press on a section's title box collapses/expands it. Checked before the
2673 // rows so a header can never be shadowed by a control under it, and only on
2674 // the press — the matching release lands on whatever the relayout moved
2675 // under the pointer, which must not toggle it straight back.
2676 if button == MouseButton::Left && state == ElementState::Pressed {
2677 let rects = self.get_param_rects();
2678 let hit = self.display_params.iter().enumerate().position(|(i, p)| {
2679 if p.2 != "section" {
2680 return false;
2681 }
2682 let (bx, by, bw, bh) = self.section_title_box(i, rects[i]);
2683 px >= bx && px <= bx + bw && py >= by && py <= by + bh
2684 });
2685 if let Some(i) = hit {
2686 let title = self.display_params[i].0.clone();
2687 let collapsed = self.collapsed.contains(&title);
2688 // Collapsing out from under a focused row would strand the editor.
2689 self.commit_and_unfocus();
2690 self.set_section_collapsed(&title, !collapsed);
2691 return true;
2692 }
2693 }
2694
2695 let hidden = self.hidden_rows();
2696
2697 // 1. Check open dropdown popovers first (since they are drawn on top)
2698 for (i, d_opt) in self.choices.iter_mut().enumerate() {
2699 if hidden[i] {
2700 continue;
2701 }
2702 if let Some(d) = d_opt {
2703 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2704 eprintln!("[pdbg] press ({px:.0},{py:.0}) choice[{i}] open-priority: popover_rect={:?}", d.popover_rect());
2705 }
2706 if let Some((ox, oy, ow, oh)) = d.popover_rect() {
2707 // Textpick pickers are press-driven end to end
2708 // (selection fires on the option PRESS): a release
2709 // over the open surface is swallowed, never
2710 // dispatched — mid-animation it can read as an
2711 // outside press and close the menu it just opened.
2712 if state != ElementState::Pressed
2713 && self.display_params[i].2.starts_with("textpick")
2714 {
2715 if px >= ox && px <= ox + ow && py >= oy && py <= oy + oh {
2716 return true;
2717 }
2718 continue;
2719 }
2720 let consumed = d.mouse_input(button, state, px, py, ui);
2721 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2722 eprintln!("[pdbg] -> open dropdown consumed={consumed}");
2723 }
2724 if consumed {
2725 if d.take_change() {
2726 if let Some(val) = d.get_value_string() {
2727 // A textpick row's pick fills its
2728 // TextBox — the box IS the value.
2729 if self.display_params[i].2.starts_with("textpick") {
2730 if let Some(tb) = &mut self.texts[i] {
2731 tb.text = val.clone();
2732 tb.edit_buffer = val.clone();
2733 }
2734 }
2735 self.display_params[i].1 = val;
2736 }
2737 }
2738 return true;
2739 }
2740 }
2741 }
2742 }
2743 // The ramp rows' field dropdowns can pop over neighboring rows too.
2744 for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
2745 if hidden[i] {
2746 continue;
2747 }
2748 if let Some(rp) = rp_opt {
2749 let ramp = rp.inner();
2750 if (ramp.preset_dropdown.popover_rect().is_some()
2751 || ramp.line_type_dropdown.popover_rect().is_some())
2752 && rp.mouse_input(button, state, px, py, ui) {
2753 self.display_params[i].1 = rp.inner().spec_string();
2754 return true;
2755 }
2756 }
2757 }
2758
2759 // 2. Propagate to our widgets
2760 for (i, p) in self.display_params.iter_mut().enumerate() {
2761 if hidden[i] {
2762 continue;
2763 }
2764 if p.2.starts_with("choice") {
2765 if let Some(d) = &mut self.choices[i] {
2766 if d.mouse_input(button, state, px, py, ui) {
2767 // Claim the param focus while the dropdown is
2768 // open — the KeyInput arm above is gated on
2769 // `focused_param`, and without this the choice
2770 // row was the ONE row type that never set it,
2771 // so Escape/arrows/Enter could not reach an
2772 // open params dropdown (found via cce-designer).
2773 if d.open {
2774 self.focused_param = Some(i);
2775 } else if self.focused_param == Some(i) {
2776 self.focused_param = None;
2777 }
2778 if d.take_change() {
2779 if let Some(val) = d.get_value_string() {
2780 p.1 = val;
2781 }
2782 }
2783 return true;
2784 }
2785 }
2786 } else if p.2 == "button" {
2787 if let Some(b) = &mut self.buttons[i] {
2788 if b.mouse_input(button, state, px, py, ui) {
2789 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2790 eprintln!("[pdbg] press ({px:.0},{py:.0}) BUTTON[{i}] '{}' consumed", p.0);
2791 }
2792 if b.take_click() {
2793 p.1 = "clicked".to_string();
2794 }
2795 return true;
2796 }
2797 }
2798 } else if is_text_row(&p.2) {
2799 if let Some(d) = &mut self.choices[i] {
2800 // The picker acts on PRESSES only; the release
2801 // over the button is swallowed. Releases used to
2802 // reach the dropdown, and one arriving before the
2803 // open animation's first frame (a fast or
2804 // injected click) read as an outside press and
2805 // closed the menu it had just opened.
2806 let (bx, by, bw, bh) = d.rect();
2807 let on_button =
2808 px >= bx && px <= bx + bw && py >= by && py <= by + bh;
2809 if state == ElementState::Pressed {
2810 if d.mouse_input(button, state, px, py, ui) {
2811 if d.take_change() {
2812 if let Some(val) = d.get_value_string() {
2813 if let Some(tb) = &mut self.texts[i] {
2814 tb.text = val.clone();
2815 tb.edit_buffer = val.clone();
2816 }
2817 p.1 = val;
2818 }
2819 }
2820 return true;
2821 }
2822 } else if on_button {
2823 return true;
2824 }
2825 }
2826 if let Some(tb) = &mut self.texts[i] {
2827 if tb.mouse_input(button, state, px, py, ui) {
2828 if tb.editing {
2829 self.focused_param = Some(i);
2830 } else {
2831 if self.focused_param == Some(i) {
2832 self.focused_param = None;
2833 }
2834 }
2835 if tb.take_change() {
2836 if let Some(val) = tb.get_value_string() {
2837 p.1 = val;
2838 }
2839 }
2840 return true;
2841 }
2842 }
2843 } else if p.2.starts_with("spinbox") {
2844 if let Some(sb) = &mut self.spinboxes[i] {
2845 if sb.mouse_input(button, state, px, py, ui) {
2846 p.1 = sb.value.to_string();
2847 if sb.editing {
2848 self.focused_param = Some(i);
2849 } else {
2850 if self.focused_param == Some(i) {
2851 self.focused_param = None;
2852 }
2853 }
2854 return true;
2855 }
2856 }
2857 } else if p.2 == "toggle" || p.2 == "checkbox" {
2858 if let Some(cb) = &mut self.toggles[i] {
2859 if cb.mouse_input(button, state, px, py, ui) {
2860 if cb.take_change() {
2861 if let Some(val) = cb.get_value_string() {
2862 p.1 = val;
2863 }
2864 }
2865 return true;
2866 }
2867 }
2868 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
2869 if let Some(c) = &mut self.colors[i] {
2870 if c.mouse_input(button, state, px, py, ui) {
2871 if let Some(val) = c.get_value_string() {
2872 p.1 = val;
2873 }
2874 if c.editing {
2875 self.focused_param = Some(i);
2876 } else {
2877 if self.focused_param == Some(i) {
2878 self.focused_param = None;
2879 }
2880 }
2881 return true;
2882 }
2883 }
2884 } else if p.2 == "ramp" {
2885 if let Some(rp) = &mut self.ramps[i] {
2886 if rp.mouse_input(button, state, px, py, ui) {
2887 p.1 = rp.inner().spec_string();
2888 return true;
2889 }
2890 }
2891 }
2892 }
2893
2894 if button == MouseButton::Left && state == ElementState::Pressed {
2895 let rects = self.get_param_rects();
2896 let mut clicked_any_focusable = false;
2897 for (i, p) in self.display_params.iter_mut().enumerate() {
2898 if hidden[i] {
2899 continue;
2900 }
2901 if p.2 == "code" {
2902 let r = rects[i];
2903 if px >= r.0 && px <= r.0 + r.2 && py >= r.1 + Self::CODE_BOX_TOP && py <= r.1 + r.3 {
2904 // Clicking inside the box already being edited moves the
2905 // caret and keeps the buffer (and its history); a click
2906 // into a different row's box starts a fresh editor.
2907 let mut editor = match (self.focused_param == Some(i), self.code_editor.take()) {
2908 (true, Some(e)) => e,
2909 (_, other) => {
2910 drop(other);
2911 self.code_history.clear();
2912 TextEditorState::new(p.1.clone())
2913 }
2914 };
2915 self.focused_param = Some(i);
2916 let click_x = px - self.code_text_x(r);
2917 let click_y = py - (r.1 + Self::CODE_TOP);
2918 let line = (click_y / Self::CODE_LINE_H).floor().max(0.0) as usize;
2919 let col = (click_x / self.code_col_w() + 0.5).floor().max(0.0) as usize;
2920 let at = map_2d_to_1d(&editor.buffer, line, col);
2921 if ui.shift_pressed {
2922 if editor.select_anchor.is_none() {
2923 editor.select_anchor = Some(editor.cursor_idx);
2924 }
2925 } else {
2926 editor.clear_selection();
2927 }
2928 editor.cursor_idx = at;
2929 self.code_editor = Some(editor);
2930 clicked_any_focusable = true;
2931 break;
2932 }
2933 } else if p.2.starts_with("slider") {
2934 let r = rects[i];
2935 if py >= r.1 && py <= r.1 + r.3 {
2936 if let Some(s) = &mut self.sliders[i] {
2937 if s.mouse_input(button, state, px, py, ui) {
2938 if s.editing {
2939 self.focused_param = Some(i);
2940 clicked_any_focusable = true;
2941 }
2942 break;
2943 }
2944 }
2945 }
2946 } else if is_vec_row(&p.2) {
2947 let r = rects[i];
2948 if py >= r.1 && py <= r.1 + r.3 {
2949 if let Some(f) = &mut self.float3s[i] {
2950 if f.mouse_input(button, state, px, py, ui) {
2951 if f.editing_idx().is_some() {
2952 self.focused_param = Some(i);
2953 clicked_any_focusable = true;
2954 }
2955 break;
2956 }
2957 }
2958 }
2959 }
2960 }
2961 if !clicked_any_focusable {
2962 self.commit_and_unfocus();
2963 }
2964 return true;
2965 }
2966 false
2967 }
2968 Event::KeyInput(event) => {
2969 if !self.visible {
2970 return false;
2971 }
2972 let Some(ui) = ectx.ui.as_deref_mut() else {
2973 return false;
2974 };
2975 if let Some(idx) = self.focused_param {
2976 if event.state == ElementState::Pressed {
2977 let p = &mut self.display_params[idx];
2978 if p.2 == "code" {
2979 if let Some(mut editor) = self.code_editor.take() {
2980 // Edits go to the BUFFER, not the value: a script
2981 // re-evaluates the node on every value change, and
2982 // a half-typed line would fail on every keystroke.
2983 // ctrl+enter applies, and so does leaving the row
2984 // (Escape, a click elsewhere, `unfocus`).
2985 let before = editor.clone();
2986 let mut handled = true;
2987 let mut edited = false;
2988 let mut apply = false;
2989 let mut should_unfocus = false;
2990 let shift = event.shift;
2991 let move_vertical = |editor: &mut TextEditorState, delta: i32| {
2992 if shift && editor.select_anchor.is_none() {
2993 editor.select_anchor = Some(editor.cursor_idx);
2994 } else if !shift {
2995 editor.clear_selection();
2996 }
2997 let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
2998 let total = editor.buffer.split('\n').count() as i32;
2999 let target = (line as i32 + delta).clamp(0, total - 1) as usize;
3000 editor.cursor_idx = map_2d_to_1d(&editor.buffer, target, col);
3001 };
3002 match &event.logical_key {
3003 Key::Named(NamedKey::Backspace) => {
3004 edited = editor.delete_backwards();
3005 }
3006 Key::Named(NamedKey::Delete) => {
3007 edited = editor.delete_forwards();
3008 }
3009 Key::Named(NamedKey::Enter) if event.ctrl => {
3010 apply = true;
3011 }
3012 Key::Named(NamedKey::Enter) => {
3013 // Auto-indent: the line's own leading whitespace,
3014 // one level deeper after an opening brace.
3015 let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
3016 let line: String = editor.buffer.chars().skip(line_start).take(editor.cursor_idx - line_start).collect();
3017 let mut indent: String = line.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
3018 if line.trim_end().ends_with('{') || line.trim_end().ends_with('(') || line.trim_end().ends_with('[') {
3019 indent.push_str(Self::CODE_INDENT);
3020 }
3021 editor.insert_text(&format!("\n{indent}"));
3022 edited = true;
3023 }
3024 Key::Named(NamedKey::Tab) if event.shift => {
3025 // Dedent the current line by one level, or what it has.
3026 let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
3027 let leading = editor.buffer.chars().skip(line_start).take_while(|c| *c == ' ').count().min(Self::CODE_INDENT.len());
3028 if leading > 0 {
3029 let chars: Vec<char> = editor.buffer.chars().collect();
3030 editor.buffer = chars[..line_start].iter().chain(&chars[line_start + leading..]).collect();
3031 editor.cursor_idx = editor.cursor_idx.saturating_sub(leading).max(line_start);
3032 editor.clear_selection();
3033 edited = true;
3034 }
3035 }
3036 Key::Named(NamedKey::Tab) => {
3037 editor.insert_text(Self::CODE_INDENT);
3038 edited = true;
3039 }
3040 Key::Named(NamedKey::Escape) => {
3041 apply = true;
3042 should_unfocus = true;
3043 }
3044 Key::Named(NamedKey::ArrowLeft) => {
3045 editor.move_cursor_left(shift);
3046 }
3047 Key::Named(NamedKey::ArrowRight) => {
3048 editor.move_cursor_right(shift);
3049 }
3050 Key::Named(NamedKey::ArrowUp) => move_vertical(&mut editor, -1),
3051 Key::Named(NamedKey::ArrowDown) => move_vertical(&mut editor, 1),
3052 Key::Named(NamedKey::Home) => {
3053 if shift && editor.select_anchor.is_none() {
3054 editor.select_anchor = Some(editor.cursor_idx);
3055 } else if !shift {
3056 editor.clear_selection();
3057 }
3058 editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
3059 }
3060 Key::Named(NamedKey::End) => {
3061 if shift && editor.select_anchor.is_none() {
3062 editor.select_anchor = Some(editor.cursor_idx);
3063 } else if !shift {
3064 editor.clear_selection();
3065 }
3066 editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
3067 }
3068 Key::Character(s) => {
3069 if event.ctrl {
3070 match s.to_lowercase().as_str() {
3071 "f" => {
3072 editor.move_cursor_right(false);
3073 }
3074 "b" => {
3075 editor.move_cursor_left(false);
3076 }
3077 "p" => move_vertical(&mut editor, -1),
3078 "n" => move_vertical(&mut editor, 1),
3079 "a" if event.shift => {
3080 editor.select_all();
3081 }
3082 "a" => {
3083 editor.clear_selection();
3084 editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
3085 }
3086 "e" => {
3087 editor.clear_selection();
3088 editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
3089 }
3090 "d" => {
3091 edited = editor.delete_forwards();
3092 }
3093 "h" => {
3094 edited = editor.delete_backwards();
3095 }
3096 "k" => {
3097 let current_idx = editor.cursor_idx;
3098 let end_idx = get_line_end(&editor.buffer, current_idx);
3099 let chars: Vec<char> = editor.buffer.chars().collect();
3100 if current_idx < chars.len() {
3101 let delete_end = if chars[current_idx] == '\n' { current_idx + 1 } else { end_idx };
3102 editor.buffer = chars[..current_idx].iter().chain(&chars[delete_end..]).collect();
3103 editor.clear_selection();
3104 edited = true;
3105 }
3106 }
3107 // The clipboard and history chords are the
3108 // context actions, so the chord, the runner's
3109 // routing and the menu row do one thing.
3110 "c" | "x" | "v" | "z" => {
3111 let action = match (s.to_lowercase().as_str(), event.shift) {
3112 ("c", _) => crate::widget::ContextAction::Copy,
3113 ("x", _) => crate::widget::ContextAction::Cut,
3114 ("v", _) => crate::widget::ContextAction::Paste,
3115 ("z", true) => crate::widget::ContextAction::Redo,
3116 _ => crate::widget::ContextAction::Undo,
3117 };
3118 apply_code_action(&mut editor, &mut self.code_history, action);
3119 }
3120 _ => {
3121 handled = false;
3122 }
3123 }
3124 } else {
3125 editor.insert_text(s);
3126 edited = true;
3127 }
3128 }
3129 _ => {
3130 handled = false;
3131 }
3132 }
3133 if edited {
3134 // Typing runs coalesce into one undo step; anything
3135 // structural (a newline, a paste, a deletion of a
3136 // selection) starts a new one.
3137 let plain_char = matches!(&event.logical_key, Key::Character(c) if !event.ctrl && c.chars().count() == 1);
3138 if plain_char {
3139 self.code_history.record_grouped(before, 1);
3140 } else {
3141 self.code_history.record(before);
3142 }
3143 }
3144 if apply {
3145 p.1 = editor.buffer.clone();
3146 }
3147 if should_unfocus {
3148 self.focused_param = None;
3149 self.code_editor = None;
3150 self.code_history.clear();
3151 } else {
3152 self.code_editor = Some(editor);
3153 }
3154 if handled {
3155 return true;
3156 }
3157 }
3158 } else if is_text_row(&p.2) {
3159 if let Some(d) = &mut self.choices[idx] {
3160 if d.open && d.keyboard_input(event, ui) {
3161 if d.take_change() {
3162 if let Some(val) = d.get_value_string() {
3163 if let Some(tb) = &mut self.texts[idx] {
3164 tb.text = val.clone();
3165 tb.edit_buffer = val.clone();
3166 }
3167 p.1 = val;
3168 }
3169 }
3170 return true;
3171 }
3172 }
3173 if let Some(tb) = &mut self.texts[idx] {
3174 if tb.keyboard_input(event, ui) {
3175 if !tb.editing {
3176 p.1 = tb.text.clone();
3177 self.focused_param = None;
3178 } else {
3179 p.1 = tb.edit_buffer.clone();
3180 }
3181 return true;
3182 }
3183 }
3184 } else if p.2.starts_with("choice") {
3185 if let Some(d) = &mut self.choices[idx] {
3186 if d.keyboard_input(event, ui) {
3187 if !d.open {
3188 if let Some(val) = d.get_value_string() {
3189 p.1 = val;
3190 }
3191 self.focused_param = None;
3192 }
3193 return true;
3194 }
3195 }
3196 } else if p.2.starts_with("spinbox") {
3197 if let Some(sb) = &mut self.spinboxes[idx] {
3198 if sb.keyboard_input(event, ui) {
3199 if !sb.editing {
3200 p.1 = sb.value.to_string();
3201 self.focused_param = None;
3202 } else {
3203 p.1 = sb.edit_buffer.clone();
3204 }
3205 return true;
3206 }
3207 }
3208 } else if p.2.starts_with("slider") {
3209 if let Some(s) = &mut self.sliders[idx] {
3210 if s.keyboard_input(event, ui) {
3211 let new_val = s.get_scaled_value();
3212 p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
3213 if !s.editing {
3214 self.focused_param = None;
3215 }
3216 return true;
3217 }
3218 }
3219 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
3220 if let Some(c) = &mut self.colors[idx] {
3221 if c.keyboard_input(event, ui) {
3222 if let Some(val) = c.get_value_string() {
3223 p.1 = val;
3224 }
3225 if !c.editing {
3226 self.focused_param = None;
3227 }
3228 return true;
3229 }
3230 }
3231 } else if is_vec_row(&p.2) {
3232 if let Some(f) = &mut self.float3s[idx] {
3233 if f.keyboard_input(event, ui) {
3234 p.1 = f.value_string();
3235 if f.editing_idx().is_none() {
3236 self.focused_param = None;
3237 }
3238 return true;
3239 }
3240 }
3241 }
3242 }
3243 }
3244 false
3245 }
3246 Event::MouseWheel { delta, x: px, y: py, .. } => {
3247 if !self.visible {
3248 return false;
3249 }
3250 let (px, py) = (*px, *py);
3251 let Some(ui) = ectx.ui.as_deref_mut() else {
3252 return false;
3253 };
3254 let mut changed = false;
3255 // A value row that took the wheel (slider/float3/spinbox),
3256 // whether or not its 2-decimal string ticked over. Gating the
3257 // pane's viewport-scroll fallback on the STRING (`changed`)
3258 // let every sub-tick trackpad event scroll the pane instead —
3259 // a slider-vs-pane tug-of-war that shifted the rows under the
3260 // pointer mid-adjust.
3261 let mut wheel_taken = false;
3262 // Gesture ownership. A gesture the PANE acquired — its first
3263 // event fell on no control and scrolled the rows — stays the
3264 // pane's until the gesture ends (`scroll_gesture_new`), however
3265 // the rows travel under the pointer meanwhile. Without this a
3266 // list scroll ran until a slider's halo or a spinbox row slid
3267 // under the pointer, which then took every remaining event of
3268 // the same gesture and adjusted a value the user never aimed
3269 // at (the Alt+D settings tab, 2026-09-20). A gesture that
3270 // began ON a control is that control's, as before, and one
3271 // nobody claimed (dead space, a control at its limit) is still
3272 // open to spatial acquisition — the band slider's feel.
3273 let pane_owns = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(self_id);
3274 // A trackpad gesture is the PANE's, from anywhere — a
3275 // two-finger swipe is a scroll everywhere on the desktop, and
3276 // a pane that is mostly controls (the Alt+D Settings list)
3277 // was otherwise scrollable only from a label. A wheel notch
3278 // still adjusts the control under the pointer. Unconditional
3279 // since 2026-09-21: the first cut kept hover-adjust for finger
3280 // gestures in a pane whose content fits, which made the main
3281 // params pane feel different from the Settings list depending
3282 // on how many parameters the node had. Finger-end frames (no
3283 // delta) take the same path so the pane's coast starts.
3284 let finger = matches!(delta, MouseScrollDelta::PixelDelta(_))
3285 && matches!(
3286 crate::widget::scroll_motion::current_scroll_phase(),
3287 crate::widget::ScrollPhase::Finger | crate::widget::ScrollPhase::FingerEnd
3288 );
3289 let rects = self.get_param_rects();
3290 // The exception to the finger rule: a VALUE control takes the
3291 // gesture that BEGINS on it, trackpad included, and keeps it
3292 // until the gesture ends — a spinbox by its row, a slider
3293 // and each row of a float3 by the band's own halo
3294 // (`Slider::scroll_hit`), the same zones a wheel notch
3295 // acquires by. A two-finger scroll is aimed like a wheel:
3296 // over a band it is the value being turned, anywhere else
3297 // (the label column, the gaps, a toggle, a section header)
3298 // the pane being scrolled. Spinboxes first (2026-09-28,
3299 // earlier the same day), sliders and float3 rows after: the
3300 // slider's wheel arm was written for trackpad streams all
3301 // along, and the pane's rule kept every one of them from
3302 // reaching it. A gesture the pane or another control
3303 // acquired never lands on a control sliding under the
3304 // pointer — that is the leak the latch exists to stop — so
3305 // the LATCHED control is asked first, and only a gesture
3306 // nobody owns is open to the control under the pointer.
3307 let owner = {
3308 let latched_to = |id| !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(id);
3309 let in_row = |r: (f32, f32, f32, f32)| {
3310 py >= r.1 && py <= r.1 + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width
3311 };
3312 let n = self.display_params.len();
3313 let latched = (0..n).find(|&i| {
3314 self.spinboxes[i].as_ref().is_some_and(|sb| latched_to(sb.base().id()))
3315 || self.sliders[i].as_ref().is_some_and(|sl| latched_to(sl.base().id()))
3316 || self.float3s[i].as_ref().is_some_and(|f| f.wheel_latched(ui))
3317 });
3318 // Under the pointer: the NEAREST control whose zone
3319 // holds it, by the distance to the band's (or the
3320 // spinbox row's) centre line. A band's halo reaches
3321 // past the band by more than the gap between rows, so
3322 // two neighbours' zones hold any point between them,
3323 // and the first in row order is the wrong answer for
3324 // the lower half of that gap.
3325 latched.or_else(|| {
3326 if pane_owns {
3327 return None;
3328 }
3329 (0..n)
3330 .filter_map(|i| {
3331 if rects[i].3 <= 0.0 {
3332 return None;
3333 }
3334 if self.spinboxes[i].is_some() {
3335 let r = rects[i];
3336 return in_row(r).then(|| (i, (py - (r.1 + r.3 * 0.5)).abs()));
3337 }
3338 if let Some(sl) = self.sliders[i].as_ref() {
3339 let (sx, sy, sw, sh) = sl.rect();
3340 let ty = sl.label_strip();
3341 let band = Rect { x: sx, y: sy + ty, width: sw, height: sh - ty };
3342 return sl
3343 .inner()
3344 .scroll_hit(band, px, py)
3345 .then(|| (i, (py - (band.y + band.height * 0.5)).abs()));
3346 }
3347 // A float3's bands, and its trackball when it has one.
3348 self.float3s[i].as_ref().and_then(|f| f.wheel_zone(px, py)).map(|d| (i, d))
3349 })
3350 .min_by(|a, b| a.1.total_cmp(&b.1))
3351 .map(|(i, _)| i)
3352 })
3353 };
3354 let pane_takes = pane_owns || (finger && owner.is_none());
3355 for (i, p) in self.display_params.iter_mut().enumerate() {
3356 if pane_takes {
3357 break;
3358 }
3359 // A gesture a control owns is nobody else's: the pointer
3360 // may have drifted onto another band's halo since it began.
3361 if owner.is_some() && owner != Some(i) {
3362 continue;
3363 }
3364 if p.2.starts_with("slider") {
3365 // The capture zone is the slider's own shape halo
3366 // (`Slider::scroll_hit` — the band plus the traveling
3367 // swell, inset), so scrolls off the shape fall through
3368 // to the pane's viewport scroll below.
3369 // `owner` made the zone test: this slider is latched
3370 // (mid-gesture the slider that acquired the scroll
3371 // keeps it — its halo travels away from the pointer
3372 // as the value moves), or its band is the nearest
3373 // whose halo holds the pointer.
3374 let in_zone = owner == Some(i);
3375 if in_zone {
3376 if let Some(s) = &mut self.sliders[i] {
3377 let was_scroll = s.scroll_enabled;
3378 s.set_scroll(true);
3379 // Ungated: the in_zone halo above already gated
3380 // spatially, and the adapter's rect gate would
3381 // clip the halo's fringe outside the row rect.
3382 if s.mouse_wheel_ungated(delta, px, py, ui) {
3383 wheel_taken = true;
3384 let new_val = s.get_scaled_value();
3385 let old_val = &p.1;
3386 let new_val_str = format!("{:.*}", slider_decimals(&p.2), new_val);
3387 if *old_val != new_val_str {
3388 p.1 = new_val_str;
3389 changed = true;
3390 }
3391 }
3392 s.set_scroll(was_scroll);
3393 }
3394 }
3395 } else if is_vec_row(&p.2) {
3396 if owner == Some(i) {
3397 // The group's rows apply the slider-row contract
3398 // themselves (band halo / gesture latch, else the
3399 // row strip; ungated forward).
3400 if let Some(f) = &mut self.float3s[i] {
3401 if f.wheel(delta, px, py, ui) {
3402 wheel_taken = true;
3403 let new_val_str = f.value_string();
3404 if p.1 != new_val_str {
3405 p.1 = new_val_str;
3406 changed = true;
3407 }
3408 }
3409 }
3410 }
3411 } else if p.2.starts_with("spinbox")
3412 && owner == Some(i) {
3413 if let Some(sb) = &mut self.spinboxes[i] {
3414 // The widget's own wheel arm: one step per
3415 // notch, fractions carried between events.
3416 // Ungated — `owner` already tested the
3417 // row, and a latched gesture may have
3418 // drifted off it.
3419 wheel_taken = true;
3420 ui.scroll_initiate_widget_id = Some(sb.base().id());
3421 sb.mouse_wheel_ungated(delta, px, py, ui);
3422 let new_val_str = sb.value.to_string();
3423 if p.1 != new_val_str {
3424 p.1 = new_val_str;
3425 changed = true;
3426 }
3427 }
3428 }
3429 }
3430
3431 // The legacy tail's `self.hit_test(px, py, ctx)`: occlusion via the adapter's
3432 // address, then rect-or-popover containment.
3433 let mut swallowed = changed || wheel_taken;
3434 if !ui.is_coordinate_covered(self_id, px, py) {
3435 let in_rect = px >= self.rect.x
3436 && px <= self.rect.x + self.rect.width
3437 && py >= self.rect.y
3438 && py <= self.rect.y + self.rect.height;
3439 let in_popover = self.own_popover_rect().is_some_and(|(rx, ry, rw, rh)| {
3440 px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
3441 });
3442 if in_rect || in_popover {
3443 if !changed && !wheel_taken {
3444 if crate::scroll_debug() {
3445 eprintln!("[scroll] params: PANE-SCROLL fallback at ({px:.0},{py:.0})");
3446 }
3447 // The pane takes the gesture (see `pane_owns`).
3448 ui.scroll_initiate_widget_id = Some(self_id);
3449 let max_scroll = (self.content_h - self.rect.height).max(0.0);
3450 self.scroll_motion.reconcile(0.0, self.scroll_y);
3451 let moved = self.scroll_motion.apply(
3452 delta,
3453 (crate::widget::LINE_PX, crate::widget::LINE_PX),
3454 crate::widget::Bounds::max(0.0),
3455 crate::widget::Bounds::max(max_scroll),
3456 );
3457 self.scroll_y = self.scroll_motion.y.pos();
3458 if moved {
3459 self.update_slider_rects();
3460 self.activity.bump();
3461 self.recompute_scrollbar_raised();
3462 self.rehover_after_scroll(ui);
3463 }
3464 }
3465 // An opaque pane swallows EVERY wheel over it, whether
3466 // anything moved or not: returning false would hand the
3467 // event to whatever lies BEHIND the plate — the designer
3468 // routes unhandled wheels to the 3D viewport, whose rect
3469 // is the whole window in the floating layout, so a
3470 // near-miss on a slider would orbit the camera through
3471 // the pane.
3472 swallowed = true;
3473 }
3474 }
3475
3476 swallowed
3477 }
3478 _ => false,
3479 }
3480 }
3481 }
3482
3483 /// Display precision for a slider row from the type string's optional 4th
3484 /// segment (`slider:min:max:decimals`); 2 when absent — the pane-wide
3485 /// historical default.
3486 fn slider_decimals(ptype: &str) -> usize {
3487 ptype.split(':').nth(3).and_then(|s| s.parse().ok()).unwrap_or(2)
3488 }
3489
3490 /// Whether a row of type `t` is a vector of sliders: `float2`, `float3` or
3491 /// `float4`, each `floatN:lo:hi` — the one [`Float3`] group with that many
3492 /// rows ([`Float3::set_components`]).
3493 fn is_vec_row(t: &str) -> bool {
3494 t.starts_with("float2") || t.starts_with("float3") || t.starts_with("float4")
3495 }
3496
3497 /// A SEPARATOR row: a hairline between two runs of rows, a pixel tall with
3498 /// the row gap either side, that nothing focuses, hovers or edits — what a
3499 /// host puts between groups of parameters that are about different things.
3500 /// Its key and value mean nothing; a host writing rows back skips it.
3501 pub const SEPARATOR: &str = "separator";
3502
3503 /// Whether a slider or vector row's range is SOFT: a `soft` segment
3504 /// anywhere after the range (`slider:lo:hi:dec:soft`,
3505 /// `float3:lo:hi:trackball:soft`). A value typed past an end widens the
3506 /// row's range rather than being clamped to it (`Slider::set_soft`); the
3507 /// host is expected to choose the range around the value.
3508 fn is_soft_row(t: &str) -> bool {
3509 t.split(':').skip(3).any(|s| s == "soft")
3510 }
3511
3512 /// How many rows a vector row has: the digit after `float`.
3513 fn vec_row_n(t: &str) -> usize {
3514 t.get(5..6).and_then(|d| d.parse().ok()).unwrap_or(3)
3515 }
3516
3517 fn parse_slider_range(ptype: &str) -> (f32, f32) {
3518 if ptype.starts_with("slider:") || (is_vec_row(ptype) && ptype.contains(':')) {
3519 let parts: Vec<&str> = ptype.split(':').collect();
3520 if parts.len() >= 3 {
3521 if let (Ok(min), Ok(max)) = (parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
3522 return (min, max);
3523 }
3524 }
3525 }
3526 (0.0, 2.0)
3527 }
3528
3529 /// `text` whole if it measures within `avail` px, else its longest head
3530 /// with "..." that does.
3531 fn fit_tail(text: &str, avail: f32, family: &str, size: f32) -> String {
3532 use crate::widget::display::{measure_text_width, truncate_tail};
3533 let fits = |t: &str| measure_text_width(t, family, size) <= avail;
3534 if fits(text) {
3535 return text.to_string();
3536 }
3537 (0..text.chars().count())
3538 .rev()
3539 .map(|n| truncate_tail(text, n))
3540 .find(|t| fits(t))
3541 .unwrap_or_default()
3542 }
3543
3544 fn parse_hex_to_rgb(s: &str) -> Option<[u8; 3]> {
3545 crate::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
3546 }
3547
3548 fn parse_hex_to_rgba(s: &str) -> Option<[u8; 4]> {
3549 crate::color::parse_hex_bytes(s)
3550 }
3551
3552 fn parse_spinbox_range(ptype: &str) -> (i32, i32, i32) {
3553 if ptype.starts_with("spinbox:") {
3554 let parts: Vec<&str> = ptype.split(':').collect();
3555 if parts.len() >= 4 {
3556 if let (Ok(min), Ok(max), Ok(step)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>(), parts[3].parse::<i32>()) {
3557 return (min, max, step);
3558 }
3559 } else if parts.len() == 3 {
3560 if let (Ok(min), Ok(max)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>()) {
3561 return (min, max, 1);
3562 }
3563 }
3564 }
3565 (0, 10000, 1)
3566 }
3567
3568 /// A vector row's text as `n` normalized values: each component over the
3569 /// row's range, mid-range where the text has none.
3570 fn parse_vec_value(val_str: &str, min: f32, max: f32, n: usize) -> Vec<f32> {
3571 let mut out = vec![0.5; n];
3572 let parts: Vec<&str> = val_str
3573 .split([':', ',', ' '])
3574 .filter(|s| !s.is_empty())
3575 .collect();
3576 for i in 0..n {
3577 if i < parts.len() {
3578 if let Ok(v) = parts[i].parse::<f32>() {
3579 let range = max - min;
3580 if range != 0.0 {
3581 out[i] = ((v - min) / range).clamp(0.0, 1.0);
3582 } else {
3583 out[i] = 0.0;
3584 }
3585 }
3586 }
3587 }
3588 out
3589 }
3590
3591 impl ParamController for ParametersBg {
3592 fn node_params(&self) -> Vec<(String, String, String)> {
3593 self.display_params.clone()
3594 }
3595
3596 fn set_display_params(&mut self, params: &[(String, String, String)]) {
3597 let mut layout_changed = self.display_params.len() != params.len();
3598 if !layout_changed {
3599 for (p_old, p_new) in self.display_params.iter().zip(params.iter()) {
3600 if p_old.0 != p_new.0 || p_old.2 != p_new.2 {
3601 layout_changed = true;
3602 break;
3603 }
3604 }
3605 }
3606
3607 if layout_changed {
3608 self.scroll_y = 0.0;
3609 self.display_params = params.to_vec();
3610 self.focused_param = None;
3611 // Re-read at every rebuild, so a reloaded style takes effect the
3612 // next time the rows are built, and geometry and widgets agree;
3613 // then decided against the cached rect's width, as a resize
3614 // decides it (`apply_label_layout`).
3615 self.inline_pref = crate::layout::param_labels_inline();
3616 self.inline_labels = self.decide_inline();
3617 let inline = self.inline_labels;
3618 self.sliders = self.display_params.iter().map(|p| {
3619 if p.2.starts_with("slider") {
3620 let val = p.1.parse::<f32>().unwrap_or(0.0);
3621 let (min, max) = parse_slider_range(&p.2);
3622 let t = if max - min != 0.0 {
3623 ((val - min) / (max - min)).clamp(0.0, 1.0)
3624 } else {
3625 0.0
3626 };
3627 let s = Slider::new().with_value(t).with_range(min, max).with_readout(true).with_decimals(slider_decimals(&p.2)).with_soft(is_soft_row(&p.2));
3628 Some(if inline { s } else { s.with_label(&p.0) })
3629 } else {
3630 None
3631 }
3632 }).collect();
3633 self.float3s = self.display_params.iter().map(|p| {
3634 if is_vec_row(&p.2) {
3635 let (min, max) = parse_slider_range(&p.2);
3636 let n = vec_row_n(&p.2);
3637 let mut f = Float3::new().with_components(n).with_range(min, max).with_trackball(Self::has_trackball(&p.2));
3638 f.set_soft(is_soft_row(&p.2));
3639 f.set_values_n(&parse_vec_value(&p.1, min, max, n));
3640 f.set_view(self.trackball_view);
3641 Some(if inline { f } else { f.with_label(&p.0) })
3642 } else {
3643 None
3644 }
3645 }).collect();
3646 self.spinboxes = self.display_params.iter().map(|p| {
3647 if p.2.starts_with("spinbox") {
3648 let (min, max, step) = parse_spinbox_range(&p.2);
3649 let val = p.1.parse::<i32>().unwrap_or(min);
3650 let sb = Spinbox::new(val, min, max, step);
3651 Some(if inline { sb } else { sb.with_label(&p.0) })
3652 } else {
3653 None
3654 }
3655 }).collect();
3656 self.buttons = self.display_params.iter().map(|p| {
3657 if p.2 == "button" {
3658 // Left-aligned, like the toggles below: the rows form one column, and
3659 // centered labels made each row's text start at a different x.
3660 Some(Button::new(0.0, 0.0, 0.0, 0.0).with_label(&p.0).with_left_align(true))
3661 } else {
3662 None
3663 }
3664 }).collect();
3665 self.choices = self.display_params.iter().map(|p| {
3666 if p.2.starts_with("choice:") {
3667 let options_str = p.2.strip_prefix("choice:").unwrap_or("");
3668 let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3669 let selected = options.iter().position(|o| o == &p.1).unwrap_or(0);
3670 let d = Dropdown::new(options, selected);
3671 Some(if inline { d } else { d.with_label(&p.0) })
3672 } else if p.2.starts_with("textpick:") {
3673 // The text row's completion picker: a menu-button Dropdown
3674 // (fixed glyph, re-fires on repeat picks) beside the box.
3675 let options: Vec<String> = p.2.strip_prefix("textpick:").unwrap_or("")
3676 .split(',')
3677 .filter(|s| !s.is_empty())
3678 .map(|s| s.to_string())
3679 .collect();
3680 if options.is_empty() {
3681 None
3682 } else {
3683 // A raised face: the picker reads as a BUTTON sitting
3684 // in the box's recess, not a bare glyph beside it.
3685 Some(
3686 Dropdown::new(options, 0)
3687 .with_custom_display_text("")
3688 .with_raised(true),
3689 )
3690 }
3691 } else {
3692 None
3693 }
3694 }).collect();
3695 self.texts = self.display_params.iter().map(|p| {
3696 if is_text_row(&p.2) {
3697 let tb = TextBox::new(p.1.clone());
3698 Some(if inline { tb } else { tb.with_label(&p.0) })
3699 } else {
3700 None
3701 }
3702 }).collect();
3703 self.toggles = self.display_params.iter().map(|p| {
3704 if p.2 == "toggle" || p.2 == "checkbox" {
3705 let on = p.1.trim().to_lowercase() == "true";
3706 let mut t = Toggle::new().with_label(&p.0).with_left_align(true);
3707 t.set_toggled(on);
3708 Some(t)
3709 } else {
3710 None
3711 }
3712 }).collect();
3713 self.colors = self.display_params.iter().map(|p| {
3714 if p.2 == "rgba" {
3715 // Alpha-carrying param: the full picker, 8-digit hex.
3716 let col = parse_hex_to_rgba(&p.1).unwrap_or([255, 255, 255, 255]);
3717 let c = ColorSelector::new_rgba(col);
3718 Some(if inline { c } else { c.with_label(&p.0) })
3719 } else if p.2.starts_with("color") || p.2 == "rgb" {
3720 let col = parse_hex_to_rgb(&p.1).unwrap_or([255, 255, 255]);
3721 let c = ColorSelector::new(col);
3722 Some(if inline { c } else { c.with_label(&p.0) })
3723 } else {
3724 None
3725 }
3726 }).collect();
3727 self.ramps = self.display_params.iter().map(|p| {
3728 if p.2 == "ramp" {
3729 let mut rp = Ramp::new();
3730 rp.inner_mut().set_spec(&p.1);
3731 Some(rp)
3732 } else {
3733 None
3734 }
3735 }).collect();
3736 } else {
3737 for (i, p_new) in params.iter().enumerate() {
3738 if Some(i) != self.focused_param && Some(i) != self.dragging_param {
3739 self.display_params[i].1 = p_new.1.clone();
3740 if let Some(ref mut s) = self.sliders[i] {
3741 let (min, max) = parse_slider_range(&p_new.2);
3742 // Idempotence guard: hosts push params straight back
3743 // after every sync, and re-seeding from the 2-decimal
3744 // string quantizes away the slider's sub-tick state —
3745 // mid-scroll that snaps the value BACKWARD between
3746 // wheel events/glide ticks (visible as jitter). Only
3747 // re-seed when the incoming string says something the
3748 // current value doesn't (a genuinely external change).
3749 let cur_str = format!("{:.*}", slider_decimals(&p_new.2), min + s.value * (max - min));
3750 if cur_str != p_new.1 {
3751 let val = p_new.1.parse::<f32>().unwrap_or(0.0);
3752 let t = if max - min != 0.0 {
3753 ((val - min) / (max - min)).clamp(0.0, 1.0)
3754 } else {
3755 0.0
3756 };
3757 s.set_value(t);
3758 }
3759 } else if let Some(ref mut f) = self.float3s[i] {
3760 let (min, max) = parse_slider_range(&p_new.2);
3761 // Same round-trip guard as the slider row.
3762 let cur_str = f.value_string();
3763 if cur_str != p_new.1 {
3764 let n = vec_row_n(&p_new.2);
3765 if f.components() != n {
3766 f.set_components(n);
3767 }
3768 f.set_values_n(&parse_vec_value(&p_new.1, min, max, n));
3769 }
3770 } else if let Some(ref mut sb) = self.spinboxes[i] {
3771 if !sb.editing {
3772 let (min, _max, _step) = parse_spinbox_range(&p_new.2);
3773 let val = p_new.1.parse::<i32>().unwrap_or(min);
3774 sb.value = val;
3775 }
3776 } else if let Some(ref mut d) = self.choices[i] {
3777 if !d.open {
3778 if let Some(options_str) = p_new.2.strip_prefix("choice:") {
3779 let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3780 if d.options != options {
3781 d.options = options.clone();
3782 }
3783 if let Some(idx) = options.iter().position(|o| o == &p_new.1) {
3784 d.selected = idx;
3785 }
3786 }
3787 }
3788 } else if let Some(ref mut tb) = self.texts[i] {
3789 if !tb.editing {
3790 tb.set_value_string(&p_new.1);
3791 }
3792 } else if let Some(ref mut t) = self.toggles[i] {
3793 let on = p_new.1.trim().to_lowercase() == "true";
3794 t.set_toggled(on);
3795 } else if let Some(ref mut c) = self.colors[i] {
3796 if !c.editing {
3797 c.set_value_string(&p_new.1);
3798 }
3799 } else if let Some(ref mut rp) = self.ramps[i] {
3800 if !rp.inner().is_dragging_key {
3801 rp.set_value_string(&p_new.1);
3802 }
3803 }
3804 }
3805 }
3806 }
3807 self.refresh_scroll_metrics();
3808 }
3809 }
3810
3811 /// One clipboard, selection or history action over a code editor and its
3812 /// history — the body [`ParametersBg::code_action`] and the editor's own
3813 /// chords share, free of `self` so a caller holding a row borrow can use it.
3814 fn apply_code_action(
3815 editor: &mut TextEditorState,
3816 history: &mut crate::history::History<TextEditorState>,
3817 action: crate::widget::ContextAction,
3818 ) {
3819 use crate::widget::ContextAction;
3820 let before = editor.clone();
3821 let mut edited = false;
3822 match action {
3823 ContextAction::Undo => {
3824 if let Some(prev) = history.undo(editor.clone()) {
3825 *editor = prev;
3826 }
3827 }
3828 ContextAction::Redo => {
3829 if let Some(next) = history.redo(editor.clone()) {
3830 *editor = next;
3831 }
3832 }
3833 ContextAction::SelectAll => editor.select_all(),
3834 ContextAction::Copy => {
3835 if let Some(text) = editor.selected_text() {
3836 crate::widget::clipboard::copy_to_clipboard(&text);
3837 }
3838 }
3839 ContextAction::Cut => {
3840 if let Some(text) = editor.selected_text() {
3841 crate::widget::clipboard::copy_to_clipboard(&text);
3842 edited = editor.delete_backwards();
3843 }
3844 }
3845 ContextAction::Paste => {
3846 if let Some(text) = crate::widget::clipboard::read_from_clipboard() {
3847 editor.insert_text(&text);
3848 edited = true;
3849 }
3850 }
3851 _ => {}
3852 }
3853 if edited {
3854 history.record(before);
3855 }
3856 }
3857
3858 fn get_cursor_line_col(buffer: &str, cursor_idx: usize) -> (usize, usize) {
3859 let mut cur_line = 0;
3860 let mut cur_col = 0;
3861 for (count, c) in buffer.chars().enumerate() {
3862 if count == cursor_idx {
3863 return (cur_line, cur_col);
3864 }
3865 if c == '\n' {
3866 cur_line += 1;
3867 cur_col = 0;
3868 } else {
3869 cur_col += 1;
3870 }
3871 }
3872 (cur_line, cur_col)
3873 }
3874
3875 fn map_2d_to_1d(buffer: &str, line: usize, col: usize) -> usize {
3876 let mut target_line = line;
3877 let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3878 if lines.is_empty() {
3879 return 0;
3880 }
3881 if target_line >= lines.len() {
3882 target_line = lines.len() - 1;
3883 }
3884 let mut target_col = col;
3885 if target_col > lines[target_line].len() {
3886 target_col = lines[target_line].len();
3887 }
3888 let mut index = 0;
3889 for i in 0..target_line {
3890 index += lines[i].len() + 1; // +1 for the '\n'
3891 }
3892 index += target_col;
3893 index
3894 }
3895
3896 fn get_line_start(buffer: &str, cursor_idx: usize) -> usize {
3897 let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3898 map_2d_to_1d(buffer, line, 0)
3899 }
3900
3901 fn get_line_end(buffer: &str, cursor_idx: usize) -> usize {
3902 let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3903 let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3904 if lines.is_empty() {
3905 return 0;
3906 }
3907 let line_len = if line < lines.len() {
3908 lines[line].len()
3909 } else {
3910 lines[lines.len() - 1].len()
3911 };
3912 map_2d_to_1d(buffer, line, line_len)
3913 }
3914
3915 #[cfg(test)]
3916 mod tests {
3917 use super::*;
3918 use crate::context::UiContext;
3919
3920 fn panel_with(params: &[(&str, &str, &str)]) -> Adapted<ParametersBg> {
3921 let mut p = ParametersBg::new();
3922 let params: Vec<(String, String, String)> = params
3923 .iter()
3924 .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
3925 .collect();
3926 ParamController::set_display_params(&mut *p, ¶ms);
3927 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
3928 p
3929 }
3930
3931 /// The textpick text-row variant: a TextBox AND a menu-button Dropdown
3932 /// share the row — the picker takes a right-edge sliver, its options come
3933 /// from the type string, and a plain text row builds no picker.
3934 /// A textpick row's picker centres its arrow, and every other dropdown
3935 /// row's arrow stands in the same column: the picker is a trigger's
3936 /// arrow slot (`dropdown::arrow_slot`).
3937 #[test]
3938 fn a_pickers_arrow_lines_up_with_a_dropdowns() {
3939 let p = panel_with(&[
3940 ("Name", "mass", "textpick:Norm,UV,Pos,Col"),
3941 ("Type", "Float", "choice:Float,Float3,Int"),
3942 ]);
3943 let arrow = |i: usize| {
3944 let d = p.choices[i].as_ref().unwrap();
3945 let (x, y, w, h) = d.rect();
3946 d.inner().arrow_rect(crate::scene::layout::Rect { x, y, width: w, height: h }).x
3947 };
3948 assert!((arrow(0) - arrow(1)).abs() < 1e-3, "picker {} vs dropdown {}", arrow(0), arrow(1));
3949 }
3950
3951 /// The pane draws its controls' glyphs: a dropdown's and a picker's
3952 /// arrow, a spinbox's minus and plus. The pane paints its rows' chrome
3953 /// itself and took only their TEXT, so when the symbols became glyphs
3954 /// (2026-10-05) every control in the pane lost them. Skipped where the
3955 /// icon set is not checked out (no glyph uploads, nothing to name).
3956 #[test]
3957 fn the_pane_draws_its_controls_glyphs() {
3958 if !std::path::Path::new(&crate::icons_dir()).join("chevron-down.svg").is_file() {
3959 eprintln!("skipped: no icon set");
3960 return;
3961 }
3962 let p = panel_with(&[
3963 ("Name", "mass", "textpick:Norm,UV,Pos,Col"),
3964 ("Type", "Float", "choice:Float,Float3,Int"),
3965 ("Count", "3", "spinbox:0:10:1"),
3966 ]);
3967 let ui = UiContext::new();
3968 let mut pc = PaintCtx::new();
3969 let r = Rect { x: 0.0, y: 0.0, width: 300.0, height: 400.0 };
3970 Paint::paint_ui(&*p, &ui, r, &mut pc);
3971 let names: Vec<String> = pc
3972 .finish()
3973 .items
3974 .into_iter()
3975 .filter_map(|item| match item.prim {
3976 crate::scene::paint::Prim::Image { image, .. } => crate::icon_source(image).map(|(n, _, _)| n),
3977 _ => None,
3978 })
3979 .collect();
3980 let count = |n: &str| names.iter().filter(|m| *m == n).count();
3981 assert_eq!(count("chevron-down"), 2, "the picker's and the dropdown's arrows: {names:?}");
3982 assert_eq!(count("minus"), 1, "the spinbox's minus: {names:?}");
3983 assert_eq!(count("plus"), 1, "the spinbox's plus: {names:?}");
3984 }
3985
3986 #[test]
3987 fn textpick_rows_carry_a_picker() {
3988 let p = panel_with(&[
3989 ("Attribute Name", "mass", "textpick:Norm,UV,Pos,Col"),
3990 ("Plain", "x", "text"),
3991 ("Empty", "y", "textpick:"),
3992 ]);
3993 assert!(p.texts[0].is_some(), "textpick keeps its TextBox");
3994 let d = p.choices[0].as_ref().expect("textpick builds the picker");
3995 assert_eq!(d.options, ["Norm", "UV", "Pos", "Col"]);
3996 assert!(d.custom_display_text.is_some(), "menu-button mode");
3997 assert!(p.choices[1].is_none(), "plain text has no picker");
3998 assert!(p.texts[2].is_some() && p.choices[2].is_none(), "no options, no picker");
3999
4000 // Layout: the picker is the field's right end — the box stops at
4001 // the seam, square there, and the picker reaches the field's outer
4002 // edge on the right, top and bottom, as a dropdown trigger does.
4003 let (tx, ty, tw, th) = p.texts[0].as_ref().unwrap().rect();
4004 let (dx, dy, dw, dh) = p.choices[0].as_ref().unwrap().rect();
4005 // The box ends where the picker begins, at the seam; the picker
4006 // is a wall wider than PICK_W, the wall being the lip its face
4007 // rises out of at the seam, so the face is PICK_W wide.
4008 let label = p.texts[0].as_ref().unwrap().label_strip();
4009 let depth = crate::layout::bevel_width().min((th - label) * 0.2);
4010 assert!((dw - (PICK_W + depth)).abs() < 1e-3, "the picker is PICK_W and a wall: {dw}");
4011 assert!((dx - (tx + tw)).abs() < 1e-3, "the picker begins where the box ends: {dx} vs {}", tx + tw);
4012 assert!(d.center_arrow, "the picker's arrow stands in its middle, its padding even");
4013 assert_eq!((dy, dy + dh), (ty + label, ty + th), "the picker spans the field's band, edge to edge");
4014 assert!(p.texts[0].as_ref().unwrap().inner().joined_right, "the box is square at the seam");
4015 assert!(!p.texts[1].as_ref().unwrap().inner().joined_right, "a plain text row is not");
4016 // ONE field: one outline round the box and the picker, its seam at
4017 // the picker — not a well and a trough each turning its own corner
4018 // there.
4019 let rr = crate::layout::textbox_corner_radius();
4020 let fields = p.inner().fields();
4021 assert_eq!(fields.len(), 1, "{fields:?}");
4022 let f = fields[0].rect;
4023 assert_eq!((f.x, f.y, f.x + f.width, f.y + f.height), (tx, dy, dx + dw, dy + dh), "the field spans box and picker");
4024 assert_eq!(fields[0].radii, (rr, rr, rr, rr), "its own radius at all four corners");
4025 assert_eq!(fields[0].run_span(), Some((dx, dx + dw)), "the run is the picker, to the field's end");
4026 assert!(p.inner().reliefs().iter().all(|r| !(r.0 == tx && r.2 == tw)), "nor the box a well of its own");
4027
4028 // Both text variants lay out at the same row height.
4029 assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
4030
4031 // The menu anchors off the WHOLE field: the popover spans at least
4032 // the box width and hangs below it, not off the button sliver.
4033 let anchor = p.choices[0].as_ref().unwrap().popover_anchor.expect("anchor set");
4034 assert_eq!(anchor.x, tx);
4035 assert!((anchor.width - (dx + dw - tx)).abs() < 1e-3, "the whole field");
4036 let d = p.choices[0].as_ref().unwrap();
4037 let (px_, py_, pw, _ph) = d.popover_geom(crate::scene::layout::Rect {
4038 x: dx, y: dy, width: dw, height: dh,
4039 });
4040 assert_eq!(px_, tx, "menu left-aligns with the box");
4041 assert!(pw >= tw, "menu at least as wide as the box");
4042 assert!(py_ >= ty + th - 1.0, "menu hangs below the box");
4043 }
4044
4045 /// A dropdown trigger's edge is a field's run: its own paint and the
4046 /// pane's re-emission both draw a field that is all run, so its edge
4047 /// matches the picker at the end of a text row and the -/+ run of a
4048 /// spinbox. Never a trough, whose outer half differed.
4049 #[test]
4050 fn a_dropdown_trigger_wears_the_runs_edge() {
4051 use crate::scene::paint::{PaintCtx, Prim};
4052 if !crate::layout::control_relief() {
4053 return;
4054 }
4055 let p = panel_with(&[("Mode", "b", "choice:a,b,c")]);
4056 let d = p.choices[0].as_ref().expect("a dropdown");
4057 let (x, y, w, h) = d.rect();
4058 let ty = d.label_strip();
4059 let fields = p.inner().fields();
4060 assert_eq!(fields.len(), 1, "{fields:?}");
4061 assert_eq!(fields[0].rect, Rect { x, y: y + ty, width: w, height: h - ty }, "on the trigger's band");
4062 assert!(!fields[0].has_well(), "all run, no well");
4063 let mut pc = PaintCtx::new();
4064 Paint::paint(d.inner(), Rect { x, y: y + ty, width: w, height: h - ty }, &mut pc);
4065 let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
4066 assert!(prims.iter().any(|p| matches!(p, Prim::Field { rect, split, .. } if *split < rect.x - 100.0)), "{prims:?}");
4067 assert!(!prims.iter().any(|p| matches!(p, Prim::Trough { .. })), "no trough");
4068 }
4069
4070 #[test]
4071 fn param_controller_roundtrip_and_row_widgets() {
4072 let p = panel_with(&[
4073 ("Size", "1.00", "slider:0:2"),
4074 ("Mode", "b", "choice:a,b,c"),
4075 ("On", "true", "checkbox"),
4076 ]);
4077 assert_eq!(ParamController::node_params(&*p).len(), 3);
4078 assert!(p.sliders[0].is_some() && p.choices[1].is_some() && p.toggles[2].is_some());
4079 // Rows were laid out from the cached rect: the slider's control rect
4080 // is the row less the label column when the labels are inline (the
4081 // default), the whole row when they are stacked.
4082 let lw = p.inner().label_col_w();
4083 if p.inner().inline_labels {
4084 assert!(lw > 0.0, "inline labels reserve a column");
4085 let labels = p.inner().own_text_labels();
4086 let size = labels.iter().find(|l| l.text == "Size").expect("the pane draws the inline label");
4087 assert_eq!(size.x, ROW_X_INSET, "the label sits at the row's left edge");
4088 } else {
4089 assert_eq!(lw, 0.0);
4090 }
4091 let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
4092 assert_eq!(
4093 (sx, sw),
4094 (ROW_X_INSET + lw, 300.0 - 2.0 * ROW_X_INSET - lw),
4095 "control rect derives from the assigned rect and the label column"
4096 );
4097 }
4098
4099 /// A pane too narrow for a label column stacks its labels above the
4100 /// controls, and takes them back into the column when it widens: the
4101 /// controls are relabelled, the column goes, the rows grow by the label
4102 /// strip. The threshold is the control's width, not the pane's.
4103 #[test]
4104 fn a_narrow_pane_stacks_its_labels_and_a_wide_one_puts_them_back() {
4105 let mut p = panel_with(&[("Size", "1.00", "slider:0:2"), ("Mode", "b", "choice:a,b,c"), ("On", "true", "checkbox")]);
4106 if !p.inner().inline_pref {
4107 return; // a stacked preference has nothing to re-flow
4108 }
4109 let strip = crate::layout::control_label_strip();
4110 // The COLUMN label sits exactly at the row's left edge; a control's
4111 // own detached label is inset from the control's edge, so the two
4112 // are told apart by x.
4113 let labels_in_column = |p: &Adapted<ParametersBg>| {
4114 p.inner().own_text_labels().iter().any(|l| l.text == "Size" && l.x == ROW_X_INSET)
4115 };
4116
4117 // 300 wide: inline. The slider carries no label; the pane draws it.
4118 assert!(p.inner().inline_labels);
4119 let col = p.inner().label_col_w();
4120 assert!(col > 0.0);
4121 assert!(labels_in_column(&p));
4122 assert!(p.sliders[0].as_ref().unwrap().base().label.is_none());
4123 let (_, _, sw_inline, _) = p.sliders[0].as_ref().unwrap().rect();
4124 let h_inline = p.get_param_rects()[0].3;
4125 // The threshold is the slider's TRACK: its rect less the readout.
4126 let chrome = crate::widget::input::slider::Slider::readout_chrome();
4127 assert!(sw_inline - chrome >= ParametersBg::MIN_INLINE_TRACK_W, "the fixture starts with room: {sw_inline}");
4128
4129 // Narrow it until the TRACK would fall under the minimum: stacked,
4130 // though the control's rect is still well over it.
4131 let narrow = 2.0 * ROW_X_INSET + col + chrome + ParametersBg::MIN_INLINE_TRACK_W - 1.0;
4132 WidgetHost::set_rect(&mut p, 0.0, 0.0, narrow, 400.0);
4133 assert!(!p.inner().inline_labels, "under the minimum the labels stack");
4134 assert_eq!(p.inner().label_col_w(), 0.0, "no column");
4135 assert!(!labels_in_column(&p), "the pane draws no column label");
4136 assert_eq!(p.sliders[0].as_ref().unwrap().base().label.as_deref(), Some("Size"), "the slider carries its label");
4137 assert_eq!(p.choices[1].as_ref().unwrap().base().label.as_deref(), Some("Mode"));
4138 let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
4139 assert_eq!((sx, sw), (ROW_X_INSET, narrow - 2.0 * ROW_X_INSET), "the control spans the row");
4140 // The row grows for the label band — by the strip, less the floor
4141 // the inline row's height table clamps at.
4142 let h_stacked = p.get_param_rects()[0].3;
4143 assert!(h_stacked > h_inline && h_stacked <= h_inline + strip, "inline {h_inline}, stacked {h_stacked}, strip {strip}");
4144 // The toggle carried its label all along.
4145 assert_eq!(p.toggles[2].as_ref().unwrap().base().label.as_deref(), Some("On"));
4146
4147 // One pixel wider than the minimum: inline again, labels off the controls.
4148 WidgetHost::set_rect(&mut p, 0.0, 0.0, narrow + 1.0, 400.0);
4149 assert!(p.inner().inline_labels, "at the minimum the column comes back");
4150 assert!(labels_in_column(&p));
4151 assert!(p.sliders[0].as_ref().unwrap().base().label.is_none(), "the label left the slider");
4152 assert!(p.choices[1].as_ref().unwrap().base().label.is_none());
4153 assert_eq!(p.get_param_rects()[0].3, h_inline);
4154 let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
4155 assert_eq!((sx, sw), (ROW_X_INSET + col, narrow + 1.0 - 2.0 * ROW_X_INSET - col));
4156
4157 // A rebuild under a narrow rect builds stacked from the start.
4158 let rows: Vec<(String, String, String)> = vec![("Size".into(), "1.00".into(), "slider:0:2".into()), ("Width".into(), "2.00".into(), "slider:0:2".into())];
4159 WidgetHost::set_rect(&mut p, 0.0, 0.0, narrow, 400.0);
4160 ParamController::set_display_params(&mut *p, &rows);
4161 assert!(!p.inner().inline_labels);
4162 assert_eq!(p.sliders[1].as_ref().unwrap().base().label.as_deref(), Some("Width"));
4163
4164 // The shortest track decides. At one width: a pane of controls with
4165 // no track keeps its column, a slider's readout costs it the
4166 // column, and a float3's axis letters cost it sooner still.
4167 let at = |rows: &[(&str, &str, &str)], width: f32| {
4168 let mut p = panel_with(rows);
4169 WidgetHost::set_rect(&mut p, 0.0, 0.0, width, 400.0);
4170 p.inner().inline_labels
4171 };
4172 let spin = [("Size", "3", "spinbox:0:10")];
4173 let slider = [("Size", "1.00", "slider:0:2")];
4174 let float3 = [("Size", "0:0:0", "float3:-1:1")];
4175 let base = 2.0 * ROW_X_INSET + col + ParametersBg::MIN_INLINE_TRACK_W;
4176 assert!(at(&spin, base), "a spinbox is measured whole");
4177 assert!(!at(&slider, base), "a slider at that width has a track {chrome} short");
4178 assert!(at(&slider, base + chrome));
4179 assert!(!at(&float3, base + chrome), "a float3 spends an axis column too");
4180 assert!(at(&float3, base + chrome + crate::widget::display::float3::AXIS_W));
4181
4182 // Collapsing the section that holds the only slider re-decides: the
4183 // rows left visible have no track, and the column comes back.
4184 let mut p = panel_with(&[("Count", "3", "spinbox:0:10"), ("Shape", "", "section"), ("Size", "1.00", "slider:0:2")]);
4185 WidgetHost::set_rect(&mut p, 0.0, 0.0, base, 400.0);
4186 assert!(!p.inner().inline_labels, "the slider's track is the shortest");
4187 p.inner_mut().set_section_collapsed("Shape", true);
4188 assert!(p.inner().inline_labels, "with the slider hidden the spinbox decides");
4189 }
4190
4191 /// A `separator` row is a hairline a pixel tall between its neighbours,
4192 /// the row gap either side, drawn as a rule and never hovered.
4193 #[test]
4194 fn a_separator_row_is_a_rule_between_rows() {
4195 let p = panel_with(&[("Input", "a", "text"), ("", "", SEPARATOR), ("Size", "1.00", "slider:0:2")]);
4196 let mut p = p;
4197 WidgetHost::set_rect(&mut p, 0.0, 0.0, 400.0, 400.0);
4198 let rects = p.get_param_rects();
4199 assert_eq!(rects[1].3, 1.0, "a pixel tall");
4200 assert!((rects[1].1 - (rects[0].1 + rects[0].3 + ROW_GAP)).abs() < 1e-3, "the row gap above");
4201 assert!((rects[2].1 - (rects[1].1 + 1.0 + ROW_GAP)).abs() < 1e-3, "and below");
4202 let rule = p.plain_quads().into_iter().find(|q| q.3 == 1.0 && (q.1 - rects[1].1).abs() < 1e-3);
4203 assert!(rule.is_some_and(|q| q.2 > 300.0), "a rule across the row");
4204 assert_eq!(p.hoverable_row_at(rects[1].0 + 50.0, rects[1].1 + 0.5), None, "nothing to hover");
4205 let near = |l: &TextLabel| (l.y - rects[1].1).abs() < 6.0;
4206 assert!(!p.own_text_labels().iter().any(near), "and no label: a fallback `name: value` once drew ':'");
4207 }
4208
4209 /// A `soft` slider or float row builds its sliders with a soft range,
4210 /// and the pane writes back the value the slider holds — past the
4211 /// row's declared range once a typed value has widened it — rather
4212 /// than re-reading the slider's fraction over the declared range.
4213 #[test]
4214 fn a_soft_row_writes_back_what_its_slider_holds() {
4215 let mut p = panel_with(&[("V", "1.00", "slider:-10:10:2:soft"), ("W", "1:2", "float2:-10:10:soft"), ("H", "1.00", "slider:-10:10")]);
4216 WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 400.0);
4217 assert!(is_soft_row("slider:-10:10:2:soft") && is_soft_row("float3:-1:1:trackball:soft"));
4218 assert!(!is_soft_row("slider:-10:10") && !is_soft_row("float3:-1:1:trackball"));
4219 assert_eq!(slider_decimals("slider:-10:10:2:soft"), 2);
4220 {
4221 let s = p.sliders[0].as_mut().unwrap();
4222 s.set_range(-10.0, 500.0);
4223 s.set_scaled_value(500.0);
4224 }
4225 p.inner_mut().focused_param = Some(0);
4226 p.inner_mut().commit_and_unfocus();
4227 assert_eq!(p.display_params[0].1, "500.00");
4228 assert_eq!(p.display_params[2].1, "1.00", "the hard row is untouched");
4229 }
4230
4231 /// `float2` and `float4` rows are the float3 group with two or four
4232 /// rows (X Y, X Y Z W): that many sliders over the row's range, laid out
4233 /// and sized for that many, read from and written back as that many
4234 /// components — and no trackball, which a direction of three numbers
4235 /// is the only thing to have.
4236 #[test]
4237 fn float2_and_float4_rows_are_the_group_with_two_or_four_sliders() {
4238 let mut p = panel_with(&[
4239 ("Two", "1.00:-2.00", "float2:-10:10"),
4240 ("Four", "1:2:3:4", "float4:-10:10:trackball"),
4241 ("Three", "0:0:0", "float3:-10:10"),
4242 ]);
4243 WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 600.0);
4244 let two = p.float3s[0].as_ref().expect("a float2 row is a group");
4245 let four = p.float3s[1].as_ref().expect("a float4 row is a group");
4246 assert_eq!((two.components(), four.components()), (2, 4));
4247 assert_eq!(two.get_row_rects().len(), 2);
4248 assert_eq!(four.get_row_rects().len(), 4);
4249 assert!(!four.has_trackball(), "four numbers have no ball");
4250 assert_eq!(two.value_string(), "1.00:-2.00");
4251 assert_eq!(four.value_string(), "1.00:2.00:3.00:4.00");
4252 assert!(
4253 Float3::preferred_height_for(false, 4) > Float3::preferred_height_for(false, 3)
4254 && Float3::preferred_height_for(false, 3) > Float3::preferred_height_for(false, 2)
4255 );
4256 assert_eq!(Float3::preferred_height_for(false, 3), Float3::preferred_height(false));
4257
4258 // A new value for the row reaches the group, and a group whose
4259 // row changed width takes the new width.
4260 let rows: Vec<(String, String, String)> = vec![
4261 ("Two".into(), "3:4".into(), "float2:-10:10".into()),
4262 ("Four".into(), "5:6:7".into(), "float3:-10:10".into()),
4263 ("Three".into(), "0:0:0".into(), "float3:-10:10".into()),
4264 ];
4265 ParamController::set_display_params(&mut *p, &rows);
4266 assert_eq!(p.float3s[0].as_ref().unwrap().value_string(), "3.00:4.00");
4267 let f = p.float3s[1].as_ref().unwrap();
4268 assert_eq!((f.components(), f.value_string()), (3, "5.00:6.00:7.00".to_string()));
4269 }
4270
4271 /// A `float3:lo:hi:trackball` row builds its group with the ball, counts
4272 /// the ball as chrome when the label layout is decided, paints it
4273 /// through the scene path, and a press-and-drag on it through the
4274 /// pane's own drag protocol turns the row's value.
4275 #[test]
4276 fn a_trackball_row_turns_its_value_through_the_pane() {
4277 use crate::scene::paint::Prim;
4278 let mut p = panel_with(&[("Name", "x", "text"), ("Pull", "0.000:0.000:2.000", "float3:-10:10:trackball"), ("Plain", "0:0:0", "float3:-10:10")]);
4279 WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 400.0);
4280 assert!(p.float3s[1].as_ref().unwrap().has_trackball());
4281 assert!(!p.float3s[2].as_ref().unwrap().has_trackball());
4282 assert_eq!(
4283 ParametersBg::control_chrome("float3:-10:10:trackball") - ParametersBg::control_chrome("float3:-10:10"),
4284 Float3::trackball_chrome(),
4285 "the ball is chrome: the tracks are what is left of it"
4286 );
4287
4288 let mut pc = crate::scene::paint::PaintCtx::new();
4289 p.inner().paint_scene_rows(&mut pc);
4290 let spheres = pc.finish().items.into_iter().filter(|i| matches!(i.prim, Prim::Sphere { .. })).count();
4291 assert_eq!(spheres, 1, "one ball, for the one row that asks");
4292
4293 let (cx, cy, r) = p.float3s[1].as_ref().unwrap().ball_circle().unwrap();
4294 let rect = Rect { x: 0.0, y: 0.0, width: 500.0, height: 400.0 };
4295 Input::drag_begin(p.inner_mut(), cx, cy, rect);
4296 assert!(Input::is_dragging(p.inner()), "the pane is dragging the row");
4297 assert!(Input::drag_update(p.inner_mut(), cx + r * std::f32::consts::FRAC_PI_2, cy, rect));
4298 Input::drag_end(p.inner_mut());
4299 let v: Vec<f32> = p.display_params[1].1.split(':').map(|c| c.parse().unwrap()).collect();
4300 assert!((v[0] - 2.0).abs() < 2e-3 && v[1].abs() < 2e-3 && v[2].abs() < 2e-3, "a quarter turn right: {v:?}");
4301 assert_eq!(p.display_params[2].1, "0:0:0", "the plain row is untouched");
4302
4303 // The pane's camera reaches every ball it has, and the ones built
4304 // after it; the same view again changes nothing.
4305 let camera = [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]];
4306 assert!(p.inner_mut().set_trackball_view(camera));
4307 assert_eq!(p.float3s[1].as_ref().unwrap().view(), camera);
4308 assert!(!p.inner_mut().set_trackball_view(camera), "unchanged");
4309 let rebuilt: Vec<(String, String, String)> = vec![("Other".into(), "0.000:0.000:1.000".into(), "float3:-10:10:trackball".into())];
4310 ParamController::set_display_params(&mut *p, &rebuilt);
4311 assert_eq!(p.float3s[0].as_ref().unwrap().view(), camera, "a rebuilt row starts from the pane's view");
4312
4313 // A trackpad gesture beginning on the ball rolls it — the pane does
4314 // not scroll — and one beginning on the label column is the pane's.
4315 use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
4316 let rows: Vec<(String, String, String)> = (0..12)
4317 .map(|i| (format!("P{i}"), "0.000:0.000:2.000".to_string(), "float3:-10:10:trackball".to_string()))
4318 .collect();
4319 let mut p = ParametersBg::new();
4320 ParamController::set_display_params(&mut *p, &rows);
4321 WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 300.0);
4322 assert!(p.content_h > 300.0, "the pane overflows, so it has somewhere to scroll");
4323 let (cx, cy, _) = p.float3s[1].as_ref().unwrap().ball_circle().unwrap();
4324 let mut ctx = UiContext::new();
4325 ctx.scroll_gesture_new = true;
4326 set_scroll_phase(ScrollPhase::Finger);
4327 assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 60.0, y: 0.0 }), cx, cy, &mut ctx));
4328 let v: Vec<f32> = p.display_params[1].1.split(':').map(|c| c.parse().unwrap()).collect();
4329 assert!(v[0] > 0.4 && v[1].abs() < 2e-3, "rolled right by a notch: {v:?}");
4330 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.float3s[1].as_ref().unwrap().ball_id()));
4331 assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
4332 assert_eq!(p.display_params[0].1, "0.000:0.000:2.000", "the row above is untouched");
4333 set_scroll_phase(ScrollPhase::Wheel);
4334 }
4335
4336 #[test]
4337 fn ramp_row_builds_from_spec_and_edits_serialize_back() {
4338 let mut ctx = UiContext::new();
4339 let mut p = panel_with(&[("Bevel Profile", "smooth;0.000:0.000,1.000:1.000", "ramp")]);
4340 let rp = p.ramps[0].as_ref().expect("ramp row builds a Ramp");
4341 assert_eq!(rp.inner().keys.len(), 2);
4342 assert!(rp.inner().smooth());
4343
4344 // A press inside the curve area adds a key, and the row value carries
4345 // the re-serialized spec (what hosts poll and persist).
4346 let (rx, ry, rw, _) = rp.rect();
4347 p.mouse_input(MouseButton::Left, ElementState::Pressed, rx + rw * 0.5, ry + 40.0, &mut ctx);
4348 assert_eq!(p.ramps[0].as_ref().unwrap().inner().keys.len(), 3);
4349 let val = &ParamController::node_params(&*p)[0].1;
4350 assert_eq!(val.split(',').count(), 3, "spec re-serialized: {val}");
4351 assert!(val.starts_with("smooth;"));
4352 }
4353
4354 #[test]
4355 fn toggle_click_commits_value_and_unfocus_commits_editor() {
4356 let mut ctx = UiContext::new();
4357 let mut p = panel_with(&[("On", "false", "checkbox")]);
4358 let (cx, cy, _, ch) = p.toggles[0].as_ref().unwrap().rect();
4359 // Click the toggle row (presses are ungated for this widget; the panel consumes
4360 // every left press, so the return is true either way — assert the value flip).
4361 p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
4362 p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
4363 assert_eq!(ParamController::node_params(&*p)[0].1, "true");
4364
4365 // Code editor: focus it via a click, type, then unfocus commits the buffer.
4366 let mut p = panel_with(&[("Src", "let x = 1;", "code")]);
4367 let rects = p.get_param_rects();
4368 let r = rects[0];
4369 p.mouse_input(MouseButton::Left, ElementState::Pressed, r.0 + 20.0, r.1 + 30.0, &mut ctx);
4370 assert_eq!(p.focused_param, Some(0), "code row focused");
4371 assert!(p.code_editor.is_some());
4372 p.code_editor.as_mut().unwrap().insert_text("y");
4373 WidgetHost::unfocus(&mut p);
4374 assert_eq!(p.focused_param, None);
4375 assert!(p.code_editor.is_none());
4376 assert!(ParamController::node_params(&*p)[0].1.contains('y'), "editor buffer committed on unfocus");
4377 }
4378
4379 fn key(k: Key, ctrl: bool, shift: bool) -> Event {
4380 Event::KeyInput(crate::widget::KeyEvent { state: ElementState::Pressed, logical_key: k, text: None, repeat: false, ctrl, shift, alt: false })
4381 }
4382
4383 fn chr(c: &str) -> Event {
4384 key(Key::Character(c.into()), false, false)
4385 }
4386
4387 fn ctrl(c: &str) -> Event {
4388 key(Key::Character(c.into()), true, false)
4389 }
4390
4391 fn named(k: NamedKey) -> Event {
4392 key(Key::Named(k), false, false)
4393 }
4394
4395 fn shift_named(k: NamedKey) -> Event {
4396 key(Key::Named(k), false, true)
4397 }
4398
4399 /// A code row focused by a click at its first character.
4400 fn code_panel(src: &str) -> (Adapted<ParametersBg>, UiContext) {
4401 let mut ctx = UiContext::new();
4402 let mut p = panel_with(&[("Src", src, "code")]);
4403 let r = p.get_param_rects()[0];
4404 let x = p.code_text_x(r);
4405 p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0, &mut ctx);
4406 assert_eq!(p.focused_param, Some(0));
4407 (p, ctx)
4408 }
4409
4410 fn send(p: &mut Adapted<ParametersBg>, ctx: &mut UiContext, ev: Event) -> bool {
4411 WidgetHost::handle_event(p, &ev, ctx)
4412 }
4413
4414 /// Typing edits the buffer and leaves the VALUE alone until ctrl+enter
4415 /// applies it — a script would otherwise re-run on every keystroke — and
4416 /// the row says so: dirty while they differ, clean once applied.
4417 #[test]
4418 fn code_edits_apply_on_ctrl_enter_not_per_keystroke() {
4419 let (mut p, mut ctx) = code_panel("let x = 1;");
4420 assert!(!p.code_is_dirty());
4421 send(&mut p, &mut ctx, chr("/"));
4422 send(&mut p, &mut ctx, chr("/"));
4423 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "//let x = 1;");
4424 assert_eq!(ParamController::node_params(&*p)[0].1, "let x = 1;", "the value waits");
4425 assert!(p.code_is_dirty());
4426 send(&mut p, &mut ctx, key(Key::Named(NamedKey::Enter), true, false));
4427 assert_eq!(ParamController::node_params(&*p)[0].1, "//let x = 1;", "ctrl+enter applies");
4428 assert!(!p.code_is_dirty());
4429 assert_eq!(p.focused_param, Some(0), "and keeps editing");
4430 // Escape applies too, and leaves the row.
4431 send(&mut p, &mut ctx, chr("!"));
4432 send(&mut p, &mut ctx, named(NamedKey::Escape));
4433 assert_eq!(ParamController::node_params(&*p)[0].1, "//!let x = 1;");
4434 assert_eq!(p.focused_param, None);
4435 }
4436
4437 /// Tab indents, shift+tab dedents, and Enter carries the indentation —
4438 /// one level deeper after an opening brace.
4439 #[test]
4440 fn code_editor_indents_like_an_editor() {
4441 let (mut p, mut ctx) = code_panel("");
4442 send(&mut p, &mut ctx, chr("i"));
4443 send(&mut p, &mut ctx, chr("f"));
4444 send(&mut p, &mut ctx, chr(" "));
4445 send(&mut p, &mut ctx, chr("{"));
4446 send(&mut p, &mut ctx, named(NamedKey::Enter));
4447 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n ", "a brace opens a level");
4448 send(&mut p, &mut ctx, chr("x"));
4449 send(&mut p, &mut ctx, named(NamedKey::Enter));
4450 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n x\n ", "the level carries");
4451 send(&mut p, &mut ctx, shift_named(NamedKey::Tab));
4452 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n x\n", "shift+tab dedents the line");
4453 send(&mut p, &mut ctx, chr("}"));
4454 send(&mut p, &mut ctx, named(NamedKey::Tab));
4455 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n x\n} ", "tab inserts a level");
4456 assert!(send(&mut p, &mut ctx, named(NamedKey::Tab)), "tab is the editor's: it does not fall through to the host");
4457 }
4458
4459 /// shift+arrows select, typing replaces the selection, ctrl+z undoes a
4460 /// typing run as one step and ctrl+shift+z redoes it.
4461 #[test]
4462 fn code_editor_selects_and_undoes() {
4463 let (mut p, mut ctx) = code_panel("abc\ndef");
4464 send(&mut p, &mut ctx, named(NamedKey::End));
4465 send(&mut p, &mut ctx, shift_named(NamedKey::ArrowDown));
4466 let e = p.code_editor.as_ref().unwrap();
4467 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");
4468 send(&mut p, &mut ctx, chr("Z"));
4469 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZ", "typing replaces the selection");
4470 send(&mut p, &mut ctx, chr("Y"));
4471 send(&mut p, &mut ctx, chr("X"));
4472 send(&mut p, &mut ctx, ctrl("z"));
4473 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc\ndef", "one typing run, one undo");
4474 send(&mut p, &mut ctx, key(Key::Character("z".into()), true, true));
4475 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZYX", "and one redo");
4476 send(&mut p, &mut ctx, key(Key::Character("a".into()), true, true));
4477 assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcZYX"), "ctrl+shift+a selects all");
4478 assert_eq!(ParamController::node_params(&*p)[0].1, "abc\ndef", "none of it applied yet");
4479 }
4480
4481 /// The host flags an error line and the gutter number turns red for it;
4482 /// a click lands the caret past the gutter, on the column it named.
4483 #[test]
4484 fn code_row_flags_an_error_line_and_clicks_land_past_the_gutter() {
4485 let (mut p, mut ctx) = code_panel("one\ntwo\nthree");
4486 p.set_code_error_line(Some(1));
4487 let labels = p.own_text_labels();
4488 let two = labels.iter().find(|l| l.text == " 2").expect("a gutter number for line 2");
4489 assert_eq!(two.color, [0xff, 0x80, 0x70]);
4490 let one = labels.iter().find(|l| l.text == " 1").unwrap();
4491 assert_eq!(one.color, [0x66, 0x66, 0x78]);
4492 let r = p.get_param_rects()[0];
4493 let x = p.code_text_x(r) + 2.0 * p.code_col_w();
4494 p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0 * ParametersBg::CODE_LINE_H + 2.0, &mut ctx);
4495 let e = p.code_editor.as_ref().unwrap();
4496 assert_eq!(get_cursor_line_col(&e.buffer, e.cursor_idx), (2, 2), "line 3, column 2");
4497 p.set_code_error_line(None);
4498 assert!(p.own_text_labels().iter().all(|l| l.color != [0xff, 0x80, 0x70]));
4499 }
4500
4501 /// The context actions reach the editor: undo through the runner's
4502 /// routing is the editor's own history, and select-all selects the
4503 /// buffer. Outside an edit the pane declines them.
4504 #[test]
4505 fn code_editor_answers_context_actions_while_editing() {
4506 use crate::widget::ContextAction;
4507 let (mut p, mut ctx) = code_panel("abc");
4508 send(&mut p, &mut ctx, named(NamedKey::End));
4509 send(&mut p, &mut ctx, chr("d"));
4510 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
4511 assert!(Input::context_action(&mut *p, ContextAction::Undo));
4512 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc", "undo through the context action");
4513 assert!(Input::context_action(&mut *p, ContextAction::Redo));
4514 assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
4515 assert!(Input::context_action(&mut *p, ContextAction::SelectAll));
4516 assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcd"));
4517 WidgetHost::unfocus(&mut p);
4518 assert!(!Input::context_action(&mut *p, ContextAction::Undo), "nothing to act on once the editor is closed");
4519 }
4520
4521 #[test]
4522 fn clicking_a_section_title_collapses_its_rows() {
4523 let mut ctx = UiContext::new();
4524 let mut p = panel_with(&[
4525 ("Transform", "", "section"),
4526 ("Size", "1.00", "slider:0:2"),
4527 ("Shading", "", "section"),
4528 ("On", "true", "checkbox"),
4529 ]);
4530 let expanded_h = p.get_total_content_height();
4531 let below_before = p.get_param_rects()[2].1;
4532
4533 // Press the first section's title box.
4534 let r_hdr = p.get_param_rects()[0];
4535 let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
4536 p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
4537
4538 assert!(p.section_collapsed("Transform"));
4539 assert_eq!(p.get_param_rects()[1].3, 0.0, "the collapsed section's row has no height");
4540 assert!(p.get_param_rects()[2].1 < below_before, "the next section moves up");
4541 assert!(p.get_total_content_height() < expanded_h);
4542 // The row's chrome and label are gone; the header's stay.
4543 assert!(!p.own_text_labels().iter().any(|l| l.text.contains("Size")));
4544 assert!(p.own_text_labels().iter().any(|l| l.text.contains("Transform")));
4545
4546 // Clicking it again restores the section.
4547 let r_hdr = p.get_param_rects()[0];
4548 let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
4549 p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
4550 assert!(!p.section_collapsed("Transform"));
4551 assert_eq!(p.get_total_content_height(), expanded_h);
4552 }
4553
4554 /// Both ends of a straight run, and of an arc, as points on the path.
4555 fn run_ends(r: &(f32, f32, f32, f32)) -> [(f32, f32); 2] {
4556 let (x, y, w, h) = *r;
4557 if w > h {
4558 [(x, y), (x + w, y)]
4559 } else {
4560 [(x, y), (x, y + h)]
4561 }
4562 }
4563 fn arc_ends(a: &(f32, f32, f32, f32, f32)) -> [(f32, f32); 2] {
4564 let (cx, cy, r, a0, a1) = *a;
4565 [
4566 (cx + r * a0.cos(), cy + r * a0.sin()),
4567 (cx + r * a1.cos(), cy + r * a1.sin()),
4568 ]
4569 }
4570
4571 #[test]
4572 fn section_outline_is_one_continuous_path() {
4573 let p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
4574 let (title, content) = p.section_boxes().into_iter().next().expect("one section");
4575 let content = content.expect("the section has a content box");
4576 let (runs, arcs) = p.section_outline(title, content.into());
4577
4578 // The tab sits flush on the body — its bottom edge is the body's top edge.
4579 assert_eq!(title.1 + title.3, content.1, "the tab fuses to the body");
4580 // Folder-tab shape: the tab's two top corners, the concave throat where its right
4581 // side turns onto the body's top edge, and the body's three remaining corners
4582 // (its top-LEFT is the tab's left side running straight through).
4583 assert_eq!(arcs.len(), 6);
4584 let (tab_right, body_top) = (title.0 + title.2, content.1);
4585 let throats = arcs
4586 .iter()
4587 .filter(|(cx, cy, ..)| *cx > tab_right - 0.01 && *cy < body_top)
4588 .count();
4589 assert_eq!(throats, 1, "the throat — centred out in the pocket right of the tab");
4590
4591 // Every corner hands off to a straight run — no arc dangles. (Within a stroke width:
4592 // runs and arcs are anchored on opposite ink sides at the concave corner.)
4593 let ends: Vec<(f32, f32)> = runs.iter().flat_map(run_ends).collect();
4594 for arc in &arcs {
4595 for (ax, ay) in arc_ends(arc) {
4596 let nearest = ends
4597 .iter()
4598 .map(|(x, y)| ((x - ax).powi(2) + (y - ay).powi(2)).sqrt())
4599 .fold(f32::INFINITY, f32::min);
4600 assert!(nearest <= SECTION_BORDER_T + 0.01, "corner at ({ax}, {ay}) dangles: {nearest}");
4601 }
4602 }
4603
4604 // The tab's bottom edge is open (no run along it), and the body's top edge runs
4605 // only right of the throat.
4606 let tab_bottom = title.1 + title.3 - SECTION_BORDER_T;
4607 let bottom_runs = runs
4608 .iter()
4609 .filter(|(_, y, w, _)| (*y - tab_bottom).abs() < 0.01 && *w > SECTION_BORDER_T)
4610 .count();
4611 assert_eq!(bottom_runs, 0, "the tab opens onto the body");
4612 let top_runs: Vec<&(f32, f32, f32, f32)> = runs
4613 .iter()
4614 .filter(|(_, y, w, _)| (*y - body_top).abs() < 0.01 && *w > SECTION_BORDER_T)
4615 .collect();
4616 assert_eq!(top_runs.len(), 1, "the body's top edge starts past the tab");
4617 assert!(top_runs[0].0 >= tab_right, "…right of the throat");
4618
4619 // The left edge is ONE straight run from the tab's top corner to the body's
4620 // bottom corner.
4621 let left_runs: Vec<&(f32, f32, f32, f32)> = runs
4622 .iter()
4623 .filter(|(x, _, _, h)| (*x - title.0).abs() < 0.01 && *h > 0.0)
4624 .collect();
4625 assert_eq!(left_runs.len(), 1, "tab + body share one left side");
4626 assert!((left_runs[0].1 - (title.1 + SECTION_R)).abs() < 0.01);
4627 assert!((left_runs[0].1 + left_runs[0].3 - (content.1 + content.3 - SECTION_R)).abs() < 0.01);
4628 }
4629
4630 #[test]
4631 fn a_collapsed_section_outline_closes_on_itself() {
4632 let mut p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
4633 p.set_section_collapsed("Transform", true);
4634 let (title, content) = p.section_boxes().into_iter().next().expect("one section");
4635 assert!(content.is_none(), "nothing to wrap below a collapsed header");
4636 let (runs, arcs) = p.section_outline(title, None);
4637 assert_eq!(arcs.len(), 4, "a plain rounded rect");
4638 assert_eq!(runs.len(), 4);
4639 }
4640
4641 #[test]
4642 fn rows_pack_on_the_channel_and_sections_separate_wider() {
4643 let p = panel_with(&[
4644 ("Transform", "", "section"),
4645 ("Size", "1.00", "slider:0:2"),
4646 ("Shading", "", "section"),
4647 ("On", "true", "checkbox"),
4648 ]);
4649 let rects = p.get_param_rects();
4650 let content_bottom = |i: usize| rects[i].1 + rects[i].3 + CONTENT_BOX_PAD;
4651 let title_top = |i: usize| rects[i].1 - TITLE_BOX_INSET;
4652
4653 // Inside a section everything packs on the channel: each tab sits flush on its
4654 // content box, and the rows keep one channel from the box's walls.
4655 for (title, content) in p.section_boxes() {
4656 let content = content.expect("both sections have content");
4657 assert_eq!(title.1 + title.3, content.1, "tab flush on its body");
4658 }
4659 let (_, content0) = p.section_boxes()[0];
4660 let content0 = content0.unwrap();
4661 assert_eq!(rects[1].1 - content0.1, CHANNEL, "row -> its box's top wall");
4662 assert_eq!(rects[1].0 - content0.0, CHANNEL, "row -> its box's side wall");
4663
4664 // Section to section stays far wider, so the blocks still read apart.
4665 assert_eq!(title_top(2) - content_bottom(1), SECTION_GAP, "section -> next section");
4666 const { assert!(SECTION_GAP > 2.0 * CHANNEL, "sections separate wider than any channel") };
4667 }
4668
4669 #[test]
4670 fn a_collapsed_section_keeps_the_same_gap_to_the_next_one() {
4671 // With no content box under it, the collapsed section's bottom edge is its own title
4672 // box — a fixed row-pitch bump would leave a double gap here.
4673 let mut p = panel_with(&[
4674 ("Transform", "", "section"),
4675 ("Size", "1.00", "slider:0:2"),
4676 ("Shading", "", "section"),
4677 ("On", "true", "checkbox"),
4678 ]);
4679 p.set_section_collapsed("Transform", true);
4680 let rects = p.get_param_rects();
4681 let collapsed_bottom = rects[0].1 - TITLE_BOX_INSET + TITLE_BOX_H;
4682 let next_title_top = rects[2].1 - TITLE_BOX_INSET;
4683 assert_eq!(next_title_top - collapsed_bottom, SECTION_GAP);
4684 }
4685
4686 #[test]
4687 fn collapse_survives_a_param_rebuild_and_swallows_row_clicks() {
4688 let mut ctx = UiContext::new();
4689 let mut p = panel_with(&[("Shading", "", "section"), ("On", "false", "checkbox")]);
4690 p.set_section_collapsed("Shading", true);
4691
4692 // A click where the toggle used to sit must not reach it.
4693 let (cx, cy, _, ch) = p.toggles[1].as_ref().unwrap().rect();
4694 p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
4695 p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
4696 assert_eq!(ParamController::node_params(&*p)[1].1, "false", "hidden row ignores clicks");
4697
4698 // The host re-syncs the panel (node change): collapse is keyed by title, so it holds.
4699 let params: Vec<(String, String, String)> = [("Shading", "", "section"), ("On", "false", "checkbox"), ("Extra", "1", "int")]
4700 .iter()
4701 .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
4702 .collect();
4703 ParamController::set_display_params(&mut *p, ¶ms);
4704 assert!(p.section_collapsed("Shading"));
4705 assert_eq!(p.get_param_rects()[1].3, 0.0);
4706 }
4707
4708 #[test]
4709 fn the_row_chrome_is_the_panes_and_the_plate_the_hosts() {
4710 let ctx = UiContext::new();
4711 let p = panel_with(&[("Size", "1.00", "slider:0:2")]);
4712 // The pane's plain quads carry the row chrome (clipped), including the slider
4713 // background it reads via rect()+color()...
4714 let plain = p.plain_quads();
4715 assert!(!plain.is_empty(), "row chrome in the pane's plain quads");
4716 // ...but NOT the panel's own PARAM_BG plate (the host draws that from color()).
4717 let (x, y, w, h) = WidgetHost::rect(&p);
4718 assert!(
4719 !plain.iter().any(|q| (q.0, q.1, q.2, q.3) == (x, y, w, h)),
4720 "panel bg plate is the host's, not the pane's"
4721 );
4722 // Per-label hatch: the walk's text prims carry the widget font and viewport bounds.
4723 let mut scratch = crate::scene::paint::PaintCtx::new();
4724 crate::scene::painter::append_widget_text(&ctx, &p, &mut scratch);
4725 let labels: Vec<_> = scratch
4726 .finish()
4727 .items
4728 .into_iter()
4729 .filter_map(|item| match item.prim {
4730 crate::scene::paint::Prim::Text { font, bounds, .. } => Some((font, bounds)),
4731 _ => None,
4732 })
4733 .collect();
4734 assert!(!labels.is_empty());
4735 assert!(labels.iter().all(|(font, bounds)| font.is_some() && bounds.is_some()));
4736 }
4737
4738 #[test]
4739 fn scroll_wheel_scrolls_when_content_overflows() {
4740 let mut ctx = UiContext::new();
4741 let rows: Vec<(String, String, String)> = (0..30)
4742 .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
4743 .collect();
4744 let mut p = ParametersBg::new();
4745 ParamController::set_display_params(&mut *p, &rows);
4746 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
4747 assert!(p.content_h > 200.0);
4748 assert!(crate::widget::WidgetHostExt::is_scrollable(&p));
4749 // Wheel over the panel body but off every slider row's x-span is impossible (rows are
4750 // full-width), so scroll via the region below the last visible row: use a y between
4751 // rows (the 2px slack above a row) — simplest is the bottom padding strip.
4752 let before = p.scroll_y;
4753 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 150.0, 199.0, &mut ctx);
4754 // Either a slider consumed it (value change) or the panel scrolled; both mark change.
4755 // The panel-scroll path must work when no slider is under the pointer:
4756 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
4757 assert!(p.scroll_y >= before, "scroll never decreases on a downward wheel");
4758 }
4759
4760 /// A scroll moves the rows under a still pointer, and the hover follows
4761 /// the rows: the control that was under the pointer goes dark and the one
4762 /// now there lights, without a pointer motion. The pointer's last
4763 /// position is what the pane re-hovers from, so the wheel's own position
4764 /// (over the label column, where the pane takes it) need not be it.
4765 #[test]
4766 fn a_scroll_re_hovers_the_control_under_a_still_pointer() {
4767 let mut ctx = UiContext::new();
4768 let rows: Vec<(String, String, String)> = (0..30)
4769 .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
4770 .collect();
4771 let mut p = ParametersBg::new();
4772 ParamController::set_display_params(&mut *p, &rows);
4773 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
4774 let hovered = |p: &Adapted<ParametersBg>| -> Vec<usize> {
4775 p.sliders.iter().enumerate().filter(|(_, s)| s.as_ref().is_some_and(|s| s.inner().hovered())).map(|(i, _)| i).collect()
4776 };
4777 assert!(hovered(&p).is_empty());
4778
4779 // Over the first row's control.
4780 let r0 = p.get_param_rects()[0];
4781 let (px, py) = (250.0, r0.1 + r0.3 * 0.5);
4782 p.on_cursor_moved(px, py, &mut ctx);
4783 assert_eq!(hovered(&p), vec![0], "the slider under the pointer hovers");
4784 assert_eq!(p.hover_row, Some(0), "and the pane lifts its label");
4785
4786 // A wheel over the label column scrolls the pane: a notch moves the
4787 // TARGET and the rows glide there over the following ticks, so the
4788 // re-hover that matters is the tick's. The pointer's stored position
4789 // has not moved, but the rows under it have.
4790 ctx.scroll_gesture_new = true;
4791 let before = p.scroll_y;
4792 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, py, &mut ctx);
4793 for _ in 0..120 {
4794 WidgetHost::tick(&mut p, 1.0 / 60.0, &mut ctx);
4795 }
4796 assert!(p.scroll_y > before, "the pane scrolled: {} -> {}", before, p.scroll_y);
4797 let now = p
4798 .get_param_rects()
4799 .iter()
4800 .position(|r| py >= r.1 && py <= r.1 + r.3)
4801 .expect("a row under the pointer after the scroll");
4802 assert_ne!(now, 0, "a different row is under the pointer");
4803 assert_eq!(hovered(&p), vec![now], "the hover followed the rows, without a motion");
4804 assert_eq!(p.hover_row, Some(now), "the label lift followed too");
4805
4806 // Off the pane, nothing is hovered.
4807 p.on_cursor_moved(-9999.0, -9999.0, &mut ctx);
4808 assert!(hovered(&p).is_empty());
4809 assert_eq!(p.hover_row, None);
4810 }
4811
4812 /// An inline label is cut only when it does not fit its column, by the
4813 /// measure the column is sized with. It used to be cut to as many
4814 /// characters as the column holds M's: in a proportional face a label
4815 /// of narrow letters is far narrower than that many M's, so a label the
4816 /// column was sized to hold lost its tail. On a runner whose fallback
4817 /// face drew "M" a pixel wider, "Count" became "C..." in a column with
4818 /// 30 px to spare.
4819 #[test]
4820 fn an_inline_label_is_cut_only_when_it_does_not_fit() {
4821 use crate::widget::display::measure_text_width;
4822 let narrow = "iiiiiiiiiiii";
4823 let long = "A parameter name far too long for any label column of this pane";
4824 let mut p = panel_with(&[(narrow, "x", "text"), ("Count", "3", "spinbox:0:10"), (long, "y", "text")]);
4825 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
4826 let (family, size) = ParametersBg::inline_label_font();
4827 let avail = p.label_col_w() - ParametersBg::LABEL_GAP;
4828 assert!(avail > 0.0, "the pane lays its labels out inline");
4829 let texts: Vec<String> = p.own_text_labels().into_iter().map(|l| l.text).collect();
4830 for name in [narrow, "Count"] {
4831 if measure_text_width(name, &family, size) <= avail {
4832 assert!(texts.iter().any(|t| t == name), "{name:?} fits its {avail} px column and is drawn whole: {texts:?}");
4833 }
4834 }
4835 assert!(measure_text_width(long, &family, size) > avail, "the long label cannot fit");
4836 let cut = texts.iter().find(|t| t.starts_with("A p") && t.ends_with("...")).expect("the long label is cut");
4837 assert!(measure_text_width(cut, &family, size) <= avail, "and what is left fits: {cut:?}");
4838 }
4839
4840 /// The hover cue is the row's LABEL and the row's OWN carves lit through
4841 /// the tint channel — and nothing else in the pane's paint changes with
4842 /// it. A hover adds no geometry: it can neither close the pane plate's
4843 /// carve-grouping window (a wash before the wells did, and every well
4844 /// flipped shading on any hover) nor composite over a well's shade lines
4845 /// (a wash after them did, and the hovered well's outline paled). Both
4846 /// shipped for part of 2026-09-28. The other rows' carves keep their
4847 /// untinted, grouped shading.
4848 #[test]
4849 fn a_hover_lifts_the_label_and_lights_only_its_own_carves() {
4850 use crate::scene::paint::Prim;
4851 let mut ctx = UiContext::new();
4852 let mut p = panel_with(&[("Name", "x", "text"), ("Count", "3", "spinbox:0:10"), ("Size", "0.5", "slider:0:1")]);
4853 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
4854 let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 400.0 };
4855 let prims = |p: &Adapted<ParametersBg>, ctx: &UiContext| {
4856 let mut pc = crate::scene::paint::PaintCtx::new();
4857 Paint::paint_ui(&**p, ctx, rect, &mut pc);
4858 pc.finish().items.into_iter().map(|i| i.prim).collect::<Vec<_>>()
4859 };
4860 let label_of = |p: &Adapted<ParametersBg>, name: &str| {
4861 p.own_text_labels().into_iter().find(|l| l.text == name).map(|l| l.color).expect("a row label")
4862 };
4863 // A carve's rect and tint, whatever its kind.
4864 let carve = |prim: &Prim| match prim {
4865 Prim::Recess { rect, tint, .. }
4866 | Prim::Boss { rect, tint, .. }
4867 | Prim::Trough { rect, tint, .. }
4868 | Prim::Field { rect, tint, .. } => Some((*rect, *tint)),
4869 _ => None,
4870 };
4871 let at_rest = prims(&p, &ctx);
4872 assert!(at_rest.iter().all(|pr| carve(pr).is_none_or(|(_, t)| t.is_none())), "nothing is tinted at rest");
4873 assert_eq!(label_of(&p, "Count"), ParametersBg::LABEL);
4874
4875 let (x, y, w, h) = p.get_param_rects()[1];
4876 p.on_cursor_moved(x + w * 0.5, y + h * 0.5, &mut ctx);
4877 assert_eq!(p.hover_row, Some(1));
4878 assert_eq!(label_of(&p, "Count"), ParametersBg::LABEL_HOVER, "the hovered row's label lifts");
4879 assert_eq!(label_of(&p, "Name"), ParametersBg::LABEL, "and only that row's");
4880
4881 let hovered = prims(&p, &ctx);
4882 assert_eq!(hovered.len(), at_rest.len(), "a hover adds no geometry");
4883 let mut lit = 0;
4884 for (a, b) in at_rest.iter().zip(&hovered) {
4885 if a == b {
4886 continue;
4887 }
4888 let ((ra, ta), (rb, tb)) = (carve(a).expect("only carves change"), carve(b).expect("only carves change"));
4889 assert_eq!(ra, rb, "a carve keeps its place");
4890 assert_eq!((ta, tb), (None, Some(ParametersBg::HOVER_TINT)), "the change is the hover tint");
4891 assert!(rb.y >= y - 0.5 && rb.y + rb.height <= y + h + 0.5, "and only inside the hovered row: {rb:?}");
4892 lit += 1;
4893 }
4894 assert!(lit > 0, "the hovered row's carves light");
4895 }
4896
4897 /// The label lift is the pane's rule, not the control's: a spinbox row,
4898 /// which draws no hover of its own under the relief style, lifts exactly
4899 /// as a slider row does, and a section header never does.
4900 #[test]
4901 fn every_control_kind_hovers_by_its_row() {
4902 let mut ctx = UiContext::new();
4903 let mut p = panel_with(&[
4904 ("Shape", "", "section"),
4905 ("Count", "3", "spinbox:0:10"),
4906 ("Mode", "A", "choice:A,B"),
4907 ("Size", "0.5", "slider:0:1"),
4908 ]);
4909 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
4910 let rects = p.get_param_rects();
4911 for i in 1..4 {
4912 let (x, y, w, h) = rects[i];
4913 p.on_cursor_moved(x + w * 0.5, y + h * 0.5, &mut ctx);
4914 assert_eq!(p.hover_row, Some(i), "row {i} under the pointer");
4915 }
4916 let (x, y, w, h) = rects[0];
4917 p.on_cursor_moved(x + w * 0.5, y + h * 0.5, &mut ctx);
4918 assert_eq!(p.hover_row, None, "a header is not a hoverable row");
4919 }
4920
4921 /// A gesture the pane acquired stays the pane's: rows travelling under
4922 /// the pointer mid-gesture must not hand the wheel to the slider that
4923 /// arrives there (the Alt+D settings-tab leak, 2026-09-20). A NEW gesture
4924 /// over the same slider still adjusts it.
4925 #[test]
4926 fn pane_owned_scroll_gesture_is_not_captured_by_a_slider_sliding_under_the_pointer() {
4927 let rows: Vec<(String, String, String)> = (0..30)
4928 .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
4929 .collect();
4930 let fresh = || {
4931 let mut p = ParametersBg::new();
4932 ParamController::set_display_params(&mut *p, &rows);
4933 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
4934 p
4935 };
4936 // A point on a slider's band: row 1's rect, below the label strip.
4937 let band_point = |p: &Adapted<ParametersBg>| {
4938 let r = p.get_param_rects()[1];
4939 (r.0 + r.2 * 0.5, r.1 + r.3 * 0.7)
4940 };
4941 let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
4942
4943 // Control: a new gesture ON the band adjusts the slider (the point is a real hit).
4944 let mut ctx = UiContext::new();
4945 let mut p = fresh();
4946 let (bx, by) = band_point(&p);
4947 ctx.scroll_gesture_new = true;
4948 ctx.scroll_initiate_widget_id = None;
4949 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4950 assert_ne!(values(&p)[1], "1.00", "a new gesture on the band adjusts the slider");
4951
4952 // The case: the gesture starts on the pane (dead space), the pane owns it...
4953 let mut ctx = UiContext::new();
4954 let mut p = fresh();
4955 ctx.scroll_gesture_new = true;
4956 ctx.scroll_initiate_widget_id = None;
4957 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
4958 // (The scroll itself is animated by scroll_motion over later ticks, so
4959 // ownership — set only on the pane-scroll path — is the witness.)
4960 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane owns the gesture");
4961 // ...and the same gesture continuing over a band adjusts nothing.
4962 let (bx, by) = band_point(&p);
4963 ctx.scroll_gesture_new = false;
4964 p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4965 assert!(values(&p).iter().all(|v| v == "1.00"), "no slider took the pane's gesture: {:?}", values(&p));
4966 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane still owns it");
4967 }
4968
4969 /// A trackpad gesture belongs to what it BEGINS on: over a slider's band
4970 /// it turns the slider — in a pane that overflows and in one that fits
4971 /// alike — and keeps turning it as the pointer drifts; beginning on the
4972 /// label column it scrolls the pane, and stays the pane's as bands pass
4973 /// under the pointer. A float3's rows are three such bands. (Until
4974 /// 2026-09-28 every finger gesture was the pane's, from anywhere, and a
4975 /// slider could be turned by a wheel notch but not by a trackpad.)
4976 #[test]
4977 fn a_finger_gesture_belongs_to_the_control_it_begins_on() {
4978 use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
4979 let rows = |n: usize| -> Vec<(String, String, String)> {
4980 (0..n).map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string())).collect()
4981 };
4982 let panel = |n: usize, h: f32| {
4983 let mut p = ParametersBg::new();
4984 ParamController::set_display_params(&mut *p, &rows(n));
4985 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
4986 p
4987 };
4988 // A point on row 1's band: the slider's own rect, below its label strip.
4989 let band_point = |p: &Adapted<ParametersBg>| {
4990 let s = p.sliders[1].as_ref().unwrap();
4991 let (sx, sy, sw, sh) = s.rect();
4992 let ty = s.label_strip();
4993 (sx + sw * 0.3, sy + ty + (sh - ty) * 0.5)
4994 };
4995 let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
4996 let finger = |dy: f64| MouseScrollDelta::PixelDelta(Position { x: 0.0, y: dy });
4997
4998 for (n, h) in [(30, 200.0), (3, 600.0)] {
4999 // Beginning ON the band: the slider turns, and owns the gesture.
5000 let mut ctx = UiContext::new();
5001 let mut p = panel(n, h);
5002 let (bx, by) = band_point(&p);
5003 ctx.scroll_gesture_new = true;
5004 set_scroll_phase(ScrollPhase::Finger);
5005 assert!(p.mouse_wheel(&finger(-60.0), bx, by, &mut ctx));
5006 let slider_id = p.sliders[1].as_ref().unwrap().base().id();
5007 assert_ne!(values(&p)[1], "1.00", "a finger gesture on the band turns the slider ({n} rows)");
5008 assert_eq!(ctx.scroll_initiate_widget_id, Some(slider_id), "the slider owns the gesture");
5009 assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
5010 // Still its gesture with the pointer drifted onto the next band.
5011 let turned = values(&p)[1].clone();
5012 let (_, sy2, _, sh2) = p.sliders[2].as_ref().unwrap().rect();
5013 ctx.scroll_gesture_new = false;
5014 assert!(p.mouse_wheel(&finger(-60.0), bx, sy2 + sh2 * 0.7, &mut ctx));
5015 assert_ne!(values(&p)[1], turned, "latched: the first slider keeps turning");
5016 assert_eq!(values(&p)[2], "1.00", "the band under the drifted pointer holds");
5017 }
5018
5019 // Beginning on the label column: the pane scrolls and owns the
5020 // gesture, and a band passing under the pointer adjusts nothing.
5021 let mut ctx = UiContext::new();
5022 let mut p = panel(30, 200.0);
5023 let (_, by) = band_point(&p);
5024 ctx.scroll_gesture_new = true;
5025 set_scroll_phase(ScrollPhase::Finger);
5026 p.mouse_wheel(&finger(-30.0), ROW_X_INSET + 2.0, by, &mut ctx);
5027 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
5028 assert!(p.scroll_y > 0.0, "the pane scrolled (finger tracks 1:1): {}", p.scroll_y);
5029 let (bx, by) = band_point(&p);
5030 ctx.scroll_gesture_new = false;
5031 p.mouse_wheel(&finger(-60.0), bx, by, &mut ctx);
5032 assert!(values(&p).iter().all(|v| v == "1.00"), "a pane-owned gesture turns nothing: {:?}", values(&p));
5033
5034 // A float3's rows are three bands: a finger gesture on the Y row
5035 // turns Y alone.
5036 let mut ctx = UiContext::new();
5037 let mut p = panel_with(&[("Name", "x", "text"), ("Offset", "0.00:0.00:0.00", "float3:-1:1")]);
5038 let f = p.float3s[1].as_ref().unwrap();
5039 let (rx, ry, rw, rh) = f.get_row_rects()[1];
5040 let chrome = crate::widget::input::slider::Slider::readout_chrome();
5041 let (bx, by) = (rx + (rw - chrome) * 0.5, ry + rh * 0.5);
5042 ctx.scroll_gesture_new = true;
5043 set_scroll_phase(ScrollPhase::Finger);
5044 assert!(p.mouse_wheel(&finger(-120.0), bx, by, &mut ctx));
5045 let parts: Vec<f32> = p.display_params[1].1.split(':').map(|v| v.parse().unwrap()).collect();
5046 assert_eq!((parts[0], parts[2]), (0.0, 0.0), "X and Z hold: {parts:?}");
5047 assert_ne!(parts[1], 0.0, "Y turned: {parts:?}");
5048 assert_eq!(p.scroll_y, 0.0);
5049 set_scroll_phase(ScrollPhase::Wheel);
5050 }
5051
5052 /// The same rule on a spinbox, whose zone is its row: a gesture that
5053 /// begins on a spinbox row steps the spinbox — a finger's fractional notches accumulating
5054 /// into whole steps — and stays the spinbox's, while a gesture the pane
5055 /// acquired still scrolls past it. A wheel notch on the row steps it too,
5056 /// however the scroll factor scales the notch.
5057 #[test]
5058 fn a_gesture_beginning_on_a_spinbox_row_steps_it_trackpad_included() {
5059 use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
5060 let rows = |n: usize| -> Vec<(String, String, String)> {
5061 (0..n)
5062 .map(|i| if i == 1 { ("Count".to_string(), "10".to_string(), "spinbox:0:100:1".to_string()) } else { (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()) })
5063 .collect()
5064 };
5065 let panel = |n: usize, h: f32| {
5066 let mut p = ParametersBg::new();
5067 ParamController::set_display_params(&mut *p, &rows(n));
5068 WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
5069 p
5070 };
5071 let row_point = |p: &Adapted<ParametersBg>| {
5072 let r = p.get_param_rects()[1];
5073 (r.0 + r.2 * 0.3, r.1 + r.3 * 0.5)
5074 };
5075 let count = |p: &Adapted<ParametersBg>| p.display_params[1].1.clone();
5076
5077 // A finger gesture beginning on the row: 60 px of travel is one step,
5078 // the spinbox owns the gesture, and the pane does not scroll.
5079 let mut ctx = UiContext::new();
5080 let mut p = panel(30, 200.0);
5081 let (x, y) = row_point(&p);
5082 ctx.scroll_gesture_new = true;
5083 set_scroll_phase(ScrollPhase::Finger);
5084 assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }), x, y, &mut ctx));
5085 assert_eq!(count(&p), "10", "half a notch: no step yet");
5086 let sb_id = p.spinboxes[1].as_ref().unwrap().base().id();
5087 assert_eq!(ctx.scroll_initiate_widget_id, Some(sb_id), "the spinbox owns the gesture");
5088 ctx.scroll_gesture_new = false;
5089 assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }), x, y, &mut ctx));
5090 assert_eq!(count(&p), "11", "the second half completes the notch");
5091 assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
5092 // Still its gesture when the pointer drifts onto the row below.
5093 let below = p.get_param_rects()[2];
5094 assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -120.0 }), x, below.1 + below.3 * 0.5, &mut ctx));
5095 assert_eq!(count(&p), "9", "two notches down, latched");
5096 assert_eq!(p.display_params[2].1, "1.00", "the slider under the drifted pointer holds");
5097
5098 // A gesture the pane acquired scrolls past the row without stepping it.
5099 let mut ctx = UiContext::new();
5100 let mut p = panel(30, 200.0);
5101 ctx.scroll_gesture_new = true;
5102 set_scroll_phase(ScrollPhase::Finger);
5103 p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), 2.0, 2.0, &mut ctx);
5104 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
5105 let (x, y) = row_point(&p);
5106 ctx.scroll_gesture_new = false;
5107 p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -120.0 }), x, y, &mut ctx);
5108 assert_eq!(count(&p), "10", "a pane-owned gesture never steps a spinbox");
5109 assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()));
5110
5111 // A scaled wheel notch (scroll_factor 0.5) still steps, over two notches.
5112 let mut ctx = UiContext::new();
5113 let mut p = panel(30, 200.0);
5114 let (x, y) = row_point(&p);
5115 ctx.scroll_gesture_new = true;
5116 set_scroll_phase(ScrollPhase::Wheel);
5117 assert!(p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), x, y, &mut ctx));
5118 assert_eq!(count(&p), "10");
5119 ctx.scroll_gesture_new = false;
5120 assert!(p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), x, y, &mut ctx));
5121 assert_eq!(count(&p), "11", "a half-notch factor is not truncated to nothing");
5122 }
5123 }