#[cfg(feature = "cpu")]
use std::ptr;
use std::sync::Arc;
#[cfg(feature = "cpu")]
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use truce_core::Float;
#[cfg(feature = "cpu")]
use truce_core::editor::Editor;
#[cfg(feature = "cpu")]
use truce_core::editor::RawWindowHandle;
use truce_core::editor::{PluginContext, PluginContextReadF32};
use truce_params::Params;
#[cfg(feature = "cpu")]
use crate::backend_cpu::CpuBackend;
use crate::interaction::{self, InputEvent, InteractionState, ParamEdit};
use crate::layout::{GridLayout, Layout, PluginLayout};
#[cfg(feature = "cpu")]
use crate::platform::EditorScale;
use crate::render::RenderBackend;
use crate::render_core::{
EditorSnapshotClosures, build_snapshot_closures as build_snapshot_closures_impl,
render_widgets as render_widgets_impl,
};
use crate::theme::Theme;
use crate::widgets;
pub struct BuiltinEditor<P: Params> {
params: Arc<P>,
layout: Layout,
theme: Theme,
#[cfg(feature = "cpu")]
backend: Option<CpuBackend>,
interaction: InteractionState,
context: Option<PluginContext>,
#[cfg(feature = "cpu")]
window: Option<baseview::WindowHandle>,
#[cfg(feature = "cpu")]
blit_backend: Option<SharedBackend>,
needs_repaint: Arc<AtomicBool>,
#[cfg(feature = "cpu")]
last_painted_values: Vec<f32>,
#[cfg(feature = "cpu")]
scale: EditorScale,
#[cfg(feature = "cpu")]
meter_ids: Vec<u32>,
#[cfg(feature = "cpu")]
last_meter_values: Vec<f32>,
}
unsafe impl<P: Params> Send for BuiltinEditor<P> {}
#[cfg(feature = "cpu")]
fn collect_meter_ids(layout: &Layout) -> Vec<u32> {
let mut ids = Vec::new();
match layout {
Layout::Rows(pl) => {
for row in &pl.rows {
for knob in &row.knobs {
if let Some(m) = &knob.meter_ids {
ids.extend_from_slice(m);
}
}
}
}
Layout::Grid(gl) => {
for widget in &gl.widgets {
if let Some(m) = &widget.meter_ids {
ids.extend_from_slice(m);
}
}
}
}
ids
}
impl<P: Params + 'static> BuiltinEditor<P> {
pub fn request_repaint(&self) {
self.needs_repaint.store(true, Ordering::Release);
}
#[cfg(feature = "cpu")]
fn take_needs_repaint(&self) -> bool {
self.needs_repaint.swap(false, Ordering::AcqRel)
}
#[cfg(feature = "cpu")]
fn detect_host_param_changes(&mut self) {
let regions = &self.interaction.knob_regions;
if regions.len() != self.last_painted_values.len() {
self.request_repaint();
return;
}
for (i, region) in regions.iter().enumerate() {
if (region.normalized_value - self.last_painted_values[i]).abs() > f32::EPSILON {
self.request_repaint();
return;
}
}
}
#[cfg(feature = "cpu")]
fn stash_painted_values(&mut self) {
let regions = &self.interaction.knob_regions;
self.last_painted_values.resize(regions.len(), 0.0);
for (slot, region) in self.last_painted_values.iter_mut().zip(regions.iter()) {
*slot = region.normalized_value;
}
}
#[cfg(feature = "cpu")]
#[allow(clippy::float_cmp)]
fn detect_meter_changes(&mut self) {
if self.meter_ids.is_empty() {
return;
}
let Some(ctx) = self.context.as_ref() else {
return;
};
let current: Vec<f32> = self.meter_ids.iter().map(|&id| ctx.get_meter(id)).collect();
if current != self.last_meter_values {
self.last_meter_values = current;
self.request_repaint();
}
}
pub fn new(params: Arc<P>, layout: PluginLayout) -> Self {
Self::with_layout_inner(params, Layout::Rows(layout))
}
pub fn new_with_layout(params: Arc<P>, layout: Layout) -> Self {
Self::with_layout_inner(params, layout)
}
pub fn new_grid(params: Arc<P>, layout: GridLayout) -> Self {
Self::with_layout_inner(params, Layout::Grid(layout))
}
fn with_layout_inner(params: Arc<P>, layout: Layout) -> Self {
#[cfg(feature = "cpu")]
let meter_ids = collect_meter_ids(&layout);
Self {
params,
layout,
theme: Theme::dark(),
#[cfg(feature = "cpu")]
backend: None,
interaction: InteractionState::default(),
context: None,
#[cfg(feature = "cpu")]
window: None,
#[cfg(feature = "cpu")]
blit_backend: None,
needs_repaint: Arc::new(AtomicBool::new(false)),
#[cfg(feature = "cpu")]
last_painted_values: Vec::new(),
#[cfg(feature = "cpu")]
scale: EditorScale::new(crate::backing_scale()),
#[cfg(feature = "cpu")]
meter_ids,
#[cfg(feature = "cpu")]
last_meter_values: Vec::new(),
}
}
#[must_use]
pub fn with_theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
#[cfg(feature = "cpu")]
pub fn render(&mut self) {
let (w, h) = (self.layout.width(), self.layout.height());
let scale = self.scale.get_f32();
let owned = self.build_snapshot_closures();
let snapshot = owned.as_snapshot();
let backend = self
.backend
.get_or_insert_with(|| CpuBackend::new(w, h, scale).expect("Failed to create backend"));
render_widgets_impl(
&self.layout,
&self.theme,
&mut self.interaction,
&snapshot,
backend,
);
}
fn build_snapshot_closures(&self) -> EditorSnapshotClosures {
build_snapshot_closures_impl(&self.params, self.context.as_ref())
}
fn apply_edit(&self, edit: ParamEdit) {
match edit {
ParamEdit::Begin { id } => {
if let Some(ref ctx) = self.context {
ctx.begin_edit(id);
}
}
ParamEdit::Set { id, normalized } => {
self.params.set_normalized(id, f64::from(normalized));
if let Some(ref ctx) = self.context {
ctx.set_param(id, f64::from(normalized));
}
self.request_repaint();
}
ParamEdit::End { id } => {
if let Some(ref ctx) = self.context {
ctx.end_edit(id);
}
}
}
}
pub fn dispatch_events(&mut self, events: &[InputEvent]) {
let hover_before = self.interaction.hover_idx;
let dd_before = self.interaction.dropdown_is_open();
let owned = self.build_snapshot_closures();
let snapshot = owned.as_snapshot();
let edits = interaction::dispatch(events, &self.layout, &snapshot, &mut self.interaction);
let had_edits = !edits.is_empty();
for e in edits {
self.apply_edit(e);
}
let explicit = self.interaction.take_repaint_request();
if had_edits
|| explicit
|| self.interaction.hover_idx != hover_before
|| self.interaction.dropdown_is_open() != dd_before
{
self.request_repaint();
}
}
#[cfg(feature = "cpu")]
#[must_use]
pub fn pixel_data(&self) -> Option<&[u8]> {
self.backend
.as_ref()
.map(super::backend_cpu::CpuBackend::data)
}
#[must_use]
pub fn has_context(&self) -> bool {
self.context.is_some()
}
pub fn take_context(&mut self) -> Option<PluginContext> {
self.context.take()
}
pub fn set_context(&mut self, context: PluginContext) {
self.context = Some(context);
match &self.layout {
Layout::Rows(pl) => self.interaction.build_regions(pl),
Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
}
}
#[must_use]
pub fn size(&self) -> (u32, u32) {
(self.layout.width(), self.layout.height())
}
pub fn state_changed(&mut self) {
self.request_repaint();
}
pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
update_interaction(self);
let owned = self.build_snapshot_closures();
let snapshot = owned.as_snapshot();
render_widgets_impl(
&self.layout,
&self.theme,
&mut self.interaction,
&snapshot,
backend,
);
}
}
#[cfg(test)]
impl<P: Params + 'static> BuiltinEditor<P> {
fn on_mouse_down(&mut self, x: f32, y: f32) {
self.dispatch_events(&[InputEvent::MouseDown {
pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
x,
y,
button: crate::interaction::MouseButton::Left,
}]);
}
fn on_mouse_up(&mut self, x: f32, y: f32) {
self.dispatch_events(&[InputEvent::MouseUp {
pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
x,
y,
button: crate::interaction::MouseButton::Left,
}]);
}
fn on_mouse_moved(&mut self, x: f32, y: f32) {
self.dispatch_events(&[InputEvent::MouseMove {
pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
x,
y,
}]);
}
}
pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
match &editor.layout {
Layout::Rows(pl) => {
editor.interaction.build_regions(pl);
let mut flat_idx = 0usize;
for row in &pl.rows {
for knob_def in &row.knobs {
if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
region.widget_type = resolve_widget_type(
knob_def.widget,
knob_def.param_id,
&*editor.params,
);
}
flat_idx += 1;
}
}
}
Layout::Grid(gl) => {
editor.interaction.build_regions_grid(gl);
for (idx, gw) in gl.widgets.iter().enumerate() {
if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
region.widget_type =
resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
}
}
}
}
for region in &mut editor.interaction.knob_regions {
if let Some(ref ctx) = editor.context {
region.normalized_value = ctx.get_param(region.param_id);
} else {
region.normalized_value =
f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
}
}
}
#[cfg(feature = "cpu")]
fn create_wgpu_backend(window: &mut baseview::Window, phys_w: u32, phys_h: u32) -> BlitBackend {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::PRIMARY;
let instance = wgpu::Instance::new(desc);
let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) }
.expect("failed to create wgpu surface");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
}))
.expect("no suitable GPU adapter");
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("truce-gui"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_defaults(),
experimental_features: wgpu::ExperimentalFeatures::default(),
memory_hints: wgpu::MemoryHints::Performance,
trace: wgpu::Trace::Off,
}))
.expect("failed to create wgpu device");
let caps = surface.get_capabilities(&adapter);
let format = caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(caps.formats[0]);
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: phys_w,
height: phys_h,
present_mode: wgpu::PresentMode::AutoVsync,
desired_maximum_frame_latency: 2,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
};
surface.configure(&device, &surface_config);
let blit = crate::blit::BlitPipeline::new(&device, format, phys_w, phys_h);
BlitBackend {
blit,
surface_config,
surface,
queue,
device,
}
}
#[cfg(feature = "cpu")]
struct BlitBackend {
blit: crate::blit::BlitPipeline,
surface_config: wgpu::SurfaceConfiguration,
surface: wgpu::Surface<'static>,
queue: wgpu::Queue,
device: wgpu::Device,
}
#[cfg(feature = "cpu")]
impl BlitBackend {
fn resize(&mut self, phys_w: u32, phys_h: u32) {
self.surface_config.width = phys_w.max(1);
self.surface_config.height = phys_h.max(1);
self.surface.configure(&self.device, &self.surface_config);
self.blit.resize(&self.device, phys_w, phys_h);
}
}
#[cfg(feature = "cpu")]
type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
#[cfg(feature = "cpu")]
struct BuiltinWindowHandler<P: Params> {
editor: *mut BuiltinEditor<P>,
backend: SharedBackend,
translator: crate::interaction::BaseviewTranslator,
last_applied_scale: f32,
}
#[cfg(feature = "cpu")]
unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
#[cfg(feature = "cpu")]
impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
fn on_frame(&mut self, _window: &mut baseview::Window) {
let Ok(mut guard) = self.backend.lock() else {
return;
};
if guard.is_none() {
return;
}
let editor = unsafe { &mut *self.editor };
if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
let (lw, lh) = editor.size();
let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
editor.backend = CpuBackend::new(lw, lh, cur_scale);
if let Some(backend) = guard.as_mut() {
backend.resize(phys_w, phys_h);
}
editor.request_repaint();
}
update_interaction(editor);
editor.detect_host_param_changes();
editor.detect_meter_changes();
if !editor.take_needs_repaint() {
return;
}
editor.render();
editor.stash_painted_values();
if let Some(pixels) = editor.pixel_data() {
let backend = guard
.as_mut()
.expect("guard was checked Some above and the lock is still held");
let BlitBackend {
device,
queue,
surface,
blit,
..
} = backend;
blit.update(queue, pixels);
let (wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame)) = surface.get_current_texture()
else {
return;
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder =
device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
blit.render(&mut encoder, &view);
queue.submit(std::iter::once(encoder.finish()));
frame.present();
}
}
fn on_event(
&mut self,
window: &mut baseview::Window,
event: baseview::Event,
) -> baseview::EventStatus {
#[cfg(not(target_os = "windows"))]
let _ = &window;
if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
button: baseview::MouseButton::Left,
..
}) = &event
{
#[cfg(target_os = "windows")]
{
if !window.has_focus() {
window.focus();
}
}
}
let Ok(guard) = self.backend.lock() else {
return baseview::EventStatus::Ignored;
};
if guard.is_none() {
return baseview::EventStatus::Ignored;
}
match event {
baseview::Event::Mouse(_) => {
let Some(input) = self.translator.translate(&event) else {
return baseview::EventStatus::Ignored;
};
let editor = unsafe { &mut *self.editor };
editor.dispatch_events(&[input]);
baseview::EventStatus::Captured
}
baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
let editor = unsafe { &mut *self.editor };
editor.scale.set(info.scale());
crate::platform::note_linux_scale_factor(info.scale());
baseview::EventStatus::Ignored
}
_ => baseview::EventStatus::Ignored,
}
}
}
fn resolve_widget_type<P: Params>(
widget: Option<crate::layout::WidgetKind>,
param_id: u32,
params: &P,
) -> widgets::WidgetType {
match widget {
Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
None => {
let param_info = params
.param_infos()
.iter()
.find(|i| i.id == param_id)
.copied();
match param_info.as_ref().map(|i| &i.range) {
Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
widgets::WidgetType::Toggle
}
Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
_ => widgets::WidgetType::Knob,
}
}
}
}
#[cfg(feature = "cpu")]
impl<P: Params + 'static> Editor for BuiltinEditor<P> {
fn size(&self) -> (u32, u32) {
(self.layout.width(), self.layout.height())
}
fn state_changed(&mut self) {
self.request_repaint();
}
fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
let (w, h) = self.size();
self.scale
.set(crate::platform::query_backing_scale(&parent));
let scale = self.scale.get();
let scale_f32 = self.scale.get_f32();
self.backend = CpuBackend::new(w, h, scale_f32);
self.context = Some(context);
match &self.layout {
Layout::Rows(pl) => self.interaction.build_regions(pl),
Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
}
self.render();
self.request_repaint();
let (lw, lh) = (f64::from(w), f64::from(h));
let phys_w = crate::platform::to_physical_px(w, scale);
let phys_h = crate::platform::to_physical_px(h, scale);
let options = baseview::WindowOpenOptions {
title: String::from("truce"),
size: baseview::Size::new(lw, lh),
scale: baseview::WindowScalePolicy::SystemScaleFactor,
};
let parent_wrapper = crate::platform::ParentWindow(parent);
let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
self.blit_backend = Some(shared_backend.clone());
let shared_for_handler = shared_backend;
let window = baseview::Window::open_parented(
&parent_wrapper,
options,
move |window: &mut baseview::Window| {
let mut backend = create_wgpu_backend(window, phys_w, phys_h);
let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
editor.render();
if let Some(pixels) = editor.pixel_data() {
let BlitBackend {
device,
queue,
surface,
blit,
..
} = &mut backend;
blit.update(queue, pixels);
if let wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
surface.get_current_texture()
{
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder =
device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: None,
});
blit.render(&mut encoder, &view);
queue.submit(std::iter::once(encoder.finish()));
frame.present();
}
}
if let Ok(mut guard) = shared_for_handler.lock() {
*guard = Some(backend);
}
BuiltinWindowHandler {
editor: editor_addr as *mut BuiltinEditor<P>,
backend: shared_for_handler.clone(),
translator: crate::interaction::BaseviewTranslator::default(),
last_applied_scale: scale_f32,
}
},
);
self.window = Some(window);
}
fn set_scale_factor(&mut self, factor: f64) {
self.scale.set(factor);
}
fn close(&mut self) {
#[cfg(target_os = "macos")]
let pool = unsafe {
unsafe extern "C" {
fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
}
objc_autoreleasePoolPush()
};
if let Some(shared) = self.blit_backend.take()
&& let Ok(mut guard) = shared.lock()
&& let Some(backend) = guard.take()
{
let BlitBackend {
blit,
surface,
surface_config,
queue,
device,
} = backend;
drop(surface_config);
drop(blit);
drop(surface);
drop(queue);
drop(device);
}
if let Some(mut window) = self.window.take() {
window.close();
}
self.context = None;
self.backend = None;
#[cfg(target_os = "macos")]
unsafe {
unsafe extern "C" {
fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
}
objc_autoreleasePoolPop(pool);
}
}
fn idle(&mut self) {
if self.window.is_none() {
self.render();
}
}
fn screenshot(
&mut self,
_params: Arc<dyn truce_params::Params>,
) -> Option<(Vec<u8>, u32, u32)> {
let (lw, lh) = self.size();
let scale = self.scale.get_f32();
let mut backend = CpuBackend::new(lw, lh, scale)?;
self.render_to(&mut backend);
let pixels = backend.data().to_vec();
let (phys_w, phys_h) = (backend.width(), backend.height());
Some((pixels, phys_w, phys_h))
}
}
#[cfg(feature = "cpu")]
impl<P: Params + 'static> Drop for BuiltinEditor<P> {
fn drop(&mut self) {
Editor::close(self);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::float_cmp, clippy::cast_precision_loss)]
use super::*;
use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
use crate::widgets::WidgetType;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
struct TestParams {
values: [AtomicU64; 2],
}
impl TestParams {
fn new() -> Self {
Self {
values: [
AtomicU64::new(0.0f64.to_bits()),
AtomicU64::new(0.0f64.to_bits()),
],
}
}
}
impl truce_params::__private::Sealed for TestParams {}
impl Params for TestParams {
fn param_infos(&self) -> Vec<ParamInfo> {
vec![
ParamInfo {
id: 0,
name: "Mode",
short_name: "Mode",
group: "",
range: ParamRange::Enum { count: 4 },
default_plain: 0.0,
flags: ParamFlags::AUTOMATABLE,
unit: ParamUnit::None,
kind: ParamValueKind::Enum,
},
ParamInfo {
id: 1,
name: "Gain",
short_name: "Gain",
group: "",
range: ParamRange::Linear { min: 0.0, max: 1.0 },
default_plain: 0.5,
flags: ParamFlags::AUTOMATABLE,
unit: ParamUnit::None,
kind: ParamValueKind::Float,
},
]
}
fn count(&self) -> usize {
2
}
fn get_normalized(&self, id: u32) -> Option<f64> {
self.values
.get(id as usize)
.map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
}
fn set_normalized(&self, id: u32, value: f64) {
if let Some(v) = self.values.get(id as usize) {
v.store(value.to_bits(), Ordering::Relaxed);
}
}
fn get_plain(&self, id: u32) -> Option<f64> {
let norm = self.get_normalized(id)?;
let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
Some(info.range.denormalize(norm))
}
fn set_plain(&self, id: u32, value: f64) {
if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
self.set_normalized(id, info.range.normalize(value));
}
}
fn format_value(&self, _id: u32, value: f64) -> Option<String> {
Some(format!("{value:.0}"))
}
fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
None
}
fn snap_smoothers(&self) {}
fn set_sample_rate(&self, _: f64) {}
fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
let ids = vec![0, 1];
let vals: Vec<f64> = ids
.iter()
.map(|&id| self.get_plain(id).unwrap_or(0.0))
.collect();
(ids, vals)
}
fn restore_values(&self, values: &[(u32, f64)]) {
for &(id, val) in values {
self.set_plain(id, val);
}
}
}
impl Default for TestParams {
fn default() -> Self {
Self::new()
}
}
fn make_editor() -> BuiltinEditor<TestParams> {
let params = Arc::new(TestParams::new());
let layout = GridLayout::build(vec![widgets(vec![
GridWidget::dropdown(0u32, "Mode"),
GridWidget::knob(1u32, "Gain"),
])]);
let mut editor = BuiltinEditor::new_grid(params, layout);
if let Layout::Grid(ref gl) = editor.layout {
editor.interaction.build_regions_grid(gl);
for (idx, gw) in gl.widgets.iter().enumerate() {
if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
region.widget_type =
resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
}
}
}
editor.render();
editor
}
fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
let params = Arc::new(TestParams::new());
let layout = GridLayout::build(vec![
section(
"SECTION A",
vec![
GridWidget::knob(1u32, "Gain"),
GridWidget::knob(1u32, "Gain 2"),
],
),
section(
"SECTION B",
vec![
GridWidget::dropdown(0u32, "Mode"),
GridWidget::knob(1u32, "Gain 3"),
],
),
]);
let mut editor = BuiltinEditor::new_grid(params, layout);
if let Layout::Grid(ref gl) = editor.layout {
editor.interaction.build_regions_grid(gl);
for (idx, gw) in gl.widgets.iter().enumerate() {
if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
region.widget_type =
resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
}
}
}
editor.render();
editor
}
fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
let region = editor
.interaction
.knob_regions
.iter()
.find(|r| r.widget_type == WidgetType::Dropdown)
.expect("no dropdown in layout");
(region.x + region.w / 2.0, region.y + region.h / 2.0)
}
#[test]
fn dropdown_click_opens() {
let mut editor = make_editor();
let (dx, dy) = dropdown_center(&editor);
editor.on_mouse_down(dx, dy);
assert!(editor.interaction.dropdown_is_open());
}
#[test]
fn dropdown_click_toggles_closed() {
let mut editor = make_editor();
let (dx, dy) = dropdown_center(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
assert!(editor.interaction.dropdown_is_open());
editor.on_mouse_down(dx, dy);
assert!(!editor.interaction.dropdown_is_open());
}
#[test]
fn dropdown_click_outside_closes() {
let mut editor = make_editor();
let (dx, dy) = dropdown_center(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
assert!(editor.interaction.dropdown_is_open());
editor.on_mouse_down(0.0, 0.0);
assert!(!editor.interaction.dropdown_is_open());
}
#[test]
fn dropdown_click_option_selects_and_closes() {
let mut editor = make_editor();
let (dx, dy) = dropdown_center(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
assert!(editor.interaction.dropdown_is_open());
let dd = editor.interaction.dropdown.as_ref().unwrap();
let (px, py, _, _) = dd.popup_rect;
let item_h = 18.0f32;
let padding = 4.0f32;
let option_y = py + padding + item_h + item_h / 2.0;
editor.on_mouse_down(px + 10.0, option_y);
editor.on_mouse_up(px + 10.0, option_y);
assert!(!editor.interaction.dropdown_is_open());
let norm = editor.params.get_normalized(0).unwrap();
let expected = 1.0 / 3.0;
assert!(
(norm - expected).abs() < 0.01,
"expected {expected:.4}, got {norm}"
);
}
#[test]
fn dropdown_anchor_set_after_render() {
let editor = make_editor();
let region = editor
.interaction
.knob_regions
.iter()
.find(|r| r.widget_type == WidgetType::Dropdown)
.unwrap();
assert!(
region.dropdown_anchor_y > region.y,
"anchor {} should be below region.y {}",
region.dropdown_anchor_y,
region.y
);
assert!(
region.dropdown_anchor_y < region.y + region.h,
"anchor {} should be above region bottom {}",
region.dropdown_anchor_y,
region.y + region.h
);
}
#[test]
fn dropdown_popup_uses_anchor() {
let mut editor = make_editor();
let (dx, dy) = dropdown_center(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
let dd = editor.interaction.dropdown.as_ref().unwrap();
let region = &editor.interaction.knob_regions[dd.region_idx];
assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
}
#[test]
fn dropdown_anchor_gap_stable_with_sections() {
let editor_plain = make_editor();
let editor_sections = make_editor_with_sections();
let r_plain = editor_plain
.interaction
.knob_regions
.iter()
.find(|r| r.widget_type == WidgetType::Dropdown)
.unwrap();
let r_sections = editor_sections
.interaction
.knob_regions
.iter()
.find(|r| r.widget_type == WidgetType::Dropdown)
.unwrap();
let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
assert!(
(gap_plain - gap_sections).abs() < 0.1,
"gap_plain={gap_plain}, gap_sections={gap_sections}"
);
}
struct ManyOptionParams {
values: [AtomicU64; 2],
}
impl ManyOptionParams {
fn new() -> Self {
Self {
values: [
AtomicU64::new(0.0f64.to_bits()),
AtomicU64::new(0.0f64.to_bits()),
],
}
}
}
impl truce_params::__private::Sealed for ManyOptionParams {}
impl Params for ManyOptionParams {
fn param_infos(&self) -> Vec<ParamInfo> {
vec![
ParamInfo {
id: 0,
name: "Note",
short_name: "Note",
group: "",
range: ParamRange::Enum { count: 20 },
default_plain: 0.0,
flags: ParamFlags::AUTOMATABLE,
unit: ParamUnit::None,
kind: ParamValueKind::Enum,
},
ParamInfo {
id: 1,
name: "Gain",
short_name: "Gain",
group: "",
range: ParamRange::Linear { min: 0.0, max: 1.0 },
default_plain: 0.5,
flags: ParamFlags::AUTOMATABLE,
unit: ParamUnit::None,
kind: ParamValueKind::Float,
},
]
}
fn count(&self) -> usize {
2
}
fn get_normalized(&self, id: u32) -> Option<f64> {
self.values
.get(id as usize)
.map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
}
fn set_normalized(&self, id: u32, value: f64) {
if let Some(v) = self.values.get(id as usize) {
v.store(value.to_bits(), Ordering::Relaxed);
}
}
fn get_plain(&self, id: u32) -> Option<f64> {
let norm = self.get_normalized(id)?;
let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
Some(info.range.denormalize(norm))
}
fn set_plain(&self, id: u32, value: f64) {
if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
self.set_normalized(id, info.range.normalize(value));
}
}
fn format_value(&self, _id: u32, value: f64) -> Option<String> {
Some(format!("{value:.0}"))
}
fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
None
}
fn snap_smoothers(&self) {}
fn set_sample_rate(&self, _: f64) {}
fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
let ids = vec![0, 1];
let vals: Vec<f64> = ids
.iter()
.map(|&id| self.get_plain(id).unwrap_or(0.0))
.collect();
(ids, vals)
}
fn restore_values(&self, values: &[(u32, f64)]) {
for &(id, val) in values {
self.set_plain(id, val);
}
}
}
impl Default for ManyOptionParams {
fn default() -> Self {
Self::new()
}
}
fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
let params = Arc::new(TestParams::new());
let layout = GridLayout::build(vec![widgets(vec![
GridWidget::knob(1u32, "K1"),
GridWidget::knob(1u32, "K2"),
GridWidget::knob(1u32, "K3"),
GridWidget::knob(1u32, "K4"),
GridWidget::dropdown(0u32, "Mode"),
GridWidget::knob(1u32, "K5"),
])])
.with_cols(2);
let mut editor = BuiltinEditor::new_grid(params, layout);
if let Layout::Grid(ref gl) = editor.layout {
editor.interaction.build_regions_grid(gl);
for (idx, gw) in gl.widgets.iter().enumerate() {
if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
region.widget_type =
resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
}
}
}
editor.render();
editor
}
fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
let params = Arc::new(TestParams::new());
let layout = GridLayout::build(vec![widgets(vec![
GridWidget::dropdown(0u32, "Mode A"),
GridWidget::dropdown(0u32, "Mode B"),
])]);
let mut editor = BuiltinEditor::new_grid(params, layout);
if let Layout::Grid(ref gl) = editor.layout {
editor.interaction.build_regions_grid(gl);
for (idx, gw) in gl.widgets.iter().enumerate() {
if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
region.widget_type =
resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
}
}
}
editor.render();
editor
}
fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
let params = Arc::new(ManyOptionParams::new());
let layout = GridLayout::build(vec![widgets(vec![
GridWidget::dropdown(0u32, "Note"),
GridWidget::knob(1u32, "Gain"),
])]);
let mut editor = BuiltinEditor::new_grid(params, layout);
if let Layout::Grid(ref gl) = editor.layout {
editor.interaction.build_regions_grid(gl);
for (idx, gw) in gl.widgets.iter().enumerate() {
if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
region.widget_type =
resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
}
}
}
editor.render();
editor
}
fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
let region = editor
.interaction
.knob_regions
.iter()
.find(|r| r.widget_type == WidgetType::Dropdown)
.expect("no dropdown in layout");
(region.x + region.w / 2.0, region.y + region.h / 2.0)
}
#[test]
fn dropdown_anchors_below_button_scrolls_when_tight() {
let mut editor = make_editor_bottom_dropdown();
let (dx, dy) = {
let region = editor
.interaction
.knob_regions
.iter()
.find(|r| r.widget_type == WidgetType::Dropdown)
.unwrap();
(region.x + region.w / 2.0, region.y + region.h / 2.0)
};
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
assert!(editor.interaction.dropdown_is_open());
let dd = editor.interaction.dropdown.as_ref().unwrap();
let region = &editor.interaction.knob_regions[dd.region_idx];
let (_, popup_y, _, popup_h) = dd.popup_rect;
let window_h = editor.layout.height() as f32;
assert_eq!(
popup_y, region.dropdown_anchor_y,
"popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
);
assert!(
popup_y + popup_h <= window_h + 1.0,
"popup bottom {} exceeds window height {window_h}",
popup_y + popup_h
);
}
#[test]
fn dropdown_clamps_horizontal_near_right_edge() {
let mut editor = make_editor_two_dropdowns();
let region = &editor.interaction.knob_regions[1];
assert_eq!(region.widget_type, WidgetType::Dropdown);
let dx = region.x + region.w / 2.0;
let dy = region.y + region.h / 2.0;
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
assert!(editor.interaction.dropdown_is_open());
let dd = editor.interaction.dropdown.as_ref().unwrap();
let (popup_x, _, popup_w, _) = dd.popup_rect;
let window_w = editor.layout.width() as f32;
assert!(
popup_x + popup_w <= window_w + 1.0,
"popup right edge {} exceeds window width {window_w}",
popup_x + popup_w
);
assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
}
#[test]
fn dropdown_scroll_long_list() {
let mut editor = make_editor_many_options();
let (dx, dy) = dropdown_center_many(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
assert!(editor.interaction.dropdown_is_open());
let dd = editor.interaction.dropdown.as_ref().unwrap();
assert!(
dd.options.len() > dd.visible_count,
"expected scroll: {} options, {} visible",
dd.options.len(),
dd.visible_count
);
assert_eq!(dd.scroll_offset, 0);
}
#[test]
fn dropdown_scroll_clamps_to_bounds() {
let mut editor = make_editor_many_options();
let (dx, dy) = dropdown_center_many(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
editor.interaction.dropdown_scroll(-10);
assert_eq!(
editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
0
);
editor.interaction.dropdown_scroll(1000);
let dd = editor.interaction.dropdown.as_ref().unwrap();
let max_offset = dd.options.len().saturating_sub(dd.visible_count);
assert_eq!(dd.scroll_offset, max_offset);
}
#[test]
fn dropdown_selected_item_visible_on_open() {
let mut editor = make_editor_many_options();
editor.params.set_normalized(0, 15.0 / 18.0);
let (dx, dy) = dropdown_center_many(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
let dd = editor.interaction.dropdown.as_ref().unwrap();
let selected = dd.selected;
assert!(
selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
"selected={selected} not in visible range {}..{}",
dd.scroll_offset,
dd.scroll_offset + dd.visible_count
);
}
#[test]
fn dropdown_scroll_then_select_correct_index() {
let mut editor = make_editor_many_options();
let (dx, dy) = dropdown_center_many(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
editor.interaction.dropdown_scroll(3);
assert_eq!(
editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
3
);
let dd = editor.interaction.dropdown.as_ref().unwrap();
let (px, py, _, _) = dd.popup_rect;
let item_h = 18.0f32;
let padding = 4.0f32;
let click_y = py + padding + item_h + item_h / 2.0;
editor.on_mouse_down(px + 10.0, click_y);
editor.on_mouse_up(px + 10.0, click_y);
assert!(!editor.interaction.dropdown_is_open());
let norm = editor.params.get_normalized(0).unwrap();
let expected = 4.0 / 19.0;
assert!(
(norm - expected).abs() < 0.01,
"expected {expected:.4}, got {norm:.4}"
);
}
#[test]
fn dropdown_click_different_dropdown_closes_first() {
let mut editor = make_editor_two_dropdowns();
let r0 = &editor.interaction.knob_regions[0];
let r1 = &editor.interaction.knob_regions[1];
let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
editor.on_mouse_down(ax, ay);
editor.on_mouse_up(ax, ay);
assert!(editor.interaction.dropdown_is_open());
assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
editor.on_mouse_down(bx, by);
editor.on_mouse_up(bx, by);
assert!(editor.interaction.dropdown_is_open());
assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
}
#[test]
fn dropdown_hover_tracks_correct_option() {
let mut editor = make_editor();
let (dx, dy) = dropdown_center(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
let dd = editor.interaction.dropdown.as_ref().unwrap();
let (px, py, pw, _) = dd.popup_rect;
let item_h = 18.0f32;
let padding = 4.0f32;
let last_visible = dd.visible_count - 1;
let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
editor.on_mouse_moved(px + pw / 2.0, hover_y);
let dd = editor.interaction.dropdown.as_ref().unwrap();
assert_eq!(
dd.hover_option,
Some(last_visible),
"expected hover on last visible option"
);
editor.on_mouse_moved(0.0, 0.0);
let dd = editor.interaction.dropdown.as_ref().unwrap();
assert_eq!(dd.hover_option, None, "hover should clear outside popup");
}
#[test]
fn dropdown_popup_within_window_bounds() {
let mut editor = make_editor();
let (dx, dy) = dropdown_center(&editor);
editor.on_mouse_down(dx, dy);
editor.on_mouse_up(dx, dy);
let dd = editor.interaction.dropdown.as_ref().unwrap();
let (px, py, pw, ph) = dd.popup_rect;
let window_w = editor.layout.width() as f32;
let window_h = editor.layout.height() as f32;
assert!(px >= 0.0, "popup left edge {px} < 0");
assert!(py >= 0.0, "popup top edge {py} < 0");
assert!(
px + pw <= window_w + 1.0,
"popup right {} > window {window_w}",
px + pw
);
assert!(
py + ph <= window_h + 1.0,
"popup bottom {} > window {window_h}",
py + ph
);
}
}