1use std::ptr;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11
12use truce_core::Float;
13use truce_core::editor::{Editor, PluginContext, PluginContextReadF32, RawWindowHandle};
14use truce_params::Params;
15
16use crate::backend_cpu::CpuBackend;
17use crate::interaction::{self, InputEvent, InteractionState, ParamEdit};
18use crate::layout::{GridLayout, Layout, PluginLayout};
19use crate::platform::EditorScale;
20use crate::render::RenderBackend;
21use crate::render_core::{
22 EditorSnapshotClosures, build_snapshot_closures as build_snapshot_closures_impl,
23 render_widgets as render_widgets_impl,
24};
25use crate::theme::Theme;
26use crate::widgets;
27
28pub struct BuiltinEditor<P: Params> {
33 params: Arc<P>,
34 layout: Layout,
35 theme: Theme,
36 backend: Option<CpuBackend>,
37 interaction: InteractionState,
38 context: Option<PluginContext>,
39 window: Option<baseview::WindowHandle>,
40 blit_backend: Option<SharedBackend>,
46 needs_repaint: Arc<AtomicBool>,
54 last_painted_values: Vec<f32>,
60 scale: EditorScale,
66}
67
68unsafe impl<P: Params> Send for BuiltinEditor<P> {}
78
79impl<P: Params + 'static> BuiltinEditor<P> {
80 pub fn request_repaint(&self) {
85 self.needs_repaint.store(true, Ordering::Release);
86 }
87
88 fn take_needs_repaint(&self) -> bool {
89 self.needs_repaint.swap(false, Ordering::AcqRel)
90 }
91
92 fn detect_host_param_changes(&mut self) {
98 let regions = &self.interaction.knob_regions;
99 if regions.len() != self.last_painted_values.len() {
100 self.request_repaint();
103 return;
104 }
105 for (i, region) in regions.iter().enumerate() {
106 if (region.normalized_value - self.last_painted_values[i]).abs() > f32::EPSILON {
107 self.request_repaint();
108 return;
109 }
110 }
111 }
112
113 fn stash_painted_values(&mut self) {
116 let regions = &self.interaction.knob_regions;
117 self.last_painted_values.resize(regions.len(), 0.0);
124 for (slot, region) in self.last_painted_values.iter_mut().zip(regions.iter()) {
125 *slot = region.normalized_value;
126 }
127 }
128
129 pub fn new(params: Arc<P>, layout: PluginLayout) -> Self {
130 Self {
131 params,
132 layout: Layout::Rows(layout),
133 theme: Theme::dark(),
134 backend: None,
135 interaction: InteractionState::default(),
136 context: None,
137 window: None,
138 blit_backend: None,
139 needs_repaint: Arc::new(AtomicBool::new(false)),
140 last_painted_values: Vec::new(),
141 scale: EditorScale::new(crate::backing_scale()),
142 }
143 }
144
145 pub fn new_with_layout(params: Arc<P>, layout: Layout) -> Self {
146 Self {
147 params,
148 layout,
149 theme: Theme::dark(),
150 backend: None,
151 interaction: InteractionState::default(),
152 context: None,
153 window: None,
154 blit_backend: None,
155 needs_repaint: Arc::new(AtomicBool::new(false)),
156 last_painted_values: Vec::new(),
157 scale: EditorScale::new(crate::backing_scale()),
158 }
159 }
160
161 pub fn new_grid(params: Arc<P>, layout: GridLayout) -> Self {
162 Self {
163 params,
164 layout: Layout::Grid(layout),
165 theme: Theme::dark(),
166 backend: None,
167 interaction: InteractionState::default(),
168 context: None,
169 window: None,
170 blit_backend: None,
171 needs_repaint: Arc::new(AtomicBool::new(false)),
172 last_painted_values: Vec::new(),
173 scale: EditorScale::new(crate::backing_scale()),
174 }
175 }
176
177 #[must_use]
178 pub fn with_theme(mut self, theme: Theme) -> Self {
179 self.theme = theme;
180 self
181 }
182
183 pub fn render(&mut self) {
191 let (w, h) = (self.layout.width(), self.layout.height());
192 let scale = self.scale.get_f32();
193 let owned = self.build_snapshot_closures();
194 let snapshot = owned.as_snapshot();
195 let backend = self
196 .backend
197 .get_or_insert_with(|| CpuBackend::new(w, h, scale).expect("Failed to create backend"));
198 render_widgets_impl(
199 &self.layout,
200 &self.theme,
201 &mut self.interaction,
202 &snapshot,
203 backend,
204 );
205 }
206
207 fn build_snapshot_closures(&self) -> EditorSnapshotClosures {
214 build_snapshot_closures_impl(&self.params, self.context.as_ref())
215 }
216
217 fn apply_edit(&self, edit: ParamEdit) {
219 match edit {
220 ParamEdit::Begin { id } => {
221 if let Some(ref ctx) = self.context {
222 ctx.begin_edit(id);
223 }
224 }
225 ParamEdit::Set { id, normalized } => {
226 self.params.set_normalized(id, f64::from(normalized));
227 if let Some(ref ctx) = self.context {
228 ctx.set_param(id, f64::from(normalized));
229 }
230 self.request_repaint();
231 }
232 ParamEdit::End { id } => {
233 if let Some(ref ctx) = self.context {
234 ctx.end_edit(id);
235 }
236 }
237 }
238 }
239
240 pub fn dispatch_events(&mut self, events: &[InputEvent]) {
248 let hover_before = self.interaction.hover_idx;
249 let dd_before = self.interaction.dropdown_is_open();
250 let owned = self.build_snapshot_closures();
251 let snapshot = owned.as_snapshot();
252 let edits = interaction::dispatch(events, &self.layout, &snapshot, &mut self.interaction);
253 let had_edits = !edits.is_empty();
254 for e in edits {
255 self.apply_edit(e);
256 }
257 let explicit = self.interaction.take_repaint_request();
263 if had_edits
264 || explicit
265 || self.interaction.hover_idx != hover_before
266 || self.interaction.dropdown_is_open() != dd_before
267 {
268 self.request_repaint();
269 }
270 }
271
272 #[must_use]
274 pub fn pixel_data(&self) -> Option<&[u8]> {
275 self.backend
276 .as_ref()
277 .map(super::backend_cpu::CpuBackend::data)
278 }
279
280 #[must_use]
284 pub fn has_context(&self) -> bool {
285 self.context.is_some()
286 }
287
288 pub fn take_context(&mut self) -> Option<PluginContext> {
291 self.context.take()
292 }
293
294 pub fn set_context(&mut self, context: PluginContext) {
296 self.context = Some(context);
297 match &self.layout {
298 Layout::Rows(pl) => self.interaction.build_regions(pl),
299 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
300 }
301 }
302
303 pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
308 update_interaction(self);
309 let owned = self.build_snapshot_closures();
310 let snapshot = owned.as_snapshot();
311 render_widgets_impl(
312 &self.layout,
313 &self.theme,
314 &mut self.interaction,
315 &snapshot,
316 backend,
317 );
318 }
319}
320
321#[cfg(test)]
325impl<P: Params + 'static> BuiltinEditor<P> {
326 fn on_mouse_down(&mut self, x: f32, y: f32) {
327 self.dispatch_events(&[InputEvent::MouseDown {
328 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
329 x,
330 y,
331 button: crate::interaction::MouseButton::Left,
332 }]);
333 }
334
335 fn on_mouse_up(&mut self, x: f32, y: f32) {
336 self.dispatch_events(&[InputEvent::MouseUp {
337 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
338 x,
339 y,
340 button: crate::interaction::MouseButton::Left,
341 }]);
342 }
343
344 fn on_mouse_moved(&mut self, x: f32, y: f32) {
345 self.dispatch_events(&[InputEvent::MouseMove {
346 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
347 x,
348 y,
349 }]);
350 }
351}
352
353pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
363 match &editor.layout {
364 Layout::Rows(pl) => {
365 editor.interaction.build_regions(pl);
366 let mut flat_idx = 0usize;
367 for row in &pl.rows {
368 for knob_def in &row.knobs {
369 if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
370 region.widget_type = resolve_widget_type(
371 knob_def.widget,
372 knob_def.param_id,
373 &*editor.params,
374 );
375 }
376 flat_idx += 1;
377 }
378 }
379 }
380 Layout::Grid(gl) => {
381 editor.interaction.build_regions_grid(gl);
382 for (idx, gw) in gl.widgets.iter().enumerate() {
383 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
384 region.widget_type =
385 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
386 }
387 }
388 }
389 }
390 for region in &mut editor.interaction.knob_regions {
391 if let Some(ref ctx) = editor.context {
392 region.normalized_value = ctx.get_param(region.param_id);
394 } else {
395 region.normalized_value =
396 f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
397 }
398 }
399}
400
401fn create_wgpu_backend(window: &mut baseview::Window, phys_w: u32, phys_h: u32) -> BlitBackend {
410 let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
411 desc.backends = wgpu::Backends::PRIMARY;
412 let instance = wgpu::Instance::new(desc);
413
414 let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) }
415 .expect("failed to create wgpu surface");
416
417 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
418 power_preference: wgpu::PowerPreference::HighPerformance,
419 compatible_surface: Some(&surface),
420 force_fallback_adapter: false,
421 }))
422 .expect("no suitable GPU adapter");
423
424 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
425 label: Some("truce-gui"),
426 required_features: wgpu::Features::empty(),
427 required_limits: wgpu::Limits::downlevel_defaults(),
428 experimental_features: wgpu::ExperimentalFeatures::default(),
429 memory_hints: wgpu::MemoryHints::Performance,
430 trace: wgpu::Trace::Off,
431 }))
432 .expect("failed to create wgpu device");
433
434 let caps = surface.get_capabilities(&adapter);
435 let format = caps
436 .formats
437 .iter()
438 .find(|f| f.is_srgb())
439 .copied()
440 .unwrap_or(caps.formats[0]);
441
442 let surface_config = wgpu::SurfaceConfiguration {
443 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
444 format,
445 width: phys_w,
446 height: phys_h,
447 present_mode: wgpu::PresentMode::AutoVsync,
448 desired_maximum_frame_latency: 2,
449 alpha_mode: wgpu::CompositeAlphaMode::Auto,
450 view_formats: vec![],
451 };
452 surface.configure(&device, &surface_config);
453
454 let blit = crate::blit::BlitPipeline::new(&device, format, phys_w, phys_h);
459
460 BlitBackend {
461 blit,
462 surface_config,
463 surface,
464 queue,
465 device,
466 }
467}
468
469struct BlitBackend {
478 blit: crate::blit::BlitPipeline,
479 surface_config: wgpu::SurfaceConfiguration,
480 surface: wgpu::Surface<'static>,
481 queue: wgpu::Queue,
482 device: wgpu::Device,
483}
484
485impl BlitBackend {
486 fn resize(&mut self, phys_w: u32, phys_h: u32) {
491 self.surface_config.width = phys_w.max(1);
492 self.surface_config.height = phys_h.max(1);
493 self.surface.configure(&self.device, &self.surface_config);
494 self.blit.resize(&self.device, phys_w, phys_h);
495 }
496}
497
498type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
505
506struct BuiltinWindowHandler<P: Params> {
507 editor: *mut BuiltinEditor<P>,
518 backend: SharedBackend,
519 translator: crate::interaction::BaseviewTranslator,
523 last_applied_scale: f32,
532}
533
534unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
537
538impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
539 fn on_frame(&mut self, _window: &mut baseview::Window) {
540 let Ok(mut guard) = self.backend.lock() else {
547 return;
548 };
549 if guard.is_none() {
550 return;
553 }
554
555 let editor = unsafe { &mut *self.editor };
556
557 if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
564 let (lw, lh) = editor.size();
565 let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
566 let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
567 editor.backend = CpuBackend::new(lw, lh, cur_scale);
568 if let Some(backend) = guard.as_mut() {
569 backend.resize(phys_w, phys_h);
570 }
571 editor.request_repaint();
572 }
573
574 update_interaction(editor);
575 editor.detect_host_param_changes();
580 if !editor.take_needs_repaint() {
581 return;
582 }
583 editor.render();
584 editor.stash_painted_values();
585
586 if let Some(pixels) = editor.pixel_data() {
587 let backend = guard
588 .as_mut()
589 .expect("guard was checked Some above and the lock is still held");
590 let BlitBackend {
591 device,
592 queue,
593 surface,
594 blit,
595 ..
596 } = backend;
597 blit.update(queue, pixels);
598 let (wgpu::CurrentSurfaceTexture::Success(frame)
599 | wgpu::CurrentSurfaceTexture::Suboptimal(frame)) = surface.get_current_texture()
600 else {
601 return;
602 };
603 let view = frame
604 .texture
605 .create_view(&wgpu::TextureViewDescriptor::default());
606 let mut encoder =
607 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
608 blit.render(&mut encoder, &view);
609 queue.submit(std::iter::once(encoder.finish()));
610 frame.present();
611 }
612 }
613
614 fn on_event(
615 &mut self,
616 window: &mut baseview::Window,
617 event: baseview::Event,
618 ) -> baseview::EventStatus {
619 #[cfg(not(target_os = "windows"))]
622 let _ = &window;
623
624 if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
625 button: baseview::MouseButton::Left,
626 ..
627 }) = &event
628 {
629 #[cfg(target_os = "windows")]
635 {
636 if !window.has_focus() {
637 window.focus();
638 }
639 }
640 }
641
642 let Ok(guard) = self.backend.lock() else {
647 return baseview::EventStatus::Ignored;
648 };
649 if guard.is_none() {
650 return baseview::EventStatus::Ignored;
651 }
652
653 match event {
654 baseview::Event::Mouse(_) => {
655 let Some(input) = self.translator.translate(&event) else {
656 return baseview::EventStatus::Ignored;
657 };
658 let editor = unsafe { &mut *self.editor };
659 editor.dispatch_events(&[input]);
660 baseview::EventStatus::Captured
661 }
662 baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
663 crate::platform::note_linux_scale_factor(info.scale());
676 baseview::EventStatus::Ignored
677 }
678 _ => baseview::EventStatus::Ignored,
679 }
680 }
681}
682
683fn resolve_widget_type<P: Params>(
689 widget: Option<crate::layout::WidgetKind>,
690 param_id: u32,
691 params: &P,
692) -> widgets::WidgetType {
693 match widget {
694 Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
695 Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
696 Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
697 Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
698 Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
699 Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
700 Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
701 None => {
702 let param_info = params
703 .param_infos()
704 .iter()
705 .find(|i| i.id == param_id)
706 .copied();
707 match param_info.as_ref().map(|i| &i.range) {
708 Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
709 widgets::WidgetType::Toggle
710 }
711 Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
712 _ => widgets::WidgetType::Knob,
713 }
714 }
715 }
716}
717
718impl<P: Params + 'static> Editor for BuiltinEditor<P> {
719 fn size(&self) -> (u32, u32) {
720 (self.layout.width(), self.layout.height())
721 }
722
723 fn state_changed(&mut self) {
724 self.request_repaint();
727 }
728
729 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
730 let (w, h) = self.size();
731 self.scale
737 .set(crate::platform::query_backing_scale(&parent));
738 let scale = self.scale.get();
739 let scale_f32 = self.scale.get_f32();
740 self.backend = CpuBackend::new(w, h, scale_f32);
741 self.context = Some(context);
742
743 match &self.layout {
745 Layout::Rows(pl) => self.interaction.build_regions(pl),
746 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
747 }
748
749 self.render();
753 self.request_repaint();
754
755 let (lw, lh) = (f64::from(w), f64::from(h));
756 let phys_w = crate::platform::to_physical_px(w, scale);
757 let phys_h = crate::platform::to_physical_px(h, scale);
758
759 let options = baseview::WindowOpenOptions {
760 title: String::from("truce"),
761 size: baseview::Size::new(lw, lh),
762 scale: baseview::WindowScalePolicy::SystemScaleFactor,
763 };
764
765 let parent_wrapper = crate::platform::ParentWindow(parent);
766 let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
767
768 let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
773 self.blit_backend = Some(shared_backend.clone());
774 let shared_for_handler = shared_backend;
775
776 let window = baseview::Window::open_parented(
777 &parent_wrapper,
778 options,
779 move |window: &mut baseview::Window| {
780 let mut backend = create_wgpu_backend(window, phys_w, phys_h);
781
782 let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
789 editor.render();
790 if let Some(pixels) = editor.pixel_data() {
791 let BlitBackend {
792 device,
793 queue,
794 surface,
795 blit,
796 ..
797 } = &mut backend;
798 blit.update(queue, pixels);
799 if let wgpu::CurrentSurfaceTexture::Success(frame)
800 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
801 surface.get_current_texture()
802 {
803 let view = frame
804 .texture
805 .create_view(&wgpu::TextureViewDescriptor::default());
806 let mut encoder =
807 device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
808 label: None,
809 });
810 blit.render(&mut encoder, &view);
811 queue.submit(std::iter::once(encoder.finish()));
812 frame.present();
813 }
814 }
815
816 if let Ok(mut guard) = shared_for_handler.lock() {
823 *guard = Some(backend);
824 }
825
826 BuiltinWindowHandler {
827 editor: editor_addr as *mut BuiltinEditor<P>,
828 backend: shared_for_handler.clone(),
829 translator: crate::interaction::BaseviewTranslator::default(),
830 last_applied_scale: scale_f32,
831 }
832 },
833 );
834
835 self.window = Some(window);
836 }
837
838 fn set_scale_factor(&mut self, factor: f64) {
839 self.scale.set(factor);
844 }
845
846 fn close(&mut self) {
847 #[cfg(target_os = "macos")]
854 let pool = unsafe {
855 unsafe extern "C" {
856 fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
857 }
858 objc_autoreleasePoolPush()
859 };
860
861 if let Some(shared) = self.blit_backend.take()
870 && let Ok(mut guard) = shared.lock()
871 && let Some(backend) = guard.take()
872 {
873 let BlitBackend {
874 blit,
875 surface,
876 surface_config,
877 queue,
878 device,
879 } = backend;
880 drop(surface_config);
881 drop(blit);
882 drop(surface);
883 drop(queue);
884 drop(device);
885 }
886
887 if let Some(mut window) = self.window.take() {
888 window.close();
889 }
890 self.context = None;
891 self.backend = None;
892
893 #[cfg(target_os = "macos")]
894 unsafe {
895 unsafe extern "C" {
896 fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
897 }
898 objc_autoreleasePoolPop(pool);
899 }
900 }
901
902 fn idle(&mut self) {
903 if self.window.is_none() {
907 self.render();
908 }
909 }
910}
911
912#[cfg(test)]
913mod tests {
914 #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
917
918 use super::*;
919 use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
920 use crate::widgets::WidgetType;
921 use std::sync::Arc;
922 use std::sync::atomic::{AtomicU64, Ordering};
923 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
924
925 struct TestParams {
928 values: [AtomicU64; 2],
929 }
930
931 impl TestParams {
932 fn new() -> Self {
933 Self {
934 values: [
935 AtomicU64::new(0.0f64.to_bits()),
936 AtomicU64::new(0.0f64.to_bits()),
937 ],
938 }
939 }
940 }
941
942 impl truce_params::__private::Sealed for TestParams {}
943 impl Params for TestParams {
944 fn param_infos(&self) -> Vec<ParamInfo> {
945 vec![
946 ParamInfo {
947 id: 0,
948 name: "Mode",
949 short_name: "Mode",
950 group: "",
951 range: ParamRange::Enum { count: 4 },
952 default_plain: 0.0,
953 flags: ParamFlags::AUTOMATABLE,
954 unit: ParamUnit::None,
955 kind: ParamValueKind::Enum,
956 },
957 ParamInfo {
958 id: 1,
959 name: "Gain",
960 short_name: "Gain",
961 group: "",
962 range: ParamRange::Linear { min: 0.0, max: 1.0 },
963 default_plain: 0.5,
964 flags: ParamFlags::AUTOMATABLE,
965 unit: ParamUnit::None,
966 kind: ParamValueKind::Float,
967 },
968 ]
969 }
970
971 fn count(&self) -> usize {
972 2
973 }
974
975 fn get_normalized(&self, id: u32) -> Option<f64> {
976 self.values
977 .get(id as usize)
978 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
979 }
980
981 fn set_normalized(&self, id: u32, value: f64) {
982 if let Some(v) = self.values.get(id as usize) {
983 v.store(value.to_bits(), Ordering::Relaxed);
984 }
985 }
986
987 fn get_plain(&self, id: u32) -> Option<f64> {
988 let norm = self.get_normalized(id)?;
989 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
990 Some(info.range.denormalize(norm))
991 }
992
993 fn set_plain(&self, id: u32, value: f64) {
994 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
995 self.set_normalized(id, info.range.normalize(value));
996 }
997 }
998
999 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1000 Some(format!("{value:.0}"))
1001 }
1002
1003 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1004 None
1005 }
1006 fn snap_smoothers(&self) {}
1007 fn set_sample_rate(&self, _: f64) {}
1008
1009 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1010 let ids = vec![0, 1];
1011 let vals: Vec<f64> = ids
1012 .iter()
1013 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1014 .collect();
1015 (ids, vals)
1016 }
1017
1018 fn restore_values(&self, values: &[(u32, f64)]) {
1019 for &(id, val) in values {
1020 self.set_plain(id, val);
1021 }
1022 }
1023 }
1024
1025 impl Default for TestParams {
1026 fn default() -> Self {
1027 Self::new()
1028 }
1029 }
1030
1031 fn make_editor() -> BuiltinEditor<TestParams> {
1035 let params = Arc::new(TestParams::new());
1036 let layout = GridLayout::build(vec![widgets(vec![
1037 GridWidget::dropdown(0u32, "Mode"),
1038 GridWidget::knob(1u32, "Gain"),
1039 ])]);
1040 let mut editor = BuiltinEditor::new_grid(params, layout);
1041 if let Layout::Grid(ref gl) = editor.layout {
1043 editor.interaction.build_regions_grid(gl);
1044 for (idx, gw) in gl.widgets.iter().enumerate() {
1045 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1046 region.widget_type =
1047 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1048 }
1049 }
1050 }
1051 editor.render();
1053 editor
1054 }
1055
1056 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1058 let params = Arc::new(TestParams::new());
1059 let layout = GridLayout::build(vec![
1060 section(
1061 "SECTION A",
1062 vec![
1063 GridWidget::knob(1u32, "Gain"),
1064 GridWidget::knob(1u32, "Gain 2"),
1065 ],
1066 ),
1067 section(
1068 "SECTION B",
1069 vec![
1070 GridWidget::dropdown(0u32, "Mode"),
1071 GridWidget::knob(1u32, "Gain 3"),
1072 ],
1073 ),
1074 ]);
1075 let mut editor = BuiltinEditor::new_grid(params, layout);
1076 if let Layout::Grid(ref gl) = editor.layout {
1077 editor.interaction.build_regions_grid(gl);
1078 for (idx, gw) in gl.widgets.iter().enumerate() {
1079 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1080 region.widget_type =
1081 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1082 }
1083 }
1084 }
1085 editor.render();
1086 editor
1087 }
1088
1089 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1091 let region = editor
1092 .interaction
1093 .knob_regions
1094 .iter()
1095 .find(|r| r.widget_type == WidgetType::Dropdown)
1096 .expect("no dropdown in layout");
1097 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1098 }
1099
1100 #[test]
1103 fn dropdown_click_opens() {
1104 let mut editor = make_editor();
1105 let (dx, dy) = dropdown_center(&editor);
1106
1107 editor.on_mouse_down(dx, dy);
1108 assert!(editor.interaction.dropdown_is_open());
1109 }
1110
1111 #[test]
1112 fn dropdown_click_toggles_closed() {
1113 let mut editor = make_editor();
1114 let (dx, dy) = dropdown_center(&editor);
1115
1116 editor.on_mouse_down(dx, dy);
1118 editor.on_mouse_up(dx, dy);
1119 assert!(editor.interaction.dropdown_is_open());
1120
1121 editor.on_mouse_down(dx, dy);
1123 assert!(!editor.interaction.dropdown_is_open());
1124 }
1125
1126 #[test]
1127 fn dropdown_click_outside_closes() {
1128 let mut editor = make_editor();
1129 let (dx, dy) = dropdown_center(&editor);
1130
1131 editor.on_mouse_down(dx, dy);
1132 editor.on_mouse_up(dx, dy);
1133 assert!(editor.interaction.dropdown_is_open());
1134
1135 editor.on_mouse_down(0.0, 0.0);
1137 assert!(!editor.interaction.dropdown_is_open());
1138 }
1139
1140 #[test]
1141 fn dropdown_click_option_selects_and_closes() {
1142 let mut editor = make_editor();
1143 let (dx, dy) = dropdown_center(&editor);
1144
1145 editor.on_mouse_down(dx, dy);
1146 editor.on_mouse_up(dx, dy);
1147 assert!(editor.interaction.dropdown_is_open());
1148
1149 let dd = editor.interaction.dropdown.as_ref().unwrap();
1151 let (px, py, _, _) = dd.popup_rect;
1152 let item_h = 18.0f32;
1153 let padding = 4.0f32;
1154 let option_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, option_y);
1160 editor.on_mouse_up(px + 10.0, option_y);
1161
1162 assert!(!editor.interaction.dropdown_is_open());
1163 let norm = editor.params.get_normalized(0).unwrap();
1165 let expected = 1.0 / 3.0;
1166 assert!(
1167 (norm - expected).abs() < 0.01,
1168 "expected {expected:.4}, got {norm}"
1169 );
1170 }
1171
1172 #[test]
1175 fn dropdown_anchor_set_after_render() {
1176 let editor = make_editor();
1177 let region = editor
1178 .interaction
1179 .knob_regions
1180 .iter()
1181 .find(|r| r.widget_type == WidgetType::Dropdown)
1182 .unwrap();
1183
1184 assert!(
1186 region.dropdown_anchor_y > region.y,
1187 "anchor {} should be below region.y {}",
1188 region.dropdown_anchor_y,
1189 region.y
1190 );
1191 assert!(
1192 region.dropdown_anchor_y < region.y + region.h,
1193 "anchor {} should be above region bottom {}",
1194 region.dropdown_anchor_y,
1195 region.y + region.h
1196 );
1197 }
1198
1199 #[test]
1200 fn dropdown_popup_uses_anchor() {
1201 let mut editor = make_editor();
1202 let (dx, dy) = dropdown_center(&editor);
1203
1204 editor.on_mouse_down(dx, dy);
1205 editor.on_mouse_up(dx, dy);
1206
1207 let dd = editor.interaction.dropdown.as_ref().unwrap();
1208 let region = &editor.interaction.knob_regions[dd.region_idx];
1209
1210 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1214 }
1215
1216 #[test]
1217 fn dropdown_anchor_gap_stable_with_sections() {
1218 let editor_plain = make_editor();
1219 let editor_sections = make_editor_with_sections();
1220
1221 let r_plain = editor_plain
1222 .interaction
1223 .knob_regions
1224 .iter()
1225 .find(|r| r.widget_type == WidgetType::Dropdown)
1226 .unwrap();
1227 let r_sections = editor_sections
1228 .interaction
1229 .knob_regions
1230 .iter()
1231 .find(|r| r.widget_type == WidgetType::Dropdown)
1232 .unwrap();
1233
1234 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1237 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1238 assert!(
1239 (gap_plain - gap_sections).abs() < 0.1,
1240 "gap_plain={gap_plain}, gap_sections={gap_sections}"
1241 );
1242 }
1243
1244 struct ManyOptionParams {
1247 values: [AtomicU64; 2],
1248 }
1249
1250 impl ManyOptionParams {
1251 fn new() -> Self {
1252 Self {
1253 values: [
1254 AtomicU64::new(0.0f64.to_bits()),
1255 AtomicU64::new(0.0f64.to_bits()),
1256 ],
1257 }
1258 }
1259 }
1260
1261 impl truce_params::__private::Sealed for ManyOptionParams {}
1262 impl Params for ManyOptionParams {
1263 fn param_infos(&self) -> Vec<ParamInfo> {
1264 vec![
1265 ParamInfo {
1266 id: 0,
1267 name: "Note",
1268 short_name: "Note",
1269 group: "",
1270 range: ParamRange::Enum { count: 20 },
1271 default_plain: 0.0,
1272 flags: ParamFlags::AUTOMATABLE,
1273 unit: ParamUnit::None,
1274 kind: ParamValueKind::Enum,
1275 },
1276 ParamInfo {
1277 id: 1,
1278 name: "Gain",
1279 short_name: "Gain",
1280 group: "",
1281 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1282 default_plain: 0.5,
1283 flags: ParamFlags::AUTOMATABLE,
1284 unit: ParamUnit::None,
1285 kind: ParamValueKind::Float,
1286 },
1287 ]
1288 }
1289
1290 fn count(&self) -> usize {
1291 2
1292 }
1293
1294 fn get_normalized(&self, id: u32) -> Option<f64> {
1295 self.values
1296 .get(id as usize)
1297 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1298 }
1299
1300 fn set_normalized(&self, id: u32, value: f64) {
1301 if let Some(v) = self.values.get(id as usize) {
1302 v.store(value.to_bits(), Ordering::Relaxed);
1303 }
1304 }
1305
1306 fn get_plain(&self, id: u32) -> Option<f64> {
1307 let norm = self.get_normalized(id)?;
1308 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1309 Some(info.range.denormalize(norm))
1310 }
1311
1312 fn set_plain(&self, id: u32, value: f64) {
1313 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1314 self.set_normalized(id, info.range.normalize(value));
1315 }
1316 }
1317
1318 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1319 Some(format!("{value:.0}"))
1320 }
1321
1322 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1323 None
1324 }
1325 fn snap_smoothers(&self) {}
1326 fn set_sample_rate(&self, _: f64) {}
1327
1328 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1329 let ids = vec![0, 1];
1330 let vals: Vec<f64> = ids
1331 .iter()
1332 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1333 .collect();
1334 (ids, vals)
1335 }
1336
1337 fn restore_values(&self, values: &[(u32, f64)]) {
1338 for &(id, val) in values {
1339 self.set_plain(id, val);
1340 }
1341 }
1342 }
1343
1344 impl Default for ManyOptionParams {
1345 fn default() -> Self {
1346 Self::new()
1347 }
1348 }
1349
1350 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1354 let params = Arc::new(TestParams::new());
1355 let layout = GridLayout::build(vec![widgets(vec![
1357 GridWidget::knob(1u32, "K1"),
1358 GridWidget::knob(1u32, "K2"),
1359 GridWidget::knob(1u32, "K3"),
1360 GridWidget::knob(1u32, "K4"),
1361 GridWidget::dropdown(0u32, "Mode"),
1362 GridWidget::knob(1u32, "K5"),
1363 ])])
1364 .with_cols(2);
1365 let mut editor = BuiltinEditor::new_grid(params, layout);
1366 if let Layout::Grid(ref gl) = editor.layout {
1367 editor.interaction.build_regions_grid(gl);
1368 for (idx, gw) in gl.widgets.iter().enumerate() {
1369 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1370 region.widget_type =
1371 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1372 }
1373 }
1374 }
1375 editor.render();
1376 editor
1377 }
1378
1379 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1381 let params = Arc::new(TestParams::new());
1382 let layout = GridLayout::build(vec![widgets(vec![
1383 GridWidget::dropdown(0u32, "Mode A"),
1384 GridWidget::dropdown(0u32, "Mode B"),
1385 ])]);
1386 let mut editor = BuiltinEditor::new_grid(params, layout);
1387 if let Layout::Grid(ref gl) = editor.layout {
1388 editor.interaction.build_regions_grid(gl);
1389 for (idx, gw) in gl.widgets.iter().enumerate() {
1390 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1391 region.widget_type =
1392 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1393 }
1394 }
1395 }
1396 editor.render();
1397 editor
1398 }
1399
1400 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
1402 let params = Arc::new(ManyOptionParams::new());
1403 let layout = GridLayout::build(vec![widgets(vec![
1404 GridWidget::dropdown(0u32, "Note"),
1405 GridWidget::knob(1u32, "Gain"),
1406 ])]);
1407 let mut editor = BuiltinEditor::new_grid(params, layout);
1408 if let Layout::Grid(ref gl) = editor.layout {
1409 editor.interaction.build_regions_grid(gl);
1410 for (idx, gw) in gl.widgets.iter().enumerate() {
1411 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1412 region.widget_type =
1413 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1414 }
1415 }
1416 }
1417 editor.render();
1418 editor
1419 }
1420
1421 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
1422 let region = editor
1423 .interaction
1424 .knob_regions
1425 .iter()
1426 .find(|r| r.widget_type == WidgetType::Dropdown)
1427 .expect("no dropdown in layout");
1428 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1429 }
1430
1431 #[test]
1434 fn dropdown_anchors_below_button_scrolls_when_tight() {
1435 let mut editor = make_editor_bottom_dropdown();
1436 let (dx, dy) = {
1437 let region = editor
1438 .interaction
1439 .knob_regions
1440 .iter()
1441 .find(|r| r.widget_type == WidgetType::Dropdown)
1442 .unwrap();
1443 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1444 };
1445
1446 editor.on_mouse_down(dx, dy);
1447 editor.on_mouse_up(dx, dy);
1448 assert!(editor.interaction.dropdown_is_open());
1449
1450 let dd = editor.interaction.dropdown.as_ref().unwrap();
1451 let region = &editor.interaction.knob_regions[dd.region_idx];
1452 let (_, popup_y, _, popup_h) = dd.popup_rect;
1453 let window_h = editor.layout.height() as f32;
1454
1455 assert_eq!(
1460 popup_y, region.dropdown_anchor_y,
1461 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
1462 );
1463 assert!(
1465 popup_y + popup_h <= window_h + 1.0,
1466 "popup bottom {} exceeds window height {window_h}",
1467 popup_y + popup_h
1468 );
1469 }
1470
1471 #[test]
1472 fn dropdown_clamps_horizontal_near_right_edge() {
1473 let mut editor = make_editor_two_dropdowns();
1474 let region = &editor.interaction.knob_regions[1];
1476 assert_eq!(region.widget_type, WidgetType::Dropdown);
1477 let dx = region.x + region.w / 2.0;
1478 let dy = region.y + region.h / 2.0;
1479
1480 editor.on_mouse_down(dx, dy);
1481 editor.on_mouse_up(dx, dy);
1482 assert!(editor.interaction.dropdown_is_open());
1483
1484 let dd = editor.interaction.dropdown.as_ref().unwrap();
1485 let (popup_x, _, popup_w, _) = dd.popup_rect;
1486 let window_w = editor.layout.width() as f32;
1487
1488 assert!(
1489 popup_x + popup_w <= window_w + 1.0,
1490 "popup right edge {} exceeds window width {window_w}",
1491 popup_x + popup_w
1492 );
1493 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
1494 }
1495
1496 #[test]
1497 fn dropdown_scroll_long_list() {
1498 let mut editor = make_editor_many_options();
1499 let (dx, dy) = dropdown_center_many(&editor);
1500
1501 editor.on_mouse_down(dx, dy);
1502 editor.on_mouse_up(dx, dy);
1503 assert!(editor.interaction.dropdown_is_open());
1504
1505 let dd = editor.interaction.dropdown.as_ref().unwrap();
1506 assert!(
1508 dd.options.len() > dd.visible_count,
1509 "expected scroll: {} options, {} visible",
1510 dd.options.len(),
1511 dd.visible_count
1512 );
1513 assert_eq!(dd.scroll_offset, 0);
1514 }
1515
1516 #[test]
1517 fn dropdown_scroll_clamps_to_bounds() {
1518 let mut editor = make_editor_many_options();
1519 let (dx, dy) = dropdown_center_many(&editor);
1520
1521 editor.on_mouse_down(dx, dy);
1522 editor.on_mouse_up(dx, dy);
1523
1524 editor.interaction.dropdown_scroll(-10);
1526 assert_eq!(
1527 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
1528 0
1529 );
1530
1531 editor.interaction.dropdown_scroll(1000);
1533 let dd = editor.interaction.dropdown.as_ref().unwrap();
1534 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
1535 assert_eq!(dd.scroll_offset, max_offset);
1536 }
1537
1538 #[test]
1539 fn dropdown_selected_item_visible_on_open() {
1540 let mut editor = make_editor_many_options();
1541 editor.params.set_normalized(0, 15.0 / 18.0);
1543
1544 let (dx, dy) = dropdown_center_many(&editor);
1545 editor.on_mouse_down(dx, dy);
1546 editor.on_mouse_up(dx, dy);
1547
1548 let dd = editor.interaction.dropdown.as_ref().unwrap();
1549 let selected = dd.selected;
1550 assert!(
1552 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
1553 "selected={selected} not in visible range {}..{}",
1554 dd.scroll_offset,
1555 dd.scroll_offset + dd.visible_count
1556 );
1557 }
1558
1559 #[test]
1560 fn dropdown_scroll_then_select_correct_index() {
1561 let mut editor = make_editor_many_options();
1562 let (dx, dy) = dropdown_center_many(&editor);
1563
1564 editor.on_mouse_down(dx, dy);
1565 editor.on_mouse_up(dx, dy);
1566
1567 editor.interaction.dropdown_scroll(3);
1569 assert_eq!(
1570 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
1571 3
1572 );
1573
1574 let dd = editor.interaction.dropdown.as_ref().unwrap();
1576 let (px, py, _, _) = dd.popup_rect;
1577 let item_h = 18.0f32;
1578 let padding = 4.0f32;
1579 let click_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, click_y);
1582 editor.on_mouse_up(px + 10.0, click_y);
1583
1584 assert!(!editor.interaction.dropdown_is_open());
1585 let norm = editor.params.get_normalized(0).unwrap();
1588 let expected = 4.0 / 19.0;
1589 assert!(
1590 (norm - expected).abs() < 0.01,
1591 "expected {expected:.4}, got {norm:.4}"
1592 );
1593 }
1594
1595 #[test]
1596 fn dropdown_click_different_dropdown_closes_first() {
1597 let mut editor = make_editor_two_dropdowns();
1598 let r0 = &editor.interaction.knob_regions[0];
1599 let r1 = &editor.interaction.knob_regions[1];
1600 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
1601 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
1602
1603 editor.on_mouse_down(ax, ay);
1605 editor.on_mouse_up(ax, ay);
1606 assert!(editor.interaction.dropdown_is_open());
1607 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
1608
1609 editor.on_mouse_down(bx, by);
1611 editor.on_mouse_up(bx, by);
1612 assert!(editor.interaction.dropdown_is_open());
1613 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
1614 }
1615
1616 #[test]
1617 fn dropdown_hover_tracks_correct_option() {
1618 let mut editor = make_editor();
1619 let (dx, dy) = dropdown_center(&editor);
1620
1621 editor.on_mouse_down(dx, dy);
1622 editor.on_mouse_up(dx, dy);
1623
1624 let dd = editor.interaction.dropdown.as_ref().unwrap();
1625 let (px, py, pw, _) = dd.popup_rect;
1626 let item_h = 18.0f32;
1627 let padding = 4.0f32;
1628 let last_visible = dd.visible_count - 1;
1629
1630 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
1632 editor.on_mouse_moved(px + pw / 2.0, hover_y);
1633
1634 let dd = editor.interaction.dropdown.as_ref().unwrap();
1635 assert_eq!(
1636 dd.hover_option,
1637 Some(last_visible),
1638 "expected hover on last visible option"
1639 );
1640
1641 editor.on_mouse_moved(0.0, 0.0);
1643 let dd = editor.interaction.dropdown.as_ref().unwrap();
1644 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
1645 }
1646
1647 #[test]
1648 fn dropdown_popup_within_window_bounds() {
1649 let mut editor = make_editor();
1651 let (dx, dy) = dropdown_center(&editor);
1652
1653 editor.on_mouse_down(dx, dy);
1654 editor.on_mouse_up(dx, dy);
1655
1656 let dd = editor.interaction.dropdown.as_ref().unwrap();
1657 let (px, py, pw, ph) = dd.popup_rect;
1658 let window_w = editor.layout.width() as f32;
1659 let window_h = editor.layout.height() as f32;
1660
1661 assert!(px >= 0.0, "popup left edge {px} < 0");
1662 assert!(py >= 0.0, "popup top edge {py} < 0");
1663 assert!(
1664 px + pw <= window_w + 1.0,
1665 "popup right {} > window {window_w}",
1666 px + pw
1667 );
1668 assert!(
1669 py + ph <= window_h + 1.0,
1670 "popup bottom {} > window {window_h}",
1671 py + ph
1672 );
1673 }
1674}