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