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 },
1529 ParamInfo {
1530 id: 1,
1531 name: "Gain",
1532 short_name: "Gain",
1533 group: "",
1534 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1535 default_plain: 0.5,
1536 flags: ParamFlags::AUTOMATABLE,
1537 unit: ParamUnit::None,
1538 kind: ParamValueKind::Float,
1539 },
1540 ]
1541 }
1542
1543 fn count(&self) -> usize {
1544 2
1545 }
1546
1547 fn get_normalized(&self, id: u32) -> Option<f64> {
1548 self.values
1549 .get(id as usize)
1550 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1551 }
1552
1553 fn set_normalized(&self, id: u32, value: f64) {
1554 if let Some(v) = self.values.get(id as usize) {
1555 v.store(value.to_bits(), Ordering::Relaxed);
1556 }
1557 }
1558
1559 fn get_plain(&self, id: u32) -> Option<f64> {
1560 let norm = self.get_normalized(id)?;
1561 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1562 Some(info.range.denormalize(norm))
1563 }
1564
1565 fn set_plain(&self, id: u32, value: f64) {
1566 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1567 self.set_normalized(id, info.range.normalize(value));
1568 }
1569 }
1570
1571 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1572 Some(format!("{value:.0}"))
1573 }
1574
1575 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1576 None
1577 }
1578 fn snap_smoothers(&self) {}
1579 fn set_sample_rate(&self, _: f64) {}
1580
1581 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1582 let ids = vec![0, 1];
1583 let vals: Vec<f64> = ids
1584 .iter()
1585 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1586 .collect();
1587 (ids, vals)
1588 }
1589
1590 fn restore_values(&self, values: &[(u32, f64)]) {
1591 for &(id, val) in values {
1592 self.set_plain(id, val);
1593 }
1594 }
1595 }
1596
1597 impl Default for TestParams {
1598 fn default() -> Self {
1599 Self::new()
1600 }
1601 }
1602
1603 fn make_editor() -> BuiltinEditor<TestParams> {
1607 let params = Arc::new(TestParams::new());
1608 let layout = GridLayout::build(vec![widgets(vec![
1609 GridWidget::dropdown(0u32, "Mode"),
1610 GridWidget::knob(1u32, "Gain"),
1611 ])]);
1612 let mut editor = BuiltinEditor::new_grid(params, layout);
1613 if let Layout::Grid(ref gl) = editor.layout {
1615 editor.interaction.build_regions_grid(gl);
1616 for (idx, gw) in gl.widgets.iter().enumerate() {
1617 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1618 region.widget_type =
1619 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1620 }
1621 }
1622 }
1623 editor.render();
1625 editor
1626 }
1627
1628 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1630 let params = Arc::new(TestParams::new());
1631 let layout = GridLayout::build(vec![
1632 section(
1633 "SECTION A",
1634 vec![
1635 GridWidget::knob(1u32, "Gain"),
1636 GridWidget::knob(1u32, "Gain 2"),
1637 ],
1638 ),
1639 section(
1640 "SECTION B",
1641 vec![
1642 GridWidget::dropdown(0u32, "Mode"),
1643 GridWidget::knob(1u32, "Gain 3"),
1644 ],
1645 ),
1646 ]);
1647 let mut editor = BuiltinEditor::new_grid(params, layout);
1648 if let Layout::Grid(ref gl) = editor.layout {
1649 editor.interaction.build_regions_grid(gl);
1650 for (idx, gw) in gl.widgets.iter().enumerate() {
1651 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1652 region.widget_type =
1653 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1654 }
1655 }
1656 }
1657 editor.render();
1658 editor
1659 }
1660
1661 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1663 let region = editor
1664 .interaction
1665 .knob_regions
1666 .iter()
1667 .find(|r| r.widget_type == WidgetType::Dropdown)
1668 .expect("no dropdown in layout");
1669 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1670 }
1671
1672 #[test]
1675 fn dropdown_click_opens() {
1676 let mut editor = make_editor();
1677 let (dx, dy) = dropdown_center(&editor);
1678
1679 editor.on_mouse_down(dx, dy);
1680 assert!(editor.interaction.dropdown_is_open());
1681 }
1682
1683 #[test]
1684 fn dropdown_click_toggles_closed() {
1685 let mut editor = make_editor();
1686 let (dx, dy) = dropdown_center(&editor);
1687
1688 editor.on_mouse_down(dx, dy);
1690 editor.on_mouse_up(dx, dy);
1691 assert!(editor.interaction.dropdown_is_open());
1692
1693 editor.on_mouse_down(dx, dy);
1695 assert!(!editor.interaction.dropdown_is_open());
1696 }
1697
1698 #[test]
1699 fn dropdown_click_outside_closes() {
1700 let mut editor = make_editor();
1701 let (dx, dy) = dropdown_center(&editor);
1702
1703 editor.on_mouse_down(dx, dy);
1704 editor.on_mouse_up(dx, dy);
1705 assert!(editor.interaction.dropdown_is_open());
1706
1707 editor.on_mouse_down(0.0, 0.0);
1709 assert!(!editor.interaction.dropdown_is_open());
1710 }
1711
1712 #[test]
1713 fn dropdown_click_option_selects_and_closes() {
1714 let mut editor = make_editor();
1715 let (dx, dy) = dropdown_center(&editor);
1716
1717 editor.on_mouse_down(dx, dy);
1718 editor.on_mouse_up(dx, dy);
1719 assert!(editor.interaction.dropdown_is_open());
1720
1721 let dd = editor.interaction.dropdown.as_ref().unwrap();
1723 let (px, py, _, _) = dd.popup_rect;
1724 let item_h = 18.0f32;
1725 let padding = 4.0f32;
1726 let option_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, option_y);
1732 editor.on_mouse_up(px + 10.0, option_y);
1733
1734 assert!(!editor.interaction.dropdown_is_open());
1735 let norm = editor.params.get_normalized(0).unwrap();
1737 let expected = 1.0 / 3.0;
1738 assert!(
1739 (norm - expected).abs() < 0.01,
1740 "expected {expected:.4}, got {norm}"
1741 );
1742 }
1743
1744 #[test]
1747 fn dropdown_anchor_set_after_render() {
1748 let editor = make_editor();
1749 let region = editor
1750 .interaction
1751 .knob_regions
1752 .iter()
1753 .find(|r| r.widget_type == WidgetType::Dropdown)
1754 .unwrap();
1755
1756 assert!(
1758 region.dropdown_anchor_y > region.y,
1759 "anchor {} should be below region.y {}",
1760 region.dropdown_anchor_y,
1761 region.y
1762 );
1763 assert!(
1764 region.dropdown_anchor_y < region.y + region.h,
1765 "anchor {} should be above region bottom {}",
1766 region.dropdown_anchor_y,
1767 region.y + region.h
1768 );
1769 }
1770
1771 #[test]
1772 fn dropdown_popup_uses_anchor() {
1773 let mut editor = make_editor();
1774 let (dx, dy) = dropdown_center(&editor);
1775
1776 editor.on_mouse_down(dx, dy);
1777 editor.on_mouse_up(dx, dy);
1778
1779 let dd = editor.interaction.dropdown.as_ref().unwrap();
1780 let region = &editor.interaction.knob_regions[dd.region_idx];
1781
1782 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1786 }
1787
1788 #[test]
1789 fn dropdown_anchor_survives_idle_rebuild() {
1790 let mut editor = make_editor();
1797
1798 update_interaction(&mut editor);
1800
1801 let (dx, dy) = dropdown_center(&editor);
1802 editor.on_mouse_down(dx, dy);
1803 editor.on_mouse_up(dx, dy);
1804
1805 let dd = editor.interaction.dropdown.as_ref().unwrap();
1806 let region = &editor.interaction.knob_regions[dd.region_idx];
1807 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1808 assert!(
1809 dd.popup_rect.1 > region.y,
1810 "popup_y {} fell back to the window top instead of anchoring below the button",
1811 dd.popup_rect.1
1812 );
1813 }
1814
1815 #[test]
1816 fn dropdown_anchor_gap_stable_with_sections() {
1817 let editor_plain = make_editor();
1818 let editor_sections = make_editor_with_sections();
1819
1820 let r_plain = editor_plain
1821 .interaction
1822 .knob_regions
1823 .iter()
1824 .find(|r| r.widget_type == WidgetType::Dropdown)
1825 .unwrap();
1826 let r_sections = editor_sections
1827 .interaction
1828 .knob_regions
1829 .iter()
1830 .find(|r| r.widget_type == WidgetType::Dropdown)
1831 .unwrap();
1832
1833 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1836 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1837 assert!(
1838 (gap_plain - gap_sections).abs() < 0.1,
1839 "gap_plain={gap_plain}, gap_sections={gap_sections}"
1840 );
1841 }
1842
1843 struct ManyOptionParams {
1846 values: [AtomicU64; 2],
1847 }
1848
1849 impl ManyOptionParams {
1850 fn new() -> Self {
1851 Self {
1852 values: [
1853 AtomicU64::new(0.0f64.to_bits()),
1854 AtomicU64::new(0.0f64.to_bits()),
1855 ],
1856 }
1857 }
1858 }
1859
1860 impl truce_params::__private::Sealed for ManyOptionParams {}
1861 impl Params for ManyOptionParams {
1862 fn param_infos(&self) -> Vec<ParamInfo> {
1863 vec![
1864 ParamInfo {
1865 id: 0,
1866 name: "Note",
1867 short_name: "Note",
1868 group: "",
1869 range: ParamRange::Enum { count: 20 },
1870 default_plain: 0.0,
1871 flags: ParamFlags::AUTOMATABLE,
1872 unit: ParamUnit::None,
1873 kind: ParamValueKind::Enum,
1874 },
1875 ParamInfo {
1876 id: 1,
1877 name: "Gain",
1878 short_name: "Gain",
1879 group: "",
1880 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1881 default_plain: 0.5,
1882 flags: ParamFlags::AUTOMATABLE,
1883 unit: ParamUnit::None,
1884 kind: ParamValueKind::Float,
1885 },
1886 ]
1887 }
1888
1889 fn count(&self) -> usize {
1890 2
1891 }
1892
1893 fn get_normalized(&self, id: u32) -> Option<f64> {
1894 self.values
1895 .get(id as usize)
1896 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1897 }
1898
1899 fn set_normalized(&self, id: u32, value: f64) {
1900 if let Some(v) = self.values.get(id as usize) {
1901 v.store(value.to_bits(), Ordering::Relaxed);
1902 }
1903 }
1904
1905 fn get_plain(&self, id: u32) -> Option<f64> {
1906 let norm = self.get_normalized(id)?;
1907 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1908 Some(info.range.denormalize(norm))
1909 }
1910
1911 fn set_plain(&self, id: u32, value: f64) {
1912 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1913 self.set_normalized(id, info.range.normalize(value));
1914 }
1915 }
1916
1917 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1918 Some(format!("{value:.0}"))
1919 }
1920
1921 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1922 None
1923 }
1924 fn snap_smoothers(&self) {}
1925 fn set_sample_rate(&self, _: f64) {}
1926
1927 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1928 let ids = vec![0, 1];
1929 let vals: Vec<f64> = ids
1930 .iter()
1931 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1932 .collect();
1933 (ids, vals)
1934 }
1935
1936 fn restore_values(&self, values: &[(u32, f64)]) {
1937 for &(id, val) in values {
1938 self.set_plain(id, val);
1939 }
1940 }
1941 }
1942
1943 impl Default for ManyOptionParams {
1944 fn default() -> Self {
1945 Self::new()
1946 }
1947 }
1948
1949 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1953 let params = Arc::new(TestParams::new());
1954 let layout = GridLayout::build(vec![widgets(vec![
1956 GridWidget::knob(1u32, "K1"),
1957 GridWidget::knob(1u32, "K2"),
1958 GridWidget::knob(1u32, "K3"),
1959 GridWidget::knob(1u32, "K4"),
1960 GridWidget::dropdown(0u32, "Mode"),
1961 GridWidget::knob(1u32, "K5"),
1962 ])])
1963 .with_cols(2);
1964 let mut editor = BuiltinEditor::new_grid(params, layout);
1965 if let Layout::Grid(ref gl) = editor.layout {
1966 editor.interaction.build_regions_grid(gl);
1967 for (idx, gw) in gl.widgets.iter().enumerate() {
1968 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1969 region.widget_type =
1970 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1971 }
1972 }
1973 }
1974 editor.render();
1975 editor
1976 }
1977
1978 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1980 let params = Arc::new(TestParams::new());
1981 let layout = GridLayout::build(vec![widgets(vec![
1982 GridWidget::dropdown(0u32, "Mode A"),
1983 GridWidget::dropdown(0u32, "Mode B"),
1984 ])]);
1985 let mut editor = BuiltinEditor::new_grid(params, layout);
1986 if let Layout::Grid(ref gl) = editor.layout {
1987 editor.interaction.build_regions_grid(gl);
1988 for (idx, gw) in gl.widgets.iter().enumerate() {
1989 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1990 region.widget_type =
1991 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1992 }
1993 }
1994 }
1995 editor.render();
1996 editor
1997 }
1998
1999 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
2001 let params = Arc::new(ManyOptionParams::new());
2002 let layout = GridLayout::build(vec![widgets(vec![
2003 GridWidget::dropdown(0u32, "Note"),
2004 GridWidget::knob(1u32, "Gain"),
2005 ])]);
2006 let mut editor = BuiltinEditor::new_grid(params, layout);
2007 if let Layout::Grid(ref gl) = editor.layout {
2008 editor.interaction.build_regions_grid(gl);
2009 for (idx, gw) in gl.widgets.iter().enumerate() {
2010 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2011 region.widget_type =
2012 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2013 }
2014 }
2015 }
2016 editor.render();
2017 editor
2018 }
2019
2020 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
2021 let region = editor
2022 .interaction
2023 .knob_regions
2024 .iter()
2025 .find(|r| r.widget_type == WidgetType::Dropdown)
2026 .expect("no dropdown in layout");
2027 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2028 }
2029
2030 #[test]
2033 fn dropdown_anchors_below_button_scrolls_when_tight() {
2034 let mut editor = make_editor_bottom_dropdown();
2035 let (dx, dy) = {
2036 let region = editor
2037 .interaction
2038 .knob_regions
2039 .iter()
2040 .find(|r| r.widget_type == WidgetType::Dropdown)
2041 .unwrap();
2042 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2043 };
2044
2045 editor.on_mouse_down(dx, dy);
2046 editor.on_mouse_up(dx, dy);
2047 assert!(editor.interaction.dropdown_is_open());
2048
2049 let dd = editor.interaction.dropdown.as_ref().unwrap();
2050 let region = &editor.interaction.knob_regions[dd.region_idx];
2051 let (_, popup_y, _, popup_h) = dd.popup_rect;
2052 let window_h = editor.layout.height() as f32;
2053
2054 assert_eq!(
2059 popup_y, region.dropdown_anchor_y,
2060 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
2061 );
2062 assert!(
2064 popup_y + popup_h <= window_h + 1.0,
2065 "popup bottom {} exceeds window height {window_h}",
2066 popup_y + popup_h
2067 );
2068 }
2069
2070 #[test]
2071 fn dropdown_clamps_horizontal_near_right_edge() {
2072 let mut editor = make_editor_two_dropdowns();
2073 let region = &editor.interaction.knob_regions[1];
2075 assert_eq!(region.widget_type, WidgetType::Dropdown);
2076 let dx = region.x + region.w / 2.0;
2077 let dy = region.y + region.h / 2.0;
2078
2079 editor.on_mouse_down(dx, dy);
2080 editor.on_mouse_up(dx, dy);
2081 assert!(editor.interaction.dropdown_is_open());
2082
2083 let dd = editor.interaction.dropdown.as_ref().unwrap();
2084 let (popup_x, _, popup_w, _) = dd.popup_rect;
2085 let window_w = editor.layout.width() as f32;
2086
2087 assert!(
2088 popup_x + popup_w <= window_w + 1.0,
2089 "popup right edge {} exceeds window width {window_w}",
2090 popup_x + popup_w
2091 );
2092 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
2093 }
2094
2095 #[test]
2096 fn dropdown_scroll_long_list() {
2097 let mut editor = make_editor_many_options();
2098 let (dx, dy) = dropdown_center_many(&editor);
2099
2100 editor.on_mouse_down(dx, dy);
2101 editor.on_mouse_up(dx, dy);
2102 assert!(editor.interaction.dropdown_is_open());
2103
2104 let dd = editor.interaction.dropdown.as_ref().unwrap();
2105 assert!(
2107 dd.options.len() > dd.visible_count,
2108 "expected scroll: {} options, {} visible",
2109 dd.options.len(),
2110 dd.visible_count
2111 );
2112 assert_eq!(dd.scroll_offset, 0);
2113 }
2114
2115 #[test]
2116 fn dropdown_scroll_clamps_to_bounds() {
2117 let mut editor = make_editor_many_options();
2118 let (dx, dy) = dropdown_center_many(&editor);
2119
2120 editor.on_mouse_down(dx, dy);
2121 editor.on_mouse_up(dx, dy);
2122
2123 editor.interaction.dropdown_scroll(-10);
2125 assert_eq!(
2126 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2127 0
2128 );
2129
2130 editor.interaction.dropdown_scroll(1000);
2132 let dd = editor.interaction.dropdown.as_ref().unwrap();
2133 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
2134 assert_eq!(dd.scroll_offset, max_offset);
2135 }
2136
2137 #[test]
2138 fn dropdown_selected_item_visible_on_open() {
2139 let mut editor = make_editor_many_options();
2140 editor.params.set_normalized(0, 15.0 / 18.0);
2142
2143 let (dx, dy) = dropdown_center_many(&editor);
2144 editor.on_mouse_down(dx, dy);
2145 editor.on_mouse_up(dx, dy);
2146
2147 let dd = editor.interaction.dropdown.as_ref().unwrap();
2148 let selected = dd.selected;
2149 assert!(
2151 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
2152 "selected={selected} not in visible range {}..{}",
2153 dd.scroll_offset,
2154 dd.scroll_offset + dd.visible_count
2155 );
2156 }
2157
2158 #[test]
2159 fn dropdown_scroll_then_select_correct_index() {
2160 let mut editor = make_editor_many_options();
2161 let (dx, dy) = dropdown_center_many(&editor);
2162
2163 editor.on_mouse_down(dx, dy);
2164 editor.on_mouse_up(dx, dy);
2165
2166 editor.interaction.dropdown_scroll(3);
2168 assert_eq!(
2169 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2170 3
2171 );
2172
2173 let dd = editor.interaction.dropdown.as_ref().unwrap();
2175 let (px, py, _, _) = dd.popup_rect;
2176 let item_h = 18.0f32;
2177 let padding = 4.0f32;
2178 let click_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, click_y);
2181 editor.on_mouse_up(px + 10.0, click_y);
2182
2183 assert!(!editor.interaction.dropdown_is_open());
2184 let norm = editor.params.get_normalized(0).unwrap();
2187 let expected = 4.0 / 19.0;
2188 assert!(
2189 (norm - expected).abs() < 0.01,
2190 "expected {expected:.4}, got {norm:.4}"
2191 );
2192 }
2193
2194 #[test]
2195 fn dropdown_click_different_dropdown_closes_first() {
2196 let mut editor = make_editor_two_dropdowns();
2197 let r0 = &editor.interaction.knob_regions[0];
2198 let r1 = &editor.interaction.knob_regions[1];
2199 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
2200 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
2201
2202 editor.on_mouse_down(ax, ay);
2204 editor.on_mouse_up(ax, ay);
2205 assert!(editor.interaction.dropdown_is_open());
2206 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
2207
2208 editor.on_mouse_down(bx, by);
2210 editor.on_mouse_up(bx, by);
2211 assert!(editor.interaction.dropdown_is_open());
2212 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
2213 }
2214
2215 #[test]
2216 fn dropdown_hover_tracks_correct_option() {
2217 let mut editor = make_editor();
2218 let (dx, dy) = dropdown_center(&editor);
2219
2220 editor.on_mouse_down(dx, dy);
2221 editor.on_mouse_up(dx, dy);
2222
2223 let dd = editor.interaction.dropdown.as_ref().unwrap();
2224 let (px, py, pw, _) = dd.popup_rect;
2225 let item_h = 18.0f32;
2226 let padding = 4.0f32;
2227 let last_visible = dd.visible_count - 1;
2228
2229 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
2231 editor.on_mouse_moved(px + pw / 2.0, hover_y);
2232
2233 let dd = editor.interaction.dropdown.as_ref().unwrap();
2234 assert_eq!(
2235 dd.hover_option,
2236 Some(last_visible),
2237 "expected hover on last visible option"
2238 );
2239
2240 editor.on_mouse_moved(0.0, 0.0);
2242 let dd = editor.interaction.dropdown.as_ref().unwrap();
2243 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
2244 }
2245
2246 #[test]
2247 fn dropdown_popup_within_window_bounds() {
2248 let mut editor = make_editor();
2250 let (dx, dy) = dropdown_center(&editor);
2251
2252 editor.on_mouse_down(dx, dy);
2253 editor.on_mouse_up(dx, dy);
2254
2255 let dd = editor.interaction.dropdown.as_ref().unwrap();
2256 let (px, py, pw, ph) = dd.popup_rect;
2257 let window_w = editor.layout.width() as f32;
2258 let window_h = editor.layout.height() as f32;
2259
2260 assert!(px >= 0.0, "popup left edge {px} < 0");
2261 assert!(py >= 0.0, "popup top edge {py} < 0");
2262 assert!(
2263 px + pw <= window_w + 1.0,
2264 "popup right {} > window {window_w}",
2265 px + pw
2266 );
2267 assert!(
2268 py + ph <= window_h + 1.0,
2269 "popup bottom {} > window {window_h}",
2270 py + ph
2271 );
2272 }
2273}