use std::f32::consts::PI;
use crate::interaction::InteractionState;
use crate::layout::{
DROPDOWN_BOX_HEIGHT, GRID_GAP, GRID_PADDING, GRID_SECTION_H, GridLayout, HEADER_HEIGHT, Layout,
PluginLayout, ROWS_COLUMN_GAP, ROWS_LAYOUT_TOP, ROWS_ROW_GAP, ROWS_SECTION_LABEL_HEIGHT,
WidgetKind, compute_section_offsets,
};
use crate::render::RenderBackend;
use crate::snapshot::ParamSnapshot;
use crate::theme::{Color, Theme};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WidgetType {
Knob,
Slider,
Toggle,
Selector,
Dropdown,
Meter,
XYPad,
}
pub fn draw_knob(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
size: f32,
value: f32,
label: &str,
value_text: &str,
theme: &Theme,
highlighted: bool,
) {
let cx = x + size / 2.0;
let cy = y + size / 2.0 - 5.0; let radius = size / 2.0 - 4.0;
let start_angle = 0.75 * PI; let end_angle = 2.25 * PI; let arc_start = start_angle;
let arc_end = end_angle;
ctx.stroke_arc(cx, cy, radius, arc_start, arc_end, theme.knob_track, 2.0);
let value_angle = arc_start + value * (arc_end - arc_start);
if value > 0.01 {
ctx.stroke_arc(cx, cy, radius, arc_start, value_angle, theme.knob_fill, 2.0);
}
let pointer_len = radius * 0.6;
let px = cx + pointer_len * value_angle.cos();
let py = cy + pointer_len * value_angle.sin();
ctx.draw_line(cx, cy, px, py, theme.knob_pointer, 1.5);
if highlighted {
ctx.stroke_arc(cx, cy, radius + 2.0, arc_start, arc_end, theme.accent, 1.0);
}
let val_size = 10.0;
let val_w = ctx.text_width(value_text, val_size);
ctx.draw_text(
value_text,
cx - val_w / 2.0,
y + size - 9.0,
val_size,
theme.text,
);
let label_size = 9.0;
let label_w = ctx.text_width(label, label_size);
ctx.draw_text(
label,
cx - label_w / 2.0,
y + size + 2.0,
label_size,
theme.text_dim,
);
}
pub fn draw_header(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
w: f32,
h: f32,
title: Option<&str>,
subtitle: Option<&str>,
theme: &Theme,
) {
ctx.fill_rect(x, y, w, h, theme.header_bg);
if let Some(title) = title {
let title_size = 12.0;
ctx.draw_text(
title,
x + 10.0,
y + (h - title_size) / 2.0 - 1.0,
title_size,
theme.header_text,
);
}
if let Some(subtitle) = subtitle {
let sub_size = 9.0;
let sub_w = ctx.text_width(subtitle, sub_size);
ctx.draw_text(
subtitle,
x + w - sub_w - 10.0,
y + (h - sub_size) / 2.0 - 1.0,
sub_size,
theme.text_dim,
);
}
}
pub fn draw_slider(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
width: f32,
height: f32,
value: f32,
label: &str,
value_text: &str,
theme: &Theme,
highlighted: bool,
) {
let track_y = y + height / 2.0 - 5.0;
let track_h = 3.0;
let margin = 4.0;
let track_w = width - margin * 2.0;
ctx.fill_rect(x + margin, track_y, track_w, track_h, theme.knob_track);
let fill_w = track_w * value;
if fill_w > 0.5 {
ctx.fill_rect(x + margin, track_y, fill_w, track_h, theme.knob_fill);
}
let thumb_x = x + margin + fill_w;
let thumb_r = 4.0;
ctx.fill_circle(
thumb_x,
track_y + track_h / 2.0,
thumb_r,
theme.knob_pointer,
);
if highlighted {
ctx.fill_circle(
thumb_x,
track_y + track_h / 2.0,
thumb_r + 1.5,
theme.accent,
);
ctx.fill_circle(
thumb_x,
track_y + track_h / 2.0,
thumb_r,
theme.knob_pointer,
);
}
let val_size = 10.0;
let cx = x + width / 2.0;
let val_w = ctx.text_width(value_text, val_size);
ctx.draw_text(
value_text,
cx - val_w / 2.0,
y + height - 9.0,
val_size,
theme.text,
);
let label_size = 9.0;
let label_w = ctx.text_width(label, label_size);
ctx.draw_text(
label,
cx - label_w / 2.0,
y + height + 2.0,
label_size,
theme.text_dim,
);
}
pub fn draw_toggle(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
width: f32,
height: f32,
value: f32,
label: &str,
value_text: &str,
theme: &Theme,
highlighted: bool,
) {
let is_on = value > 0.5;
let cx = x + width / 2.0;
let cy = y + height / 2.0 - 5.0;
let track_w = 20.0;
let track_h = 10.0;
let track_x = cx - track_w / 2.0;
let track_y = cy - track_h / 2.0;
let bg = if is_on {
theme.knob_fill
} else {
theme.knob_track
};
ctx.fill_rect(track_x, track_y, track_w, track_h, bg);
let thumb_x = if is_on {
track_x + track_w - track_h / 2.0
} else {
track_x + track_h / 2.0
};
ctx.fill_circle(thumb_x, cy, track_h / 2.0 - 1.0, theme.knob_pointer);
if highlighted {
ctx.fill_rect(
track_x - 1.0,
track_y - 1.0,
track_w + 2.0,
track_h + 2.0,
theme.accent,
);
ctx.fill_rect(track_x, track_y, track_w, track_h, bg);
ctx.fill_circle(thumb_x, cy, track_h / 2.0 - 1.0, theme.knob_pointer);
}
let val_size = 10.0;
let val_w = ctx.text_width(value_text, val_size);
ctx.draw_text(
value_text,
cx - val_w / 2.0,
y + height - 9.0,
val_size,
theme.text,
);
let label_size = 9.0;
let label_w = ctx.text_width(label, label_size);
ctx.draw_text(
label,
cx - label_w / 2.0,
y + height + 2.0,
label_size,
theme.text_dim,
);
}
pub fn draw_selector(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
width: f32,
height: f32,
_value: f32,
label: &str,
value_text: &str,
theme: &Theme,
highlighted: bool,
) {
let cx = x + width / 2.0;
let cy = y + height / 2.0 - 5.0;
let val_size = 10.0;
let arrow_size = 8.0;
let arrow_pad = 9.0; let val_w = ctx.text_width(value_text, val_size);
let box_w = (val_w + arrow_pad * 2.0 + 5.0).max(width - 8.0);
let box_h = 13.0;
let box_x = cx - box_w / 2.0;
let box_y = cy - box_h / 2.0;
let bg = if highlighted {
theme.accent
} else {
theme.knob_track
};
ctx.fill_rect(box_x, box_y, box_w, box_h, bg);
ctx.draw_text(
value_text,
cx - val_w / 2.0,
cy - val_size / 2.0,
val_size,
theme.text,
);
ctx.draw_text(
"<",
box_x + 3.0,
cy - arrow_size / 2.0,
arrow_size,
theme.text_dim,
);
let gt_w = ctx.text_width(">", arrow_size);
ctx.draw_text(
">",
box_x + box_w - gt_w - 3.0,
cy - arrow_size / 2.0,
arrow_size,
theme.text_dim,
);
let label_size = 9.0;
let label_w = ctx.text_width(label, label_size);
ctx.draw_text(
label,
cx - label_w / 2.0,
y + height + 2.0,
label_size,
theme.text_dim,
);
}
pub fn draw_dropdown(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
width: f32,
height: f32,
_value: f32,
label: &str,
value_text: &str,
theme: &Theme,
highlighted: bool,
is_open: bool,
) {
let cx = x + width / 2.0;
let cy = y + height / 2.0 - 8.0;
let val_size = 10.0;
let arrow_pad = 14.0;
let val_w = ctx.text_width(value_text, val_size);
let box_w = (val_w + arrow_pad + 12.0).max(width - 12.0);
let box_h = DROPDOWN_BOX_HEIGHT;
let box_x = cx - box_w / 2.0;
let box_y = cy - box_h / 2.0;
let bg = if is_open || highlighted {
theme.accent
} else {
theme.knob_track
};
ctx.fill_rect(box_x, box_y, box_w, box_h, bg);
ctx.draw_text(
value_text,
box_x + 6.0,
cy - val_size / 2.0,
val_size,
theme.text,
);
let arrow_size = 8.0;
let arrow = if is_open { "\u{25B2}" } else { "\u{25BC}" }; let aw = ctx.text_width(arrow, arrow_size);
ctx.draw_text(
arrow,
box_x + box_w - aw - 4.0,
cy - arrow_size / 2.0,
arrow_size,
theme.text_dim,
);
let label_size = 9.0;
let label_w = ctx.text_width(label, label_size);
ctx.draw_text(
label,
cx - label_w / 2.0,
y + height + 2.0,
label_size,
theme.text_dim,
);
}
#[allow(clippy::cast_precision_loss)]
pub fn draw_dropdown_popup(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
width: f32,
options: &[String],
selected_index: usize,
hover_index: Option<usize>,
scroll_offset: usize,
visible_count: usize,
theme: &Theme,
) {
let item_h = 18.0;
let padding = 4.0;
let popup_w = width.max(80.0);
let popup_h = visible_count as f32 * item_h + padding * 2.0;
let popup_x = x;
let popup_y = y;
ctx.fill_rect(popup_x, popup_y, popup_w, popup_h, theme.surface);
ctx.draw_line(
popup_x,
popup_y,
popup_x + popup_w,
popup_y,
theme.text_dim,
1.0,
);
ctx.draw_line(
popup_x + popup_w,
popup_y,
popup_x + popup_w,
popup_y + popup_h,
theme.text_dim,
1.0,
);
ctx.draw_line(
popup_x + popup_w,
popup_y + popup_h,
popup_x,
popup_y + popup_h,
theme.text_dim,
1.0,
);
ctx.draw_line(
popup_x,
popup_y + popup_h,
popup_x,
popup_y,
theme.text_dim,
1.0,
);
let text_size = 10.0;
let visible_end = (scroll_offset + visible_count).min(options.len());
for (vis_i, abs_i) in (scroll_offset..visible_end).enumerate() {
let iy = popup_y + padding + vis_i as f32 * item_h;
if hover_index == Some(abs_i) {
ctx.fill_rect(popup_x + 1.0, iy, popup_w - 2.0, item_h, theme.accent);
} else if abs_i == selected_index {
ctx.fill_rect(popup_x + 1.0, iy, popup_w - 2.0, item_h, theme.knob_track);
}
ctx.draw_text(
&options[abs_i],
popup_x + 6.0,
iy + (item_h - text_size) / 2.0,
text_size,
theme.text,
);
}
let arrow_size = 8.0;
let cx = popup_x + popup_w / 2.0;
if scroll_offset > 0 {
let aw = ctx.text_width("\u{25B2}", arrow_size);
ctx.draw_text(
"\u{25B2}",
cx - aw / 2.0,
popup_y + 1.0,
arrow_size,
theme.text_dim,
);
}
if visible_end < options.len() {
let aw = ctx.text_width("\u{25BC}", arrow_size);
ctx.draw_text(
"\u{25BC}",
cx - aw / 2.0,
popup_y + popup_h - arrow_size - 1.0,
arrow_size,
theme.text_dim,
);
}
}
#[allow(clippy::cast_precision_loss)]
pub fn draw_meter(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
width: f32,
height: f32,
levels: &[f32],
label: &str,
theme: &Theme,
) {
let cx = x + width / 2.0;
let num = levels.len().max(1);
let bar_w = 4.0f32;
let gap = 2.0f32;
let total_bar_w = num as f32 * bar_w + (num as f32 - 1.0).max(0.0) * gap;
let bar_h = height - 4.0; let bar_start_x = cx - total_bar_w / 2.0;
let bar_y = y + 2.0;
for (i, &level) in levels.iter().enumerate() {
let bx = bar_start_x + i as f32 * (bar_w + gap);
ctx.fill_rect(bx, bar_y, bar_w, bar_h, theme.knob_track);
let display = truce_core::meter_display(level);
let fill_h = bar_h * display;
if fill_h > 0.5 {
let color = if display > 0.95 {
Color::rgb(0.88, 0.27, 0.27)
} else {
theme.knob_fill
};
ctx.fill_rect(bx, bar_y + bar_h - fill_h, bar_w, fill_h, color);
}
}
let label_size = 8.0;
let label_w = ctx.text_width(label, label_size);
ctx.draw_text(
label,
cx - label_w / 2.0,
y + height + 4.0,
label_size,
theme.text_dim,
);
}
pub fn draw_xy_pad(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
width: f32,
height: f32,
value_x: f32,
value_y: f32,
label_x: &str,
label_y: &str,
theme: &Theme,
highlighted: bool,
) {
let pad_margin = 4.0;
let pad_x = x + pad_margin;
let pad_y = y + pad_margin;
let pad_w = width - pad_margin * 2.0;
let pad_h = height - pad_margin * 2.0;
ctx.fill_rect(pad_x, pad_y, pad_w, pad_h, theme.knob_track);
let dot_x = pad_x + value_x.clamp(0.0, 1.0) * pad_w;
let dot_y = pad_y + (1.0 - value_y.clamp(0.0, 1.0)) * pad_h; let line_color = theme.text_dim;
ctx.draw_line(dot_x, pad_y, dot_x, pad_y + pad_h, line_color, 1.0);
ctx.draw_line(pad_x, dot_y, pad_x + pad_w, dot_y, line_color, 1.0);
let dot_color = if highlighted {
theme.accent
} else {
theme.knob_fill
};
ctx.fill_circle(dot_x, dot_y, 3.0, dot_color);
ctx.fill_circle(dot_x, dot_y, 2.0, theme.knob_pointer);
if highlighted {
ctx.draw_line(pad_x, pad_y, pad_x + pad_w, pad_y, theme.accent, 1.0);
ctx.draw_line(
pad_x + pad_w,
pad_y,
pad_x + pad_w,
pad_y + pad_h,
theme.accent,
1.0,
);
ctx.draw_line(
pad_x + pad_w,
pad_y + pad_h,
pad_x,
pad_y + pad_h,
theme.accent,
1.0,
);
ctx.draw_line(pad_x, pad_y + pad_h, pad_x, pad_y, theme.accent, 1.0);
}
let label_size = 8.0;
let x_label_w = ctx.text_width(label_x, label_size);
let cx = x + width / 2.0;
ctx.draw_text(
label_x,
cx - x_label_w / 2.0,
y + height + 3.0,
label_size,
theme.text_dim,
);
if !label_y.is_empty() {
ctx.draw_text(
label_y,
pad_x + 2.0,
pad_y + 1.0,
label_size,
theme.text_dim,
);
}
}
pub fn draw_section_label(
ctx: &mut dyn RenderBackend,
x: f32,
y: f32,
w: f32,
label: &str,
theme: &Theme,
) {
let size = 9.0;
let label_w = ctx.text_width(label, size);
ctx.draw_text(label, x + (w - label_w) / 2.0, y, size, theme.text_dim);
}
pub fn draw(
backend: &mut dyn RenderBackend,
layout: &Layout,
theme: &Theme,
snapshot: &ParamSnapshot<'_>,
state: &mut InteractionState,
) {
match layout {
Layout::Rows(pl) => draw_rows(backend, pl, theme, snapshot, state),
Layout::Grid(gl) => draw_grid(backend, gl, theme, snapshot, state),
}
draw_dropdown_overlay(backend, theme, state);
}
fn resolve_wkind_to_type(
kind: Option<WidgetKind>,
param_id: u32,
snapshot: &ParamSnapshot<'_>,
) -> WidgetType {
match kind {
Some(WidgetKind::Knob) => WidgetType::Knob,
Some(WidgetKind::Slider) => WidgetType::Slider,
Some(WidgetKind::Toggle) => WidgetType::Toggle,
Some(WidgetKind::Selector) => WidgetType::Selector,
Some(WidgetKind::Dropdown) => WidgetType::Dropdown,
Some(WidgetKind::Meter) => WidgetType::Meter,
Some(WidgetKind::XYPad) => WidgetType::XYPad,
None => (snapshot.widget_type)(param_id),
}
}
#[allow(clippy::cast_precision_loss)]
fn draw_rows(
backend: &mut dyn RenderBackend,
pl: &PluginLayout,
theme: &Theme,
snapshot: &ParamSnapshot<'_>,
state: &mut InteractionState,
) {
let w = pl.width;
let knob_size = pl.knob_size;
let pitch = knob_size + ROWS_COLUMN_GAP;
if !pl.titles.is_empty() {
draw_header(
backend,
0.0,
0.0,
w as f32,
HEADER_HEIGHT,
pl.titles.title,
pl.titles.subtitle,
theme,
);
}
let mut y = ROWS_LAYOUT_TOP;
let mut region_idx = 0usize;
for row in &pl.rows {
if let Some(label) = row.label {
draw_section_label(backend, 0.0, y, w as f32, label, theme);
y += ROWS_SECTION_LABEL_HEIGHT;
}
let total_cols: u32 = row.knobs.iter().map(|k| k.span.max(1)).sum();
let total_w = total_cols as f32 * pitch - ROWS_COLUMN_GAP;
let start_x = (w as f32 - total_w) / 2.0;
let mut col = 0u32;
for kd in &row.knobs {
let span = kd.span.max(1);
let x = start_x + col as f32 * pitch;
let widget_w = span as f32 * pitch - ROWS_COLUMN_GAP;
let widget_h = knob_size;
draw_widget_entry(
&mut WidgetDrawCtx {
backend,
theme,
snapshot,
state,
},
&WidgetDraw {
region_idx,
x,
y,
w: widget_w,
h: widget_h,
param_id: kd.param_id,
param_id_y: kd.param_id_y,
meter_ids: kd.meter_ids.as_deref(),
label: kd.label,
explicit_kind: kd.widget,
center_knob_in_cell: false, },
);
region_idx += 1;
col += span;
}
y += knob_size + ROWS_ROW_GAP;
}
}
#[allow(clippy::cast_precision_loss)]
fn draw_grid(
backend: &mut dyn RenderBackend,
grid: &GridLayout,
theme: &Theme,
snapshot: &ParamSnapshot<'_>,
state: &mut InteractionState,
) {
let w = grid.width;
if !grid.titles.is_empty() {
draw_header(
backend,
0.0,
0.0,
w as f32,
HEADER_HEIGHT,
grid.titles.title,
grid.titles.subtitle,
theme,
);
}
let header_h = grid.header_height();
let section_offsets = compute_section_offsets(grid);
for &(row_idx, label) in &grid.sections {
let y = header_h
+ GRID_PADDING
+ row_idx as f32 * (grid.cell_size + GRID_GAP)
+ section_offsets[row_idx as usize]
- GRID_SECTION_H;
draw_section_label(backend, 0.0, y, w as f32, label, theme);
}
for (idx, gw) in grid.widgets.iter().enumerate() {
let x = GRID_PADDING + gw.col as f32 * (grid.cell_size + GRID_GAP);
let y = header_h
+ GRID_PADDING
+ gw.row as f32 * (grid.cell_size + GRID_GAP)
+ section_offsets[gw.row as usize];
let widget_w = gw.col_span as f32 * (grid.cell_size + GRID_GAP) - GRID_GAP;
let widget_h = gw.row_span as f32 * (grid.cell_size + GRID_GAP) - GRID_GAP;
draw_widget_entry(
&mut WidgetDrawCtx {
backend,
theme,
snapshot,
state,
},
&WidgetDraw {
region_idx: idx,
x,
y,
w: widget_w,
h: widget_h,
param_id: gw.param_id,
param_id_y: gw.param_id_y,
meter_ids: gw.meter_ids.as_deref(),
label: gw.label,
explicit_kind: gw.widget,
center_knob_in_cell: true, },
);
}
}
struct WidgetDraw<'a> {
region_idx: usize,
x: f32,
y: f32,
w: f32,
h: f32,
param_id: u32,
param_id_y: Option<u32>,
meter_ids: Option<&'a [u32]>,
label: &'static str,
explicit_kind: Option<WidgetKind>,
center_knob_in_cell: bool,
}
struct WidgetDrawCtx<'a> {
backend: &'a mut dyn RenderBackend,
theme: &'a Theme,
snapshot: &'a ParamSnapshot<'a>,
state: &'a mut InteractionState,
}
fn draw_widget_entry(ctx: &mut WidgetDrawCtx<'_>, w: &WidgetDraw<'_>) {
let normalized = (ctx.snapshot.get_param)(w.param_id);
let value_text = (ctx.snapshot.format_param)(w.param_id);
let is_hovered = ctx.state.hover_idx == Some(w.region_idx);
let wtype = resolve_wkind_to_type(w.explicit_kind, w.param_id, ctx.snapshot);
match wtype {
WidgetType::Toggle => draw_toggle(
ctx.backend,
w.x,
w.y,
w.w,
w.h,
normalized,
w.label,
&value_text,
ctx.theme,
is_hovered,
),
WidgetType::Slider => draw_slider(
ctx.backend,
w.x,
w.y,
w.w,
w.h,
normalized,
w.label,
&value_text,
ctx.theme,
is_hovered,
),
WidgetType::Selector => draw_selector(
ctx.backend,
w.x,
w.y,
w.w,
w.h,
normalized,
w.label,
&value_text,
ctx.theme,
is_hovered,
),
WidgetType::Dropdown => {
let is_open = ctx
.state
.dropdown
.as_ref()
.is_some_and(|dd| dd.region_idx == w.region_idx);
draw_dropdown(
ctx.backend,
w.x,
w.y,
w.w,
w.h,
normalized,
w.label,
&value_text,
ctx.theme,
is_hovered,
is_open,
);
let anchor_cy = w.y + w.h / 2.0 - 8.0;
if let Some(region) = ctx.state.knob_regions.get_mut(w.region_idx) {
region.dropdown_anchor_y = anchor_cy + DROPDOWN_BOX_HEIGHT / 2.0;
}
}
WidgetType::Meter => {
let fallback = [w.param_id];
let ids = w.meter_ids.unwrap_or(&fallback);
let levels: Vec<f32> = ids.iter().map(|&id| (ctx.snapshot.get_meter)(id)).collect();
draw_meter(ctx.backend, w.x, w.y, w.w, w.h, &levels, w.label, ctx.theme);
}
WidgetType::XYPad => {
let val_y_id = w.param_id_y.unwrap_or(w.param_id);
let vx = (ctx.snapshot.get_param)(w.param_id);
let vy = (ctx.snapshot.get_param)(val_y_id);
let x_name_str = (ctx.snapshot.param_name)(w.param_id);
let y_name_str = (ctx.snapshot.param_name)(val_y_id);
let x_name: &str = if x_name_str.is_empty() {
w.label
} else {
&x_name_str
};
let y_name: &str = &y_name_str;
draw_xy_pad(
ctx.backend,
w.x,
w.y,
w.w,
w.h,
vx,
vy,
x_name,
y_name,
ctx.theme,
is_hovered,
);
}
WidgetType::Knob => {
if w.center_knob_in_cell {
let knob_size = w.w.min(w.h);
let kx = w.x + (w.w - knob_size) / 2.0;
let ky = w.y + (w.h - knob_size) / 2.0;
draw_knob(
ctx.backend,
kx,
ky,
knob_size,
normalized,
w.label,
&value_text,
ctx.theme,
is_hovered,
);
} else {
draw_knob(
ctx.backend,
w.x,
w.y,
w.h,
normalized,
w.label,
&value_text,
ctx.theme,
is_hovered,
);
}
}
}
}
fn draw_dropdown_overlay(backend: &mut dyn RenderBackend, theme: &Theme, state: &InteractionState) {
if let Some(ref dd) = state.dropdown {
let (px, py, pw, _) = dd.popup_rect;
draw_dropdown_popup(
backend,
px,
py,
pw,
&dd.options,
dd.selected,
dd.hover_option,
dd.scroll_offset,
dd.visible_count,
theme,
);
}
}