Skip to main content

truce_gui_types/
widgets.rs

1//! Audio plugin UI widgets: knobs, sliders, toggles, labels, headers.
2
3use std::f32::consts::PI;
4
5use crate::interaction::InteractionState;
6use crate::layout::{
7    DROPDOWN_BOX_HEIGHT, GRID_GAP, GRID_PADDING, GRID_SECTION_H, GridLayout, HEADER_HEIGHT, Layout,
8    PluginLayout, ROWS_COLUMN_GAP, ROWS_LAYOUT_TOP, ROWS_ROW_GAP, ROWS_SECTION_LABEL_HEIGHT,
9    WidgetKind, compute_section_offsets,
10};
11use crate::render::RenderBackend;
12use crate::snapshot::ParamSnapshot;
13use crate::theme::{Color, Theme};
14
15/// Widget type for interaction state tracking.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum WidgetType {
18    Knob,
19    Slider,
20    Toggle,
21    Selector,
22    /// Dropdown list - click to open a popup of all options.
23    Dropdown,
24    Meter,
25    XYPad,
26}
27
28/// Draw a rotary knob.
29///
30/// `value` is normalized 0.0–1.0.
31/// `label` is shown below the knob.
32/// `value_text` is shown below the label.
33pub fn draw_knob(
34    ctx: &mut dyn RenderBackend,
35    x: f32,
36    y: f32,
37    size: f32,
38    value: f32,
39    label: &str,
40    value_text: &str,
41    theme: &Theme,
42    highlighted: bool,
43) {
44    let cx = x + size / 2.0;
45    let cy = y + size / 2.0 - 5.0; // leave room for label below
46    let radius = size / 2.0 - 4.0;
47
48    // Knob range: from 225° (bottom-left) to -45° (bottom-right), going clockwise
49    // In radians: 225° = 5π/4, -45° = -π/4 (or 315° = 7π/4)
50    let start_angle = 0.75 * PI; // 135° from 12 o'clock → 225° in standard math
51    let end_angle = 2.25 * PI; // 405° = 45° past full rotation
52    let arc_start = start_angle;
53    let arc_end = end_angle;
54
55    // Track arc (full range background)
56    ctx.stroke_arc(cx, cy, radius, arc_start, arc_end, theme.knob_track, 2.0);
57
58    // Value arc (filled portion)
59    let value_angle = arc_start + value * (arc_end - arc_start);
60    if value > 0.01 {
61        ctx.stroke_arc(cx, cy, radius, arc_start, value_angle, theme.knob_fill, 2.0);
62    }
63
64    // Pointer line from center to current position
65    let pointer_len = radius * 0.6;
66    let px = cx + pointer_len * value_angle.cos();
67    let py = cy + pointer_len * value_angle.sin();
68    ctx.draw_line(cx, cy, px, py, theme.knob_pointer, 1.5);
69
70    // Hover highlight ring
71    if highlighted {
72        ctx.stroke_arc(cx, cy, radius + 2.0, arc_start, arc_end, theme.accent, 1.0);
73    }
74
75    // Value text (below knob)
76    let val_size = 10.0;
77    let val_w = ctx.text_width(value_text, val_size);
78    ctx.draw_text(
79        value_text,
80        cx - val_w / 2.0,
81        y + size - 9.0,
82        val_size,
83        theme.text,
84    );
85
86    // Label text (below value)
87    let label_size = 9.0;
88    let label_w = ctx.text_width(label, label_size);
89    ctx.draw_text(
90        label,
91        cx - label_w / 2.0,
92        y + size + 2.0,
93        label_size,
94        theme.text_dim,
95    );
96}
97
98/// Draw a header bar. Each slot is independently optional - passing
99/// `None` for both should be avoided (the caller is expected to skip
100/// `draw_header` entirely when the layout has no header).
101pub fn draw_header(
102    ctx: &mut dyn RenderBackend,
103    x: f32,
104    y: f32,
105    w: f32,
106    h: f32,
107    title: Option<&str>,
108    subtitle: Option<&str>,
109    theme: &Theme,
110) {
111    ctx.fill_rect(x, y, w, h, theme.header_bg);
112
113    if let Some(title) = title {
114        let title_size = 12.0;
115        ctx.draw_text(
116            title,
117            x + 10.0,
118            y + (h - title_size) / 2.0 - 1.0,
119            title_size,
120            theme.header_text,
121        );
122    }
123
124    if let Some(subtitle) = subtitle {
125        let sub_size = 9.0;
126        let sub_w = ctx.text_width(subtitle, sub_size);
127        ctx.draw_text(
128            subtitle,
129            x + w - sub_w - 10.0,
130            y + (h - sub_size) / 2.0 - 1.0,
131            sub_size,
132            theme.text_dim,
133        );
134    }
135}
136
137/// Draw a horizontal slider.
138///
139/// `value` is normalized 0.0–1.0.
140pub fn draw_slider(
141    ctx: &mut dyn RenderBackend,
142    x: f32,
143    y: f32,
144    width: f32,
145    height: f32,
146    value: f32,
147    label: &str,
148    value_text: &str,
149    theme: &Theme,
150    highlighted: bool,
151) {
152    let track_y = y + height / 2.0 - 5.0;
153    let track_h = 3.0;
154    let margin = 4.0;
155    let track_w = width - margin * 2.0;
156
157    // Track background
158    ctx.fill_rect(x + margin, track_y, track_w, track_h, theme.knob_track);
159
160    // Filled portion
161    let fill_w = track_w * value;
162    if fill_w > 0.5 {
163        ctx.fill_rect(x + margin, track_y, fill_w, track_h, theme.knob_fill);
164    }
165
166    // Thumb
167    let thumb_x = x + margin + fill_w;
168    let thumb_r = 4.0;
169    ctx.fill_circle(
170        thumb_x,
171        track_y + track_h / 2.0,
172        thumb_r,
173        theme.knob_pointer,
174    );
175    if highlighted {
176        ctx.fill_circle(
177            thumb_x,
178            track_y + track_h / 2.0,
179            thumb_r + 1.5,
180            theme.accent,
181        );
182        ctx.fill_circle(
183            thumb_x,
184            track_y + track_h / 2.0,
185            thumb_r,
186            theme.knob_pointer,
187        );
188    }
189
190    // Value text
191    let val_size = 10.0;
192    let cx = x + width / 2.0;
193    let val_w = ctx.text_width(value_text, val_size);
194    ctx.draw_text(
195        value_text,
196        cx - val_w / 2.0,
197        y + height - 9.0,
198        val_size,
199        theme.text,
200    );
201
202    // Label
203    let label_size = 9.0;
204    let label_w = ctx.text_width(label, label_size);
205    ctx.draw_text(
206        label,
207        cx - label_w / 2.0,
208        y + height + 2.0,
209        label_size,
210        theme.text_dim,
211    );
212}
213
214/// Draw a toggle button (on/off).
215///
216/// `value` > 0.5 = on, <= 0.5 = off.
217pub fn draw_toggle(
218    ctx: &mut dyn RenderBackend,
219    x: f32,
220    y: f32,
221    width: f32,
222    height: f32,
223    value: f32,
224    label: &str,
225    value_text: &str,
226    theme: &Theme,
227    highlighted: bool,
228) {
229    let is_on = value > 0.5;
230    let cx = x + width / 2.0;
231    let cy = y + height / 2.0 - 5.0;
232
233    // Toggle track (pill shape)
234    let track_w = 20.0;
235    let track_h = 10.0;
236    let track_x = cx - track_w / 2.0;
237    let track_y = cy - track_h / 2.0;
238    let bg = if is_on {
239        theme.knob_fill
240    } else {
241        theme.knob_track
242    };
243    ctx.fill_rect(track_x, track_y, track_w, track_h, bg);
244
245    // Thumb circle
246    let thumb_x = if is_on {
247        track_x + track_w - track_h / 2.0
248    } else {
249        track_x + track_h / 2.0
250    };
251    ctx.fill_circle(thumb_x, cy, track_h / 2.0 - 1.0, theme.knob_pointer);
252
253    if highlighted {
254        ctx.fill_rect(
255            track_x - 1.0,
256            track_y - 1.0,
257            track_w + 2.0,
258            track_h + 2.0,
259            theme.accent,
260        );
261        ctx.fill_rect(track_x, track_y, track_w, track_h, bg);
262        ctx.fill_circle(thumb_x, cy, track_h / 2.0 - 1.0, theme.knob_pointer);
263    }
264
265    // Value text
266    let val_size = 10.0;
267    let val_w = ctx.text_width(value_text, val_size);
268    ctx.draw_text(
269        value_text,
270        cx - val_w / 2.0,
271        y + height - 9.0,
272        val_size,
273        theme.text,
274    );
275
276    // Label
277    let label_size = 9.0;
278    let label_w = ctx.text_width(label, label_size);
279    ctx.draw_text(
280        label,
281        cx - label_w / 2.0,
282        y + height + 2.0,
283        label_size,
284        theme.text_dim,
285    );
286}
287
288/// Draw a selector (enum parameter - click to cycle through values).
289///
290/// Shows the current value name with < > arrows.
291pub fn draw_selector(
292    ctx: &mut dyn RenderBackend,
293    x: f32,
294    y: f32,
295    width: f32,
296    height: f32,
297    _value: f32,
298    label: &str,
299    value_text: &str,
300    theme: &Theme,
301    highlighted: bool,
302) {
303    let cx = x + width / 2.0;
304    let cy = y + height / 2.0 - 5.0;
305
306    // Background box - size to fit content
307    let val_size = 10.0;
308    let arrow_size = 8.0;
309    let arrow_pad = 9.0; // space for arrow on each side
310    let val_w = ctx.text_width(value_text, val_size);
311    let box_w = (val_w + arrow_pad * 2.0 + 5.0).max(width - 8.0);
312    let box_h = 13.0;
313    let box_x = cx - box_w / 2.0;
314    let box_y = cy - box_h / 2.0;
315    let bg = if highlighted {
316        theme.accent
317    } else {
318        theme.knob_track
319    };
320    ctx.fill_rect(box_x, box_y, box_w, box_h, bg);
321
322    // Value text (centered)
323    ctx.draw_text(
324        value_text,
325        cx - val_w / 2.0,
326        cy - val_size / 2.0,
327        val_size,
328        theme.text,
329    );
330
331    // Left/right arrows
332    ctx.draw_text(
333        "<",
334        box_x + 3.0,
335        cy - arrow_size / 2.0,
336        arrow_size,
337        theme.text_dim,
338    );
339    let gt_w = ctx.text_width(">", arrow_size);
340    ctx.draw_text(
341        ">",
342        box_x + box_w - gt_w - 3.0,
343        cy - arrow_size / 2.0,
344        arrow_size,
345        theme.text_dim,
346    );
347
348    // Label (below)
349    let label_size = 9.0;
350    let label_w = ctx.text_width(label, label_size);
351    ctx.draw_text(
352        label,
353        cx - label_w / 2.0,
354        y + height + 2.0,
355        label_size,
356        theme.text_dim,
357    );
358}
359
360/// Draw a dropdown (closed state) - shows current value with a down arrow.
361///
362/// When open, `draw_dropdown_popup` renders the option list as an overlay.
363pub fn draw_dropdown(
364    ctx: &mut dyn RenderBackend,
365    x: f32,
366    y: f32,
367    width: f32,
368    height: f32,
369    _value: f32,
370    label: &str,
371    value_text: &str,
372    theme: &Theme,
373    highlighted: bool,
374    is_open: bool,
375) {
376    let cx = x + width / 2.0;
377    let cy = y + height / 2.0 - 8.0;
378
379    let val_size = 10.0;
380    let arrow_pad = 14.0;
381    let val_w = ctx.text_width(value_text, val_size);
382    let box_w = (val_w + arrow_pad + 12.0).max(width - 12.0);
383    let box_h = DROPDOWN_BOX_HEIGHT;
384    let box_x = cx - box_w / 2.0;
385    let box_y = cy - box_h / 2.0;
386    let bg = if is_open || highlighted {
387        theme.accent
388    } else {
389        theme.knob_track
390    };
391    ctx.fill_rect(box_x, box_y, box_w, box_h, bg);
392
393    // Value text (left-aligned with padding)
394    ctx.draw_text(
395        value_text,
396        box_x + 6.0,
397        cy - val_size / 2.0,
398        val_size,
399        theme.text,
400    );
401
402    // Down arrow on the right
403    let arrow_size = 8.0;
404    let arrow = if is_open { "\u{25B2}" } else { "\u{25BC}" }; // ▲ / ▼
405    let aw = ctx.text_width(arrow, arrow_size);
406    ctx.draw_text(
407        arrow,
408        box_x + box_w - aw - 4.0,
409        cy - arrow_size / 2.0,
410        arrow_size,
411        theme.text_dim,
412    );
413
414    // Label (below)
415    let label_size = 9.0;
416    let label_w = ctx.text_width(label, label_size);
417    ctx.draw_text(
418        label,
419        cx - label_w / 2.0,
420        y + height + 2.0,
421        label_size,
422        theme.text_dim,
423    );
424}
425
426/// Draw the dropdown popup overlay showing visible options.
427///
428/// `scroll_offset` is the index of the first visible option.
429/// `visible_count` is how many options to draw (may be less than total).
430// Visible-count and option-index → f32 for geometry; both are
431// bounded by the popup item count (typically < 100).
432#[allow(clippy::cast_precision_loss)]
433pub fn draw_dropdown_popup(
434    ctx: &mut dyn RenderBackend,
435    x: f32,
436    y: f32,
437    width: f32,
438    options: &[String],
439    selected_index: usize,
440    hover_index: Option<usize>,
441    scroll_offset: usize,
442    visible_count: usize,
443    theme: &Theme,
444) {
445    let item_h = 18.0;
446    let padding = 4.0;
447    let popup_w = width.max(80.0);
448    let popup_h = visible_count as f32 * item_h + padding * 2.0;
449    let popup_x = x;
450    let popup_y = y;
451
452    // Background
453    ctx.fill_rect(popup_x, popup_y, popup_w, popup_h, theme.surface);
454    // Border
455    ctx.draw_line(
456        popup_x,
457        popup_y,
458        popup_x + popup_w,
459        popup_y,
460        theme.text_dim,
461        1.0,
462    );
463    ctx.draw_line(
464        popup_x + popup_w,
465        popup_y,
466        popup_x + popup_w,
467        popup_y + popup_h,
468        theme.text_dim,
469        1.0,
470    );
471    ctx.draw_line(
472        popup_x + popup_w,
473        popup_y + popup_h,
474        popup_x,
475        popup_y + popup_h,
476        theme.text_dim,
477        1.0,
478    );
479    ctx.draw_line(
480        popup_x,
481        popup_y + popup_h,
482        popup_x,
483        popup_y,
484        theme.text_dim,
485        1.0,
486    );
487
488    let text_size = 10.0;
489    let visible_end = (scroll_offset + visible_count).min(options.len());
490    for (vis_i, abs_i) in (scroll_offset..visible_end).enumerate() {
491        let iy = popup_y + padding + vis_i as f32 * item_h;
492
493        // Highlight selected or hovered item
494        if hover_index == Some(abs_i) {
495            ctx.fill_rect(popup_x + 1.0, iy, popup_w - 2.0, item_h, theme.accent);
496        } else if abs_i == selected_index {
497            ctx.fill_rect(popup_x + 1.0, iy, popup_w - 2.0, item_h, theme.knob_track);
498        }
499
500        ctx.draw_text(
501            &options[abs_i],
502            popup_x + 6.0,
503            iy + (item_h - text_size) / 2.0,
504            text_size,
505            theme.text,
506        );
507    }
508
509    // Scroll indicators
510    let arrow_size = 8.0;
511    let cx = popup_x + popup_w / 2.0;
512    if scroll_offset > 0 {
513        let aw = ctx.text_width("\u{25B2}", arrow_size);
514        ctx.draw_text(
515            "\u{25B2}",
516            cx - aw / 2.0,
517            popup_y + 1.0,
518            arrow_size,
519            theme.text_dim,
520        );
521    }
522    if visible_end < options.len() {
523        let aw = ctx.text_width("\u{25BC}", arrow_size);
524        ctx.draw_text(
525            "\u{25BC}",
526            cx - aw / 2.0,
527            popup_y + popup_h - arrow_size - 1.0,
528            arrow_size,
529            theme.text_dim,
530        );
531    }
532}
533
534/// Draw a vertical level meter with one or more channels.
535///
536/// Each level is 0.0–1.0 (linear, not dB).
537// Channel counts and indices → f32 for geometry; both are bounded
538// by `num_channels` (typically ≤ 8).
539#[allow(clippy::cast_precision_loss)]
540pub fn draw_meter(
541    ctx: &mut dyn RenderBackend,
542    x: f32,
543    y: f32,
544    width: f32,
545    height: f32,
546    levels: &[f32],
547    label: &str,
548    theme: &Theme,
549) {
550    let cx = x + width / 2.0;
551    let num = levels.len().max(1);
552    let bar_w = 4.0f32;
553    let gap = 2.0f32;
554    let total_bar_w = num as f32 * bar_w + (num as f32 - 1.0).max(0.0) * gap;
555    let bar_h = height - 4.0; // fill nearly full height
556    let bar_start_x = cx - total_bar_w / 2.0;
557    let bar_y = y + 2.0;
558
559    for (i, &level) in levels.iter().enumerate() {
560        let bx = bar_start_x + i as f32 * (bar_w + gap);
561
562        // Background
563        ctx.fill_rect(bx, bar_y, bar_w, bar_h, theme.knob_track);
564
565        // dB-scaled fill from bottom
566        let display = truce_core::meter_display(level);
567        let fill_h = bar_h * display;
568        if fill_h > 0.5 {
569            // Blue normally, red when clipping (> -3 dB ≈ display > 0.95)
570            let color = if display > 0.95 {
571                Color::rgb(0.88, 0.27, 0.27)
572            } else {
573                theme.knob_fill
574            };
575            ctx.fill_rect(bx, bar_y + bar_h - fill_h, bar_w, fill_h, color);
576        }
577    }
578
579    // Label (below the widget, same position as knob labels)
580    let label_size = 8.0;
581    let label_w = ctx.text_width(label, label_size);
582    ctx.draw_text(
583        label,
584        cx - label_w / 2.0,
585        y + height + 4.0,
586        label_size,
587        theme.text_dim,
588    );
589}
590
591/// Draw an XY pad (2D control for two parameters).
592///
593/// `value_x` and `value_y` are normalized 0.0–1.0.
594pub fn draw_xy_pad(
595    ctx: &mut dyn RenderBackend,
596    x: f32,
597    y: f32,
598    width: f32,
599    height: f32,
600    value_x: f32,
601    value_y: f32,
602    label_x: &str,
603    label_y: &str,
604    theme: &Theme,
605    highlighted: bool,
606) {
607    let pad_margin = 4.0;
608    let pad_x = x + pad_margin;
609    let pad_y = y + pad_margin;
610    let pad_w = width - pad_margin * 2.0;
611    let pad_h = height - pad_margin * 2.0;
612
613    // Background
614    ctx.fill_rect(pad_x, pad_y, pad_w, pad_h, theme.knob_track);
615
616    // Crosshair lines
617    let dot_x = pad_x + value_x.clamp(0.0, 1.0) * pad_w;
618    let dot_y = pad_y + (1.0 - value_y.clamp(0.0, 1.0)) * pad_h; // invert Y
619    let line_color = theme.text_dim;
620    ctx.draw_line(dot_x, pad_y, dot_x, pad_y + pad_h, line_color, 1.0);
621    ctx.draw_line(pad_x, dot_y, pad_x + pad_w, dot_y, line_color, 1.0);
622
623    // Dot at intersection
624    let dot_color = if highlighted {
625        theme.accent
626    } else {
627        theme.knob_fill
628    };
629    ctx.fill_circle(dot_x, dot_y, 3.0, dot_color);
630    ctx.fill_circle(dot_x, dot_y, 2.0, theme.knob_pointer);
631
632    // Border
633    if highlighted {
634        ctx.draw_line(pad_x, pad_y, pad_x + pad_w, pad_y, theme.accent, 1.0);
635        ctx.draw_line(
636            pad_x + pad_w,
637            pad_y,
638            pad_x + pad_w,
639            pad_y + pad_h,
640            theme.accent,
641            1.0,
642        );
643        ctx.draw_line(
644            pad_x + pad_w,
645            pad_y + pad_h,
646            pad_x,
647            pad_y + pad_h,
648            theme.accent,
649            1.0,
650        );
651        ctx.draw_line(pad_x, pad_y + pad_h, pad_x, pad_y, theme.accent, 1.0);
652    }
653
654    // Axis labels: X below the widget (like knob labels), Y at top-left inside pad
655    let label_size = 8.0;
656    let x_label_w = ctx.text_width(label_x, label_size);
657    let cx = x + width / 2.0;
658    ctx.draw_text(
659        label_x,
660        cx - x_label_w / 2.0,
661        y + height + 3.0,
662        label_size,
663        theme.text_dim,
664    );
665
666    if !label_y.is_empty() {
667        ctx.draw_text(
668            label_y,
669            pad_x + 2.0,
670            pad_y + 1.0,
671            label_size,
672            theme.text_dim,
673        );
674    }
675}
676
677/// Draw a group/section label.
678pub fn draw_section_label(
679    ctx: &mut dyn RenderBackend,
680    x: f32,
681    y: f32,
682    w: f32,
683    label: &str,
684    theme: &Theme,
685) {
686    let size = 9.0;
687    let label_w = ctx.text_width(label, size);
688    ctx.draw_text(label, x + (w - label_w) / 2.0, y, size, theme.text_dim);
689}
690
691// ---------------------------------------------------------------------------
692// Public compositor - draws an entire layout in one call.
693// ---------------------------------------------------------------------------
694
695/// Render every widget in `layout` onto `backend` using `theme`,
696/// reading live values from `snapshot` and interaction flags from
697/// `state`.
698///
699/// Does not call `backend.clear()` or `backend.present()` - the caller
700/// owns the surrounding frame. This lets plugins with custom renderers
701/// draw their own content first (or last) and still get the same widget
702/// chrome as `BuiltinEditor`.
703///
704/// `state.knob_regions` is expected to be up to date for `layout`;
705/// callers typically call `state.build_regions_any(layout)` after any
706/// layout change. `draw` updates `dropdown_anchor_y` on each region it
707/// draws so that subsequent dropdown opens via `interaction::dispatch`
708/// position the popup under the current button.
709pub fn draw(
710    backend: &mut dyn RenderBackend,
711    layout: &Layout,
712    theme: &Theme,
713    snapshot: &ParamSnapshot<'_>,
714    state: &mut InteractionState,
715) {
716    match layout {
717        Layout::Rows(pl) => draw_rows(backend, pl, theme, snapshot, state),
718        Layout::Grid(gl) => draw_grid(backend, gl, theme, snapshot, state),
719    }
720    draw_dropdown_overlay(backend, theme, state);
721}
722
723fn resolve_wkind_to_type(
724    kind: Option<WidgetKind>,
725    param_id: u32,
726    snapshot: &ParamSnapshot<'_>,
727) -> WidgetType {
728    match kind {
729        Some(WidgetKind::Knob) => WidgetType::Knob,
730        Some(WidgetKind::Slider) => WidgetType::Slider,
731        Some(WidgetKind::Toggle) => WidgetType::Toggle,
732        Some(WidgetKind::Selector) => WidgetType::Selector,
733        Some(WidgetKind::Dropdown) => WidgetType::Dropdown,
734        Some(WidgetKind::Meter) => WidgetType::Meter,
735        Some(WidgetKind::XYPad) => WidgetType::XYPad,
736        None => (snapshot.widget_type)(param_id),
737    }
738}
739
740// Window dimensions and widget indices → f32 for geometry; bounded
741// by widget count (< 1000) and pixel dimensions (< 16384).
742#[allow(clippy::cast_precision_loss)]
743fn draw_rows(
744    backend: &mut dyn RenderBackend,
745    pl: &PluginLayout,
746    theme: &Theme,
747    snapshot: &ParamSnapshot<'_>,
748    state: &mut InteractionState,
749) {
750    let w = pl.width;
751    let knob_size = pl.knob_size;
752    let pitch = knob_size + ROWS_COLUMN_GAP;
753    if !pl.titles.is_empty() {
754        draw_header(
755            backend,
756            0.0,
757            0.0,
758            w as f32,
759            HEADER_HEIGHT,
760            pl.titles.title,
761            pl.titles.subtitle,
762            theme,
763        );
764    }
765
766    let mut y = ROWS_LAYOUT_TOP;
767    let mut region_idx = 0usize;
768
769    for row in &pl.rows {
770        if let Some(label) = row.label {
771            draw_section_label(backend, 0.0, y, w as f32, label, theme);
772            y += ROWS_SECTION_LABEL_HEIGHT;
773        }
774
775        let total_cols: u32 = row.knobs.iter().map(|k| k.span.max(1)).sum();
776        let total_w = total_cols as f32 * pitch - ROWS_COLUMN_GAP;
777        let start_x = (w as f32 - total_w) / 2.0;
778
779        let mut col = 0u32;
780        for kd in &row.knobs {
781            let span = kd.span.max(1);
782            let x = start_x + col as f32 * pitch;
783            let widget_w = span as f32 * pitch - ROWS_COLUMN_GAP;
784            let widget_h = knob_size;
785
786            draw_widget_entry(
787                &mut WidgetDrawCtx {
788                    backend,
789                    theme,
790                    snapshot,
791                    state,
792                },
793                &WidgetDraw {
794                    region_idx,
795                    x,
796                    y,
797                    w: widget_w,
798                    h: widget_h,
799                    param_id: kd.param_id,
800                    param_id_y: kd.param_id_y,
801                    meter_ids: kd.meter_ids.as_deref(),
802                    label: kd.label,
803                    explicit_kind: kd.widget,
804                    center_knob_in_cell: false, // rows: never center the knob in its cell
805                },
806            );
807
808            region_idx += 1;
809            col += span;
810        }
811
812        y += knob_size + ROWS_ROW_GAP;
813    }
814}
815
816// Window dimensions and grid indices → f32 for geometry; bounded
817// by grid size (< 1000 cells) and pixel dimensions (< 16384).
818#[allow(clippy::cast_precision_loss)]
819fn draw_grid(
820    backend: &mut dyn RenderBackend,
821    grid: &GridLayout,
822    theme: &Theme,
823    snapshot: &ParamSnapshot<'_>,
824    state: &mut InteractionState,
825) {
826    let w = grid.width;
827    if !grid.titles.is_empty() {
828        draw_header(
829            backend,
830            0.0,
831            0.0,
832            w as f32,
833            HEADER_HEIGHT,
834            grid.titles.title,
835            grid.titles.subtitle,
836            theme,
837        );
838    }
839
840    let header_h = grid.header_height();
841    let section_offsets = compute_section_offsets(grid);
842
843    for &(row_idx, label) in &grid.sections {
844        let y = header_h
845            + GRID_PADDING
846            + row_idx as f32 * (grid.cell_size + GRID_GAP)
847            + section_offsets[row_idx as usize]
848            - GRID_SECTION_H;
849        draw_section_label(backend, 0.0, y, w as f32, label, theme);
850    }
851
852    for (idx, gw) in grid.widgets.iter().enumerate() {
853        let x = GRID_PADDING + gw.col as f32 * (grid.cell_size + GRID_GAP);
854        let y = header_h
855            + GRID_PADDING
856            + gw.row as f32 * (grid.cell_size + GRID_GAP)
857            + section_offsets[gw.row as usize];
858        let widget_w = gw.col_span as f32 * (grid.cell_size + GRID_GAP) - GRID_GAP;
859        let widget_h = gw.row_span as f32 * (grid.cell_size + GRID_GAP) - GRID_GAP;
860
861        draw_widget_entry(
862            &mut WidgetDrawCtx {
863                backend,
864                theme,
865                snapshot,
866                state,
867            },
868            &WidgetDraw {
869                region_idx: idx,
870                x,
871                y,
872                w: widget_w,
873                h: widget_h,
874                param_id: gw.param_id,
875                param_id_y: gw.param_id_y,
876                meter_ids: gw.meter_ids.as_deref(),
877                label: gw.label,
878                explicit_kind: gw.widget,
879                center_knob_in_cell: true, // grid: center knobs within their cell
880            },
881        );
882    }
883}
884
885/// Per-call arguments to [`draw_widget_entry`] - what's being
886/// drawn, where, and which `InteractionState` region it owns.
887/// Splits cleanly from [`WidgetDrawCtx`] (the rendering
888/// infrastructure) so the function signature reads as
889/// `(ctx, widget)` rather than 14 positional parameters.
890struct WidgetDraw<'a> {
891    region_idx: usize,
892    /// Top-left of the widget's rect, in logical points.
893    x: f32,
894    y: f32,
895    w: f32,
896    h: f32,
897    param_id: u32,
898    /// Only `WidgetType::XYPad` reads this - the second axis's
899    /// param. `None` falls back to `param_id` for one-axis widgets
900    /// that accidentally route through the XY path.
901    param_id_y: Option<u32>,
902    /// Only `WidgetType::Meter` reads this - the meter IDs to
903    /// sample. `None` falls back to `[param_id]`.
904    meter_ids: Option<&'a [u32]>,
905    label: &'static str,
906    explicit_kind: Option<WidgetKind>,
907    /// When `true`, knobs are centered inside their cell (grid
908    /// layout). When `false`, knobs left-align (row layout).
909    center_knob_in_cell: bool,
910}
911
912/// Rendering infrastructure shared across every widget in a frame:
913/// the rendering backend, theme, snapshot of parameter values, and
914/// the running interaction state.
915struct WidgetDrawCtx<'a> {
916    backend: &'a mut dyn RenderBackend,
917    theme: &'a Theme,
918    snapshot: &'a ParamSnapshot<'a>,
919    state: &'a mut InteractionState,
920}
921
922fn draw_widget_entry(ctx: &mut WidgetDrawCtx<'_>, w: &WidgetDraw<'_>) {
923    let normalized = (ctx.snapshot.get_param)(w.param_id);
924    let value_text = (ctx.snapshot.format_param)(w.param_id);
925    let is_hovered = ctx.state.hover_idx == Some(w.region_idx);
926    let wtype = resolve_wkind_to_type(w.explicit_kind, w.param_id, ctx.snapshot);
927
928    match wtype {
929        WidgetType::Toggle => draw_toggle(
930            ctx.backend,
931            w.x,
932            w.y,
933            w.w,
934            w.h,
935            normalized,
936            w.label,
937            &value_text,
938            ctx.theme,
939            is_hovered,
940        ),
941        WidgetType::Slider => draw_slider(
942            ctx.backend,
943            w.x,
944            w.y,
945            w.w,
946            w.h,
947            normalized,
948            w.label,
949            &value_text,
950            ctx.theme,
951            is_hovered,
952        ),
953        WidgetType::Selector => draw_selector(
954            ctx.backend,
955            w.x,
956            w.y,
957            w.w,
958            w.h,
959            normalized,
960            w.label,
961            &value_text,
962            ctx.theme,
963            is_hovered,
964        ),
965        WidgetType::Dropdown => {
966            let is_open = ctx
967                .state
968                .dropdown
969                .as_ref()
970                .is_some_and(|dd| dd.region_idx == w.region_idx);
971            draw_dropdown(
972                ctx.backend,
973                w.x,
974                w.y,
975                w.w,
976                w.h,
977                normalized,
978                w.label,
979                &value_text,
980                ctx.theme,
981                is_hovered,
982                is_open,
983            );
984            // The visible button box is `DROPDOWN_BOX_HEIGHT` tall,
985            // centered on `cy = y + h/2 - 8`. Store the *bottom* of
986            // that box so `open_dropdown` can anchor the popup
987            // directly underneath.
988            let anchor_cy = w.y + w.h / 2.0 - 8.0;
989            if let Some(region) = ctx.state.knob_regions.get_mut(w.region_idx) {
990                region.dropdown_anchor_y = anchor_cy + DROPDOWN_BOX_HEIGHT / 2.0;
991            }
992        }
993        WidgetType::Meter => {
994            let fallback = [w.param_id];
995            let ids = w.meter_ids.unwrap_or(&fallback);
996            let levels: Vec<f32> = ids.iter().map(|&id| (ctx.snapshot.get_meter)(id)).collect();
997            draw_meter(ctx.backend, w.x, w.y, w.w, w.h, &levels, w.label, ctx.theme);
998        }
999        WidgetType::XYPad => {
1000            let val_y_id = w.param_id_y.unwrap_or(w.param_id);
1001            let vx = (ctx.snapshot.get_param)(w.param_id);
1002            let vy = (ctx.snapshot.get_param)(val_y_id);
1003            let x_name_str = (ctx.snapshot.param_name)(w.param_id);
1004            let y_name_str = (ctx.snapshot.param_name)(val_y_id);
1005            let x_name: &str = if x_name_str.is_empty() {
1006                w.label
1007            } else {
1008                &x_name_str
1009            };
1010            let y_name: &str = &y_name_str;
1011            draw_xy_pad(
1012                ctx.backend,
1013                w.x,
1014                w.y,
1015                w.w,
1016                w.h,
1017                vx,
1018                vy,
1019                x_name,
1020                y_name,
1021                ctx.theme,
1022                is_hovered,
1023            );
1024        }
1025        WidgetType::Knob => {
1026            if w.center_knob_in_cell {
1027                let knob_size = w.w.min(w.h);
1028                let kx = w.x + (w.w - knob_size) / 2.0;
1029                let ky = w.y + (w.h - knob_size) / 2.0;
1030                draw_knob(
1031                    ctx.backend,
1032                    kx,
1033                    ky,
1034                    knob_size,
1035                    normalized,
1036                    w.label,
1037                    &value_text,
1038                    ctx.theme,
1039                    is_hovered,
1040                );
1041            } else {
1042                draw_knob(
1043                    ctx.backend,
1044                    w.x,
1045                    w.y,
1046                    w.h,
1047                    normalized,
1048                    w.label,
1049                    &value_text,
1050                    ctx.theme,
1051                    is_hovered,
1052                );
1053            }
1054        }
1055    }
1056}
1057
1058fn draw_dropdown_overlay(backend: &mut dyn RenderBackend, theme: &Theme, state: &InteractionState) {
1059    if let Some(ref dd) = state.dropdown {
1060        let (px, py, pw, _) = dd.popup_rect;
1061        draw_dropdown_popup(
1062            backend,
1063            px,
1064            py,
1065            pw,
1066            &dd.options,
1067            dd.selected,
1068            dd.hover_option,
1069            dd.scroll_offset,
1070            dd.visible_count,
1071            theme,
1072        );
1073    }
1074}