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 pub fn set_size(&mut self, width: u32, height: u32) -> bool {
488 if width == 0 || height == 0 || !self.can_resize() {
489 return false;
490 }
491 let Layout::Grid(ref mut gl) = self.layout else {
492 return false;
493 };
494 gl.refit_cols(width);
502 let (new_w, new_h) = gl.refit_rows(height);
503 #[cfg(feature = "cpu")]
509 self.pending_size.store(
510 (u64::from(new_w) << 32) | u64::from(new_h),
511 Ordering::Release,
512 );
513 #[cfg(not(feature = "cpu"))]
514 let _ = (new_w, new_h);
515 self.request_repaint();
519 true
520 }
521
522 pub fn state_changed(&mut self) {
526 self.request_repaint();
527 }
528
529 pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
534 update_interaction(self);
535 let owned = self.build_snapshot_closures();
536 let snapshot = owned.as_snapshot();
537 render_widgets_impl(
538 &self.layout,
539 &self.theme,
540 &mut self.interaction,
541 &snapshot,
542 backend,
543 );
544 }
545}
546
547#[cfg(test)]
551impl<P: Params + 'static> BuiltinEditor<P> {
552 fn on_mouse_down(&mut self, x: f32, y: f32) {
553 self.dispatch_events(&[InputEvent::MouseDown {
554 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
555 x,
556 y,
557 button: crate::interaction::MouseButton::Left,
558 }]);
559 }
560
561 fn on_mouse_up(&mut self, x: f32, y: f32) {
562 self.dispatch_events(&[InputEvent::MouseUp {
563 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
564 x,
565 y,
566 button: crate::interaction::MouseButton::Left,
567 }]);
568 }
569
570 fn on_mouse_moved(&mut self, x: f32, y: f32) {
571 self.dispatch_events(&[InputEvent::MouseMove {
572 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
573 x,
574 y,
575 }]);
576 }
577}
578
579pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
589 match &editor.layout {
590 Layout::Rows(pl) => {
591 editor.interaction.build_regions(pl);
592 let mut flat_idx = 0usize;
593 for row in &pl.rows {
594 for knob_def in &row.knobs {
595 if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
596 region.widget_type = resolve_widget_type(
597 knob_def.widget,
598 knob_def.param_id,
599 &*editor.params,
600 );
601 }
602 flat_idx += 1;
603 }
604 }
605 }
606 Layout::Grid(gl) => {
607 editor.interaction.build_regions_grid(gl);
608 for (idx, gw) in gl.widgets.iter().enumerate() {
609 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
610 region.widget_type =
611 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
612 }
613 }
614 }
615 }
616 for region in &mut editor.interaction.knob_regions {
617 if let Some(ref ctx) = editor.context {
618 region.normalized_value = ctx.get_param(region.param_id);
620 } else {
621 region.normalized_value =
622 f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
623 }
624 }
625}
626
627#[cfg(feature = "cpu")]
642fn create_wgpu_backend(window: &mut baseview::Window, phys_w: u32, phys_h: u32) -> BlitBackend {
643 let instance = wgpu::Instance::new(crate::platform::editor_instance_descriptor());
644
645 let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) }
646 .expect("failed to create wgpu surface");
647
648 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
649 power_preference: wgpu::PowerPreference::HighPerformance,
650 compatible_surface: Some(&surface),
651 force_fallback_adapter: false,
652 }))
653 .expect("no suitable GPU adapter");
654
655 let adapter_limits = adapter.limits();
663 let max_texture_dim = adapter_limits.max_texture_dimension_2d;
664 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
665 label: Some("truce-gui"),
666 required_features: wgpu::Features::empty(),
667 required_limits: adapter_limits,
668 experimental_features: wgpu::ExperimentalFeatures::default(),
669 memory_hints: wgpu::MemoryHints::Performance,
670 trace: wgpu::Trace::Off,
671 }))
672 .expect("failed to create wgpu device");
673
674 let caps = surface.get_capabilities(&adapter);
675 let format = caps
676 .formats
677 .iter()
678 .find(|f| f.is_srgb())
679 .copied()
680 .unwrap_or(caps.formats[0]);
681
682 let init_w = phys_w.clamp(1, max_texture_dim);
688 let init_h = phys_h.clamp(1, max_texture_dim);
689 let surface_config = wgpu::SurfaceConfiguration {
690 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
691 format,
692 width: init_w,
693 height: init_h,
694 present_mode: wgpu::PresentMode::AutoVsync,
695 desired_maximum_frame_latency: 2,
696 alpha_mode: wgpu::CompositeAlphaMode::Auto,
697 view_formats: vec![],
698 };
699 surface.configure(&device, &surface_config);
700
701 let blit = crate::blit::BlitPipeline::new(&device, format, init_w, init_h);
706
707 BlitBackend {
708 blit,
709 surface_config,
710 surface,
711 queue,
712 device,
713 max_texture_dim,
714 }
715}
716
717#[cfg(feature = "cpu")]
726struct BlitBackend {
727 blit: crate::blit::BlitPipeline,
728 surface_config: wgpu::SurfaceConfiguration,
729 surface: wgpu::Surface<'static>,
730 queue: wgpu::Queue,
731 device: wgpu::Device,
732 max_texture_dim: u32,
739}
740
741#[cfg(feature = "cpu")]
742impl BlitBackend {
743 fn resize(&mut self, phys_w: u32, phys_h: u32) {
748 let phys_w = phys_w.clamp(1, self.max_texture_dim);
749 let phys_h = phys_h.clamp(1, self.max_texture_dim);
750 self.surface_config.width = phys_w;
751 self.surface_config.height = phys_h;
752 self.surface.configure(&self.device, &self.surface_config);
753 self.blit.resize(&self.device, phys_w, phys_h);
754 }
755
756 fn configure_surface(&mut self, phys_w: u32, phys_h: u32) {
770 let phys_w = phys_w.clamp(1, self.max_texture_dim);
771 let phys_h = phys_h.clamp(1, self.max_texture_dim);
772 if self.surface_config.width == phys_w && self.surface_config.height == phys_h {
773 return;
774 }
775 self.surface_config.width = phys_w;
776 self.surface_config.height = phys_h;
777 self.surface.configure(&self.device, &self.surface_config);
778 }
779}
780
781#[cfg(feature = "cpu")]
788type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
789
790#[cfg(feature = "cpu")]
791struct BuiltinWindowHandler<P: Params> {
792 editor: *mut BuiltinEditor<P>,
803 backend: SharedBackend,
804 translator: crate::interaction::BaseviewTranslator,
808 last_applied_scale: f32,
817}
818
819#[cfg(feature = "cpu")]
822unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
823
824#[cfg(feature = "cpu")]
825impl<P: Params + 'static> BuiltinWindowHandler<P> {
826 fn on_frame_inner(&mut self, window: &mut baseview::Window) {
827 let Ok(mut guard) = self.backend.lock() else {
834 return;
835 };
836 if guard.is_none() {
837 return;
840 }
841
842 let editor = unsafe { &mut *self.editor };
843
844 let pending = editor.pending_size.swap(0, Ordering::Acquire);
856 if pending != 0 {
857 #[allow(clippy::cast_possible_truncation)]
858 let new_w = (pending >> 32) as u32;
859 #[allow(clippy::cast_possible_truncation)]
860 let new_h = (pending & 0xFFFF_FFFF) as u32;
861 if new_w > 0 && new_h > 0 {
862 let scale = editor.scale.get();
863 let scale_f32 = editor.scale.get_f32();
864 let phys_w = crate::platform::to_physical_px(new_w, scale);
865 let phys_h = crate::platform::to_physical_px(new_h, scale);
866 editor.backend = CpuBackend::new(new_w, new_h, scale_f32);
867 if let Some(backend) = guard.as_mut() {
868 backend.resize(phys_w, phys_h);
869 }
870 match &editor.layout {
871 Layout::Rows(pl) => editor.interaction.build_regions(pl),
872 Layout::Grid(gl) => editor.interaction.build_regions_grid(gl),
873 }
874 window.resize(baseview::Size::new(f64::from(new_w), f64::from(new_h)));
875 editor.request_repaint();
876 }
877 }
878
879 #[cfg(target_os = "macos")]
896 {
897 use raw_window_handle::HasRawWindowHandle;
898 crate::platform::reanchor_to_superview_top(window.raw_window_handle());
899 }
900
901 if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
908 let (lw, lh) = editor.size();
909 let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
910 let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
911 editor.backend = CpuBackend::new(lw, lh, cur_scale);
912 if let Some(backend) = guard.as_mut() {
913 backend.resize(phys_w, phys_h);
914 }
915 editor.request_repaint();
916 }
917
918 update_interaction(editor);
919 editor.detect_host_param_changes();
924 editor.detect_meter_changes();
925 if !editor.take_needs_repaint() {
926 return;
927 }
928 editor.render();
929 editor.stash_painted_values();
930
931 if let Some(pixels) = editor.pixel_data() {
932 let backend = guard
933 .as_mut()
934 .expect("guard was checked Some above and the lock is still held");
935 let BlitBackend {
936 device,
937 queue,
938 surface,
939 surface_config,
940 blit,
941 ..
942 } = backend;
943 blit.update(queue, pixels);
944 let mut acquired = None;
955 for _ in 0..2 {
956 match surface.get_current_texture() {
957 wgpu::CurrentSurfaceTexture::Success(frame)
958 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
959 acquired = Some(frame);
960 break;
961 }
962 wgpu::CurrentSurfaceTexture::Outdated
963 | wgpu::CurrentSurfaceTexture::Lost
964 | wgpu::CurrentSurfaceTexture::Validation => {
965 surface.configure(device, surface_config);
966 }
967 wgpu::CurrentSurfaceTexture::Timeout
968 | wgpu::CurrentSurfaceTexture::Occluded => return,
969 }
970 }
971 let Some(frame) = acquired else {
972 editor.request_repaint();
976 return;
977 };
978 let view = frame
979 .texture
980 .create_view(&wgpu::TextureViewDescriptor::default());
981 let mut encoder =
982 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
983 blit.render(&mut encoder, &view);
984 queue.submit(std::iter::once(encoder.finish()));
985 frame.present();
986 }
987 }
988
989 #[allow(clippy::needless_pass_by_value)]
993 fn on_event_inner(
994 &mut self,
995 window: &mut baseview::Window,
996 event: baseview::Event,
997 ) -> baseview::EventStatus {
998 #[cfg(not(target_os = "windows"))]
1001 let _ = &window;
1002
1003 if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
1004 button: baseview::MouseButton::Left,
1005 ..
1006 }) = &event
1007 {
1008 #[cfg(target_os = "windows")]
1014 {
1015 if !window.has_focus() {
1016 window.focus();
1017 }
1018 }
1019 }
1020
1021 let Ok(mut guard) = self.backend.lock() else {
1026 return baseview::EventStatus::Ignored;
1027 };
1028 if guard.is_none() {
1029 return baseview::EventStatus::Ignored;
1030 }
1031
1032 match event {
1033 baseview::Event::Mouse(_) => {
1034 let Some(input) = self.translator.translate(&event) else {
1035 return baseview::EventStatus::Ignored;
1036 };
1037 let editor = unsafe { &mut *self.editor };
1038 editor.dispatch_events(&[input]);
1039 baseview::EventStatus::Captured
1040 }
1041 baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
1042 let editor = unsafe { &mut *self.editor };
1054 editor.scale.set(info.scale());
1055 crate::platform::note_linux_scale_factor(info.scale());
1056 let phys = info.physical_size();
1057 if editor.can_resize() {
1058 let scale = info.scale();
1059 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1060 let (lw, lh) = if scale > 0.0 {
1061 (
1062 (f64::from(phys.width) / scale).round() as u32,
1063 (f64::from(phys.height) / scale).round() as u32,
1064 )
1065 } else {
1066 (phys.width, phys.height)
1067 };
1068 let (cur_w, cur_h) = editor.size();
1069 if (lw, lh) != (cur_w, cur_h) && lw > 0 && lh > 0 {
1070 editor.set_size(lw, lh);
1071 }
1072 }
1073 if phys.width > 0
1082 && phys.height > 0
1083 && let Some(backend) = guard.as_mut()
1084 {
1085 backend.configure_surface(phys.width, phys.height);
1086 }
1087 editor.request_repaint();
1099 baseview::EventStatus::Ignored
1100 }
1101 _ => baseview::EventStatus::Ignored,
1102 }
1103 }
1104}
1105
1106#[cfg(feature = "cpu")]
1107impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
1108 fn on_frame(&mut self, window: &mut baseview::Window) {
1109 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1115 self.on_frame_inner(window);
1116 }));
1117 if let Err(e) = result {
1118 let msg = if let Some(s) = e.downcast_ref::<&str>() {
1119 s.to_string()
1120 } else if let Some(s) = e.downcast_ref::<String>() {
1121 s.clone()
1122 } else {
1123 "unknown panic".to_string()
1124 };
1125 log::error!("BuiltinWindowHandler::on_frame panic swallowed: {msg}");
1126 }
1127 }
1128
1129 fn on_event(
1130 &mut self,
1131 window: &mut baseview::Window,
1132 event: baseview::Event,
1133 ) -> baseview::EventStatus {
1134 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1135 self.on_event_inner(window, event)
1136 }));
1137 result.unwrap_or_else(|e| {
1138 let msg = if let Some(s) = e.downcast_ref::<&str>() {
1139 s.to_string()
1140 } else if let Some(s) = e.downcast_ref::<String>() {
1141 s.clone()
1142 } else {
1143 "unknown panic".to_string()
1144 };
1145 log::error!("BuiltinWindowHandler::on_event panic swallowed: {msg}");
1146 baseview::EventStatus::Ignored
1147 })
1148 }
1149}
1150
1151fn resolve_widget_type<P: Params>(
1157 widget: Option<crate::layout::WidgetKind>,
1158 param_id: u32,
1159 params: &P,
1160) -> widgets::WidgetType {
1161 match widget {
1162 Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
1163 Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
1164 Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
1165 Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
1166 Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
1167 Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
1168 Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
1169 None => {
1170 let param_info = params
1171 .param_infos()
1172 .iter()
1173 .find(|i| i.id == param_id)
1174 .copied();
1175 match param_info.as_ref().map(|i| &i.range) {
1176 Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
1177 widgets::WidgetType::Toggle
1178 }
1179 Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
1180 _ => widgets::WidgetType::Knob,
1181 }
1182 }
1183 }
1184}
1185
1186#[cfg(feature = "cpu")]
1187impl<P: Params + 'static> Editor for BuiltinEditor<P> {
1188 fn size(&self) -> (u32, u32) {
1189 (self.layout.width(), self.layout.height())
1190 }
1191
1192 fn state_changed(&mut self) {
1193 self.request_repaint();
1196 }
1197
1198 fn can_resize(&self) -> bool {
1203 self.can_resize()
1204 }
1205
1206 fn min_size(&self) -> (u32, u32) {
1207 self.min_size()
1208 }
1209
1210 fn max_size(&self) -> (u32, u32) {
1211 self.max_size()
1212 }
1213
1214 fn size_increment(&self) -> Option<(u32, u32)> {
1215 self.size_increment()
1216 }
1217
1218 fn set_size(&mut self, width: u32, height: u32) -> bool {
1219 self.set_size(width, height)
1220 }
1221
1222 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
1223 let (w, h) = self.size();
1224 self.pending_size.store(0, Ordering::Relaxed);
1228 self.scale
1234 .set(crate::platform::query_backing_scale(&parent));
1235 let scale = self.scale.get();
1236 let scale_f32 = self.scale.get_f32();
1237 self.backend = CpuBackend::new(w, h, scale_f32);
1238 self.context = Some(context);
1239
1240 match &self.layout {
1242 Layout::Rows(pl) => self.interaction.build_regions(pl),
1243 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
1244 }
1245
1246 self.render();
1250 self.request_repaint();
1251
1252 let (lw, lh) = (f64::from(w), f64::from(h));
1253 let phys_w = crate::platform::to_physical_px(w, scale);
1254 let phys_h = crate::platform::to_physical_px(h, scale);
1255
1256 let options = baseview::WindowOpenOptions {
1257 title: String::from("truce"),
1258 size: baseview::Size::new(lw, lh),
1259 scale: baseview::WindowScalePolicy::SystemScaleFactor,
1260 };
1261
1262 let parent_wrapper = crate::platform::ParentWindow(parent);
1263 let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
1264
1265 let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
1270 self.blit_backend = Some(shared_backend.clone());
1271 let shared_for_handler = shared_backend;
1272
1273 let window = baseview::Window::open_parented(
1274 &parent_wrapper,
1275 options,
1276 move |window: &mut baseview::Window| {
1277 let mut backend = create_wgpu_backend(window, phys_w, phys_h);
1278
1279 let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
1286 editor.render();
1287 if let Some(pixels) = editor.pixel_data() {
1288 let BlitBackend {
1289 device,
1290 queue,
1291 surface,
1292 blit,
1293 ..
1294 } = &mut backend;
1295 blit.update(queue, pixels);
1296 if let wgpu::CurrentSurfaceTexture::Success(frame)
1297 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
1298 surface.get_current_texture()
1299 {
1300 let view = frame
1301 .texture
1302 .create_view(&wgpu::TextureViewDescriptor::default());
1303 let mut encoder =
1304 device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1305 label: None,
1306 });
1307 blit.render(&mut encoder, &view);
1308 queue.submit(std::iter::once(encoder.finish()));
1309 frame.present();
1310 }
1311 }
1312
1313 if let Ok(mut guard) = shared_for_handler.lock() {
1320 *guard = Some(backend);
1321 }
1322
1323 BuiltinWindowHandler {
1324 editor: editor_addr as *mut BuiltinEditor<P>,
1325 backend: shared_for_handler.clone(),
1326 translator: crate::interaction::BaseviewTranslator::default(),
1327 last_applied_scale: scale_f32,
1328 }
1329 },
1330 );
1331
1332 self.window = Some(window);
1333 }
1334
1335 fn set_scale_factor(&mut self, factor: f64) {
1336 self.scale.set(factor);
1341 }
1342
1343 fn close(&mut self) {
1344 #[cfg(target_os = "macos")]
1351 let pool = unsafe {
1352 unsafe extern "C" {
1353 fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
1354 }
1355 objc_autoreleasePoolPush()
1356 };
1357
1358 if let Some(shared) = self.blit_backend.take()
1367 && let Ok(mut guard) = shared.lock()
1368 && let Some(backend) = guard.take()
1369 {
1370 let BlitBackend {
1371 blit,
1372 surface,
1373 surface_config,
1374 queue,
1375 device,
1376 max_texture_dim: _,
1377 } = backend;
1378 drop(surface_config);
1379 drop(blit);
1380 drop(surface);
1381 drop(queue);
1382 drop(device);
1383 }
1384
1385 if let Some(mut window) = self.window.take() {
1386 window.close();
1387 }
1388 self.context = None;
1389 self.backend = None;
1390
1391 #[cfg(target_os = "macos")]
1392 unsafe {
1393 unsafe extern "C" {
1394 fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
1395 }
1396 objc_autoreleasePoolPop(pool);
1397 }
1398 }
1399
1400 fn idle(&mut self) {
1401 if self.window.is_none() {
1405 self.render();
1406 }
1407 }
1408
1409 fn screenshot(
1410 &mut self,
1411 _params: Arc<dyn truce_params::Params>,
1412 ) -> Option<(Vec<u8>, u32, u32)> {
1413 let (lw, lh) = self.size();
1420 let scale = self.scale.get_f32();
1421 let mut backend = CpuBackend::new(lw, lh, scale)?;
1422 self.render_to(&mut backend);
1423 let pixels = backend.data().to_vec();
1424 let (phys_w, phys_h) = (backend.width(), backend.height());
1425 Some((pixels, phys_w, phys_h))
1426 }
1427}
1428
1429#[cfg(feature = "cpu")]
1430impl<P: Params + 'static> Drop for BuiltinEditor<P> {
1431 fn drop(&mut self) {
1432 Editor::close(self);
1444 }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
1452
1453 use super::*;
1454 use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
1455 use crate::widgets::WidgetType;
1456 use std::sync::Arc;
1457 use std::sync::atomic::{AtomicU64, Ordering};
1458 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
1459
1460 struct TestParams {
1463 values: [AtomicU64; 2],
1464 }
1465
1466 impl TestParams {
1467 fn new() -> Self {
1468 Self {
1469 values: [
1470 AtomicU64::new(0.0f64.to_bits()),
1471 AtomicU64::new(0.0f64.to_bits()),
1472 ],
1473 }
1474 }
1475 }
1476
1477 impl truce_params::__private::Sealed for TestParams {}
1478 impl Params for TestParams {
1479 fn param_infos(&self) -> Vec<ParamInfo> {
1480 vec![
1481 ParamInfo {
1482 id: 0,
1483 name: "Mode",
1484 short_name: "Mode",
1485 group: "",
1486 range: ParamRange::Enum { count: 4 },
1487 default_plain: 0.0,
1488 flags: ParamFlags::AUTOMATABLE,
1489 unit: ParamUnit::None,
1490 kind: ParamValueKind::Enum,
1491 },
1492 ParamInfo {
1493 id: 1,
1494 name: "Gain",
1495 short_name: "Gain",
1496 group: "",
1497 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1498 default_plain: 0.5,
1499 flags: ParamFlags::AUTOMATABLE,
1500 unit: ParamUnit::None,
1501 kind: ParamValueKind::Float,
1502 },
1503 ]
1504 }
1505
1506 fn count(&self) -> usize {
1507 2
1508 }
1509
1510 fn get_normalized(&self, id: u32) -> Option<f64> {
1511 self.values
1512 .get(id as usize)
1513 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1514 }
1515
1516 fn set_normalized(&self, id: u32, value: f64) {
1517 if let Some(v) = self.values.get(id as usize) {
1518 v.store(value.to_bits(), Ordering::Relaxed);
1519 }
1520 }
1521
1522 fn get_plain(&self, id: u32) -> Option<f64> {
1523 let norm = self.get_normalized(id)?;
1524 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1525 Some(info.range.denormalize(norm))
1526 }
1527
1528 fn set_plain(&self, id: u32, value: f64) {
1529 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1530 self.set_normalized(id, info.range.normalize(value));
1531 }
1532 }
1533
1534 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1535 Some(format!("{value:.0}"))
1536 }
1537
1538 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1539 None
1540 }
1541 fn snap_smoothers(&self) {}
1542 fn set_sample_rate(&self, _: f64) {}
1543
1544 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1545 let ids = vec![0, 1];
1546 let vals: Vec<f64> = ids
1547 .iter()
1548 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1549 .collect();
1550 (ids, vals)
1551 }
1552
1553 fn restore_values(&self, values: &[(u32, f64)]) {
1554 for &(id, val) in values {
1555 self.set_plain(id, val);
1556 }
1557 }
1558 }
1559
1560 impl Default for TestParams {
1561 fn default() -> Self {
1562 Self::new()
1563 }
1564 }
1565
1566 fn make_editor() -> BuiltinEditor<TestParams> {
1570 let params = Arc::new(TestParams::new());
1571 let layout = GridLayout::build(vec![widgets(vec![
1572 GridWidget::dropdown(0u32, "Mode"),
1573 GridWidget::knob(1u32, "Gain"),
1574 ])]);
1575 let mut editor = BuiltinEditor::new_grid(params, layout);
1576 if let Layout::Grid(ref gl) = editor.layout {
1578 editor.interaction.build_regions_grid(gl);
1579 for (idx, gw) in gl.widgets.iter().enumerate() {
1580 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1581 region.widget_type =
1582 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1583 }
1584 }
1585 }
1586 editor.render();
1588 editor
1589 }
1590
1591 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1593 let params = Arc::new(TestParams::new());
1594 let layout = GridLayout::build(vec![
1595 section(
1596 "SECTION A",
1597 vec![
1598 GridWidget::knob(1u32, "Gain"),
1599 GridWidget::knob(1u32, "Gain 2"),
1600 ],
1601 ),
1602 section(
1603 "SECTION B",
1604 vec![
1605 GridWidget::dropdown(0u32, "Mode"),
1606 GridWidget::knob(1u32, "Gain 3"),
1607 ],
1608 ),
1609 ]);
1610 let mut editor = BuiltinEditor::new_grid(params, layout);
1611 if let Layout::Grid(ref gl) = editor.layout {
1612 editor.interaction.build_regions_grid(gl);
1613 for (idx, gw) in gl.widgets.iter().enumerate() {
1614 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1615 region.widget_type =
1616 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1617 }
1618 }
1619 }
1620 editor.render();
1621 editor
1622 }
1623
1624 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1626 let region = editor
1627 .interaction
1628 .knob_regions
1629 .iter()
1630 .find(|r| r.widget_type == WidgetType::Dropdown)
1631 .expect("no dropdown in layout");
1632 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1633 }
1634
1635 #[test]
1638 fn dropdown_click_opens() {
1639 let mut editor = make_editor();
1640 let (dx, dy) = dropdown_center(&editor);
1641
1642 editor.on_mouse_down(dx, dy);
1643 assert!(editor.interaction.dropdown_is_open());
1644 }
1645
1646 #[test]
1647 fn dropdown_click_toggles_closed() {
1648 let mut editor = make_editor();
1649 let (dx, dy) = dropdown_center(&editor);
1650
1651 editor.on_mouse_down(dx, dy);
1653 editor.on_mouse_up(dx, dy);
1654 assert!(editor.interaction.dropdown_is_open());
1655
1656 editor.on_mouse_down(dx, dy);
1658 assert!(!editor.interaction.dropdown_is_open());
1659 }
1660
1661 #[test]
1662 fn dropdown_click_outside_closes() {
1663 let mut editor = make_editor();
1664 let (dx, dy) = dropdown_center(&editor);
1665
1666 editor.on_mouse_down(dx, dy);
1667 editor.on_mouse_up(dx, dy);
1668 assert!(editor.interaction.dropdown_is_open());
1669
1670 editor.on_mouse_down(0.0, 0.0);
1672 assert!(!editor.interaction.dropdown_is_open());
1673 }
1674
1675 #[test]
1676 fn dropdown_click_option_selects_and_closes() {
1677 let mut editor = make_editor();
1678 let (dx, dy) = dropdown_center(&editor);
1679
1680 editor.on_mouse_down(dx, dy);
1681 editor.on_mouse_up(dx, dy);
1682 assert!(editor.interaction.dropdown_is_open());
1683
1684 let dd = editor.interaction.dropdown.as_ref().unwrap();
1686 let (px, py, _, _) = dd.popup_rect;
1687 let item_h = 18.0f32;
1688 let padding = 4.0f32;
1689 let option_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, option_y);
1695 editor.on_mouse_up(px + 10.0, option_y);
1696
1697 assert!(!editor.interaction.dropdown_is_open());
1698 let norm = editor.params.get_normalized(0).unwrap();
1700 let expected = 1.0 / 3.0;
1701 assert!(
1702 (norm - expected).abs() < 0.01,
1703 "expected {expected:.4}, got {norm}"
1704 );
1705 }
1706
1707 #[test]
1710 fn dropdown_anchor_set_after_render() {
1711 let editor = make_editor();
1712 let region = editor
1713 .interaction
1714 .knob_regions
1715 .iter()
1716 .find(|r| r.widget_type == WidgetType::Dropdown)
1717 .unwrap();
1718
1719 assert!(
1721 region.dropdown_anchor_y > region.y,
1722 "anchor {} should be below region.y {}",
1723 region.dropdown_anchor_y,
1724 region.y
1725 );
1726 assert!(
1727 region.dropdown_anchor_y < region.y + region.h,
1728 "anchor {} should be above region bottom {}",
1729 region.dropdown_anchor_y,
1730 region.y + region.h
1731 );
1732 }
1733
1734 #[test]
1735 fn dropdown_popup_uses_anchor() {
1736 let mut editor = make_editor();
1737 let (dx, dy) = dropdown_center(&editor);
1738
1739 editor.on_mouse_down(dx, dy);
1740 editor.on_mouse_up(dx, dy);
1741
1742 let dd = editor.interaction.dropdown.as_ref().unwrap();
1743 let region = &editor.interaction.knob_regions[dd.region_idx];
1744
1745 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1749 }
1750
1751 #[test]
1752 fn dropdown_anchor_survives_idle_rebuild() {
1753 let mut editor = make_editor();
1760
1761 update_interaction(&mut editor);
1763
1764 let (dx, dy) = dropdown_center(&editor);
1765 editor.on_mouse_down(dx, dy);
1766 editor.on_mouse_up(dx, dy);
1767
1768 let dd = editor.interaction.dropdown.as_ref().unwrap();
1769 let region = &editor.interaction.knob_regions[dd.region_idx];
1770 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1771 assert!(
1772 dd.popup_rect.1 > region.y,
1773 "popup_y {} fell back to the window top instead of anchoring below the button",
1774 dd.popup_rect.1
1775 );
1776 }
1777
1778 #[test]
1779 fn dropdown_anchor_gap_stable_with_sections() {
1780 let editor_plain = make_editor();
1781 let editor_sections = make_editor_with_sections();
1782
1783 let r_plain = editor_plain
1784 .interaction
1785 .knob_regions
1786 .iter()
1787 .find(|r| r.widget_type == WidgetType::Dropdown)
1788 .unwrap();
1789 let r_sections = editor_sections
1790 .interaction
1791 .knob_regions
1792 .iter()
1793 .find(|r| r.widget_type == WidgetType::Dropdown)
1794 .unwrap();
1795
1796 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1799 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1800 assert!(
1801 (gap_plain - gap_sections).abs() < 0.1,
1802 "gap_plain={gap_plain}, gap_sections={gap_sections}"
1803 );
1804 }
1805
1806 struct ManyOptionParams {
1809 values: [AtomicU64; 2],
1810 }
1811
1812 impl ManyOptionParams {
1813 fn new() -> Self {
1814 Self {
1815 values: [
1816 AtomicU64::new(0.0f64.to_bits()),
1817 AtomicU64::new(0.0f64.to_bits()),
1818 ],
1819 }
1820 }
1821 }
1822
1823 impl truce_params::__private::Sealed for ManyOptionParams {}
1824 impl Params for ManyOptionParams {
1825 fn param_infos(&self) -> Vec<ParamInfo> {
1826 vec![
1827 ParamInfo {
1828 id: 0,
1829 name: "Note",
1830 short_name: "Note",
1831 group: "",
1832 range: ParamRange::Enum { count: 20 },
1833 default_plain: 0.0,
1834 flags: ParamFlags::AUTOMATABLE,
1835 unit: ParamUnit::None,
1836 kind: ParamValueKind::Enum,
1837 },
1838 ParamInfo {
1839 id: 1,
1840 name: "Gain",
1841 short_name: "Gain",
1842 group: "",
1843 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1844 default_plain: 0.5,
1845 flags: ParamFlags::AUTOMATABLE,
1846 unit: ParamUnit::None,
1847 kind: ParamValueKind::Float,
1848 },
1849 ]
1850 }
1851
1852 fn count(&self) -> usize {
1853 2
1854 }
1855
1856 fn get_normalized(&self, id: u32) -> Option<f64> {
1857 self.values
1858 .get(id as usize)
1859 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1860 }
1861
1862 fn set_normalized(&self, id: u32, value: f64) {
1863 if let Some(v) = self.values.get(id as usize) {
1864 v.store(value.to_bits(), Ordering::Relaxed);
1865 }
1866 }
1867
1868 fn get_plain(&self, id: u32) -> Option<f64> {
1869 let norm = self.get_normalized(id)?;
1870 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1871 Some(info.range.denormalize(norm))
1872 }
1873
1874 fn set_plain(&self, id: u32, value: f64) {
1875 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1876 self.set_normalized(id, info.range.normalize(value));
1877 }
1878 }
1879
1880 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1881 Some(format!("{value:.0}"))
1882 }
1883
1884 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1885 None
1886 }
1887 fn snap_smoothers(&self) {}
1888 fn set_sample_rate(&self, _: f64) {}
1889
1890 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1891 let ids = vec![0, 1];
1892 let vals: Vec<f64> = ids
1893 .iter()
1894 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1895 .collect();
1896 (ids, vals)
1897 }
1898
1899 fn restore_values(&self, values: &[(u32, f64)]) {
1900 for &(id, val) in values {
1901 self.set_plain(id, val);
1902 }
1903 }
1904 }
1905
1906 impl Default for ManyOptionParams {
1907 fn default() -> Self {
1908 Self::new()
1909 }
1910 }
1911
1912 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1916 let params = Arc::new(TestParams::new());
1917 let layout = GridLayout::build(vec![widgets(vec![
1919 GridWidget::knob(1u32, "K1"),
1920 GridWidget::knob(1u32, "K2"),
1921 GridWidget::knob(1u32, "K3"),
1922 GridWidget::knob(1u32, "K4"),
1923 GridWidget::dropdown(0u32, "Mode"),
1924 GridWidget::knob(1u32, "K5"),
1925 ])])
1926 .with_cols(2);
1927 let mut editor = BuiltinEditor::new_grid(params, layout);
1928 if let Layout::Grid(ref gl) = editor.layout {
1929 editor.interaction.build_regions_grid(gl);
1930 for (idx, gw) in gl.widgets.iter().enumerate() {
1931 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1932 region.widget_type =
1933 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1934 }
1935 }
1936 }
1937 editor.render();
1938 editor
1939 }
1940
1941 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1943 let params = Arc::new(TestParams::new());
1944 let layout = GridLayout::build(vec![widgets(vec![
1945 GridWidget::dropdown(0u32, "Mode A"),
1946 GridWidget::dropdown(0u32, "Mode B"),
1947 ])]);
1948 let mut editor = BuiltinEditor::new_grid(params, layout);
1949 if let Layout::Grid(ref gl) = editor.layout {
1950 editor.interaction.build_regions_grid(gl);
1951 for (idx, gw) in gl.widgets.iter().enumerate() {
1952 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1953 region.widget_type =
1954 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1955 }
1956 }
1957 }
1958 editor.render();
1959 editor
1960 }
1961
1962 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
1964 let params = Arc::new(ManyOptionParams::new());
1965 let layout = GridLayout::build(vec![widgets(vec![
1966 GridWidget::dropdown(0u32, "Note"),
1967 GridWidget::knob(1u32, "Gain"),
1968 ])]);
1969 let mut editor = BuiltinEditor::new_grid(params, layout);
1970 if let Layout::Grid(ref gl) = editor.layout {
1971 editor.interaction.build_regions_grid(gl);
1972 for (idx, gw) in gl.widgets.iter().enumerate() {
1973 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1974 region.widget_type =
1975 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1976 }
1977 }
1978 }
1979 editor.render();
1980 editor
1981 }
1982
1983 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
1984 let region = editor
1985 .interaction
1986 .knob_regions
1987 .iter()
1988 .find(|r| r.widget_type == WidgetType::Dropdown)
1989 .expect("no dropdown in layout");
1990 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1991 }
1992
1993 #[test]
1996 fn dropdown_anchors_below_button_scrolls_when_tight() {
1997 let mut editor = make_editor_bottom_dropdown();
1998 let (dx, dy) = {
1999 let region = editor
2000 .interaction
2001 .knob_regions
2002 .iter()
2003 .find(|r| r.widget_type == WidgetType::Dropdown)
2004 .unwrap();
2005 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2006 };
2007
2008 editor.on_mouse_down(dx, dy);
2009 editor.on_mouse_up(dx, dy);
2010 assert!(editor.interaction.dropdown_is_open());
2011
2012 let dd = editor.interaction.dropdown.as_ref().unwrap();
2013 let region = &editor.interaction.knob_regions[dd.region_idx];
2014 let (_, popup_y, _, popup_h) = dd.popup_rect;
2015 let window_h = editor.layout.height() as f32;
2016
2017 assert_eq!(
2022 popup_y, region.dropdown_anchor_y,
2023 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
2024 );
2025 assert!(
2027 popup_y + popup_h <= window_h + 1.0,
2028 "popup bottom {} exceeds window height {window_h}",
2029 popup_y + popup_h
2030 );
2031 }
2032
2033 #[test]
2034 fn dropdown_clamps_horizontal_near_right_edge() {
2035 let mut editor = make_editor_two_dropdowns();
2036 let region = &editor.interaction.knob_regions[1];
2038 assert_eq!(region.widget_type, WidgetType::Dropdown);
2039 let dx = region.x + region.w / 2.0;
2040 let dy = region.y + region.h / 2.0;
2041
2042 editor.on_mouse_down(dx, dy);
2043 editor.on_mouse_up(dx, dy);
2044 assert!(editor.interaction.dropdown_is_open());
2045
2046 let dd = editor.interaction.dropdown.as_ref().unwrap();
2047 let (popup_x, _, popup_w, _) = dd.popup_rect;
2048 let window_w = editor.layout.width() as f32;
2049
2050 assert!(
2051 popup_x + popup_w <= window_w + 1.0,
2052 "popup right edge {} exceeds window width {window_w}",
2053 popup_x + popup_w
2054 );
2055 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
2056 }
2057
2058 #[test]
2059 fn dropdown_scroll_long_list() {
2060 let mut editor = make_editor_many_options();
2061 let (dx, dy) = dropdown_center_many(&editor);
2062
2063 editor.on_mouse_down(dx, dy);
2064 editor.on_mouse_up(dx, dy);
2065 assert!(editor.interaction.dropdown_is_open());
2066
2067 let dd = editor.interaction.dropdown.as_ref().unwrap();
2068 assert!(
2070 dd.options.len() > dd.visible_count,
2071 "expected scroll: {} options, {} visible",
2072 dd.options.len(),
2073 dd.visible_count
2074 );
2075 assert_eq!(dd.scroll_offset, 0);
2076 }
2077
2078 #[test]
2079 fn dropdown_scroll_clamps_to_bounds() {
2080 let mut editor = make_editor_many_options();
2081 let (dx, dy) = dropdown_center_many(&editor);
2082
2083 editor.on_mouse_down(dx, dy);
2084 editor.on_mouse_up(dx, dy);
2085
2086 editor.interaction.dropdown_scroll(-10);
2088 assert_eq!(
2089 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2090 0
2091 );
2092
2093 editor.interaction.dropdown_scroll(1000);
2095 let dd = editor.interaction.dropdown.as_ref().unwrap();
2096 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
2097 assert_eq!(dd.scroll_offset, max_offset);
2098 }
2099
2100 #[test]
2101 fn dropdown_selected_item_visible_on_open() {
2102 let mut editor = make_editor_many_options();
2103 editor.params.set_normalized(0, 15.0 / 18.0);
2105
2106 let (dx, dy) = dropdown_center_many(&editor);
2107 editor.on_mouse_down(dx, dy);
2108 editor.on_mouse_up(dx, dy);
2109
2110 let dd = editor.interaction.dropdown.as_ref().unwrap();
2111 let selected = dd.selected;
2112 assert!(
2114 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
2115 "selected={selected} not in visible range {}..{}",
2116 dd.scroll_offset,
2117 dd.scroll_offset + dd.visible_count
2118 );
2119 }
2120
2121 #[test]
2122 fn dropdown_scroll_then_select_correct_index() {
2123 let mut editor = make_editor_many_options();
2124 let (dx, dy) = dropdown_center_many(&editor);
2125
2126 editor.on_mouse_down(dx, dy);
2127 editor.on_mouse_up(dx, dy);
2128
2129 editor.interaction.dropdown_scroll(3);
2131 assert_eq!(
2132 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2133 3
2134 );
2135
2136 let dd = editor.interaction.dropdown.as_ref().unwrap();
2138 let (px, py, _, _) = dd.popup_rect;
2139 let item_h = 18.0f32;
2140 let padding = 4.0f32;
2141 let click_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, click_y);
2144 editor.on_mouse_up(px + 10.0, click_y);
2145
2146 assert!(!editor.interaction.dropdown_is_open());
2147 let norm = editor.params.get_normalized(0).unwrap();
2150 let expected = 4.0 / 19.0;
2151 assert!(
2152 (norm - expected).abs() < 0.01,
2153 "expected {expected:.4}, got {norm:.4}"
2154 );
2155 }
2156
2157 #[test]
2158 fn dropdown_click_different_dropdown_closes_first() {
2159 let mut editor = make_editor_two_dropdowns();
2160 let r0 = &editor.interaction.knob_regions[0];
2161 let r1 = &editor.interaction.knob_regions[1];
2162 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
2163 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
2164
2165 editor.on_mouse_down(ax, ay);
2167 editor.on_mouse_up(ax, ay);
2168 assert!(editor.interaction.dropdown_is_open());
2169 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
2170
2171 editor.on_mouse_down(bx, by);
2173 editor.on_mouse_up(bx, by);
2174 assert!(editor.interaction.dropdown_is_open());
2175 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
2176 }
2177
2178 #[test]
2179 fn dropdown_hover_tracks_correct_option() {
2180 let mut editor = make_editor();
2181 let (dx, dy) = dropdown_center(&editor);
2182
2183 editor.on_mouse_down(dx, dy);
2184 editor.on_mouse_up(dx, dy);
2185
2186 let dd = editor.interaction.dropdown.as_ref().unwrap();
2187 let (px, py, pw, _) = dd.popup_rect;
2188 let item_h = 18.0f32;
2189 let padding = 4.0f32;
2190 let last_visible = dd.visible_count - 1;
2191
2192 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
2194 editor.on_mouse_moved(px + pw / 2.0, hover_y);
2195
2196 let dd = editor.interaction.dropdown.as_ref().unwrap();
2197 assert_eq!(
2198 dd.hover_option,
2199 Some(last_visible),
2200 "expected hover on last visible option"
2201 );
2202
2203 editor.on_mouse_moved(0.0, 0.0);
2205 let dd = editor.interaction.dropdown.as_ref().unwrap();
2206 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
2207 }
2208
2209 #[test]
2210 fn dropdown_popup_within_window_bounds() {
2211 let mut editor = make_editor();
2213 let (dx, dy) = dropdown_center(&editor);
2214
2215 editor.on_mouse_down(dx, dy);
2216 editor.on_mouse_up(dx, dy);
2217
2218 let dd = editor.interaction.dropdown.as_ref().unwrap();
2219 let (px, py, pw, ph) = dd.popup_rect;
2220 let window_w = editor.layout.width() as f32;
2221 let window_h = editor.layout.height() as f32;
2222
2223 assert!(px >= 0.0, "popup left edge {px} < 0");
2224 assert!(py >= 0.0, "popup top edge {py} < 0");
2225 assert!(
2226 px + pw <= window_w + 1.0,
2227 "popup right {} > window {window_w}",
2228 px + pw
2229 );
2230 assert!(
2231 py + ph <= window_h + 1.0,
2232 "popup bottom {} > window {window_h}",
2233 py + ph
2234 );
2235 }
2236}