Skip to main content

truce_gui/
widgets.rs

1//! Audio plugin UI widgets: knobs, sliders, toggles, labels, headers.
2
3use std::f32::consts::PI;
4
5use crate::render::RenderBackend;
6use crate::theme::Theme;
7
8/// Widget type for interaction state tracking.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum WidgetType {
11    Knob,
12    Slider,
13    Toggle,
14    Selector,
15    Meter,
16    XYPad,
17}
18
19/// Draw a rotary knob.
20///
21/// `value` is normalized 0.0–1.0.
22/// `label` is shown below the knob.
23/// `value_text` is shown below the label.
24pub fn draw_knob(
25    ctx: &mut dyn RenderBackend,
26    x: f32,
27    y: f32,
28    size: f32,
29    value: f32,
30    label: &str,
31    value_text: &str,
32    theme: &Theme,
33    highlighted: bool,
34) {
35    let cx = x + size / 2.0;
36    let cy = y + size / 2.0 - 8.0; // leave room for label below
37    let radius = size / 2.0 - 6.0;
38
39    // Knob range: from 225° (bottom-left) to -45° (bottom-right), going clockwise
40    // In radians: 225° = 5π/4, -45° = -π/4 (or 315° = 7π/4)
41    let start_angle = 0.75 * PI; // 135° from 12 o'clock → 225° in standard math
42    let end_angle = 2.25 * PI; // 405° = 45° past full rotation
43    let arc_start = start_angle;
44    let arc_end = end_angle;
45
46    // Track arc (full range background)
47    ctx.stroke_arc(cx, cy, radius, arc_start, arc_end, theme.knob_track, 3.0);
48
49    // Value arc (filled portion)
50    let value_angle = arc_start + value * (arc_end - arc_start);
51    if value > 0.01 {
52        ctx.stroke_arc(cx, cy, radius, arc_start, value_angle, theme.knob_fill, 3.0);
53    }
54
55    // Pointer line from center to current position
56    let pointer_len = radius * 0.6;
57    let px = cx + pointer_len * value_angle.cos();
58    let py = cy + pointer_len * value_angle.sin();
59    ctx.draw_line(cx, cy, px, py, theme.knob_pointer, 2.0);
60
61    // Center dot
62    ctx.fill_circle(cx, cy, 3.0, theme.surface);
63
64    // Hover highlight ring
65    if highlighted {
66        ctx.stroke_arc(cx, cy, radius + 3.0, arc_start, arc_end, theme.accent, 1.5);
67    }
68
69    // Value text (below knob)
70    let val_size = 10.0;
71    let val_w = ctx.text_width(value_text, val_size);
72    ctx.draw_text(value_text, cx - val_w / 2.0, y + size - 2.0, val_size, theme.text);
73
74    // Label text (below value)
75    let label_size = 9.0;
76    let label_w = ctx.text_width(label, label_size);
77    ctx.draw_text(label, cx - label_w / 2.0, y + size + 10.0, label_size, theme.text_dim);
78}
79
80/// Draw a header bar.
81pub fn draw_header(
82    ctx: &mut dyn RenderBackend,
83    x: f32,
84    y: f32,
85    w: f32,
86    h: f32,
87    title: &str,
88    version: &str,
89    theme: &Theme,
90) {
91    ctx.fill_rect(x, y, w, h, theme.header_bg);
92
93    let title_size = 12.0;
94    ctx.draw_text(
95        title,
96        x + 10.0,
97        y + (h - title_size) / 2.0,
98        title_size,
99        theme.header_text,
100    );
101
102    let ver_size = 9.0;
103    let ver_w = ctx.text_width(version, ver_size);
104    ctx.draw_text(
105        version,
106        x + w - ver_w - 10.0,
107        y + (h - ver_size) / 2.0,
108        ver_size,
109        theme.text_dim,
110    );
111}
112
113/// Draw a horizontal slider.
114///
115/// `value` is normalized 0.0–1.0.
116pub fn draw_slider(
117    ctx: &mut dyn RenderBackend,
118    x: f32,
119    y: f32,
120    width: f32,
121    height: f32,
122    value: f32,
123    label: &str,
124    value_text: &str,
125    theme: &Theme,
126    highlighted: bool,
127) {
128    let track_y = y + height / 2.0 - 8.0;
129    let track_h = 4.0;
130    let margin = 6.0;
131    let track_w = width - margin * 2.0;
132
133    // Track background
134    ctx.fill_rect(x + margin, track_y, track_w, track_h, theme.knob_track);
135
136    // Filled portion
137    let fill_w = track_w * value;
138    if fill_w > 0.5 {
139        ctx.fill_rect(x + margin, track_y, fill_w, track_h, theme.knob_fill);
140    }
141
142    // Thumb
143    let thumb_x = x + margin + fill_w;
144    let thumb_r = 6.0;
145    ctx.fill_circle(thumb_x, track_y + track_h / 2.0, thumb_r, theme.knob_pointer);
146    if highlighted {
147        ctx.fill_circle(thumb_x, track_y + track_h / 2.0, thumb_r + 2.0, theme.accent);
148        ctx.fill_circle(thumb_x, track_y + track_h / 2.0, thumb_r, theme.knob_pointer);
149    }
150
151    // Value text
152    let val_size = 10.0;
153    let cx = x + width / 2.0;
154    let val_w = ctx.text_width(value_text, val_size);
155    ctx.draw_text(value_text, cx - val_w / 2.0, y + height - 2.0, val_size, theme.text);
156
157    // Label
158    let label_size = 9.0;
159    let label_w = ctx.text_width(label, label_size);
160    ctx.draw_text(label, cx - label_w / 2.0, y + height + 10.0, label_size, theme.text_dim);
161}
162
163/// Draw a toggle button (on/off).
164///
165/// `value` > 0.5 = on, <= 0.5 = off.
166pub fn draw_toggle(
167    ctx: &mut dyn RenderBackend,
168    x: f32,
169    y: f32,
170    width: f32,
171    height: f32,
172    value: f32,
173    label: &str,
174    value_text: &str,
175    theme: &Theme,
176    highlighted: bool,
177) {
178    let is_on = value > 0.5;
179    let cx = x + width / 2.0;
180    let cy = y + height / 2.0 - 8.0;
181
182    // Toggle track (pill shape)
183    let track_w = 32.0;
184    let track_h = 16.0;
185    let track_x = cx - track_w / 2.0;
186    let track_y = cy - track_h / 2.0;
187    let bg = if is_on { theme.knob_fill } else { theme.knob_track };
188    ctx.fill_rect(track_x, track_y, track_w, track_h, bg);
189
190    // Thumb circle
191    let thumb_x = if is_on {
192        track_x + track_w - track_h / 2.0
193    } else {
194        track_x + track_h / 2.0
195    };
196    ctx.fill_circle(thumb_x, cy, track_h / 2.0 - 2.0, theme.knob_pointer);
197
198    if highlighted {
199        ctx.fill_rect(track_x - 2.0, track_y - 2.0, track_w + 4.0, track_h + 4.0, theme.accent);
200        ctx.fill_rect(track_x, track_y, track_w, track_h, bg);
201        ctx.fill_circle(thumb_x, cy, track_h / 2.0 - 2.0, theme.knob_pointer);
202    }
203
204    // Value text
205    let val_size = 10.0;
206    let val_w = ctx.text_width(value_text, val_size);
207    ctx.draw_text(value_text, cx - val_w / 2.0, y + height - 2.0, val_size, theme.text);
208
209    // Label
210    let label_size = 9.0;
211    let label_w = ctx.text_width(label, label_size);
212    ctx.draw_text(label, cx - label_w / 2.0, y + height + 10.0, label_size, theme.text_dim);
213}
214
215/// Draw a selector (enum parameter — click to cycle through values).
216///
217/// Shows the current value name with < > arrows.
218pub fn draw_selector(
219    ctx: &mut dyn RenderBackend,
220    x: f32,
221    y: f32,
222    width: f32,
223    height: f32,
224    _value: f32,
225    label: &str,
226    value_text: &str,
227    theme: &Theme,
228    highlighted: bool,
229) {
230    let cx = x + width / 2.0;
231    let cy = y + height / 2.0 - 8.0;
232
233    // Background box — size to fit content
234    let val_size = 10.0;
235    let arrow_size = 8.0;
236    let arrow_pad = 14.0; // space for arrow on each side
237    let val_w = ctx.text_width(value_text, val_size);
238    let box_w = (val_w + arrow_pad * 2.0 + 8.0).max(width - 12.0);
239    let box_h = 20.0;
240    let box_x = cx - box_w / 2.0;
241    let box_y = cy - box_h / 2.0;
242    let bg = if highlighted { theme.accent } else { theme.knob_track };
243    ctx.fill_rect(box_x, box_y, box_w, box_h, bg);
244
245    // Value text (centered)
246    ctx.draw_text(
247        value_text,
248        cx - val_w / 2.0,
249        cy - val_size / 2.0,
250        val_size,
251        theme.text,
252    );
253
254    // Left/right arrows
255    ctx.draw_text("<", box_x + 4.0, cy - arrow_size / 2.0, arrow_size, theme.text_dim);
256    let gt_w = ctx.text_width(">", arrow_size);
257    ctx.draw_text(">", box_x + box_w - gt_w - 4.0, cy - arrow_size / 2.0, arrow_size, theme.text_dim);
258
259    // Label (below)
260    let label_size = 9.0;
261    let label_w = ctx.text_width(label, label_size);
262    ctx.draw_text(label, cx - label_w / 2.0, y + height + 10.0, label_size, theme.text_dim);
263}
264
265/// Draw a vertical level meter with one or more channels.
266///
267/// Each level is 0.0–1.0 (linear, not dB).
268pub fn draw_meter(
269    ctx: &mut dyn RenderBackend,
270    x: f32,
271    y: f32,
272    width: f32,
273    height: f32,
274    levels: &[f32],
275    label: &str,
276    theme: &Theme,
277) {
278    let cx = x + width / 2.0;
279    let num = levels.len().max(1);
280    let bar_w = 6.0f32;
281    let gap = 2.0f32;
282    let total_bar_w = num as f32 * bar_w + (num as f32 - 1.0).max(0.0) * gap;
283    let bar_h = height - 4.0; // fill nearly full height
284    let bar_start_x = cx - total_bar_w / 2.0;
285    let bar_y = y + 2.0;
286
287    for (i, &level) in levels.iter().enumerate() {
288        let bx = bar_start_x + i as f32 * (bar_w + gap);
289
290        // Background
291        ctx.fill_rect(bx, bar_y, bar_w, bar_h, theme.knob_track);
292
293        // Fill from bottom
294        let fill_h = bar_h * level.clamp(0.0, 1.0);
295        if fill_h > 0.5 {
296            let color = if level > 0.9 { theme.accent } else { theme.knob_fill };
297            ctx.fill_rect(bx, bar_y + bar_h - fill_h, bar_w, fill_h, color);
298        }
299    }
300
301    // Label (below the widget, same position as knob labels)
302    let label_size = 8.0;
303    let label_w = ctx.text_width(label, label_size);
304    ctx.draw_text(label, cx - label_w / 2.0, y + height + 4.0, label_size, theme.text_dim);
305}
306
307/// Draw an XY pad (2D control for two parameters).
308///
309/// `value_x` and `value_y` are normalized 0.0–1.0.
310pub fn draw_xy_pad(
311    ctx: &mut dyn RenderBackend,
312    x: f32,
313    y: f32,
314    width: f32,
315    height: f32,
316    value_x: f32,
317    value_y: f32,
318    label_x: &str,
319    label_y: &str,
320    theme: &Theme,
321    highlighted: bool,
322) {
323    let pad_margin = 4.0;
324    let pad_x = x + pad_margin;
325    let pad_y = y + pad_margin;
326    let pad_w = width - pad_margin * 2.0;
327    let pad_h = height - pad_margin * 2.0;
328
329    // Background
330    ctx.fill_rect(pad_x, pad_y, pad_w, pad_h, theme.knob_track);
331
332    // Crosshair lines
333    let dot_x = pad_x + value_x.clamp(0.0, 1.0) * pad_w;
334    let dot_y = pad_y + (1.0 - value_y.clamp(0.0, 1.0)) * pad_h; // invert Y
335    let line_color = theme.text_dim;
336    ctx.draw_line(dot_x, pad_y, dot_x, pad_y + pad_h, line_color, 1.0);
337    ctx.draw_line(pad_x, dot_y, pad_x + pad_w, dot_y, line_color, 1.0);
338
339    // Dot at intersection
340    let dot_color = if highlighted { theme.accent } else { theme.knob_fill };
341    ctx.fill_circle(dot_x, dot_y, 5.0, dot_color);
342    ctx.fill_circle(dot_x, dot_y, 3.0, theme.knob_pointer);
343
344    // Border
345    if highlighted {
346        ctx.draw_line(pad_x, pad_y, pad_x + pad_w, pad_y, theme.accent, 1.5);
347        ctx.draw_line(pad_x + pad_w, pad_y, pad_x + pad_w, pad_y + pad_h, theme.accent, 1.5);
348        ctx.draw_line(pad_x + pad_w, pad_y + pad_h, pad_x, pad_y + pad_h, theme.accent, 1.5);
349        ctx.draw_line(pad_x, pad_y + pad_h, pad_x, pad_y, theme.accent, 1.5);
350    }
351
352    // Axis labels: X below the widget (like knob labels), Y at top-left inside pad
353    let label_size = 8.0;
354    let x_label_w = ctx.text_width(label_x, label_size);
355    let cx = x + width / 2.0;
356    ctx.draw_text(label_x, cx - x_label_w / 2.0, y + height + 4.0, label_size, theme.text_dim);
357
358    if !label_y.is_empty() {
359        ctx.draw_text(label_y, pad_x + 3.0, pad_y + 2.0, label_size, theme.text_dim);
360    }
361}
362
363/// Draw a group/section label.
364pub fn draw_section_label(
365    ctx: &mut dyn RenderBackend,
366    x: f32,
367    y: f32,
368    w: f32,
369    label: &str,
370    theme: &Theme,
371) {
372    let size = 9.0;
373    let label_w = ctx.text_width(label, size);
374    ctx.draw_text(label, x + (w - label_w) / 2.0, y, size, theme.text_dim);
375}