1#[cfg(feature = "cpu")]
9use std::ptr;
10use std::sync::Arc;
11#[cfg(feature = "cpu")]
12use std::sync::Mutex;
13#[cfg(feature = "cpu")]
14use std::sync::atomic::AtomicU64;
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use truce_core::Float;
18#[cfg(feature = "cpu")]
19use truce_core::editor::Editor;
20#[cfg(feature = "cpu")]
21use truce_core::editor::RawWindowHandle;
22use truce_core::editor::{PluginContext, PluginContextReadF32};
23use truce_params::Params;
24
25#[cfg(feature = "cpu")]
26use crate::backend_cpu::CpuBackend;
27use crate::interaction::{self, InputEvent, InteractionState, ParamEdit};
28use crate::layout::{GridLayout, Layout, PluginLayout};
29#[cfg(feature = "cpu")]
30use crate::platform::EditorScale;
31use crate::render::RenderBackend;
32use crate::render_core::{
33 EditorSnapshotClosures, build_snapshot_closures as build_snapshot_closures_impl,
34 render_widgets as render_widgets_impl,
35};
36use crate::theme::Theme;
37use crate::widgets;
38
39pub struct BuiltinEditor<P: Params> {
44 params: Arc<P>,
45 layout: Layout,
46 theme: Theme,
47 #[cfg(feature = "cpu")]
52 backend: Option<CpuBackend>,
53 interaction: InteractionState,
54 context: Option<PluginContext>,
55 #[cfg(feature = "cpu")]
58 window: Option<baseview::WindowHandle>,
59 #[cfg(feature = "cpu")]
65 blit_backend: Option<SharedBackend>,
66 needs_repaint: Arc<AtomicBool>,
74 #[cfg(feature = "cpu")]
81 last_painted_values: Vec<f32>,
82 #[cfg(feature = "cpu")]
90 scale: EditorScale,
91 #[cfg(feature = "cpu")]
98 meter_ids: Vec<u32>,
99 #[cfg(feature = "cpu")]
104 last_meter_values: Vec<f32>,
105 #[cfg(feature = "cpu")]
116 pending_size: Arc<AtomicU64>,
117}
118
119unsafe impl<P: Params> Send for BuiltinEditor<P> {}
129
130#[cfg(feature = "cpu")]
134fn collect_meter_ids(layout: &Layout) -> Vec<u32> {
135 let mut ids = Vec::new();
136 match layout {
137 Layout::Rows(pl) => {
138 for row in &pl.rows {
139 for knob in &row.knobs {
140 if let Some(m) = &knob.meter_ids {
141 ids.extend_from_slice(m);
142 }
143 }
144 }
145 }
146 Layout::Grid(gl) => {
147 for widget in &gl.widgets {
148 if let Some(m) = &widget.meter_ids {
149 ids.extend_from_slice(m);
150 }
151 }
152 }
153 }
154 ids
155}
156
157impl<P: Params + 'static> BuiltinEditor<P> {
158 pub fn request_repaint(&self) {
163 self.needs_repaint.store(true, Ordering::Release);
164 }
165
166 #[cfg(feature = "cpu")]
168 fn take_needs_repaint(&self) -> bool {
169 self.needs_repaint.swap(false, Ordering::AcqRel)
170 }
171
172 #[cfg(feature = "cpu")]
181 fn detect_host_param_changes(&mut self) {
182 let regions = &self.interaction.knob_regions;
183 if regions.len() != self.last_painted_values.len() {
184 self.request_repaint();
187 return;
188 }
189 for (i, region) in regions.iter().enumerate() {
190 if (region.normalized_value - self.last_painted_values[i]).abs() > f32::EPSILON {
191 self.request_repaint();
192 return;
193 }
194 }
195 }
196
197 #[cfg(feature = "cpu")]
201 fn stash_painted_values(&mut self) {
202 let regions = &self.interaction.knob_regions;
203 self.last_painted_values.resize(regions.len(), 0.0);
210 for (slot, region) in self.last_painted_values.iter_mut().zip(regions.iter()) {
211 *slot = region.normalized_value;
212 }
213 }
214
215 #[cfg(feature = "cpu")]
224 #[allow(clippy::float_cmp)]
225 fn detect_meter_changes(&mut self) {
226 if self.meter_ids.is_empty() {
227 return;
228 }
229 let Some(ctx) = self.context.as_ref() else {
230 return;
231 };
232 let current: Vec<f32> = self.meter_ids.iter().map(|&id| ctx.get_meter(id)).collect();
233 if current != self.last_meter_values {
234 self.last_meter_values = current;
235 self.request_repaint();
236 }
237 }
238
239 pub fn new(params: Arc<P>, layout: PluginLayout) -> Self {
240 Self::with_layout_inner(params, Layout::Rows(layout))
241 }
242
243 pub fn new_with_layout(params: Arc<P>, layout: Layout) -> Self {
244 Self::with_layout_inner(params, layout)
245 }
246
247 pub fn new_grid(params: Arc<P>, layout: GridLayout) -> Self {
248 Self::with_layout_inner(params, Layout::Grid(layout))
249 }
250
251 fn with_layout_inner(params: Arc<P>, layout: Layout) -> Self {
252 #[cfg(feature = "cpu")]
253 let meter_ids = collect_meter_ids(&layout);
254 Self {
255 params,
256 layout,
257 theme: Theme::dark(),
258 #[cfg(feature = "cpu")]
259 backend: None,
260 interaction: InteractionState::default(),
261 context: None,
262 #[cfg(feature = "cpu")]
263 window: None,
264 #[cfg(feature = "cpu")]
265 blit_backend: None,
266 needs_repaint: Arc::new(AtomicBool::new(false)),
267 #[cfg(feature = "cpu")]
268 last_painted_values: Vec::new(),
269 #[cfg(feature = "cpu")]
270 scale: EditorScale::new(crate::backing_scale()),
271 #[cfg(feature = "cpu")]
272 meter_ids,
273 #[cfg(feature = "cpu")]
274 last_meter_values: Vec::new(),
275 #[cfg(feature = "cpu")]
276 pending_size: Arc::new(AtomicU64::new(0)),
277 }
278 }
279
280 #[must_use]
281 pub fn with_theme(mut self, theme: Theme) -> Self {
282 self.theme = theme;
283 self
284 }
285
286 #[cfg(feature = "cpu")]
298 pub fn render(&mut self) {
299 let (w, h) = (self.layout.width(), self.layout.height());
300 let scale = self.scale.get_f32();
301 let owned = self.build_snapshot_closures();
302 let snapshot = owned.as_snapshot();
303 let backend = if let Some(ref mut b) = self.backend {
314 b
315 } else {
316 let Some(b) = CpuBackend::new(w, h, scale) else {
317 log::warn!("CpuBackend allocation failed for {w}x{h} @ {scale}x; skipping frame");
318 return;
319 };
320 self.backend.insert(b)
321 };
322 render_widgets_impl(
323 &self.layout,
324 &self.theme,
325 &mut self.interaction,
326 &snapshot,
327 backend,
328 );
329 }
330
331 fn build_snapshot_closures(&self) -> EditorSnapshotClosures {
338 build_snapshot_closures_impl(&self.params, self.context.as_ref())
339 }
340
341 fn apply_edit(&self, edit: ParamEdit) {
343 match edit {
344 ParamEdit::Begin { id } => {
345 if let Some(ref ctx) = self.context {
346 ctx.begin_edit(id);
347 }
348 }
349 ParamEdit::Set { id, normalized } => {
350 self.params.set_normalized(id, f64::from(normalized));
351 if let Some(ref ctx) = self.context {
352 ctx.set_param(id, f64::from(normalized));
353 }
354 self.request_repaint();
355 }
356 ParamEdit::End { id } => {
357 if let Some(ref ctx) = self.context {
358 ctx.end_edit(id);
359 }
360 }
361 }
362 }
363
364 pub fn dispatch_events(&mut self, events: &[InputEvent]) {
372 let hover_before = self.interaction.hover_idx;
373 let dd_before = self.interaction.dropdown_is_open();
374 let owned = self.build_snapshot_closures();
375 let snapshot = owned.as_snapshot();
376 let edits = interaction::dispatch(events, &self.layout, &snapshot, &mut self.interaction);
377 let had_edits = !edits.is_empty();
378 for e in edits {
379 self.apply_edit(e);
380 }
381 let explicit = self.interaction.take_repaint_request();
387 if had_edits
388 || explicit
389 || self.interaction.hover_idx != hover_before
390 || self.interaction.dropdown_is_open() != dd_before
391 {
392 self.request_repaint();
393 }
394 }
395
396 #[cfg(feature = "cpu")]
399 #[must_use]
400 pub fn pixel_data(&self) -> Option<&[u8]> {
401 self.backend
402 .as_ref()
403 .map(super::backend_cpu::CpuBackend::data)
404 }
405
406 #[must_use]
410 pub fn has_context(&self) -> bool {
411 self.context.is_some()
412 }
413
414 pub fn take_context(&mut self) -> Option<PluginContext> {
417 self.context.take()
418 }
419
420 pub fn set_context(&mut self, context: PluginContext) {
422 self.context = Some(context);
423 match &self.layout {
424 Layout::Rows(pl) => self.interaction.build_regions(pl),
425 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
426 }
427 }
428
429 #[must_use]
433 pub fn size(&self) -> (u32, u32) {
434 (self.layout.width(), self.layout.height())
435 }
436
437 #[must_use]
442 pub fn can_resize(&self) -> bool {
443 match &self.layout {
444 Layout::Grid(gl) => gl.resizable,
445 Layout::Rows(_) => false,
448 }
449 }
450
451 #[must_use]
453 pub fn min_size(&self) -> (u32, u32) {
454 match &self.layout {
455 Layout::Grid(gl) => gl.min_snapped_size(),
456 Layout::Rows(_) => self.size(),
457 }
458 }
459
460 #[must_use]
462 pub fn max_size(&self) -> (u32, u32) {
463 match &self.layout {
464 Layout::Grid(gl) => gl.max_snapped_size(),
465 Layout::Rows(_) => self.size(),
466 }
467 }
468
469 #[must_use]
472 pub fn size_increment(&self) -> Option<(u32, u32)> {
473 match &self.layout {
474 Layout::Grid(gl) if gl.resizable => {
477 let step = gl.resize_step();
478 Some((step, step))
479 }
480 _ => None,
481 }
482 }
483
484 #[must_use]
491 pub fn can_maximize(&self) -> bool {
492 match &self.layout {
493 Layout::Grid(gl) => gl.maximizable,
494 Layout::Rows(_) => false,
495 }
496 }
497
498 pub fn set_size(&mut self, width: u32, height: u32) -> bool {
502 if width == 0 || height == 0 || !self.can_resize() {
503 return false;
504 }
505 let Layout::Grid(ref mut gl) = self.layout else {
506 return false;
507 };
508 gl.refit_cols(width);
516 let (new_w, new_h) = gl.refit_rows(height);
517 #[cfg(feature = "cpu")]
523 self.pending_size.store(
524 (u64::from(new_w) << 32) | u64::from(new_h),
525 Ordering::Release,
526 );
527 #[cfg(not(feature = "cpu"))]
528 let _ = (new_w, new_h);
529 self.request_repaint();
533 true
534 }
535
536 pub fn state_changed(&mut self) {
540 self.request_repaint();
541 }
542
543 pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
548 update_interaction(self);
549 let owned = self.build_snapshot_closures();
550 let snapshot = owned.as_snapshot();
551 render_widgets_impl(
552 &self.layout,
553 &self.theme,
554 &mut self.interaction,
555 &snapshot,
556 backend,
557 );
558 }
559}
560
561#[cfg(test)]
565impl<P: Params + 'static> BuiltinEditor<P> {
566 fn on_mouse_down(&mut self, x: f32, y: f32) {
567 self.dispatch_events(&[InputEvent::MouseDown {
568 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
569 x,
570 y,
571 button: crate::interaction::MouseButton::Left,
572 }]);
573 }
574
575 fn on_mouse_up(&mut self, x: f32, y: f32) {
576 self.dispatch_events(&[InputEvent::MouseUp {
577 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
578 x,
579 y,
580 button: crate::interaction::MouseButton::Left,
581 }]);
582 }
583
584 fn on_mouse_moved(&mut self, x: f32, y: f32) {
585 self.dispatch_events(&[InputEvent::MouseMove {
586 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
587 x,
588 y,
589 }]);
590 }
591}
592
593pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
603 match &editor.layout {
604 Layout::Rows(pl) => {
605 editor.interaction.build_regions(pl);
606 let mut flat_idx = 0usize;
607 for row in &pl.rows {
608 for knob_def in &row.knobs {
609 if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
610 region.widget_type = resolve_widget_type(
611 knob_def.widget,
612 knob_def.param_id,
613 &*editor.params,
614 );
615 }
616 flat_idx += 1;
617 }
618 }
619 }
620 Layout::Grid(gl) => {
621 editor.interaction.build_regions_grid(gl);
622 for (idx, gw) in gl.widgets.iter().enumerate() {
623 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
624 region.widget_type =
625 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
626 }
627 }
628 }
629 }
630 for region in &mut editor.interaction.knob_regions {
631 if let Some(ref ctx) = editor.context {
632 region.normalized_value = ctx.get_param(region.param_id);
634 } else {
635 region.normalized_value =
636 f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
637 }
638 }
639}
640
641#[cfg(feature = "cpu")]
656fn create_wgpu_backend(window: &mut baseview::Window, phys_w: u32, phys_h: u32) -> BlitBackend {
657 let instance = wgpu::Instance::new(crate::platform::editor_instance_descriptor());
658
659 let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) }
660 .expect("failed to create wgpu surface");
661
662 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
663 power_preference: wgpu::PowerPreference::HighPerformance,
664 compatible_surface: Some(&surface),
665 force_fallback_adapter: false,
666 }))
667 .expect("no suitable GPU adapter");
668
669 let adapter_limits = adapter.limits();
677 let max_texture_dim = adapter_limits.max_texture_dimension_2d;
678 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
679 label: Some("truce-gui"),
680 required_features: wgpu::Features::empty(),
681 required_limits: adapter_limits,
682 experimental_features: wgpu::ExperimentalFeatures::default(),
683 memory_hints: wgpu::MemoryHints::Performance,
684 trace: wgpu::Trace::Off,
685 }))
686 .expect("failed to create wgpu device");
687
688 let caps = surface.get_capabilities(&adapter);
689 let format = caps
690 .formats
691 .iter()
692 .find(|f| f.is_srgb())
693 .copied()
694 .unwrap_or(caps.formats[0]);
695
696 let init_w = phys_w.clamp(1, max_texture_dim);
702 let init_h = phys_h.clamp(1, max_texture_dim);
703 let surface_config = wgpu::SurfaceConfiguration {
704 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
705 format,
706 width: init_w,
707 height: init_h,
708 present_mode: wgpu::PresentMode::AutoVsync,
709 desired_maximum_frame_latency: 2,
710 alpha_mode: wgpu::CompositeAlphaMode::Auto,
711 view_formats: vec![],
712 };
713 surface.configure(&device, &surface_config);
714
715 let blit = crate::blit::BlitPipeline::new(&device, format, init_w, init_h);
720
721 BlitBackend {
722 blit,
723 surface_config,
724 surface,
725 queue,
726 device,
727 max_texture_dim,
728 }
729}
730
731#[cfg(feature = "cpu")]
740struct BlitBackend {
741 blit: crate::blit::BlitPipeline,
742 surface_config: wgpu::SurfaceConfiguration,
743 surface: wgpu::Surface<'static>,
744 queue: wgpu::Queue,
745 device: wgpu::Device,
746 max_texture_dim: u32,
753}
754
755#[cfg(feature = "cpu")]
756impl BlitBackend {
757 fn resize(&mut self, phys_w: u32, phys_h: u32) {
762 let phys_w = phys_w.clamp(1, self.max_texture_dim);
763 let phys_h = phys_h.clamp(1, self.max_texture_dim);
764 self.surface_config.width = phys_w;
765 self.surface_config.height = phys_h;
766 self.surface.configure(&self.device, &self.surface_config);
767 self.blit.resize(&self.device, phys_w, phys_h);
768 }
769
770 fn configure_surface(&mut self, phys_w: u32, phys_h: u32) {
784 let phys_w = phys_w.clamp(1, self.max_texture_dim);
785 let phys_h = phys_h.clamp(1, self.max_texture_dim);
786 if self.surface_config.width == phys_w && self.surface_config.height == phys_h {
787 return;
788 }
789 self.surface_config.width = phys_w;
790 self.surface_config.height = phys_h;
791 self.surface.configure(&self.device, &self.surface_config);
792 }
793}
794
795#[cfg(feature = "cpu")]
802type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
803
804#[cfg(feature = "cpu")]
805struct BuiltinWindowHandler<P: Params> {
806 editor: *mut BuiltinEditor<P>,
817 backend: SharedBackend,
818 translator: crate::interaction::BaseviewTranslator,
822 last_applied_scale: f32,
831}
832
833#[cfg(feature = "cpu")]
836unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
837
838#[cfg(feature = "cpu")]
839impl<P: Params + 'static> BuiltinWindowHandler<P> {
840 fn on_frame_inner(&mut self, window: &mut baseview::Window) {
841 let Ok(mut guard) = self.backend.lock() else {
848 return;
849 };
850 if guard.is_none() {
851 return;
854 }
855
856 let editor = unsafe { &mut *self.editor };
857
858 let pending = editor.pending_size.swap(0, Ordering::Acquire);
870 if pending != 0 {
871 #[allow(clippy::cast_possible_truncation)]
872 let new_w = (pending >> 32) as u32;
873 #[allow(clippy::cast_possible_truncation)]
874 let new_h = (pending & 0xFFFF_FFFF) as u32;
875 if new_w > 0 && new_h > 0 {
876 let scale = editor.scale.get();
877 let scale_f32 = editor.scale.get_f32();
878 let phys_w = crate::platform::to_physical_px(new_w, scale);
879 let phys_h = crate::platform::to_physical_px(new_h, scale);
880 editor.backend = CpuBackend::new(new_w, new_h, scale_f32);
881 if let Some(backend) = guard.as_mut() {
882 backend.resize(phys_w, phys_h);
883 }
884 match &editor.layout {
885 Layout::Rows(pl) => editor.interaction.build_regions(pl),
886 Layout::Grid(gl) => editor.interaction.build_regions_grid(gl),
887 }
888 window.resize(baseview::Size::new(f64::from(new_w), f64::from(new_h)));
889 editor.request_repaint();
890 }
891 }
892
893 #[cfg(target_os = "macos")]
910 {
911 use raw_window_handle::HasRawWindowHandle;
912 if crate::platform::should_skip_frame(window.raw_window_handle()) {
916 return;
917 }
918 crate::platform::reanchor_to_superview_top(window.raw_window_handle());
919 }
920
921 if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
928 let (lw, lh) = editor.size();
929 let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
930 let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
931 editor.backend = CpuBackend::new(lw, lh, cur_scale);
932 if let Some(backend) = guard.as_mut() {
933 backend.resize(phys_w, phys_h);
934 }
935 editor.request_repaint();
936 }
937
938 update_interaction(editor);
939 editor.detect_host_param_changes();
944 editor.detect_meter_changes();
945 if !editor.take_needs_repaint() {
946 return;
947 }
948 editor.render();
949 editor.stash_painted_values();
950
951 if let Some(pixels) = editor.pixel_data() {
952 let backend = guard
953 .as_mut()
954 .expect("guard was checked Some above and the lock is still held");
955 let BlitBackend {
956 device,
957 queue,
958 surface,
959 surface_config,
960 blit,
961 ..
962 } = backend;
963 blit.update(queue, pixels);
964 let mut acquired = None;
975 for _ in 0..2 {
976 match surface.get_current_texture() {
977 wgpu::CurrentSurfaceTexture::Success(frame)
978 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
979 acquired = Some(frame);
980 break;
981 }
982 wgpu::CurrentSurfaceTexture::Outdated
983 | wgpu::CurrentSurfaceTexture::Lost
984 | wgpu::CurrentSurfaceTexture::Validation => {
985 surface.configure(device, surface_config);
986 }
987 wgpu::CurrentSurfaceTexture::Timeout
988 | wgpu::CurrentSurfaceTexture::Occluded => return,
989 }
990 }
991 let Some(frame) = acquired else {
992 editor.request_repaint();
996 return;
997 };
998 let view = frame
999 .texture
1000 .create_view(&wgpu::TextureViewDescriptor::default());
1001 let mut encoder =
1002 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1003 blit.render(
1004 queue,
1005 &mut encoder,
1006 &view,
1007 surface_config.width,
1008 surface_config.height,
1009 );
1010 queue.submit(std::iter::once(encoder.finish()));
1011 frame.present();
1012 }
1013 }
1014
1015 #[allow(clippy::needless_pass_by_value)]
1019 fn on_event_inner(
1020 &mut self,
1021 window: &mut baseview::Window,
1022 event: baseview::Event,
1023 ) -> baseview::EventStatus {
1024 #[cfg(not(target_os = "windows"))]
1027 let _ = &window;
1028
1029 if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
1030 button: baseview::MouseButton::Left,
1031 ..
1032 }) = &event
1033 {
1034 #[cfg(target_os = "windows")]
1040 {
1041 if !window.has_focus() {
1042 window.focus();
1043 }
1044 }
1045 }
1046
1047 let Ok(mut guard) = self.backend.lock() else {
1052 return baseview::EventStatus::Ignored;
1053 };
1054 if guard.is_none() {
1055 return baseview::EventStatus::Ignored;
1056 }
1057
1058 match event {
1059 baseview::Event::Mouse(_) => {
1060 let Some(input) = self.translator.translate(&event) else {
1061 return baseview::EventStatus::Ignored;
1062 };
1063 let editor = unsafe { &mut *self.editor };
1064 editor.dispatch_events(&[input]);
1065 baseview::EventStatus::Captured
1066 }
1067 baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
1068 let editor = unsafe { &mut *self.editor };
1080 editor.scale.set(info.scale());
1081 crate::platform::note_linux_scale_factor(info.scale());
1082 let phys = info.physical_size();
1083 if editor.can_resize() {
1084 let scale = info.scale();
1085 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1086 let (lw, lh) = if scale > 0.0 {
1087 (
1088 (f64::from(phys.width) / scale).round() as u32,
1089 (f64::from(phys.height) / scale).round() as u32,
1090 )
1091 } else {
1092 (phys.width, phys.height)
1093 };
1094 let (cur_w, cur_h) = editor.size();
1095 if (lw, lh) != (cur_w, cur_h) && lw > 0 && lh > 0 {
1096 editor.set_size(lw, lh);
1097 }
1098 }
1099 if phys.width > 0
1108 && phys.height > 0
1109 && let Some(backend) = guard.as_mut()
1110 {
1111 backend.configure_surface(phys.width, phys.height);
1112 }
1113 editor.request_repaint();
1125 baseview::EventStatus::Ignored
1126 }
1127 _ => baseview::EventStatus::Ignored,
1128 }
1129 }
1130}
1131
1132#[cfg(feature = "cpu")]
1133impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
1134 fn on_frame(&mut self, window: &mut baseview::Window) {
1135 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1141 self.on_frame_inner(window);
1142 }));
1143 if let Err(e) = result {
1144 let msg = if let Some(s) = e.downcast_ref::<&str>() {
1145 s.to_string()
1146 } else if let Some(s) = e.downcast_ref::<String>() {
1147 s.clone()
1148 } else {
1149 "unknown panic".to_string()
1150 };
1151 log::error!("BuiltinWindowHandler::on_frame panic swallowed: {msg}");
1152 }
1153 }
1154
1155 fn on_event(
1156 &mut self,
1157 window: &mut baseview::Window,
1158 event: baseview::Event,
1159 ) -> baseview::EventStatus {
1160 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1161 self.on_event_inner(window, event)
1162 }));
1163 result.unwrap_or_else(|e| {
1164 let msg = if let Some(s) = e.downcast_ref::<&str>() {
1165 s.to_string()
1166 } else if let Some(s) = e.downcast_ref::<String>() {
1167 s.clone()
1168 } else {
1169 "unknown panic".to_string()
1170 };
1171 log::error!("BuiltinWindowHandler::on_event panic swallowed: {msg}");
1172 baseview::EventStatus::Ignored
1173 })
1174 }
1175}
1176
1177fn resolve_widget_type<P: Params>(
1183 widget: Option<crate::layout::WidgetKind>,
1184 param_id: u32,
1185 params: &P,
1186) -> widgets::WidgetType {
1187 match widget {
1188 Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
1189 Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
1190 Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
1191 Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
1192 Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
1193 Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
1194 Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
1195 None => {
1196 let param_info = params
1197 .param_infos()
1198 .iter()
1199 .find(|i| i.id == param_id)
1200 .copied();
1201 match param_info.as_ref().map(|i| &i.range) {
1202 Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
1203 widgets::WidgetType::Toggle
1204 }
1205 Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
1206 _ => widgets::WidgetType::Knob,
1207 }
1208 }
1209 }
1210}
1211
1212#[cfg(feature = "cpu")]
1213impl<P: Params + 'static> Editor for BuiltinEditor<P> {
1214 fn size(&self) -> (u32, u32) {
1215 (self.layout.width(), self.layout.height())
1216 }
1217
1218 fn state_changed(&mut self) {
1219 self.request_repaint();
1222 }
1223
1224 fn can_resize(&self) -> bool {
1229 self.can_resize()
1230 }
1231
1232 fn can_maximize(&self) -> bool {
1233 self.can_maximize()
1234 }
1235
1236 fn min_size(&self) -> (u32, u32) {
1237 self.min_size()
1238 }
1239
1240 fn max_size(&self) -> (u32, u32) {
1241 self.max_size()
1242 }
1243
1244 fn size_increment(&self) -> Option<(u32, u32)> {
1245 self.size_increment()
1246 }
1247
1248 fn set_size(&mut self, width: u32, height: u32) -> bool {
1249 self.set_size(width, height)
1250 }
1251
1252 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
1253 let (w, h) = self.size();
1254 self.pending_size.store(0, Ordering::Relaxed);
1258 self.scale
1264 .set(crate::platform::query_backing_scale(&parent));
1265 let scale = self.scale.get();
1266 let scale_f32 = self.scale.get_f32();
1267 self.backend = CpuBackend::new(w, h, scale_f32);
1268 self.context = Some(context);
1269
1270 match &self.layout {
1272 Layout::Rows(pl) => self.interaction.build_regions(pl),
1273 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
1274 }
1275
1276 self.render();
1280 self.request_repaint();
1281
1282 let (lw, lh) = (f64::from(w), f64::from(h));
1283 let phys_w = crate::platform::to_physical_px(w, scale);
1284 let phys_h = crate::platform::to_physical_px(h, scale);
1285
1286 let options = baseview::WindowOpenOptions {
1287 title: String::from("truce"),
1288 size: baseview::Size::new(lw, lh),
1289 scale: baseview::WindowScalePolicy::SystemScaleFactor,
1290 };
1291
1292 let parent_wrapper = crate::platform::ParentWindow(parent);
1293 let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
1294
1295 let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
1300 self.blit_backend = Some(shared_backend.clone());
1301 let shared_for_handler = shared_backend;
1302
1303 let window = baseview::Window::open_parented(
1304 &parent_wrapper,
1305 options,
1306 move |window: &mut baseview::Window| {
1307 let mut backend = create_wgpu_backend(window, phys_w, phys_h);
1308
1309 let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
1316 editor.render();
1317 if let Some(pixels) = editor.pixel_data() {
1318 let BlitBackend {
1319 device,
1320 queue,
1321 surface,
1322 surface_config,
1323 blit,
1324 ..
1325 } = &mut backend;
1326 blit.update(queue, pixels);
1327 if let wgpu::CurrentSurfaceTexture::Success(frame)
1328 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
1329 surface.get_current_texture()
1330 {
1331 let view = frame
1332 .texture
1333 .create_view(&wgpu::TextureViewDescriptor::default());
1334 let mut encoder =
1335 device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1336 label: None,
1337 });
1338 blit.render(
1339 queue,
1340 &mut encoder,
1341 &view,
1342 surface_config.width,
1343 surface_config.height,
1344 );
1345 queue.submit(std::iter::once(encoder.finish()));
1346 frame.present();
1347 }
1348 }
1349
1350 if let Ok(mut guard) = shared_for_handler.lock() {
1357 *guard = Some(backend);
1358 }
1359
1360 BuiltinWindowHandler {
1361 editor: editor_addr as *mut BuiltinEditor<P>,
1362 backend: shared_for_handler.clone(),
1363 translator: crate::interaction::BaseviewTranslator::default(),
1364 last_applied_scale: scale_f32,
1365 }
1366 },
1367 );
1368
1369 self.window = Some(window);
1370 }
1371
1372 fn set_scale_factor(&mut self, factor: f64) {
1373 self.scale.set(factor);
1378 }
1379
1380 fn close(&mut self) {
1381 #[cfg(target_os = "macos")]
1388 let pool = unsafe {
1389 unsafe extern "C" {
1390 fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
1391 }
1392 objc_autoreleasePoolPush()
1393 };
1394
1395 if let Some(shared) = self.blit_backend.take()
1404 && let Ok(mut guard) = shared.lock()
1405 && let Some(backend) = guard.take()
1406 {
1407 let BlitBackend {
1408 blit,
1409 surface,
1410 surface_config,
1411 queue,
1412 device,
1413 max_texture_dim: _,
1414 } = backend;
1415 drop(surface_config);
1416 drop(blit);
1417 drop(surface);
1418 drop(queue);
1419 drop(device);
1420 }
1421
1422 if let Some(mut window) = self.window.take() {
1423 window.close();
1424 }
1425 self.context = None;
1426 self.backend = None;
1427
1428 #[cfg(target_os = "macos")]
1429 unsafe {
1430 unsafe extern "C" {
1431 fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
1432 }
1433 objc_autoreleasePoolPop(pool);
1434 }
1435 }
1436
1437 fn idle(&mut self) {
1438 if self.window.is_none() {
1442 self.render();
1443 }
1444 }
1445
1446 fn screenshot(
1447 &mut self,
1448 _params: Arc<dyn truce_params::Params>,
1449 ) -> Option<(Vec<u8>, u32, u32)> {
1450 let (lw, lh) = self.size();
1457 let scale = self.scale.get_f32();
1458 let mut backend = CpuBackend::new(lw, lh, scale)?;
1459 self.render_to(&mut backend);
1460 let pixels = backend.data().to_vec();
1461 let (phys_w, phys_h) = (backend.width(), backend.height());
1462 Some((pixels, phys_w, phys_h))
1463 }
1464}
1465
1466#[cfg(feature = "cpu")]
1467impl<P: Params + 'static> Drop for BuiltinEditor<P> {
1468 fn drop(&mut self) {
1469 Editor::close(self);
1481 }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486 #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
1489
1490 use super::*;
1491 use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
1492 use crate::widgets::WidgetType;
1493 use std::sync::Arc;
1494 use std::sync::atomic::{AtomicU64, Ordering};
1495 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
1496
1497 struct TestParams {
1500 values: [AtomicU64; 2],
1501 }
1502
1503 impl TestParams {
1504 fn new() -> Self {
1505 Self {
1506 values: [
1507 AtomicU64::new(0.0f64.to_bits()),
1508 AtomicU64::new(0.0f64.to_bits()),
1509 ],
1510 }
1511 }
1512 }
1513
1514 impl truce_params::__private::Sealed for TestParams {}
1515 impl Params for TestParams {
1516 fn param_infos(&self) -> Vec<ParamInfo> {
1517 vec![
1518 ParamInfo {
1519 id: 0,
1520 name: "Mode",
1521 short_name: "Mode",
1522 group: "",
1523 range: ParamRange::Enum { count: 4 },
1524 default_plain: 0.0,
1525 flags: ParamFlags::AUTOMATABLE,
1526 unit: ParamUnit::None,
1527 kind: ParamValueKind::Enum,
1528 midi_map: None,
1529 midi_channel: None,
1530 },
1531 ParamInfo {
1532 id: 1,
1533 name: "Gain",
1534 short_name: "Gain",
1535 group: "",
1536 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1537 default_plain: 0.5,
1538 flags: ParamFlags::AUTOMATABLE,
1539 unit: ParamUnit::None,
1540 kind: ParamValueKind::Float,
1541 midi_map: None,
1542 midi_channel: None,
1543 },
1544 ]
1545 }
1546
1547 fn count(&self) -> usize {
1548 2
1549 }
1550
1551 fn get_normalized(&self, id: u32) -> Option<f64> {
1552 self.values
1553 .get(id as usize)
1554 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1555 }
1556
1557 fn set_normalized(&self, id: u32, value: f64) {
1558 if let Some(v) = self.values.get(id as usize) {
1559 v.store(value.to_bits(), Ordering::Relaxed);
1560 }
1561 }
1562
1563 fn get_plain(&self, id: u32) -> Option<f64> {
1564 let norm = self.get_normalized(id)?;
1565 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1566 Some(info.range.denormalize(norm))
1567 }
1568
1569 fn set_plain(&self, id: u32, value: f64) {
1570 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1571 self.set_normalized(id, info.range.normalize(value));
1572 }
1573 }
1574
1575 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1576 Some(format!("{value:.0}"))
1577 }
1578
1579 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1580 None
1581 }
1582 fn snap_smoothers(&self) {}
1583 fn set_sample_rate(&self, _: f64) {}
1584
1585 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1586 let ids = vec![0, 1];
1587 let vals: Vec<f64> = ids
1588 .iter()
1589 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1590 .collect();
1591 (ids, vals)
1592 }
1593
1594 fn restore_values(&self, values: &[(u32, f64)]) {
1595 for &(id, val) in values {
1596 self.set_plain(id, val);
1597 }
1598 }
1599 }
1600
1601 impl Default for TestParams {
1602 fn default() -> Self {
1603 Self::new()
1604 }
1605 }
1606
1607 fn make_editor() -> BuiltinEditor<TestParams> {
1611 let params = Arc::new(TestParams::new());
1612 let layout = GridLayout::build(vec![widgets(vec![
1613 GridWidget::dropdown(0u32, "Mode"),
1614 GridWidget::knob(1u32, "Gain"),
1615 ])]);
1616 let mut editor = BuiltinEditor::new_grid(params, layout);
1617 if let Layout::Grid(ref gl) = editor.layout {
1619 editor.interaction.build_regions_grid(gl);
1620 for (idx, gw) in gl.widgets.iter().enumerate() {
1621 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1622 region.widget_type =
1623 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1624 }
1625 }
1626 }
1627 editor.render();
1629 editor
1630 }
1631
1632 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1634 let params = Arc::new(TestParams::new());
1635 let layout = GridLayout::build(vec![
1636 section(
1637 "SECTION A",
1638 vec![
1639 GridWidget::knob(1u32, "Gain"),
1640 GridWidget::knob(1u32, "Gain 2"),
1641 ],
1642 ),
1643 section(
1644 "SECTION B",
1645 vec![
1646 GridWidget::dropdown(0u32, "Mode"),
1647 GridWidget::knob(1u32, "Gain 3"),
1648 ],
1649 ),
1650 ]);
1651 let mut editor = BuiltinEditor::new_grid(params, layout);
1652 if let Layout::Grid(ref gl) = editor.layout {
1653 editor.interaction.build_regions_grid(gl);
1654 for (idx, gw) in gl.widgets.iter().enumerate() {
1655 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1656 region.widget_type =
1657 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1658 }
1659 }
1660 }
1661 editor.render();
1662 editor
1663 }
1664
1665 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1667 let region = editor
1668 .interaction
1669 .knob_regions
1670 .iter()
1671 .find(|r| r.widget_type == WidgetType::Dropdown)
1672 .expect("no dropdown in layout");
1673 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1674 }
1675
1676 #[test]
1679 fn dropdown_click_opens() {
1680 let mut editor = make_editor();
1681 let (dx, dy) = dropdown_center(&editor);
1682
1683 editor.on_mouse_down(dx, dy);
1684 assert!(editor.interaction.dropdown_is_open());
1685 }
1686
1687 #[test]
1688 fn dropdown_click_toggles_closed() {
1689 let mut editor = make_editor();
1690 let (dx, dy) = dropdown_center(&editor);
1691
1692 editor.on_mouse_down(dx, dy);
1694 editor.on_mouse_up(dx, dy);
1695 assert!(editor.interaction.dropdown_is_open());
1696
1697 editor.on_mouse_down(dx, dy);
1699 assert!(!editor.interaction.dropdown_is_open());
1700 }
1701
1702 #[test]
1703 fn dropdown_click_outside_closes() {
1704 let mut editor = make_editor();
1705 let (dx, dy) = dropdown_center(&editor);
1706
1707 editor.on_mouse_down(dx, dy);
1708 editor.on_mouse_up(dx, dy);
1709 assert!(editor.interaction.dropdown_is_open());
1710
1711 editor.on_mouse_down(0.0, 0.0);
1713 assert!(!editor.interaction.dropdown_is_open());
1714 }
1715
1716 #[test]
1717 fn dropdown_click_option_selects_and_closes() {
1718 let mut editor = make_editor();
1719 let (dx, dy) = dropdown_center(&editor);
1720
1721 editor.on_mouse_down(dx, dy);
1722 editor.on_mouse_up(dx, dy);
1723 assert!(editor.interaction.dropdown_is_open());
1724
1725 let dd = editor.interaction.dropdown.as_ref().unwrap();
1727 let (px, py, _, _) = dd.popup_rect;
1728 let item_h = 18.0f32;
1729 let padding = 4.0f32;
1730 let option_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, option_y);
1736 editor.on_mouse_up(px + 10.0, option_y);
1737
1738 assert!(!editor.interaction.dropdown_is_open());
1739 let norm = editor.params.get_normalized(0).unwrap();
1741 let expected = 1.0 / 3.0;
1742 assert!(
1743 (norm - expected).abs() < 0.01,
1744 "expected {expected:.4}, got {norm}"
1745 );
1746 }
1747
1748 #[test]
1751 fn dropdown_anchor_set_after_render() {
1752 let editor = make_editor();
1753 let region = editor
1754 .interaction
1755 .knob_regions
1756 .iter()
1757 .find(|r| r.widget_type == WidgetType::Dropdown)
1758 .unwrap();
1759
1760 assert!(
1762 region.dropdown_anchor_y > region.y,
1763 "anchor {} should be below region.y {}",
1764 region.dropdown_anchor_y,
1765 region.y
1766 );
1767 assert!(
1768 region.dropdown_anchor_y < region.y + region.h,
1769 "anchor {} should be above region bottom {}",
1770 region.dropdown_anchor_y,
1771 region.y + region.h
1772 );
1773 }
1774
1775 #[test]
1776 fn dropdown_popup_uses_anchor() {
1777 let mut editor = make_editor();
1778 let (dx, dy) = dropdown_center(&editor);
1779
1780 editor.on_mouse_down(dx, dy);
1781 editor.on_mouse_up(dx, dy);
1782
1783 let dd = editor.interaction.dropdown.as_ref().unwrap();
1784 let region = &editor.interaction.knob_regions[dd.region_idx];
1785
1786 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1790 }
1791
1792 #[test]
1793 fn dropdown_anchor_survives_idle_rebuild() {
1794 let mut editor = make_editor();
1801
1802 update_interaction(&mut editor);
1804
1805 let (dx, dy) = dropdown_center(&editor);
1806 editor.on_mouse_down(dx, dy);
1807 editor.on_mouse_up(dx, dy);
1808
1809 let dd = editor.interaction.dropdown.as_ref().unwrap();
1810 let region = &editor.interaction.knob_regions[dd.region_idx];
1811 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1812 assert!(
1813 dd.popup_rect.1 > region.y,
1814 "popup_y {} fell back to the window top instead of anchoring below the button",
1815 dd.popup_rect.1
1816 );
1817 }
1818
1819 #[test]
1820 fn dropdown_anchor_gap_stable_with_sections() {
1821 let editor_plain = make_editor();
1822 let editor_sections = make_editor_with_sections();
1823
1824 let r_plain = editor_plain
1825 .interaction
1826 .knob_regions
1827 .iter()
1828 .find(|r| r.widget_type == WidgetType::Dropdown)
1829 .unwrap();
1830 let r_sections = editor_sections
1831 .interaction
1832 .knob_regions
1833 .iter()
1834 .find(|r| r.widget_type == WidgetType::Dropdown)
1835 .unwrap();
1836
1837 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1840 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1841 assert!(
1842 (gap_plain - gap_sections).abs() < 0.1,
1843 "gap_plain={gap_plain}, gap_sections={gap_sections}"
1844 );
1845 }
1846
1847 struct ManyOptionParams {
1850 values: [AtomicU64; 2],
1851 }
1852
1853 impl ManyOptionParams {
1854 fn new() -> Self {
1855 Self {
1856 values: [
1857 AtomicU64::new(0.0f64.to_bits()),
1858 AtomicU64::new(0.0f64.to_bits()),
1859 ],
1860 }
1861 }
1862 }
1863
1864 impl truce_params::__private::Sealed for ManyOptionParams {}
1865 impl Params for ManyOptionParams {
1866 fn param_infos(&self) -> Vec<ParamInfo> {
1867 vec![
1868 ParamInfo {
1869 id: 0,
1870 name: "Note",
1871 short_name: "Note",
1872 group: "",
1873 range: ParamRange::Enum { count: 20 },
1874 default_plain: 0.0,
1875 flags: ParamFlags::AUTOMATABLE,
1876 unit: ParamUnit::None,
1877 kind: ParamValueKind::Enum,
1878 midi_map: None,
1879 midi_channel: None,
1880 },
1881 ParamInfo {
1882 id: 1,
1883 name: "Gain",
1884 short_name: "Gain",
1885 group: "",
1886 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1887 default_plain: 0.5,
1888 flags: ParamFlags::AUTOMATABLE,
1889 unit: ParamUnit::None,
1890 kind: ParamValueKind::Float,
1891 midi_map: None,
1892 midi_channel: None,
1893 },
1894 ]
1895 }
1896
1897 fn count(&self) -> usize {
1898 2
1899 }
1900
1901 fn get_normalized(&self, id: u32) -> Option<f64> {
1902 self.values
1903 .get(id as usize)
1904 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1905 }
1906
1907 fn set_normalized(&self, id: u32, value: f64) {
1908 if let Some(v) = self.values.get(id as usize) {
1909 v.store(value.to_bits(), Ordering::Relaxed);
1910 }
1911 }
1912
1913 fn get_plain(&self, id: u32) -> Option<f64> {
1914 let norm = self.get_normalized(id)?;
1915 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1916 Some(info.range.denormalize(norm))
1917 }
1918
1919 fn set_plain(&self, id: u32, value: f64) {
1920 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1921 self.set_normalized(id, info.range.normalize(value));
1922 }
1923 }
1924
1925 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1926 Some(format!("{value:.0}"))
1927 }
1928
1929 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1930 None
1931 }
1932 fn snap_smoothers(&self) {}
1933 fn set_sample_rate(&self, _: f64) {}
1934
1935 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1936 let ids = vec![0, 1];
1937 let vals: Vec<f64> = ids
1938 .iter()
1939 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1940 .collect();
1941 (ids, vals)
1942 }
1943
1944 fn restore_values(&self, values: &[(u32, f64)]) {
1945 for &(id, val) in values {
1946 self.set_plain(id, val);
1947 }
1948 }
1949 }
1950
1951 impl Default for ManyOptionParams {
1952 fn default() -> Self {
1953 Self::new()
1954 }
1955 }
1956
1957 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1961 let params = Arc::new(TestParams::new());
1962 let layout = GridLayout::build(vec![widgets(vec![
1964 GridWidget::knob(1u32, "K1"),
1965 GridWidget::knob(1u32, "K2"),
1966 GridWidget::knob(1u32, "K3"),
1967 GridWidget::knob(1u32, "K4"),
1968 GridWidget::dropdown(0u32, "Mode"),
1969 GridWidget::knob(1u32, "K5"),
1970 ])])
1971 .with_cols(2);
1972 let mut editor = BuiltinEditor::new_grid(params, layout);
1973 if let Layout::Grid(ref gl) = editor.layout {
1974 editor.interaction.build_regions_grid(gl);
1975 for (idx, gw) in gl.widgets.iter().enumerate() {
1976 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1977 region.widget_type =
1978 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1979 }
1980 }
1981 }
1982 editor.render();
1983 editor
1984 }
1985
1986 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1988 let params = Arc::new(TestParams::new());
1989 let layout = GridLayout::build(vec![widgets(vec![
1990 GridWidget::dropdown(0u32, "Mode A"),
1991 GridWidget::dropdown(0u32, "Mode B"),
1992 ])]);
1993 let mut editor = BuiltinEditor::new_grid(params, layout);
1994 if let Layout::Grid(ref gl) = editor.layout {
1995 editor.interaction.build_regions_grid(gl);
1996 for (idx, gw) in gl.widgets.iter().enumerate() {
1997 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1998 region.widget_type =
1999 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2000 }
2001 }
2002 }
2003 editor.render();
2004 editor
2005 }
2006
2007 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
2009 let params = Arc::new(ManyOptionParams::new());
2010 let layout = GridLayout::build(vec![widgets(vec![
2011 GridWidget::dropdown(0u32, "Note"),
2012 GridWidget::knob(1u32, "Gain"),
2013 ])]);
2014 let mut editor = BuiltinEditor::new_grid(params, layout);
2015 if let Layout::Grid(ref gl) = editor.layout {
2016 editor.interaction.build_regions_grid(gl);
2017 for (idx, gw) in gl.widgets.iter().enumerate() {
2018 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2019 region.widget_type =
2020 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2021 }
2022 }
2023 }
2024 editor.render();
2025 editor
2026 }
2027
2028 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
2029 let region = editor
2030 .interaction
2031 .knob_regions
2032 .iter()
2033 .find(|r| r.widget_type == WidgetType::Dropdown)
2034 .expect("no dropdown in layout");
2035 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2036 }
2037
2038 #[test]
2041 fn dropdown_anchors_below_button_scrolls_when_tight() {
2042 let mut editor = make_editor_bottom_dropdown();
2043 let (dx, dy) = {
2044 let region = editor
2045 .interaction
2046 .knob_regions
2047 .iter()
2048 .find(|r| r.widget_type == WidgetType::Dropdown)
2049 .unwrap();
2050 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2051 };
2052
2053 editor.on_mouse_down(dx, dy);
2054 editor.on_mouse_up(dx, dy);
2055 assert!(editor.interaction.dropdown_is_open());
2056
2057 let dd = editor.interaction.dropdown.as_ref().unwrap();
2058 let region = &editor.interaction.knob_regions[dd.region_idx];
2059 let (_, popup_y, _, popup_h) = dd.popup_rect;
2060 let window_h = editor.layout.height() as f32;
2061
2062 assert_eq!(
2067 popup_y, region.dropdown_anchor_y,
2068 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
2069 );
2070 assert!(
2072 popup_y + popup_h <= window_h + 1.0,
2073 "popup bottom {} exceeds window height {window_h}",
2074 popup_y + popup_h
2075 );
2076 }
2077
2078 #[test]
2079 fn dropdown_clamps_horizontal_near_right_edge() {
2080 let mut editor = make_editor_two_dropdowns();
2081 let region = &editor.interaction.knob_regions[1];
2083 assert_eq!(region.widget_type, WidgetType::Dropdown);
2084 let dx = region.x + region.w / 2.0;
2085 let dy = region.y + region.h / 2.0;
2086
2087 editor.on_mouse_down(dx, dy);
2088 editor.on_mouse_up(dx, dy);
2089 assert!(editor.interaction.dropdown_is_open());
2090
2091 let dd = editor.interaction.dropdown.as_ref().unwrap();
2092 let (popup_x, _, popup_w, _) = dd.popup_rect;
2093 let window_w = editor.layout.width() as f32;
2094
2095 assert!(
2096 popup_x + popup_w <= window_w + 1.0,
2097 "popup right edge {} exceeds window width {window_w}",
2098 popup_x + popup_w
2099 );
2100 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
2101 }
2102
2103 #[test]
2104 fn dropdown_scroll_long_list() {
2105 let mut editor = make_editor_many_options();
2106 let (dx, dy) = dropdown_center_many(&editor);
2107
2108 editor.on_mouse_down(dx, dy);
2109 editor.on_mouse_up(dx, dy);
2110 assert!(editor.interaction.dropdown_is_open());
2111
2112 let dd = editor.interaction.dropdown.as_ref().unwrap();
2113 assert!(
2115 dd.options.len() > dd.visible_count,
2116 "expected scroll: {} options, {} visible",
2117 dd.options.len(),
2118 dd.visible_count
2119 );
2120 assert_eq!(dd.scroll_offset, 0);
2121 }
2122
2123 #[test]
2124 fn dropdown_scroll_clamps_to_bounds() {
2125 let mut editor = make_editor_many_options();
2126 let (dx, dy) = dropdown_center_many(&editor);
2127
2128 editor.on_mouse_down(dx, dy);
2129 editor.on_mouse_up(dx, dy);
2130
2131 editor.interaction.dropdown_scroll(-10);
2133 assert_eq!(
2134 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2135 0
2136 );
2137
2138 editor.interaction.dropdown_scroll(1000);
2140 let dd = editor.interaction.dropdown.as_ref().unwrap();
2141 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
2142 assert_eq!(dd.scroll_offset, max_offset);
2143 }
2144
2145 #[test]
2146 fn dropdown_selected_item_visible_on_open() {
2147 let mut editor = make_editor_many_options();
2148 editor.params.set_normalized(0, 15.0 / 18.0);
2150
2151 let (dx, dy) = dropdown_center_many(&editor);
2152 editor.on_mouse_down(dx, dy);
2153 editor.on_mouse_up(dx, dy);
2154
2155 let dd = editor.interaction.dropdown.as_ref().unwrap();
2156 let selected = dd.selected;
2157 assert!(
2159 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
2160 "selected={selected} not in visible range {}..{}",
2161 dd.scroll_offset,
2162 dd.scroll_offset + dd.visible_count
2163 );
2164 }
2165
2166 #[test]
2167 fn dropdown_scroll_then_select_correct_index() {
2168 let mut editor = make_editor_many_options();
2169 let (dx, dy) = dropdown_center_many(&editor);
2170
2171 editor.on_mouse_down(dx, dy);
2172 editor.on_mouse_up(dx, dy);
2173
2174 editor.interaction.dropdown_scroll(3);
2176 assert_eq!(
2177 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2178 3
2179 );
2180
2181 let dd = editor.interaction.dropdown.as_ref().unwrap();
2183 let (px, py, _, _) = dd.popup_rect;
2184 let item_h = 18.0f32;
2185 let padding = 4.0f32;
2186 let click_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, click_y);
2189 editor.on_mouse_up(px + 10.0, click_y);
2190
2191 assert!(!editor.interaction.dropdown_is_open());
2192 let norm = editor.params.get_normalized(0).unwrap();
2195 let expected = 4.0 / 19.0;
2196 assert!(
2197 (norm - expected).abs() < 0.01,
2198 "expected {expected:.4}, got {norm:.4}"
2199 );
2200 }
2201
2202 #[test]
2203 fn dropdown_click_different_dropdown_closes_first() {
2204 let mut editor = make_editor_two_dropdowns();
2205 let r0 = &editor.interaction.knob_regions[0];
2206 let r1 = &editor.interaction.knob_regions[1];
2207 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
2208 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
2209
2210 editor.on_mouse_down(ax, ay);
2212 editor.on_mouse_up(ax, ay);
2213 assert!(editor.interaction.dropdown_is_open());
2214 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
2215
2216 editor.on_mouse_down(bx, by);
2218 editor.on_mouse_up(bx, by);
2219 assert!(editor.interaction.dropdown_is_open());
2220 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
2221 }
2222
2223 #[test]
2224 fn dropdown_hover_tracks_correct_option() {
2225 let mut editor = make_editor();
2226 let (dx, dy) = dropdown_center(&editor);
2227
2228 editor.on_mouse_down(dx, dy);
2229 editor.on_mouse_up(dx, dy);
2230
2231 let dd = editor.interaction.dropdown.as_ref().unwrap();
2232 let (px, py, pw, _) = dd.popup_rect;
2233 let item_h = 18.0f32;
2234 let padding = 4.0f32;
2235 let last_visible = dd.visible_count - 1;
2236
2237 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
2239 editor.on_mouse_moved(px + pw / 2.0, hover_y);
2240
2241 let dd = editor.interaction.dropdown.as_ref().unwrap();
2242 assert_eq!(
2243 dd.hover_option,
2244 Some(last_visible),
2245 "expected hover on last visible option"
2246 );
2247
2248 editor.on_mouse_moved(0.0, 0.0);
2250 let dd = editor.interaction.dropdown.as_ref().unwrap();
2251 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
2252 }
2253
2254 #[test]
2255 fn dropdown_popup_within_window_bounds() {
2256 let mut editor = make_editor();
2258 let (dx, dy) = dropdown_center(&editor);
2259
2260 editor.on_mouse_down(dx, dy);
2261 editor.on_mouse_up(dx, dy);
2262
2263 let dd = editor.interaction.dropdown.as_ref().unwrap();
2264 let (px, py, pw, ph) = dd.popup_rect;
2265 let window_w = editor.layout.width() as f32;
2266 let window_h = editor.layout.height() as f32;
2267
2268 assert!(px >= 0.0, "popup left edge {px} < 0");
2269 assert!(py >= 0.0, "popup top edge {py} < 0");
2270 assert!(
2271 px + pw <= window_w + 1.0,
2272 "popup right {} > window {window_w}",
2273 px + pw
2274 );
2275 assert!(
2276 py + ph <= window_h + 1.0,
2277 "popup bottom {} > window {window_h}",
2278 py + ph
2279 );
2280 }
2281}