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 = self
304 .backend
305 .get_or_insert_with(|| CpuBackend::new(w, h, scale).expect("Failed to create backend"));
306 render_widgets_impl(
307 &self.layout,
308 &self.theme,
309 &mut self.interaction,
310 &snapshot,
311 backend,
312 );
313 }
314
315 fn build_snapshot_closures(&self) -> EditorSnapshotClosures {
322 build_snapshot_closures_impl(&self.params, self.context.as_ref())
323 }
324
325 fn apply_edit(&self, edit: ParamEdit) {
327 match edit {
328 ParamEdit::Begin { id } => {
329 if let Some(ref ctx) = self.context {
330 ctx.begin_edit(id);
331 }
332 }
333 ParamEdit::Set { id, normalized } => {
334 self.params.set_normalized(id, f64::from(normalized));
335 if let Some(ref ctx) = self.context {
336 ctx.set_param(id, f64::from(normalized));
337 }
338 self.request_repaint();
339 }
340 ParamEdit::End { id } => {
341 if let Some(ref ctx) = self.context {
342 ctx.end_edit(id);
343 }
344 }
345 }
346 }
347
348 pub fn dispatch_events(&mut self, events: &[InputEvent]) {
356 let hover_before = self.interaction.hover_idx;
357 let dd_before = self.interaction.dropdown_is_open();
358 let owned = self.build_snapshot_closures();
359 let snapshot = owned.as_snapshot();
360 let edits = interaction::dispatch(events, &self.layout, &snapshot, &mut self.interaction);
361 let had_edits = !edits.is_empty();
362 for e in edits {
363 self.apply_edit(e);
364 }
365 let explicit = self.interaction.take_repaint_request();
371 if had_edits
372 || explicit
373 || self.interaction.hover_idx != hover_before
374 || self.interaction.dropdown_is_open() != dd_before
375 {
376 self.request_repaint();
377 }
378 }
379
380 #[cfg(feature = "cpu")]
383 #[must_use]
384 pub fn pixel_data(&self) -> Option<&[u8]> {
385 self.backend
386 .as_ref()
387 .map(super::backend_cpu::CpuBackend::data)
388 }
389
390 #[must_use]
394 pub fn has_context(&self) -> bool {
395 self.context.is_some()
396 }
397
398 pub fn take_context(&mut self) -> Option<PluginContext> {
401 self.context.take()
402 }
403
404 pub fn set_context(&mut self, context: PluginContext) {
406 self.context = Some(context);
407 match &self.layout {
408 Layout::Rows(pl) => self.interaction.build_regions(pl),
409 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
410 }
411 }
412
413 #[must_use]
417 pub fn size(&self) -> (u32, u32) {
418 (self.layout.width(), self.layout.height())
419 }
420
421 #[must_use]
426 pub fn can_resize(&self) -> bool {
427 match &self.layout {
428 Layout::Grid(gl) => gl.resizable,
429 Layout::Rows(_) => false,
432 }
433 }
434
435 #[must_use]
437 pub fn min_size(&self) -> (u32, u32) {
438 match &self.layout {
439 Layout::Grid(gl) => gl.min_snapped_size(),
440 Layout::Rows(_) => self.size(),
441 }
442 }
443
444 #[must_use]
446 pub fn max_size(&self) -> (u32, u32) {
447 match &self.layout {
448 Layout::Grid(gl) => gl.max_snapped_size(),
449 Layout::Rows(_) => self.size(),
450 }
451 }
452
453 #[must_use]
456 pub fn size_increment(&self) -> Option<(u32, u32)> {
457 match &self.layout {
458 Layout::Grid(gl) if gl.resizable => {
461 let step = gl.resize_step();
462 Some((step, step))
463 }
464 _ => None,
465 }
466 }
467
468 pub fn set_size(&mut self, width: u32, height: u32) -> bool {
472 if width == 0 || height == 0 || !self.can_resize() {
473 return false;
474 }
475 let Layout::Grid(ref mut gl) = self.layout else {
476 return false;
477 };
478 gl.refit_cols(width);
486 let (new_w, new_h) = gl.refit_rows(height);
487 #[cfg(feature = "cpu")]
493 self.pending_size.store(
494 (u64::from(new_w) << 32) | u64::from(new_h),
495 Ordering::Release,
496 );
497 #[cfg(not(feature = "cpu"))]
498 let _ = (new_w, new_h);
499 self.request_repaint();
503 true
504 }
505
506 pub fn state_changed(&mut self) {
510 self.request_repaint();
511 }
512
513 pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
518 update_interaction(self);
519 let owned = self.build_snapshot_closures();
520 let snapshot = owned.as_snapshot();
521 render_widgets_impl(
522 &self.layout,
523 &self.theme,
524 &mut self.interaction,
525 &snapshot,
526 backend,
527 );
528 }
529}
530
531#[cfg(test)]
535impl<P: Params + 'static> BuiltinEditor<P> {
536 fn on_mouse_down(&mut self, x: f32, y: f32) {
537 self.dispatch_events(&[InputEvent::MouseDown {
538 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
539 x,
540 y,
541 button: crate::interaction::MouseButton::Left,
542 }]);
543 }
544
545 fn on_mouse_up(&mut self, x: f32, y: f32) {
546 self.dispatch_events(&[InputEvent::MouseUp {
547 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
548 x,
549 y,
550 button: crate::interaction::MouseButton::Left,
551 }]);
552 }
553
554 fn on_mouse_moved(&mut self, x: f32, y: f32) {
555 self.dispatch_events(&[InputEvent::MouseMove {
556 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
557 x,
558 y,
559 }]);
560 }
561}
562
563pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
573 match &editor.layout {
574 Layout::Rows(pl) => {
575 editor.interaction.build_regions(pl);
576 let mut flat_idx = 0usize;
577 for row in &pl.rows {
578 for knob_def in &row.knobs {
579 if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
580 region.widget_type = resolve_widget_type(
581 knob_def.widget,
582 knob_def.param_id,
583 &*editor.params,
584 );
585 }
586 flat_idx += 1;
587 }
588 }
589 }
590 Layout::Grid(gl) => {
591 editor.interaction.build_regions_grid(gl);
592 for (idx, gw) in gl.widgets.iter().enumerate() {
593 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
594 region.widget_type =
595 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
596 }
597 }
598 }
599 }
600 for region in &mut editor.interaction.knob_regions {
601 if let Some(ref ctx) = editor.context {
602 region.normalized_value = ctx.get_param(region.param_id);
604 } else {
605 region.normalized_value =
606 f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
607 }
608 }
609}
610
611#[cfg(feature = "cpu")]
626fn create_wgpu_backend(window: &mut baseview::Window, phys_w: u32, phys_h: u32) -> BlitBackend {
627 let instance = wgpu::Instance::new(crate::platform::editor_instance_descriptor());
628
629 let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) }
630 .expect("failed to create wgpu surface");
631
632 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
633 power_preference: wgpu::PowerPreference::HighPerformance,
634 compatible_surface: Some(&surface),
635 force_fallback_adapter: false,
636 }))
637 .expect("no suitable GPU adapter");
638
639 let adapter_limits = adapter.limits();
647 let max_texture_dim = adapter_limits.max_texture_dimension_2d;
648 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
649 label: Some("truce-gui"),
650 required_features: wgpu::Features::empty(),
651 required_limits: adapter_limits,
652 experimental_features: wgpu::ExperimentalFeatures::default(),
653 memory_hints: wgpu::MemoryHints::Performance,
654 trace: wgpu::Trace::Off,
655 }))
656 .expect("failed to create wgpu device");
657
658 let caps = surface.get_capabilities(&adapter);
659 let format = caps
660 .formats
661 .iter()
662 .find(|f| f.is_srgb())
663 .copied()
664 .unwrap_or(caps.formats[0]);
665
666 let init_w = phys_w.clamp(1, max_texture_dim);
672 let init_h = phys_h.clamp(1, max_texture_dim);
673 let surface_config = wgpu::SurfaceConfiguration {
674 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
675 format,
676 width: init_w,
677 height: init_h,
678 present_mode: wgpu::PresentMode::AutoVsync,
679 desired_maximum_frame_latency: 2,
680 alpha_mode: wgpu::CompositeAlphaMode::Auto,
681 view_formats: vec![],
682 };
683 surface.configure(&device, &surface_config);
684
685 let blit = crate::blit::BlitPipeline::new(&device, format, init_w, init_h);
690
691 BlitBackend {
692 blit,
693 surface_config,
694 surface,
695 queue,
696 device,
697 max_texture_dim,
698 }
699}
700
701#[cfg(feature = "cpu")]
710struct BlitBackend {
711 blit: crate::blit::BlitPipeline,
712 surface_config: wgpu::SurfaceConfiguration,
713 surface: wgpu::Surface<'static>,
714 queue: wgpu::Queue,
715 device: wgpu::Device,
716 max_texture_dim: u32,
723}
724
725#[cfg(feature = "cpu")]
726impl BlitBackend {
727 fn resize(&mut self, phys_w: u32, phys_h: u32) {
732 let phys_w = phys_w.clamp(1, self.max_texture_dim);
733 let phys_h = phys_h.clamp(1, self.max_texture_dim);
734 self.surface_config.width = phys_w;
735 self.surface_config.height = phys_h;
736 self.surface.configure(&self.device, &self.surface_config);
737 self.blit.resize(&self.device, phys_w, phys_h);
738 }
739
740 fn configure_surface(&mut self, phys_w: u32, phys_h: u32) {
754 let phys_w = phys_w.clamp(1, self.max_texture_dim);
755 let phys_h = phys_h.clamp(1, self.max_texture_dim);
756 if self.surface_config.width == phys_w && self.surface_config.height == phys_h {
757 return;
758 }
759 self.surface_config.width = phys_w;
760 self.surface_config.height = phys_h;
761 self.surface.configure(&self.device, &self.surface_config);
762 }
763}
764
765#[cfg(feature = "cpu")]
772type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
773
774#[cfg(feature = "cpu")]
775struct BuiltinWindowHandler<P: Params> {
776 editor: *mut BuiltinEditor<P>,
787 backend: SharedBackend,
788 translator: crate::interaction::BaseviewTranslator,
792 last_applied_scale: f32,
801}
802
803#[cfg(feature = "cpu")]
806unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
807
808#[cfg(feature = "cpu")]
809impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
810 fn on_frame(&mut self, window: &mut baseview::Window) {
811 let Ok(mut guard) = self.backend.lock() else {
818 return;
819 };
820 if guard.is_none() {
821 return;
824 }
825
826 let editor = unsafe { &mut *self.editor };
827
828 let pending = editor.pending_size.swap(0, Ordering::Acquire);
840 if pending != 0 {
841 #[allow(clippy::cast_possible_truncation)]
842 let new_w = (pending >> 32) as u32;
843 #[allow(clippy::cast_possible_truncation)]
844 let new_h = (pending & 0xFFFF_FFFF) as u32;
845 if new_w > 0 && new_h > 0 {
846 let scale = editor.scale.get();
847 let scale_f32 = editor.scale.get_f32();
848 let phys_w = crate::platform::to_physical_px(new_w, scale);
849 let phys_h = crate::platform::to_physical_px(new_h, scale);
850 editor.backend = CpuBackend::new(new_w, new_h, scale_f32);
851 if let Some(backend) = guard.as_mut() {
852 backend.resize(phys_w, phys_h);
853 }
854 match &editor.layout {
855 Layout::Rows(pl) => editor.interaction.build_regions(pl),
856 Layout::Grid(gl) => editor.interaction.build_regions_grid(gl),
857 }
858 window.resize(baseview::Size::new(f64::from(new_w), f64::from(new_h)));
859 editor.request_repaint();
860 }
861 }
862
863 if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
870 let (lw, lh) = editor.size();
871 let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
872 let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
873 editor.backend = CpuBackend::new(lw, lh, cur_scale);
874 if let Some(backend) = guard.as_mut() {
875 backend.resize(phys_w, phys_h);
876 }
877 editor.request_repaint();
878 }
879
880 update_interaction(editor);
881 editor.detect_host_param_changes();
886 editor.detect_meter_changes();
887 if !editor.take_needs_repaint() {
888 return;
889 }
890 editor.render();
891 editor.stash_painted_values();
892
893 if let Some(pixels) = editor.pixel_data() {
894 let backend = guard
895 .as_mut()
896 .expect("guard was checked Some above and the lock is still held");
897 let BlitBackend {
898 device,
899 queue,
900 surface,
901 surface_config,
902 blit,
903 ..
904 } = backend;
905 blit.update(queue, pixels);
906 let mut acquired = None;
917 for _ in 0..2 {
918 match surface.get_current_texture() {
919 wgpu::CurrentSurfaceTexture::Success(frame)
920 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
921 acquired = Some(frame);
922 break;
923 }
924 wgpu::CurrentSurfaceTexture::Outdated
925 | wgpu::CurrentSurfaceTexture::Lost
926 | wgpu::CurrentSurfaceTexture::Validation => {
927 surface.configure(device, surface_config);
928 }
929 wgpu::CurrentSurfaceTexture::Timeout
930 | wgpu::CurrentSurfaceTexture::Occluded => return,
931 }
932 }
933 let Some(frame) = acquired else {
934 editor.request_repaint();
938 return;
939 };
940 let view = frame
941 .texture
942 .create_view(&wgpu::TextureViewDescriptor::default());
943 let mut encoder =
944 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
945 blit.render(&mut encoder, &view);
946 queue.submit(std::iter::once(encoder.finish()));
947 frame.present();
948 }
949 }
950
951 fn on_event(
952 &mut self,
953 window: &mut baseview::Window,
954 event: baseview::Event,
955 ) -> baseview::EventStatus {
956 #[cfg(not(target_os = "windows"))]
959 let _ = &window;
960
961 if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
962 button: baseview::MouseButton::Left,
963 ..
964 }) = &event
965 {
966 #[cfg(target_os = "windows")]
972 {
973 if !window.has_focus() {
974 window.focus();
975 }
976 }
977 }
978
979 let Ok(mut guard) = self.backend.lock() else {
984 return baseview::EventStatus::Ignored;
985 };
986 if guard.is_none() {
987 return baseview::EventStatus::Ignored;
988 }
989
990 match event {
991 baseview::Event::Mouse(_) => {
992 let Some(input) = self.translator.translate(&event) else {
993 return baseview::EventStatus::Ignored;
994 };
995 let editor = unsafe { &mut *self.editor };
996 editor.dispatch_events(&[input]);
997 baseview::EventStatus::Captured
998 }
999 baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
1000 let editor = unsafe { &mut *self.editor };
1012 editor.scale.set(info.scale());
1013 crate::platform::note_linux_scale_factor(info.scale());
1014 let phys = info.physical_size();
1015 if editor.can_resize() {
1016 let scale = info.scale();
1017 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1018 let (lw, lh) = if scale > 0.0 {
1019 (
1020 (f64::from(phys.width) / scale).round() as u32,
1021 (f64::from(phys.height) / scale).round() as u32,
1022 )
1023 } else {
1024 (phys.width, phys.height)
1025 };
1026 let (cur_w, cur_h) = editor.size();
1027 if (lw, lh) != (cur_w, cur_h) && lw > 0 && lh > 0 {
1028 editor.set_size(lw, lh);
1029 }
1030 }
1031 if phys.width > 0
1040 && phys.height > 0
1041 && let Some(backend) = guard.as_mut()
1042 {
1043 backend.configure_surface(phys.width, phys.height);
1044 }
1045 editor.request_repaint();
1057 baseview::EventStatus::Ignored
1058 }
1059 _ => baseview::EventStatus::Ignored,
1060 }
1061 }
1062}
1063
1064fn resolve_widget_type<P: Params>(
1070 widget: Option<crate::layout::WidgetKind>,
1071 param_id: u32,
1072 params: &P,
1073) -> widgets::WidgetType {
1074 match widget {
1075 Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
1076 Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
1077 Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
1078 Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
1079 Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
1080 Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
1081 Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
1082 None => {
1083 let param_info = params
1084 .param_infos()
1085 .iter()
1086 .find(|i| i.id == param_id)
1087 .copied();
1088 match param_info.as_ref().map(|i| &i.range) {
1089 Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
1090 widgets::WidgetType::Toggle
1091 }
1092 Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
1093 _ => widgets::WidgetType::Knob,
1094 }
1095 }
1096 }
1097}
1098
1099#[cfg(feature = "cpu")]
1100impl<P: Params + 'static> Editor for BuiltinEditor<P> {
1101 fn size(&self) -> (u32, u32) {
1102 (self.layout.width(), self.layout.height())
1103 }
1104
1105 fn state_changed(&mut self) {
1106 self.request_repaint();
1109 }
1110
1111 fn can_resize(&self) -> bool {
1116 self.can_resize()
1117 }
1118
1119 fn min_size(&self) -> (u32, u32) {
1120 self.min_size()
1121 }
1122
1123 fn max_size(&self) -> (u32, u32) {
1124 self.max_size()
1125 }
1126
1127 fn size_increment(&self) -> Option<(u32, u32)> {
1128 self.size_increment()
1129 }
1130
1131 fn set_size(&mut self, width: u32, height: u32) -> bool {
1132 self.set_size(width, height)
1133 }
1134
1135 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
1136 let (w, h) = self.size();
1137 self.pending_size.store(0, Ordering::Relaxed);
1141 self.scale
1147 .set(crate::platform::query_backing_scale(&parent));
1148 let scale = self.scale.get();
1149 let scale_f32 = self.scale.get_f32();
1150 self.backend = CpuBackend::new(w, h, scale_f32);
1151 self.context = Some(context);
1152
1153 match &self.layout {
1155 Layout::Rows(pl) => self.interaction.build_regions(pl),
1156 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
1157 }
1158
1159 self.render();
1163 self.request_repaint();
1164
1165 let (lw, lh) = (f64::from(w), f64::from(h));
1166 let phys_w = crate::platform::to_physical_px(w, scale);
1167 let phys_h = crate::platform::to_physical_px(h, scale);
1168
1169 let options = baseview::WindowOpenOptions {
1170 title: String::from("truce"),
1171 size: baseview::Size::new(lw, lh),
1172 scale: baseview::WindowScalePolicy::SystemScaleFactor,
1173 };
1174
1175 let parent_wrapper = crate::platform::ParentWindow(parent);
1176 let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
1177
1178 let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
1183 self.blit_backend = Some(shared_backend.clone());
1184 let shared_for_handler = shared_backend;
1185
1186 let window = baseview::Window::open_parented(
1187 &parent_wrapper,
1188 options,
1189 move |window: &mut baseview::Window| {
1190 let mut backend = create_wgpu_backend(window, phys_w, phys_h);
1191
1192 let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
1199 editor.render();
1200 if let Some(pixels) = editor.pixel_data() {
1201 let BlitBackend {
1202 device,
1203 queue,
1204 surface,
1205 blit,
1206 ..
1207 } = &mut backend;
1208 blit.update(queue, pixels);
1209 if let wgpu::CurrentSurfaceTexture::Success(frame)
1210 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
1211 surface.get_current_texture()
1212 {
1213 let view = frame
1214 .texture
1215 .create_view(&wgpu::TextureViewDescriptor::default());
1216 let mut encoder =
1217 device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1218 label: None,
1219 });
1220 blit.render(&mut encoder, &view);
1221 queue.submit(std::iter::once(encoder.finish()));
1222 frame.present();
1223 }
1224 }
1225
1226 if let Ok(mut guard) = shared_for_handler.lock() {
1233 *guard = Some(backend);
1234 }
1235
1236 BuiltinWindowHandler {
1237 editor: editor_addr as *mut BuiltinEditor<P>,
1238 backend: shared_for_handler.clone(),
1239 translator: crate::interaction::BaseviewTranslator::default(),
1240 last_applied_scale: scale_f32,
1241 }
1242 },
1243 );
1244
1245 self.window = Some(window);
1246 }
1247
1248 fn set_scale_factor(&mut self, factor: f64) {
1249 self.scale.set(factor);
1254 }
1255
1256 fn close(&mut self) {
1257 #[cfg(target_os = "macos")]
1264 let pool = unsafe {
1265 unsafe extern "C" {
1266 fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
1267 }
1268 objc_autoreleasePoolPush()
1269 };
1270
1271 if let Some(shared) = self.blit_backend.take()
1280 && let Ok(mut guard) = shared.lock()
1281 && let Some(backend) = guard.take()
1282 {
1283 let BlitBackend {
1284 blit,
1285 surface,
1286 surface_config,
1287 queue,
1288 device,
1289 max_texture_dim: _,
1290 } = backend;
1291 drop(surface_config);
1292 drop(blit);
1293 drop(surface);
1294 drop(queue);
1295 drop(device);
1296 }
1297
1298 if let Some(mut window) = self.window.take() {
1299 window.close();
1300 }
1301 self.context = None;
1302 self.backend = None;
1303
1304 #[cfg(target_os = "macos")]
1305 unsafe {
1306 unsafe extern "C" {
1307 fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
1308 }
1309 objc_autoreleasePoolPop(pool);
1310 }
1311 }
1312
1313 fn idle(&mut self) {
1314 if self.window.is_none() {
1318 self.render();
1319 }
1320 }
1321
1322 fn screenshot(
1323 &mut self,
1324 _params: Arc<dyn truce_params::Params>,
1325 ) -> Option<(Vec<u8>, u32, u32)> {
1326 let (lw, lh) = self.size();
1333 let scale = self.scale.get_f32();
1334 let mut backend = CpuBackend::new(lw, lh, scale)?;
1335 self.render_to(&mut backend);
1336 let pixels = backend.data().to_vec();
1337 let (phys_w, phys_h) = (backend.width(), backend.height());
1338 Some((pixels, phys_w, phys_h))
1339 }
1340}
1341
1342#[cfg(feature = "cpu")]
1343impl<P: Params + 'static> Drop for BuiltinEditor<P> {
1344 fn drop(&mut self) {
1345 Editor::close(self);
1357 }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362 #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
1365
1366 use super::*;
1367 use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
1368 use crate::widgets::WidgetType;
1369 use std::sync::Arc;
1370 use std::sync::atomic::{AtomicU64, Ordering};
1371 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
1372
1373 struct TestParams {
1376 values: [AtomicU64; 2],
1377 }
1378
1379 impl TestParams {
1380 fn new() -> Self {
1381 Self {
1382 values: [
1383 AtomicU64::new(0.0f64.to_bits()),
1384 AtomicU64::new(0.0f64.to_bits()),
1385 ],
1386 }
1387 }
1388 }
1389
1390 impl truce_params::__private::Sealed for TestParams {}
1391 impl Params for TestParams {
1392 fn param_infos(&self) -> Vec<ParamInfo> {
1393 vec![
1394 ParamInfo {
1395 id: 0,
1396 name: "Mode",
1397 short_name: "Mode",
1398 group: "",
1399 range: ParamRange::Enum { count: 4 },
1400 default_plain: 0.0,
1401 flags: ParamFlags::AUTOMATABLE,
1402 unit: ParamUnit::None,
1403 kind: ParamValueKind::Enum,
1404 },
1405 ParamInfo {
1406 id: 1,
1407 name: "Gain",
1408 short_name: "Gain",
1409 group: "",
1410 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1411 default_plain: 0.5,
1412 flags: ParamFlags::AUTOMATABLE,
1413 unit: ParamUnit::None,
1414 kind: ParamValueKind::Float,
1415 },
1416 ]
1417 }
1418
1419 fn count(&self) -> usize {
1420 2
1421 }
1422
1423 fn get_normalized(&self, id: u32) -> Option<f64> {
1424 self.values
1425 .get(id as usize)
1426 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1427 }
1428
1429 fn set_normalized(&self, id: u32, value: f64) {
1430 if let Some(v) = self.values.get(id as usize) {
1431 v.store(value.to_bits(), Ordering::Relaxed);
1432 }
1433 }
1434
1435 fn get_plain(&self, id: u32) -> Option<f64> {
1436 let norm = self.get_normalized(id)?;
1437 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1438 Some(info.range.denormalize(norm))
1439 }
1440
1441 fn set_plain(&self, id: u32, value: f64) {
1442 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1443 self.set_normalized(id, info.range.normalize(value));
1444 }
1445 }
1446
1447 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1448 Some(format!("{value:.0}"))
1449 }
1450
1451 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1452 None
1453 }
1454 fn snap_smoothers(&self) {}
1455 fn set_sample_rate(&self, _: f64) {}
1456
1457 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1458 let ids = vec![0, 1];
1459 let vals: Vec<f64> = ids
1460 .iter()
1461 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1462 .collect();
1463 (ids, vals)
1464 }
1465
1466 fn restore_values(&self, values: &[(u32, f64)]) {
1467 for &(id, val) in values {
1468 self.set_plain(id, val);
1469 }
1470 }
1471 }
1472
1473 impl Default for TestParams {
1474 fn default() -> Self {
1475 Self::new()
1476 }
1477 }
1478
1479 fn make_editor() -> BuiltinEditor<TestParams> {
1483 let params = Arc::new(TestParams::new());
1484 let layout = GridLayout::build(vec![widgets(vec![
1485 GridWidget::dropdown(0u32, "Mode"),
1486 GridWidget::knob(1u32, "Gain"),
1487 ])]);
1488 let mut editor = BuiltinEditor::new_grid(params, layout);
1489 if let Layout::Grid(ref gl) = editor.layout {
1491 editor.interaction.build_regions_grid(gl);
1492 for (idx, gw) in gl.widgets.iter().enumerate() {
1493 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1494 region.widget_type =
1495 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1496 }
1497 }
1498 }
1499 editor.render();
1501 editor
1502 }
1503
1504 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1506 let params = Arc::new(TestParams::new());
1507 let layout = GridLayout::build(vec![
1508 section(
1509 "SECTION A",
1510 vec![
1511 GridWidget::knob(1u32, "Gain"),
1512 GridWidget::knob(1u32, "Gain 2"),
1513 ],
1514 ),
1515 section(
1516 "SECTION B",
1517 vec![
1518 GridWidget::dropdown(0u32, "Mode"),
1519 GridWidget::knob(1u32, "Gain 3"),
1520 ],
1521 ),
1522 ]);
1523 let mut editor = BuiltinEditor::new_grid(params, layout);
1524 if let Layout::Grid(ref gl) = editor.layout {
1525 editor.interaction.build_regions_grid(gl);
1526 for (idx, gw) in gl.widgets.iter().enumerate() {
1527 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1528 region.widget_type =
1529 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1530 }
1531 }
1532 }
1533 editor.render();
1534 editor
1535 }
1536
1537 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1539 let region = editor
1540 .interaction
1541 .knob_regions
1542 .iter()
1543 .find(|r| r.widget_type == WidgetType::Dropdown)
1544 .expect("no dropdown in layout");
1545 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1546 }
1547
1548 #[test]
1551 fn dropdown_click_opens() {
1552 let mut editor = make_editor();
1553 let (dx, dy) = dropdown_center(&editor);
1554
1555 editor.on_mouse_down(dx, dy);
1556 assert!(editor.interaction.dropdown_is_open());
1557 }
1558
1559 #[test]
1560 fn dropdown_click_toggles_closed() {
1561 let mut editor = make_editor();
1562 let (dx, dy) = dropdown_center(&editor);
1563
1564 editor.on_mouse_down(dx, dy);
1566 editor.on_mouse_up(dx, dy);
1567 assert!(editor.interaction.dropdown_is_open());
1568
1569 editor.on_mouse_down(dx, dy);
1571 assert!(!editor.interaction.dropdown_is_open());
1572 }
1573
1574 #[test]
1575 fn dropdown_click_outside_closes() {
1576 let mut editor = make_editor();
1577 let (dx, dy) = dropdown_center(&editor);
1578
1579 editor.on_mouse_down(dx, dy);
1580 editor.on_mouse_up(dx, dy);
1581 assert!(editor.interaction.dropdown_is_open());
1582
1583 editor.on_mouse_down(0.0, 0.0);
1585 assert!(!editor.interaction.dropdown_is_open());
1586 }
1587
1588 #[test]
1589 fn dropdown_click_option_selects_and_closes() {
1590 let mut editor = make_editor();
1591 let (dx, dy) = dropdown_center(&editor);
1592
1593 editor.on_mouse_down(dx, dy);
1594 editor.on_mouse_up(dx, dy);
1595 assert!(editor.interaction.dropdown_is_open());
1596
1597 let dd = editor.interaction.dropdown.as_ref().unwrap();
1599 let (px, py, _, _) = dd.popup_rect;
1600 let item_h = 18.0f32;
1601 let padding = 4.0f32;
1602 let option_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, option_y);
1608 editor.on_mouse_up(px + 10.0, option_y);
1609
1610 assert!(!editor.interaction.dropdown_is_open());
1611 let norm = editor.params.get_normalized(0).unwrap();
1613 let expected = 1.0 / 3.0;
1614 assert!(
1615 (norm - expected).abs() < 0.01,
1616 "expected {expected:.4}, got {norm}"
1617 );
1618 }
1619
1620 #[test]
1623 fn dropdown_anchor_set_after_render() {
1624 let editor = make_editor();
1625 let region = editor
1626 .interaction
1627 .knob_regions
1628 .iter()
1629 .find(|r| r.widget_type == WidgetType::Dropdown)
1630 .unwrap();
1631
1632 assert!(
1634 region.dropdown_anchor_y > region.y,
1635 "anchor {} should be below region.y {}",
1636 region.dropdown_anchor_y,
1637 region.y
1638 );
1639 assert!(
1640 region.dropdown_anchor_y < region.y + region.h,
1641 "anchor {} should be above region bottom {}",
1642 region.dropdown_anchor_y,
1643 region.y + region.h
1644 );
1645 }
1646
1647 #[test]
1648 fn dropdown_popup_uses_anchor() {
1649 let mut editor = make_editor();
1650 let (dx, dy) = dropdown_center(&editor);
1651
1652 editor.on_mouse_down(dx, dy);
1653 editor.on_mouse_up(dx, dy);
1654
1655 let dd = editor.interaction.dropdown.as_ref().unwrap();
1656 let region = &editor.interaction.knob_regions[dd.region_idx];
1657
1658 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1662 }
1663
1664 #[test]
1665 fn dropdown_anchor_survives_idle_rebuild() {
1666 let mut editor = make_editor();
1673
1674 update_interaction(&mut editor);
1676
1677 let (dx, dy) = dropdown_center(&editor);
1678 editor.on_mouse_down(dx, dy);
1679 editor.on_mouse_up(dx, dy);
1680
1681 let dd = editor.interaction.dropdown.as_ref().unwrap();
1682 let region = &editor.interaction.knob_regions[dd.region_idx];
1683 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1684 assert!(
1685 dd.popup_rect.1 > region.y,
1686 "popup_y {} fell back to the window top instead of anchoring below the button",
1687 dd.popup_rect.1
1688 );
1689 }
1690
1691 #[test]
1692 fn dropdown_anchor_gap_stable_with_sections() {
1693 let editor_plain = make_editor();
1694 let editor_sections = make_editor_with_sections();
1695
1696 let r_plain = editor_plain
1697 .interaction
1698 .knob_regions
1699 .iter()
1700 .find(|r| r.widget_type == WidgetType::Dropdown)
1701 .unwrap();
1702 let r_sections = editor_sections
1703 .interaction
1704 .knob_regions
1705 .iter()
1706 .find(|r| r.widget_type == WidgetType::Dropdown)
1707 .unwrap();
1708
1709 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1712 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1713 assert!(
1714 (gap_plain - gap_sections).abs() < 0.1,
1715 "gap_plain={gap_plain}, gap_sections={gap_sections}"
1716 );
1717 }
1718
1719 struct ManyOptionParams {
1722 values: [AtomicU64; 2],
1723 }
1724
1725 impl ManyOptionParams {
1726 fn new() -> Self {
1727 Self {
1728 values: [
1729 AtomicU64::new(0.0f64.to_bits()),
1730 AtomicU64::new(0.0f64.to_bits()),
1731 ],
1732 }
1733 }
1734 }
1735
1736 impl truce_params::__private::Sealed for ManyOptionParams {}
1737 impl Params for ManyOptionParams {
1738 fn param_infos(&self) -> Vec<ParamInfo> {
1739 vec![
1740 ParamInfo {
1741 id: 0,
1742 name: "Note",
1743 short_name: "Note",
1744 group: "",
1745 range: ParamRange::Enum { count: 20 },
1746 default_plain: 0.0,
1747 flags: ParamFlags::AUTOMATABLE,
1748 unit: ParamUnit::None,
1749 kind: ParamValueKind::Enum,
1750 },
1751 ParamInfo {
1752 id: 1,
1753 name: "Gain",
1754 short_name: "Gain",
1755 group: "",
1756 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1757 default_plain: 0.5,
1758 flags: ParamFlags::AUTOMATABLE,
1759 unit: ParamUnit::None,
1760 kind: ParamValueKind::Float,
1761 },
1762 ]
1763 }
1764
1765 fn count(&self) -> usize {
1766 2
1767 }
1768
1769 fn get_normalized(&self, id: u32) -> Option<f64> {
1770 self.values
1771 .get(id as usize)
1772 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1773 }
1774
1775 fn set_normalized(&self, id: u32, value: f64) {
1776 if let Some(v) = self.values.get(id as usize) {
1777 v.store(value.to_bits(), Ordering::Relaxed);
1778 }
1779 }
1780
1781 fn get_plain(&self, id: u32) -> Option<f64> {
1782 let norm = self.get_normalized(id)?;
1783 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1784 Some(info.range.denormalize(norm))
1785 }
1786
1787 fn set_plain(&self, id: u32, value: f64) {
1788 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1789 self.set_normalized(id, info.range.normalize(value));
1790 }
1791 }
1792
1793 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1794 Some(format!("{value:.0}"))
1795 }
1796
1797 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1798 None
1799 }
1800 fn snap_smoothers(&self) {}
1801 fn set_sample_rate(&self, _: f64) {}
1802
1803 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1804 let ids = vec![0, 1];
1805 let vals: Vec<f64> = ids
1806 .iter()
1807 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1808 .collect();
1809 (ids, vals)
1810 }
1811
1812 fn restore_values(&self, values: &[(u32, f64)]) {
1813 for &(id, val) in values {
1814 self.set_plain(id, val);
1815 }
1816 }
1817 }
1818
1819 impl Default for ManyOptionParams {
1820 fn default() -> Self {
1821 Self::new()
1822 }
1823 }
1824
1825 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1829 let params = Arc::new(TestParams::new());
1830 let layout = GridLayout::build(vec![widgets(vec![
1832 GridWidget::knob(1u32, "K1"),
1833 GridWidget::knob(1u32, "K2"),
1834 GridWidget::knob(1u32, "K3"),
1835 GridWidget::knob(1u32, "K4"),
1836 GridWidget::dropdown(0u32, "Mode"),
1837 GridWidget::knob(1u32, "K5"),
1838 ])])
1839 .with_cols(2);
1840 let mut editor = BuiltinEditor::new_grid(params, layout);
1841 if let Layout::Grid(ref gl) = editor.layout {
1842 editor.interaction.build_regions_grid(gl);
1843 for (idx, gw) in gl.widgets.iter().enumerate() {
1844 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1845 region.widget_type =
1846 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1847 }
1848 }
1849 }
1850 editor.render();
1851 editor
1852 }
1853
1854 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1856 let params = Arc::new(TestParams::new());
1857 let layout = GridLayout::build(vec![widgets(vec![
1858 GridWidget::dropdown(0u32, "Mode A"),
1859 GridWidget::dropdown(0u32, "Mode B"),
1860 ])]);
1861 let mut editor = BuiltinEditor::new_grid(params, layout);
1862 if let Layout::Grid(ref gl) = editor.layout {
1863 editor.interaction.build_regions_grid(gl);
1864 for (idx, gw) in gl.widgets.iter().enumerate() {
1865 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1866 region.widget_type =
1867 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1868 }
1869 }
1870 }
1871 editor.render();
1872 editor
1873 }
1874
1875 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
1877 let params = Arc::new(ManyOptionParams::new());
1878 let layout = GridLayout::build(vec![widgets(vec![
1879 GridWidget::dropdown(0u32, "Note"),
1880 GridWidget::knob(1u32, "Gain"),
1881 ])]);
1882 let mut editor = BuiltinEditor::new_grid(params, layout);
1883 if let Layout::Grid(ref gl) = editor.layout {
1884 editor.interaction.build_regions_grid(gl);
1885 for (idx, gw) in gl.widgets.iter().enumerate() {
1886 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1887 region.widget_type =
1888 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1889 }
1890 }
1891 }
1892 editor.render();
1893 editor
1894 }
1895
1896 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
1897 let region = editor
1898 .interaction
1899 .knob_regions
1900 .iter()
1901 .find(|r| r.widget_type == WidgetType::Dropdown)
1902 .expect("no dropdown in layout");
1903 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1904 }
1905
1906 #[test]
1909 fn dropdown_anchors_below_button_scrolls_when_tight() {
1910 let mut editor = make_editor_bottom_dropdown();
1911 let (dx, dy) = {
1912 let region = editor
1913 .interaction
1914 .knob_regions
1915 .iter()
1916 .find(|r| r.widget_type == WidgetType::Dropdown)
1917 .unwrap();
1918 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1919 };
1920
1921 editor.on_mouse_down(dx, dy);
1922 editor.on_mouse_up(dx, dy);
1923 assert!(editor.interaction.dropdown_is_open());
1924
1925 let dd = editor.interaction.dropdown.as_ref().unwrap();
1926 let region = &editor.interaction.knob_regions[dd.region_idx];
1927 let (_, popup_y, _, popup_h) = dd.popup_rect;
1928 let window_h = editor.layout.height() as f32;
1929
1930 assert_eq!(
1935 popup_y, region.dropdown_anchor_y,
1936 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
1937 );
1938 assert!(
1940 popup_y + popup_h <= window_h + 1.0,
1941 "popup bottom {} exceeds window height {window_h}",
1942 popup_y + popup_h
1943 );
1944 }
1945
1946 #[test]
1947 fn dropdown_clamps_horizontal_near_right_edge() {
1948 let mut editor = make_editor_two_dropdowns();
1949 let region = &editor.interaction.knob_regions[1];
1951 assert_eq!(region.widget_type, WidgetType::Dropdown);
1952 let dx = region.x + region.w / 2.0;
1953 let dy = region.y + region.h / 2.0;
1954
1955 editor.on_mouse_down(dx, dy);
1956 editor.on_mouse_up(dx, dy);
1957 assert!(editor.interaction.dropdown_is_open());
1958
1959 let dd = editor.interaction.dropdown.as_ref().unwrap();
1960 let (popup_x, _, popup_w, _) = dd.popup_rect;
1961 let window_w = editor.layout.width() as f32;
1962
1963 assert!(
1964 popup_x + popup_w <= window_w + 1.0,
1965 "popup right edge {} exceeds window width {window_w}",
1966 popup_x + popup_w
1967 );
1968 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
1969 }
1970
1971 #[test]
1972 fn dropdown_scroll_long_list() {
1973 let mut editor = make_editor_many_options();
1974 let (dx, dy) = dropdown_center_many(&editor);
1975
1976 editor.on_mouse_down(dx, dy);
1977 editor.on_mouse_up(dx, dy);
1978 assert!(editor.interaction.dropdown_is_open());
1979
1980 let dd = editor.interaction.dropdown.as_ref().unwrap();
1981 assert!(
1983 dd.options.len() > dd.visible_count,
1984 "expected scroll: {} options, {} visible",
1985 dd.options.len(),
1986 dd.visible_count
1987 );
1988 assert_eq!(dd.scroll_offset, 0);
1989 }
1990
1991 #[test]
1992 fn dropdown_scroll_clamps_to_bounds() {
1993 let mut editor = make_editor_many_options();
1994 let (dx, dy) = dropdown_center_many(&editor);
1995
1996 editor.on_mouse_down(dx, dy);
1997 editor.on_mouse_up(dx, dy);
1998
1999 editor.interaction.dropdown_scroll(-10);
2001 assert_eq!(
2002 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2003 0
2004 );
2005
2006 editor.interaction.dropdown_scroll(1000);
2008 let dd = editor.interaction.dropdown.as_ref().unwrap();
2009 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
2010 assert_eq!(dd.scroll_offset, max_offset);
2011 }
2012
2013 #[test]
2014 fn dropdown_selected_item_visible_on_open() {
2015 let mut editor = make_editor_many_options();
2016 editor.params.set_normalized(0, 15.0 / 18.0);
2018
2019 let (dx, dy) = dropdown_center_many(&editor);
2020 editor.on_mouse_down(dx, dy);
2021 editor.on_mouse_up(dx, dy);
2022
2023 let dd = editor.interaction.dropdown.as_ref().unwrap();
2024 let selected = dd.selected;
2025 assert!(
2027 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
2028 "selected={selected} not in visible range {}..{}",
2029 dd.scroll_offset,
2030 dd.scroll_offset + dd.visible_count
2031 );
2032 }
2033
2034 #[test]
2035 fn dropdown_scroll_then_select_correct_index() {
2036 let mut editor = make_editor_many_options();
2037 let (dx, dy) = dropdown_center_many(&editor);
2038
2039 editor.on_mouse_down(dx, dy);
2040 editor.on_mouse_up(dx, dy);
2041
2042 editor.interaction.dropdown_scroll(3);
2044 assert_eq!(
2045 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
2046 3
2047 );
2048
2049 let dd = editor.interaction.dropdown.as_ref().unwrap();
2051 let (px, py, _, _) = dd.popup_rect;
2052 let item_h = 18.0f32;
2053 let padding = 4.0f32;
2054 let click_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, click_y);
2057 editor.on_mouse_up(px + 10.0, click_y);
2058
2059 assert!(!editor.interaction.dropdown_is_open());
2060 let norm = editor.params.get_normalized(0).unwrap();
2063 let expected = 4.0 / 19.0;
2064 assert!(
2065 (norm - expected).abs() < 0.01,
2066 "expected {expected:.4}, got {norm:.4}"
2067 );
2068 }
2069
2070 #[test]
2071 fn dropdown_click_different_dropdown_closes_first() {
2072 let mut editor = make_editor_two_dropdowns();
2073 let r0 = &editor.interaction.knob_regions[0];
2074 let r1 = &editor.interaction.knob_regions[1];
2075 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
2076 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
2077
2078 editor.on_mouse_down(ax, ay);
2080 editor.on_mouse_up(ax, ay);
2081 assert!(editor.interaction.dropdown_is_open());
2082 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
2083
2084 editor.on_mouse_down(bx, by);
2086 editor.on_mouse_up(bx, by);
2087 assert!(editor.interaction.dropdown_is_open());
2088 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
2089 }
2090
2091 #[test]
2092 fn dropdown_hover_tracks_correct_option() {
2093 let mut editor = make_editor();
2094 let (dx, dy) = dropdown_center(&editor);
2095
2096 editor.on_mouse_down(dx, dy);
2097 editor.on_mouse_up(dx, dy);
2098
2099 let dd = editor.interaction.dropdown.as_ref().unwrap();
2100 let (px, py, pw, _) = dd.popup_rect;
2101 let item_h = 18.0f32;
2102 let padding = 4.0f32;
2103 let last_visible = dd.visible_count - 1;
2104
2105 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
2107 editor.on_mouse_moved(px + pw / 2.0, hover_y);
2108
2109 let dd = editor.interaction.dropdown.as_ref().unwrap();
2110 assert_eq!(
2111 dd.hover_option,
2112 Some(last_visible),
2113 "expected hover on last visible option"
2114 );
2115
2116 editor.on_mouse_moved(0.0, 0.0);
2118 let dd = editor.interaction.dropdown.as_ref().unwrap();
2119 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
2120 }
2121
2122 #[test]
2123 fn dropdown_popup_within_window_bounds() {
2124 let mut editor = make_editor();
2126 let (dx, dy) = dropdown_center(&editor);
2127
2128 editor.on_mouse_down(dx, dy);
2129 editor.on_mouse_up(dx, dy);
2130
2131 let dd = editor.interaction.dropdown.as_ref().unwrap();
2132 let (px, py, pw, ph) = dd.popup_rect;
2133 let window_w = editor.layout.width() as f32;
2134 let window_h = editor.layout.height() as f32;
2135
2136 assert!(px >= 0.0, "popup left edge {px} < 0");
2137 assert!(py >= 0.0, "popup top edge {py} < 0");
2138 assert!(
2139 px + pw <= window_w + 1.0,
2140 "popup right {} > window {window_w}",
2141 px + pw
2142 );
2143 assert!(
2144 py + ph <= window_h + 1.0,
2145 "popup bottom {} > window {window_h}",
2146 py + ph
2147 );
2148 }
2149}