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 crate::platform::reanchor_to_superview_top(window.raw_window_handle());
913 }
914
915 if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
922 let (lw, lh) = editor.size();
923 let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
924 let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
925 editor.backend = CpuBackend::new(lw, lh, cur_scale);
926 if let Some(backend) = guard.as_mut() {
927 backend.resize(phys_w, phys_h);
928 }
929 editor.request_repaint();
930 }
931
932 update_interaction(editor);
933 editor.detect_host_param_changes();
938 editor.detect_meter_changes();
939 if !editor.take_needs_repaint() {
940 return;
941 }
942 editor.render();
943 editor.stash_painted_values();
944
945 if let Some(pixels) = editor.pixel_data() {
946 let backend = guard
947 .as_mut()
948 .expect("guard was checked Some above and the lock is still held");
949 let BlitBackend {
950 device,
951 queue,
952 surface,
953 surface_config,
954 blit,
955 ..
956 } = backend;
957 blit.update(queue, pixels);
958 let mut acquired = None;
969 for _ in 0..2 {
970 match surface.get_current_texture() {
971 wgpu::CurrentSurfaceTexture::Success(frame)
972 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
973 acquired = Some(frame);
974 break;
975 }
976 wgpu::CurrentSurfaceTexture::Outdated
977 | wgpu::CurrentSurfaceTexture::Lost
978 | wgpu::CurrentSurfaceTexture::Validation => {
979 surface.configure(device, surface_config);
980 }
981 wgpu::CurrentSurfaceTexture::Timeout
982 | wgpu::CurrentSurfaceTexture::Occluded => return,
983 }
984 }
985 let Some(frame) = acquired else {
986 editor.request_repaint();
990 return;
991 };
992 let view = frame
993 .texture
994 .create_view(&wgpu::TextureViewDescriptor::default());
995 let mut encoder =
996 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
997 blit.render(
998 queue,
999 &mut encoder,
1000 &view,
1001 surface_config.width,
1002 surface_config.height,
1003 );
1004 queue.submit(std::iter::once(encoder.finish()));
1005 frame.present();
1006 }
1007 }
1008
1009 #[allow(clippy::needless_pass_by_value)]
1013 fn on_event_inner(
1014 &mut self,
1015 window: &mut baseview::Window,
1016 event: baseview::Event,
1017 ) -> baseview::EventStatus {
1018 #[cfg(not(target_os = "windows"))]
1021 let _ = &window;
1022
1023 if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
1024 button: baseview::MouseButton::Left,
1025 ..
1026 }) = &event
1027 {
1028 #[cfg(target_os = "windows")]
1034 {
1035 if !window.has_focus() {
1036 window.focus();
1037 }
1038 }
1039 }
1040
1041 let Ok(mut guard) = self.backend.lock() else {
1046 return baseview::EventStatus::Ignored;
1047 };
1048 if guard.is_none() {
1049 return baseview::EventStatus::Ignored;
1050 }
1051
1052 match event {
1053 baseview::Event::Mouse(_) => {
1054 let Some(input) = self.translator.translate(&event) else {
1055 return baseview::EventStatus::Ignored;
1056 };
1057 let editor = unsafe { &mut *self.editor };
1058 editor.dispatch_events(&[input]);
1059 baseview::EventStatus::Captured
1060 }
1061 baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
1062 let editor = unsafe { &mut *self.editor };
1074 editor.scale.set(info.scale());
1075 crate::platform::note_linux_scale_factor(info.scale());
1076 let phys = info.physical_size();
1077 if editor.can_resize() {
1078 let scale = info.scale();
1079 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1080 let (lw, lh) = if scale > 0.0 {
1081 (
1082 (f64::from(phys.width) / scale).round() as u32,
1083 (f64::from(phys.height) / scale).round() as u32,
1084 )
1085 } else {
1086 (phys.width, phys.height)
1087 };
1088 let (cur_w, cur_h) = editor.size();
1089 if (lw, lh) != (cur_w, cur_h) && lw > 0 && lh > 0 {
1090 editor.set_size(lw, lh);
1091 }
1092 }
1093 if phys.width > 0
1102 && phys.height > 0
1103 && let Some(backend) = guard.as_mut()
1104 {
1105 backend.configure_surface(phys.width, phys.height);
1106 }
1107 editor.request_repaint();
1119 baseview::EventStatus::Ignored
1120 }
1121 _ => baseview::EventStatus::Ignored,
1122 }
1123 }
1124}
1125
1126#[cfg(feature = "cpu")]
1127impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
1128 fn on_frame(&mut self, window: &mut baseview::Window) {
1129 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1135 self.on_frame_inner(window);
1136 }));
1137 if let Err(e) = result {
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_frame panic swallowed: {msg}");
1146 }
1147 }
1148
1149 fn on_event(
1150 &mut self,
1151 window: &mut baseview::Window,
1152 event: baseview::Event,
1153 ) -> baseview::EventStatus {
1154 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1155 self.on_event_inner(window, event)
1156 }));
1157 result.unwrap_or_else(|e| {
1158 let msg = if let Some(s) = e.downcast_ref::<&str>() {
1159 s.to_string()
1160 } else if let Some(s) = e.downcast_ref::<String>() {
1161 s.clone()
1162 } else {
1163 "unknown panic".to_string()
1164 };
1165 log::error!("BuiltinWindowHandler::on_event panic swallowed: {msg}");
1166 baseview::EventStatus::Ignored
1167 })
1168 }
1169}
1170
1171fn resolve_widget_type<P: Params>(
1177 widget: Option<crate::layout::WidgetKind>,
1178 param_id: u32,
1179 params: &P,
1180) -> widgets::WidgetType {
1181 match widget {
1182 Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
1183 Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
1184 Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
1185 Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
1186 Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
1187 Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
1188 Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
1189 None => {
1190 let param_info = params
1191 .param_infos()
1192 .iter()
1193 .find(|i| i.id == param_id)
1194 .copied();
1195 match param_info.as_ref().map(|i| &i.range) {
1196 Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
1197 widgets::WidgetType::Toggle
1198 }
1199 Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
1200 _ => widgets::WidgetType::Knob,
1201 }
1202 }
1203 }
1204}
1205
1206#[cfg(feature = "cpu")]
1207impl<P: Params + 'static> Editor for BuiltinEditor<P> {
1208 fn size(&self) -> (u32, u32) {
1209 (self.layout.width(), self.layout.height())
1210 }
1211
1212 fn state_changed(&mut self) {
1213 self.request_repaint();
1216 }
1217
1218 fn can_resize(&self) -> bool {
1223 self.can_resize()
1224 }
1225
1226 fn can_maximize(&self) -> bool {
1227 self.can_maximize()
1228 }
1229
1230 fn min_size(&self) -> (u32, u32) {
1231 self.min_size()
1232 }
1233
1234 fn max_size(&self) -> (u32, u32) {
1235 self.max_size()
1236 }
1237
1238 fn size_increment(&self) -> Option<(u32, u32)> {
1239 self.size_increment()
1240 }
1241
1242 fn set_size(&mut self, width: u32, height: u32) -> bool {
1243 self.set_size(width, height)
1244 }
1245
1246 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
1247 let (w, h) = self.size();
1248 self.pending_size.store(0, Ordering::Relaxed);
1252 self.scale
1258 .set(crate::platform::query_backing_scale(&parent));
1259 let scale = self.scale.get();
1260 let scale_f32 = self.scale.get_f32();
1261 self.backend = CpuBackend::new(w, h, scale_f32);
1262 self.context = Some(context);
1263
1264 match &self.layout {
1266 Layout::Rows(pl) => self.interaction.build_regions(pl),
1267 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
1268 }
1269
1270 self.render();
1274 self.request_repaint();
1275
1276 let (lw, lh) = (f64::from(w), f64::from(h));
1277 let phys_w = crate::platform::to_physical_px(w, scale);
1278 let phys_h = crate::platform::to_physical_px(h, scale);
1279
1280 let options = baseview::WindowOpenOptions {
1281 title: String::from("truce"),
1282 size: baseview::Size::new(lw, lh),
1283 scale: baseview::WindowScalePolicy::SystemScaleFactor,
1284 };
1285
1286 let parent_wrapper = crate::platform::ParentWindow(parent);
1287 let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
1288
1289 let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
1294 self.blit_backend = Some(shared_backend.clone());
1295 let shared_for_handler = shared_backend;
1296
1297 let window = baseview::Window::open_parented(
1298 &parent_wrapper,
1299 options,
1300 move |window: &mut baseview::Window| {
1301 let mut backend = create_wgpu_backend(window, phys_w, phys_h);
1302
1303 let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
1310 editor.render();
1311 if let Some(pixels) = editor.pixel_data() {
1312 let BlitBackend {
1313 device,
1314 queue,
1315 surface,
1316 surface_config,
1317 blit,
1318 ..
1319 } = &mut backend;
1320 blit.update(queue, pixels);
1321 if let wgpu::CurrentSurfaceTexture::Success(frame)
1322 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
1323 surface.get_current_texture()
1324 {
1325 let view = frame
1326 .texture
1327 .create_view(&wgpu::TextureViewDescriptor::default());
1328 let mut encoder =
1329 device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1330 label: None,
1331 });
1332 blit.render(
1333 queue,
1334 &mut encoder,
1335 &view,
1336 surface_config.width,
1337 surface_config.height,
1338 );
1339 queue.submit(std::iter::once(encoder.finish()));
1340 frame.present();
1341 }
1342 }
1343
1344 if let Ok(mut guard) = shared_for_handler.lock() {
1351 *guard = Some(backend);
1352 }
1353
1354 BuiltinWindowHandler {
1355 editor: editor_addr as *mut BuiltinEditor<P>,
1356 backend: shared_for_handler.clone(),
1357 translator: crate::interaction::BaseviewTranslator::default(),
1358 last_applied_scale: scale_f32,
1359 }
1360 },
1361 );
1362
1363 self.window = Some(window);
1364 }
1365
1366 fn set_scale_factor(&mut self, factor: f64) {
1367 self.scale.set(factor);
1372 }
1373
1374 fn close(&mut self) {
1375 #[cfg(target_os = "macos")]
1382 let pool = unsafe {
1383 unsafe extern "C" {
1384 fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
1385 }
1386 objc_autoreleasePoolPush()
1387 };
1388
1389 if let Some(shared) = self.blit_backend.take()
1398 && let Ok(mut guard) = shared.lock()
1399 && let Some(backend) = guard.take()
1400 {
1401 let BlitBackend {
1402 blit,
1403 surface,
1404 surface_config,
1405 queue,
1406 device,
1407 max_texture_dim: _,
1408 } = backend;
1409 drop(surface_config);
1410 drop(blit);
1411 drop(surface);
1412 drop(queue);
1413 drop(device);
1414 }
1415
1416 if let Some(mut window) = self.window.take() {
1417 window.close();
1418 }
1419 self.context = None;
1420 self.backend = None;
1421
1422 #[cfg(target_os = "macos")]
1423 unsafe {
1424 unsafe extern "C" {
1425 fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
1426 }
1427 objc_autoreleasePoolPop(pool);
1428 }
1429 }
1430
1431 fn idle(&mut self) {
1432 if self.window.is_none() {
1436 self.render();
1437 }
1438 }
1439
1440 fn screenshot(
1441 &mut self,
1442 _params: Arc<dyn truce_params::Params>,
1443 ) -> Option<(Vec<u8>, u32, u32)> {
1444 let (lw, lh) = self.size();
1451 let scale = self.scale.get_f32();
1452 let mut backend = CpuBackend::new(lw, lh, scale)?;
1453 self.render_to(&mut backend);
1454 let pixels = backend.data().to_vec();
1455 let (phys_w, phys_h) = (backend.width(), backend.height());
1456 Some((pixels, phys_w, phys_h))
1457 }
1458}
1459
1460#[cfg(feature = "cpu")]
1461impl<P: Params + 'static> Drop for BuiltinEditor<P> {
1462 fn drop(&mut self) {
1463 Editor::close(self);
1475 }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480 #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
1483
1484 use super::*;
1485 use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
1486 use crate::widgets::WidgetType;
1487 use std::sync::Arc;
1488 use std::sync::atomic::{AtomicU64, Ordering};
1489 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
1490
1491 struct TestParams {
1494 values: [AtomicU64; 2],
1495 }
1496
1497 impl TestParams {
1498 fn new() -> Self {
1499 Self {
1500 values: [
1501 AtomicU64::new(0.0f64.to_bits()),
1502 AtomicU64::new(0.0f64.to_bits()),
1503 ],
1504 }
1505 }
1506 }
1507
1508 impl truce_params::__private::Sealed for TestParams {}
1509 impl Params for TestParams {
1510 fn param_infos(&self) -> Vec<ParamInfo> {
1511 vec![
1512 ParamInfo {
1513 id: 0,
1514 name: "Mode",
1515 short_name: "Mode",
1516 group: "",
1517 range: ParamRange::Enum { count: 4 },
1518 default_plain: 0.0,
1519 flags: ParamFlags::AUTOMATABLE,
1520 unit: ParamUnit::None,
1521 kind: ParamValueKind::Enum,
1522 },
1523 ParamInfo {
1524 id: 1,
1525 name: "Gain",
1526 short_name: "Gain",
1527 group: "",
1528 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1529 default_plain: 0.5,
1530 flags: ParamFlags::AUTOMATABLE,
1531 unit: ParamUnit::None,
1532 kind: ParamValueKind::Float,
1533 },
1534 ]
1535 }
1536
1537 fn count(&self) -> usize {
1538 2
1539 }
1540
1541 fn get_normalized(&self, id: u32) -> Option<f64> {
1542 self.values
1543 .get(id as usize)
1544 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1545 }
1546
1547 fn set_normalized(&self, id: u32, value: f64) {
1548 if let Some(v) = self.values.get(id as usize) {
1549 v.store(value.to_bits(), Ordering::Relaxed);
1550 }
1551 }
1552
1553 fn get_plain(&self, id: u32) -> Option<f64> {
1554 let norm = self.get_normalized(id)?;
1555 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1556 Some(info.range.denormalize(norm))
1557 }
1558
1559 fn set_plain(&self, id: u32, value: f64) {
1560 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1561 self.set_normalized(id, info.range.normalize(value));
1562 }
1563 }
1564
1565 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1566 Some(format!("{value:.0}"))
1567 }
1568
1569 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1570 None
1571 }
1572 fn snap_smoothers(&self) {}
1573 fn set_sample_rate(&self, _: f64) {}
1574
1575 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1576 let ids = vec![0, 1];
1577 let vals: Vec<f64> = ids
1578 .iter()
1579 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1580 .collect();
1581 (ids, vals)
1582 }
1583
1584 fn restore_values(&self, values: &[(u32, f64)]) {
1585 for &(id, val) in values {
1586 self.set_plain(id, val);
1587 }
1588 }
1589 }
1590
1591 impl Default for TestParams {
1592 fn default() -> Self {
1593 Self::new()
1594 }
1595 }
1596
1597 fn make_editor() -> BuiltinEditor<TestParams> {
1601 let params = Arc::new(TestParams::new());
1602 let layout = GridLayout::build(vec![widgets(vec![
1603 GridWidget::dropdown(0u32, "Mode"),
1604 GridWidget::knob(1u32, "Gain"),
1605 ])]);
1606 let mut editor = BuiltinEditor::new_grid(params, layout);
1607 if let Layout::Grid(ref gl) = editor.layout {
1609 editor.interaction.build_regions_grid(gl);
1610 for (idx, gw) in gl.widgets.iter().enumerate() {
1611 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1612 region.widget_type =
1613 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1614 }
1615 }
1616 }
1617 editor.render();
1619 editor
1620 }
1621
1622 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1624 let params = Arc::new(TestParams::new());
1625 let layout = GridLayout::build(vec![
1626 section(
1627 "SECTION A",
1628 vec![
1629 GridWidget::knob(1u32, "Gain"),
1630 GridWidget::knob(1u32, "Gain 2"),
1631 ],
1632 ),
1633 section(
1634 "SECTION B",
1635 vec![
1636 GridWidget::dropdown(0u32, "Mode"),
1637 GridWidget::knob(1u32, "Gain 3"),
1638 ],
1639 ),
1640 ]);
1641 let mut editor = BuiltinEditor::new_grid(params, layout);
1642 if let Layout::Grid(ref gl) = editor.layout {
1643 editor.interaction.build_regions_grid(gl);
1644 for (idx, gw) in gl.widgets.iter().enumerate() {
1645 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1646 region.widget_type =
1647 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1648 }
1649 }
1650 }
1651 editor.render();
1652 editor
1653 }
1654
1655 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1657 let region = editor
1658 .interaction
1659 .knob_regions
1660 .iter()
1661 .find(|r| r.widget_type == WidgetType::Dropdown)
1662 .expect("no dropdown in layout");
1663 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1664 }
1665
1666 #[test]
1669 fn dropdown_click_opens() {
1670 let mut editor = make_editor();
1671 let (dx, dy) = dropdown_center(&editor);
1672
1673 editor.on_mouse_down(dx, dy);
1674 assert!(editor.interaction.dropdown_is_open());
1675 }
1676
1677 #[test]
1678 fn dropdown_click_toggles_closed() {
1679 let mut editor = make_editor();
1680 let (dx, dy) = dropdown_center(&editor);
1681
1682 editor.on_mouse_down(dx, dy);
1684 editor.on_mouse_up(dx, dy);
1685 assert!(editor.interaction.dropdown_is_open());
1686
1687 editor.on_mouse_down(dx, dy);
1689 assert!(!editor.interaction.dropdown_is_open());
1690 }
1691
1692 #[test]
1693 fn dropdown_click_outside_closes() {
1694 let mut editor = make_editor();
1695 let (dx, dy) = dropdown_center(&editor);
1696
1697 editor.on_mouse_down(dx, dy);
1698 editor.on_mouse_up(dx, dy);
1699 assert!(editor.interaction.dropdown_is_open());
1700
1701 editor.on_mouse_down(0.0, 0.0);
1703 assert!(!editor.interaction.dropdown_is_open());
1704 }
1705
1706 #[test]
1707 fn dropdown_click_option_selects_and_closes() {
1708 let mut editor = make_editor();
1709 let (dx, dy) = dropdown_center(&editor);
1710
1711 editor.on_mouse_down(dx, dy);
1712 editor.on_mouse_up(dx, dy);
1713 assert!(editor.interaction.dropdown_is_open());
1714
1715 let dd = editor.interaction.dropdown.as_ref().unwrap();
1717 let (px, py, _, _) = dd.popup_rect;
1718 let item_h = 18.0f32;
1719 let padding = 4.0f32;
1720 let option_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, option_y);
1726 editor.on_mouse_up(px + 10.0, option_y);
1727
1728 assert!(!editor.interaction.dropdown_is_open());
1729 let norm = editor.params.get_normalized(0).unwrap();
1731 let expected = 1.0 / 3.0;
1732 assert!(
1733 (norm - expected).abs() < 0.01,
1734 "expected {expected:.4}, got {norm}"
1735 );
1736 }
1737
1738 #[test]
1741 fn dropdown_anchor_set_after_render() {
1742 let editor = make_editor();
1743 let region = editor
1744 .interaction
1745 .knob_regions
1746 .iter()
1747 .find(|r| r.widget_type == WidgetType::Dropdown)
1748 .unwrap();
1749
1750 assert!(
1752 region.dropdown_anchor_y > region.y,
1753 "anchor {} should be below region.y {}",
1754 region.dropdown_anchor_y,
1755 region.y
1756 );
1757 assert!(
1758 region.dropdown_anchor_y < region.y + region.h,
1759 "anchor {} should be above region bottom {}",
1760 region.dropdown_anchor_y,
1761 region.y + region.h
1762 );
1763 }
1764
1765 #[test]
1766 fn dropdown_popup_uses_anchor() {
1767 let mut editor = make_editor();
1768 let (dx, dy) = dropdown_center(&editor);
1769
1770 editor.on_mouse_down(dx, dy);
1771 editor.on_mouse_up(dx, dy);
1772
1773 let dd = editor.interaction.dropdown.as_ref().unwrap();
1774 let region = &editor.interaction.knob_regions[dd.region_idx];
1775
1776 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1780 }
1781
1782 #[test]
1783 fn dropdown_anchor_survives_idle_rebuild() {
1784 let mut editor = make_editor();
1791
1792 update_interaction(&mut editor);
1794
1795 let (dx, dy) = dropdown_center(&editor);
1796 editor.on_mouse_down(dx, dy);
1797 editor.on_mouse_up(dx, dy);
1798
1799 let dd = editor.interaction.dropdown.as_ref().unwrap();
1800 let region = &editor.interaction.knob_regions[dd.region_idx];
1801 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1802 assert!(
1803 dd.popup_rect.1 > region.y,
1804 "popup_y {} fell back to the window top instead of anchoring below the button",
1805 dd.popup_rect.1
1806 );
1807 }
1808
1809 #[test]
1810 fn dropdown_anchor_gap_stable_with_sections() {
1811 let editor_plain = make_editor();
1812 let editor_sections = make_editor_with_sections();
1813
1814 let r_plain = editor_plain
1815 .interaction
1816 .knob_regions
1817 .iter()
1818 .find(|r| r.widget_type == WidgetType::Dropdown)
1819 .unwrap();
1820 let r_sections = editor_sections
1821 .interaction
1822 .knob_regions
1823 .iter()
1824 .find(|r| r.widget_type == WidgetType::Dropdown)
1825 .unwrap();
1826
1827 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1830 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1831 assert!(
1832 (gap_plain - gap_sections).abs() < 0.1,
1833 "gap_plain={gap_plain}, gap_sections={gap_sections}"
1834 );
1835 }
1836
1837 struct ManyOptionParams {
1840 values: [AtomicU64; 2],
1841 }
1842
1843 impl ManyOptionParams {
1844 fn new() -> Self {
1845 Self {
1846 values: [
1847 AtomicU64::new(0.0f64.to_bits()),
1848 AtomicU64::new(0.0f64.to_bits()),
1849 ],
1850 }
1851 }
1852 }
1853
1854 impl truce_params::__private::Sealed for ManyOptionParams {}
1855 impl Params for ManyOptionParams {
1856 fn param_infos(&self) -> Vec<ParamInfo> {
1857 vec![
1858 ParamInfo {
1859 id: 0,
1860 name: "Note",
1861 short_name: "Note",
1862 group: "",
1863 range: ParamRange::Enum { count: 20 },
1864 default_plain: 0.0,
1865 flags: ParamFlags::AUTOMATABLE,
1866 unit: ParamUnit::None,
1867 kind: ParamValueKind::Enum,
1868 },
1869 ParamInfo {
1870 id: 1,
1871 name: "Gain",
1872 short_name: "Gain",
1873 group: "",
1874 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1875 default_plain: 0.5,
1876 flags: ParamFlags::AUTOMATABLE,
1877 unit: ParamUnit::None,
1878 kind: ParamValueKind::Float,
1879 },
1880 ]
1881 }
1882
1883 fn count(&self) -> usize {
1884 2
1885 }
1886
1887 fn get_normalized(&self, id: u32) -> Option<f64> {
1888 self.values
1889 .get(id as usize)
1890 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1891 }
1892
1893 fn set_normalized(&self, id: u32, value: f64) {
1894 if let Some(v) = self.values.get(id as usize) {
1895 v.store(value.to_bits(), Ordering::Relaxed);
1896 }
1897 }
1898
1899 fn get_plain(&self, id: u32) -> Option<f64> {
1900 let norm = self.get_normalized(id)?;
1901 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1902 Some(info.range.denormalize(norm))
1903 }
1904
1905 fn set_plain(&self, id: u32, value: f64) {
1906 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1907 self.set_normalized(id, info.range.normalize(value));
1908 }
1909 }
1910
1911 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1912 Some(format!("{value:.0}"))
1913 }
1914
1915 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1916 None
1917 }
1918 fn snap_smoothers(&self) {}
1919 fn set_sample_rate(&self, _: f64) {}
1920
1921 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1922 let ids = vec![0, 1];
1923 let vals: Vec<f64> = ids
1924 .iter()
1925 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1926 .collect();
1927 (ids, vals)
1928 }
1929
1930 fn restore_values(&self, values: &[(u32, f64)]) {
1931 for &(id, val) in values {
1932 self.set_plain(id, val);
1933 }
1934 }
1935 }
1936
1937 impl Default for ManyOptionParams {
1938 fn default() -> Self {
1939 Self::new()
1940 }
1941 }
1942
1943 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1947 let params = Arc::new(TestParams::new());
1948 let layout = GridLayout::build(vec![widgets(vec![
1950 GridWidget::knob(1u32, "K1"),
1951 GridWidget::knob(1u32, "K2"),
1952 GridWidget::knob(1u32, "K3"),
1953 GridWidget::knob(1u32, "K4"),
1954 GridWidget::dropdown(0u32, "Mode"),
1955 GridWidget::knob(1u32, "K5"),
1956 ])])
1957 .with_cols(2);
1958 let mut editor = BuiltinEditor::new_grid(params, layout);
1959 if let Layout::Grid(ref gl) = editor.layout {
1960 editor.interaction.build_regions_grid(gl);
1961 for (idx, gw) in gl.widgets.iter().enumerate() {
1962 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1963 region.widget_type =
1964 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1965 }
1966 }
1967 }
1968 editor.render();
1969 editor
1970 }
1971
1972 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1974 let params = Arc::new(TestParams::new());
1975 let layout = GridLayout::build(vec![widgets(vec![
1976 GridWidget::dropdown(0u32, "Mode A"),
1977 GridWidget::dropdown(0u32, "Mode B"),
1978 ])]);
1979 let mut editor = BuiltinEditor::new_grid(params, layout);
1980 if let Layout::Grid(ref gl) = editor.layout {
1981 editor.interaction.build_regions_grid(gl);
1982 for (idx, gw) in gl.widgets.iter().enumerate() {
1983 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1984 region.widget_type =
1985 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1986 }
1987 }
1988 }
1989 editor.render();
1990 editor
1991 }
1992
1993 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
1995 let params = Arc::new(ManyOptionParams::new());
1996 let layout = GridLayout::build(vec![widgets(vec![
1997 GridWidget::dropdown(0u32, "Note"),
1998 GridWidget::knob(1u32, "Gain"),
1999 ])]);
2000 let mut editor = BuiltinEditor::new_grid(params, layout);
2001 if let Layout::Grid(ref gl) = editor.layout {
2002 editor.interaction.build_regions_grid(gl);
2003 for (idx, gw) in gl.widgets.iter().enumerate() {
2004 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
2005 region.widget_type =
2006 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
2007 }
2008 }
2009 }
2010 editor.render();
2011 editor
2012 }
2013
2014 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
2015 let region = editor
2016 .interaction
2017 .knob_regions
2018 .iter()
2019 .find(|r| r.widget_type == WidgetType::Dropdown)
2020 .expect("no dropdown in layout");
2021 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2022 }
2023
2024 #[test]
2027 fn dropdown_anchors_below_button_scrolls_when_tight() {
2028 let mut editor = make_editor_bottom_dropdown();
2029 let (dx, dy) = {
2030 let region = editor
2031 .interaction
2032 .knob_regions
2033 .iter()
2034 .find(|r| r.widget_type == WidgetType::Dropdown)
2035 .unwrap();
2036 (region.x + region.w / 2.0, region.y + region.h / 2.0)
2037 };
2038
2039 editor.on_mouse_down(dx, dy);
2040 editor.on_mouse_up(dx, dy);
2041 assert!(editor.interaction.dropdown_is_open());
2042
2043 let dd = editor.interaction.dropdown.as_ref().unwrap();
2044 let region = &editor.interaction.knob_regions[dd.region_idx];
2045 let (_, popup_y, _, popup_h) = dd.popup_rect;
2046 let window_h = editor.layout.height() as f32;
2047
2048 assert_eq!(
2053 popup_y, region.dropdown_anchor_y,
2054 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
2055 );
2056 assert!(
2058 popup_y + popup_h <= window_h + 1.0,
2059 "popup bottom {} exceeds window height {window_h}",
2060 popup_y + popup_h
2061 );
2062 }
2063
2064 #[test]
2065 fn dropdown_clamps_horizontal_near_right_edge() {
2066 let mut editor = make_editor_two_dropdowns();
2067 let region = &editor.interaction.knob_regions[1];
2069 assert_eq!(region.widget_type, WidgetType::Dropdown);
2070 let dx = region.x + region.w / 2.0;
2071 let dy = region.y + region.h / 2.0;
2072
2073 editor.on_mouse_down(dx, dy);
2074 editor.on_mouse_up(dx, dy);
2075 assert!(editor.interaction.dropdown_is_open());
2076
2077 let dd = editor.interaction.dropdown.as_ref().unwrap();
2078 let (popup_x, _, popup_w, _) = dd.popup_rect;
2079 let window_w = editor.layout.width() as f32;
2080
2081 assert!(
2082 popup_x + popup_w <= window_w + 1.0,
2083 "popup right edge {} exceeds window width {window_w}",
2084 popup_x + popup_w
2085 );
2086 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
2087 }
2088
2089 #[test]
2090 fn dropdown_scroll_long_list() {
2091 let mut editor = make_editor_many_options();
2092 let (dx, dy) = dropdown_center_many(&editor);
2093
2094 editor.on_mouse_down(dx, dy);
2095 editor.on_mouse_up(dx, dy);
2096 assert!(editor.interaction.dropdown_is_open());
2097
2098 let dd = editor.interaction.dropdown.as_ref().unwrap();
2099 assert!(
2101 dd.options.len() > dd.visible_count,
2102 "expected scroll: {} options, {} visible",
2103 dd.options.len(),
2104 dd.visible_count
2105 );
2106 assert_eq!(dd.scroll_offset, 0);
2107 }
2108
2109 #[test]
2110 fn dropdown_scroll_clamps_to_bounds() {
2111 let mut editor = make_editor_many_options();
2112 let (dx, dy) = dropdown_center_many(&editor);
2113
2114 editor.on_mouse_down(dx, dy);
2115 editor.on_mouse_up(dx, dy);
2116
2117 editor.interaction.dropdown_scroll(-10);
2119 assert_eq!(
2120 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2121 0
2122 );
2123
2124 editor.interaction.dropdown_scroll(1000);
2126 let dd = editor.interaction.dropdown.as_ref().unwrap();
2127 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
2128 assert_eq!(dd.scroll_offset, max_offset);
2129 }
2130
2131 #[test]
2132 fn dropdown_selected_item_visible_on_open() {
2133 let mut editor = make_editor_many_options();
2134 editor.params.set_normalized(0, 15.0 / 18.0);
2136
2137 let (dx, dy) = dropdown_center_many(&editor);
2138 editor.on_mouse_down(dx, dy);
2139 editor.on_mouse_up(dx, dy);
2140
2141 let dd = editor.interaction.dropdown.as_ref().unwrap();
2142 let selected = dd.selected;
2143 assert!(
2145 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
2146 "selected={selected} not in visible range {}..{}",
2147 dd.scroll_offset,
2148 dd.scroll_offset + dd.visible_count
2149 );
2150 }
2151
2152 #[test]
2153 fn dropdown_scroll_then_select_correct_index() {
2154 let mut editor = make_editor_many_options();
2155 let (dx, dy) = dropdown_center_many(&editor);
2156
2157 editor.on_mouse_down(dx, dy);
2158 editor.on_mouse_up(dx, dy);
2159
2160 editor.interaction.dropdown_scroll(3);
2162 assert_eq!(
2163 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2164 3
2165 );
2166
2167 let dd = editor.interaction.dropdown.as_ref().unwrap();
2169 let (px, py, _, _) = dd.popup_rect;
2170 let item_h = 18.0f32;
2171 let padding = 4.0f32;
2172 let click_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, click_y);
2175 editor.on_mouse_up(px + 10.0, click_y);
2176
2177 assert!(!editor.interaction.dropdown_is_open());
2178 let norm = editor.params.get_normalized(0).unwrap();
2181 let expected = 4.0 / 19.0;
2182 assert!(
2183 (norm - expected).abs() < 0.01,
2184 "expected {expected:.4}, got {norm:.4}"
2185 );
2186 }
2187
2188 #[test]
2189 fn dropdown_click_different_dropdown_closes_first() {
2190 let mut editor = make_editor_two_dropdowns();
2191 let r0 = &editor.interaction.knob_regions[0];
2192 let r1 = &editor.interaction.knob_regions[1];
2193 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
2194 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
2195
2196 editor.on_mouse_down(ax, ay);
2198 editor.on_mouse_up(ax, ay);
2199 assert!(editor.interaction.dropdown_is_open());
2200 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
2201
2202 editor.on_mouse_down(bx, by);
2204 editor.on_mouse_up(bx, by);
2205 assert!(editor.interaction.dropdown_is_open());
2206 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
2207 }
2208
2209 #[test]
2210 fn dropdown_hover_tracks_correct_option() {
2211 let mut editor = make_editor();
2212 let (dx, dy) = dropdown_center(&editor);
2213
2214 editor.on_mouse_down(dx, dy);
2215 editor.on_mouse_up(dx, dy);
2216
2217 let dd = editor.interaction.dropdown.as_ref().unwrap();
2218 let (px, py, pw, _) = dd.popup_rect;
2219 let item_h = 18.0f32;
2220 let padding = 4.0f32;
2221 let last_visible = dd.visible_count - 1;
2222
2223 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
2225 editor.on_mouse_moved(px + pw / 2.0, hover_y);
2226
2227 let dd = editor.interaction.dropdown.as_ref().unwrap();
2228 assert_eq!(
2229 dd.hover_option,
2230 Some(last_visible),
2231 "expected hover on last visible option"
2232 );
2233
2234 editor.on_mouse_moved(0.0, 0.0);
2236 let dd = editor.interaction.dropdown.as_ref().unwrap();
2237 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
2238 }
2239
2240 #[test]
2241 fn dropdown_popup_within_window_bounds() {
2242 let mut editor = make_editor();
2244 let (dx, dy) = dropdown_center(&editor);
2245
2246 editor.on_mouse_down(dx, dy);
2247 editor.on_mouse_up(dx, dy);
2248
2249 let dd = editor.interaction.dropdown.as_ref().unwrap();
2250 let (px, py, pw, ph) = dd.popup_rect;
2251 let window_w = editor.layout.width() as f32;
2252 let window_h = editor.layout.height() as f32;
2253
2254 assert!(px >= 0.0, "popup left edge {px} < 0");
2255 assert!(py >= 0.0, "popup top edge {py} < 0");
2256 assert!(
2257 px + pw <= window_w + 1.0,
2258 "popup right {} > window {window_w}",
2259 px + pw
2260 );
2261 assert!(
2262 py + ph <= window_h + 1.0,
2263 "popup bottom {} > window {window_h}",
2264 py + ph
2265 );
2266 }
2267}