1#[cfg(feature = "cpu")]
9use std::ptr;
10use std::sync::Arc;
11#[cfg(feature = "cpu")]
12use std::sync::Mutex;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use truce_core::Float;
16#[cfg(feature = "cpu")]
17use truce_core::editor::Editor;
18#[cfg(feature = "cpu")]
19use truce_core::editor::RawWindowHandle;
20use truce_core::editor::{PluginContext, PluginContextReadF32};
21use truce_params::Params;
22
23#[cfg(feature = "cpu")]
24use crate::backend_cpu::CpuBackend;
25use crate::interaction::{self, InputEvent, InteractionState, ParamEdit};
26use crate::layout::{GridLayout, Layout, PluginLayout};
27#[cfg(feature = "cpu")]
28use crate::platform::EditorScale;
29use crate::render::RenderBackend;
30use crate::render_core::{
31 EditorSnapshotClosures, build_snapshot_closures as build_snapshot_closures_impl,
32 render_widgets as render_widgets_impl,
33};
34use crate::theme::Theme;
35use crate::widgets;
36
37pub struct BuiltinEditor<P: Params> {
42 params: Arc<P>,
43 layout: Layout,
44 theme: Theme,
45 #[cfg(feature = "cpu")]
50 backend: Option<CpuBackend>,
51 interaction: InteractionState,
52 context: Option<PluginContext>,
53 #[cfg(feature = "cpu")]
56 window: Option<baseview::WindowHandle>,
57 #[cfg(feature = "cpu")]
63 blit_backend: Option<SharedBackend>,
64 needs_repaint: Arc<AtomicBool>,
72 #[cfg(feature = "cpu")]
79 last_painted_values: Vec<f32>,
80 #[cfg(feature = "cpu")]
88 scale: EditorScale,
89 #[cfg(feature = "cpu")]
96 meter_ids: Vec<u32>,
97 #[cfg(feature = "cpu")]
102 last_meter_values: Vec<f32>,
103}
104
105unsafe impl<P: Params> Send for BuiltinEditor<P> {}
115
116#[cfg(feature = "cpu")]
120fn collect_meter_ids(layout: &Layout) -> Vec<u32> {
121 let mut ids = Vec::new();
122 match layout {
123 Layout::Rows(pl) => {
124 for row in &pl.rows {
125 for knob in &row.knobs {
126 if let Some(m) = &knob.meter_ids {
127 ids.extend_from_slice(m);
128 }
129 }
130 }
131 }
132 Layout::Grid(gl) => {
133 for widget in &gl.widgets {
134 if let Some(m) = &widget.meter_ids {
135 ids.extend_from_slice(m);
136 }
137 }
138 }
139 }
140 ids
141}
142
143impl<P: Params + 'static> BuiltinEditor<P> {
144 pub fn request_repaint(&self) {
149 self.needs_repaint.store(true, Ordering::Release);
150 }
151
152 #[cfg(feature = "cpu")]
154 fn take_needs_repaint(&self) -> bool {
155 self.needs_repaint.swap(false, Ordering::AcqRel)
156 }
157
158 #[cfg(feature = "cpu")]
167 fn detect_host_param_changes(&mut self) {
168 let regions = &self.interaction.knob_regions;
169 if regions.len() != self.last_painted_values.len() {
170 self.request_repaint();
173 return;
174 }
175 for (i, region) in regions.iter().enumerate() {
176 if (region.normalized_value - self.last_painted_values[i]).abs() > f32::EPSILON {
177 self.request_repaint();
178 return;
179 }
180 }
181 }
182
183 #[cfg(feature = "cpu")]
187 fn stash_painted_values(&mut self) {
188 let regions = &self.interaction.knob_regions;
189 self.last_painted_values.resize(regions.len(), 0.0);
196 for (slot, region) in self.last_painted_values.iter_mut().zip(regions.iter()) {
197 *slot = region.normalized_value;
198 }
199 }
200
201 #[cfg(feature = "cpu")]
210 #[allow(clippy::float_cmp)]
211 fn detect_meter_changes(&mut self) {
212 if self.meter_ids.is_empty() {
213 return;
214 }
215 let Some(ctx) = self.context.as_ref() else {
216 return;
217 };
218 let current: Vec<f32> = self.meter_ids.iter().map(|&id| ctx.get_meter(id)).collect();
219 if current != self.last_meter_values {
220 self.last_meter_values = current;
221 self.request_repaint();
222 }
223 }
224
225 pub fn new(params: Arc<P>, layout: PluginLayout) -> Self {
226 Self::with_layout_inner(params, Layout::Rows(layout))
227 }
228
229 pub fn new_with_layout(params: Arc<P>, layout: Layout) -> Self {
230 Self::with_layout_inner(params, layout)
231 }
232
233 pub fn new_grid(params: Arc<P>, layout: GridLayout) -> Self {
234 Self::with_layout_inner(params, Layout::Grid(layout))
235 }
236
237 fn with_layout_inner(params: Arc<P>, layout: Layout) -> Self {
238 #[cfg(feature = "cpu")]
239 let meter_ids = collect_meter_ids(&layout);
240 Self {
241 params,
242 layout,
243 theme: Theme::dark(),
244 #[cfg(feature = "cpu")]
245 backend: None,
246 interaction: InteractionState::default(),
247 context: None,
248 #[cfg(feature = "cpu")]
249 window: None,
250 #[cfg(feature = "cpu")]
251 blit_backend: None,
252 needs_repaint: Arc::new(AtomicBool::new(false)),
253 #[cfg(feature = "cpu")]
254 last_painted_values: Vec::new(),
255 #[cfg(feature = "cpu")]
256 scale: EditorScale::new(crate::backing_scale()),
257 #[cfg(feature = "cpu")]
258 meter_ids,
259 #[cfg(feature = "cpu")]
260 last_meter_values: Vec::new(),
261 }
262 }
263
264 #[must_use]
265 pub fn with_theme(mut self, theme: Theme) -> Self {
266 self.theme = theme;
267 self
268 }
269
270 #[cfg(feature = "cpu")]
282 pub fn render(&mut self) {
283 let (w, h) = (self.layout.width(), self.layout.height());
284 let scale = self.scale.get_f32();
285 let owned = self.build_snapshot_closures();
286 let snapshot = owned.as_snapshot();
287 let backend = self
288 .backend
289 .get_or_insert_with(|| CpuBackend::new(w, h, scale).expect("Failed to create backend"));
290 render_widgets_impl(
291 &self.layout,
292 &self.theme,
293 &mut self.interaction,
294 &snapshot,
295 backend,
296 );
297 }
298
299 fn build_snapshot_closures(&self) -> EditorSnapshotClosures {
306 build_snapshot_closures_impl(&self.params, self.context.as_ref())
307 }
308
309 fn apply_edit(&self, edit: ParamEdit) {
311 match edit {
312 ParamEdit::Begin { id } => {
313 if let Some(ref ctx) = self.context {
314 ctx.begin_edit(id);
315 }
316 }
317 ParamEdit::Set { id, normalized } => {
318 self.params.set_normalized(id, f64::from(normalized));
319 if let Some(ref ctx) = self.context {
320 ctx.set_param(id, f64::from(normalized));
321 }
322 self.request_repaint();
323 }
324 ParamEdit::End { id } => {
325 if let Some(ref ctx) = self.context {
326 ctx.end_edit(id);
327 }
328 }
329 }
330 }
331
332 pub fn dispatch_events(&mut self, events: &[InputEvent]) {
340 let hover_before = self.interaction.hover_idx;
341 let dd_before = self.interaction.dropdown_is_open();
342 let owned = self.build_snapshot_closures();
343 let snapshot = owned.as_snapshot();
344 let edits = interaction::dispatch(events, &self.layout, &snapshot, &mut self.interaction);
345 let had_edits = !edits.is_empty();
346 for e in edits {
347 self.apply_edit(e);
348 }
349 let explicit = self.interaction.take_repaint_request();
355 if had_edits
356 || explicit
357 || self.interaction.hover_idx != hover_before
358 || self.interaction.dropdown_is_open() != dd_before
359 {
360 self.request_repaint();
361 }
362 }
363
364 #[cfg(feature = "cpu")]
367 #[must_use]
368 pub fn pixel_data(&self) -> Option<&[u8]> {
369 self.backend
370 .as_ref()
371 .map(super::backend_cpu::CpuBackend::data)
372 }
373
374 #[must_use]
378 pub fn has_context(&self) -> bool {
379 self.context.is_some()
380 }
381
382 pub fn take_context(&mut self) -> Option<PluginContext> {
385 self.context.take()
386 }
387
388 pub fn set_context(&mut self, context: PluginContext) {
390 self.context = Some(context);
391 match &self.layout {
392 Layout::Rows(pl) => self.interaction.build_regions(pl),
393 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
394 }
395 }
396
397 #[must_use]
401 pub fn size(&self) -> (u32, u32) {
402 (self.layout.width(), self.layout.height())
403 }
404
405 pub fn state_changed(&mut self) {
409 self.request_repaint();
410 }
411
412 pub fn render_to(&mut self, backend: &mut dyn RenderBackend) {
417 update_interaction(self);
418 let owned = self.build_snapshot_closures();
419 let snapshot = owned.as_snapshot();
420 render_widgets_impl(
421 &self.layout,
422 &self.theme,
423 &mut self.interaction,
424 &snapshot,
425 backend,
426 );
427 }
428}
429
430#[cfg(test)]
434impl<P: Params + 'static> BuiltinEditor<P> {
435 fn on_mouse_down(&mut self, x: f32, y: f32) {
436 self.dispatch_events(&[InputEvent::MouseDown {
437 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
438 x,
439 y,
440 button: crate::interaction::MouseButton::Left,
441 }]);
442 }
443
444 fn on_mouse_up(&mut self, x: f32, y: f32) {
445 self.dispatch_events(&[InputEvent::MouseUp {
446 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
447 x,
448 y,
449 button: crate::interaction::MouseButton::Left,
450 }]);
451 }
452
453 fn on_mouse_moved(&mut self, x: f32, y: f32) {
454 self.dispatch_events(&[InputEvent::MouseMove {
455 pointer_id: truce_gui_types::interaction::SINGLE_POINTER,
456 x,
457 y,
458 }]);
459 }
460}
461
462pub fn update_interaction<P: Params + 'static>(editor: &mut BuiltinEditor<P>) {
472 match &editor.layout {
473 Layout::Rows(pl) => {
474 editor.interaction.build_regions(pl);
475 let mut flat_idx = 0usize;
476 for row in &pl.rows {
477 for knob_def in &row.knobs {
478 if let Some(region) = editor.interaction.knob_regions.get_mut(flat_idx) {
479 region.widget_type = resolve_widget_type(
480 knob_def.widget,
481 knob_def.param_id,
482 &*editor.params,
483 );
484 }
485 flat_idx += 1;
486 }
487 }
488 }
489 Layout::Grid(gl) => {
490 editor.interaction.build_regions_grid(gl);
491 for (idx, gw) in gl.widgets.iter().enumerate() {
492 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
493 region.widget_type =
494 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
495 }
496 }
497 }
498 }
499 for region in &mut editor.interaction.knob_regions {
500 if let Some(ref ctx) = editor.context {
501 region.normalized_value = ctx.get_param(region.param_id);
503 } else {
504 region.normalized_value =
505 f32::from_f64(editor.params.get_normalized(region.param_id).unwrap_or(0.0));
506 }
507 }
508}
509
510#[cfg(feature = "cpu")]
525fn create_wgpu_backend(window: &mut baseview::Window, phys_w: u32, phys_h: u32) -> BlitBackend {
526 let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
527 desc.backends = wgpu::Backends::PRIMARY;
528 let instance = wgpu::Instance::new(desc);
529
530 let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) }
531 .expect("failed to create wgpu surface");
532
533 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
534 power_preference: wgpu::PowerPreference::HighPerformance,
535 compatible_surface: Some(&surface),
536 force_fallback_adapter: false,
537 }))
538 .expect("no suitable GPU adapter");
539
540 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
541 label: Some("truce-gui"),
542 required_features: wgpu::Features::empty(),
543 required_limits: wgpu::Limits::downlevel_defaults(),
544 experimental_features: wgpu::ExperimentalFeatures::default(),
545 memory_hints: wgpu::MemoryHints::Performance,
546 trace: wgpu::Trace::Off,
547 }))
548 .expect("failed to create wgpu device");
549
550 let caps = surface.get_capabilities(&adapter);
551 let format = caps
552 .formats
553 .iter()
554 .find(|f| f.is_srgb())
555 .copied()
556 .unwrap_or(caps.formats[0]);
557
558 let surface_config = wgpu::SurfaceConfiguration {
559 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
560 format,
561 width: phys_w,
562 height: phys_h,
563 present_mode: wgpu::PresentMode::AutoVsync,
564 desired_maximum_frame_latency: 2,
565 alpha_mode: wgpu::CompositeAlphaMode::Auto,
566 view_formats: vec![],
567 };
568 surface.configure(&device, &surface_config);
569
570 let blit = crate::blit::BlitPipeline::new(&device, format, phys_w, phys_h);
575
576 BlitBackend {
577 blit,
578 surface_config,
579 surface,
580 queue,
581 device,
582 }
583}
584
585#[cfg(feature = "cpu")]
594struct BlitBackend {
595 blit: crate::blit::BlitPipeline,
596 surface_config: wgpu::SurfaceConfiguration,
597 surface: wgpu::Surface<'static>,
598 queue: wgpu::Queue,
599 device: wgpu::Device,
600}
601
602#[cfg(feature = "cpu")]
603impl BlitBackend {
604 fn resize(&mut self, phys_w: u32, phys_h: u32) {
609 self.surface_config.width = phys_w.max(1);
610 self.surface_config.height = phys_h.max(1);
611 self.surface.configure(&self.device, &self.surface_config);
612 self.blit.resize(&self.device, phys_w, phys_h);
613 }
614}
615
616#[cfg(feature = "cpu")]
623type SharedBackend = Arc<Mutex<Option<BlitBackend>>>;
624
625#[cfg(feature = "cpu")]
626struct BuiltinWindowHandler<P: Params> {
627 editor: *mut BuiltinEditor<P>,
638 backend: SharedBackend,
639 translator: crate::interaction::BaseviewTranslator,
643 last_applied_scale: f32,
652}
653
654#[cfg(feature = "cpu")]
657unsafe impl<P: Params> Send for BuiltinWindowHandler<P> {}
658
659#[cfg(feature = "cpu")]
660impl<P: Params + 'static> baseview::WindowHandler for BuiltinWindowHandler<P> {
661 fn on_frame(&mut self, _window: &mut baseview::Window) {
662 let Ok(mut guard) = self.backend.lock() else {
669 return;
670 };
671 if guard.is_none() {
672 return;
675 }
676
677 let editor = unsafe { &mut *self.editor };
678
679 if let Some(cur_scale) = editor.scale.take_change(&mut self.last_applied_scale) {
687 let (lw, lh) = editor.size();
688 let phys_w = crate::platform::to_physical_px(lw, f64::from(cur_scale));
689 let phys_h = crate::platform::to_physical_px(lh, f64::from(cur_scale));
690 editor.backend = CpuBackend::new(lw, lh, cur_scale);
691 if let Some(backend) = guard.as_mut() {
692 backend.resize(phys_w, phys_h);
693 }
694 editor.request_repaint();
695 }
696
697 update_interaction(editor);
698 editor.detect_host_param_changes();
703 editor.detect_meter_changes();
704 if !editor.take_needs_repaint() {
705 return;
706 }
707 editor.render();
708 editor.stash_painted_values();
709
710 if let Some(pixels) = editor.pixel_data() {
711 let backend = guard
712 .as_mut()
713 .expect("guard was checked Some above and the lock is still held");
714 let BlitBackend {
715 device,
716 queue,
717 surface,
718 blit,
719 ..
720 } = backend;
721 blit.update(queue, pixels);
722 let (wgpu::CurrentSurfaceTexture::Success(frame)
723 | wgpu::CurrentSurfaceTexture::Suboptimal(frame)) = surface.get_current_texture()
724 else {
725 return;
726 };
727 let view = frame
728 .texture
729 .create_view(&wgpu::TextureViewDescriptor::default());
730 let mut encoder =
731 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
732 blit.render(&mut encoder, &view);
733 queue.submit(std::iter::once(encoder.finish()));
734 frame.present();
735 }
736 }
737
738 fn on_event(
739 &mut self,
740 window: &mut baseview::Window,
741 event: baseview::Event,
742 ) -> baseview::EventStatus {
743 #[cfg(not(target_os = "windows"))]
746 let _ = &window;
747
748 if let baseview::Event::Mouse(baseview::MouseEvent::ButtonPressed {
749 button: baseview::MouseButton::Left,
750 ..
751 }) = &event
752 {
753 #[cfg(target_os = "windows")]
759 {
760 if !window.has_focus() {
761 window.focus();
762 }
763 }
764 }
765
766 let Ok(guard) = self.backend.lock() else {
771 return baseview::EventStatus::Ignored;
772 };
773 if guard.is_none() {
774 return baseview::EventStatus::Ignored;
775 }
776
777 match event {
778 baseview::Event::Mouse(_) => {
779 let Some(input) = self.translator.translate(&event) else {
780 return baseview::EventStatus::Ignored;
781 };
782 let editor = unsafe { &mut *self.editor };
783 editor.dispatch_events(&[input]);
784 baseview::EventStatus::Captured
785 }
786 baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
787 let editor = unsafe { &mut *self.editor };
799 editor.scale.set(info.scale());
800 crate::platform::note_linux_scale_factor(info.scale());
801 baseview::EventStatus::Ignored
802 }
803 _ => baseview::EventStatus::Ignored,
804 }
805 }
806}
807
808fn resolve_widget_type<P: Params>(
814 widget: Option<crate::layout::WidgetKind>,
815 param_id: u32,
816 params: &P,
817) -> widgets::WidgetType {
818 match widget {
819 Some(crate::layout::WidgetKind::Knob) => widgets::WidgetType::Knob,
820 Some(crate::layout::WidgetKind::Slider) => widgets::WidgetType::Slider,
821 Some(crate::layout::WidgetKind::Toggle) => widgets::WidgetType::Toggle,
822 Some(crate::layout::WidgetKind::Selector) => widgets::WidgetType::Selector,
823 Some(crate::layout::WidgetKind::Dropdown) => widgets::WidgetType::Dropdown,
824 Some(crate::layout::WidgetKind::Meter) => widgets::WidgetType::Meter,
825 Some(crate::layout::WidgetKind::XYPad) => widgets::WidgetType::XYPad,
826 None => {
827 let param_info = params
828 .param_infos()
829 .iter()
830 .find(|i| i.id == param_id)
831 .copied();
832 match param_info.as_ref().map(|i| &i.range) {
833 Some(truce_params::ParamRange::Discrete { min: 0, max: 1 }) => {
834 widgets::WidgetType::Toggle
835 }
836 Some(truce_params::ParamRange::Enum { .. }) => widgets::WidgetType::Dropdown,
837 _ => widgets::WidgetType::Knob,
838 }
839 }
840 }
841}
842
843#[cfg(feature = "cpu")]
844impl<P: Params + 'static> Editor for BuiltinEditor<P> {
845 fn size(&self) -> (u32, u32) {
846 (self.layout.width(), self.layout.height())
847 }
848
849 fn state_changed(&mut self) {
850 self.request_repaint();
853 }
854
855 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
856 let (w, h) = self.size();
857 self.scale
863 .set(crate::platform::query_backing_scale(&parent));
864 let scale = self.scale.get();
865 let scale_f32 = self.scale.get_f32();
866 self.backend = CpuBackend::new(w, h, scale_f32);
867 self.context = Some(context);
868
869 match &self.layout {
871 Layout::Rows(pl) => self.interaction.build_regions(pl),
872 Layout::Grid(gl) => self.interaction.build_regions_grid(gl),
873 }
874
875 self.render();
879 self.request_repaint();
880
881 let (lw, lh) = (f64::from(w), f64::from(h));
882 let phys_w = crate::platform::to_physical_px(w, scale);
883 let phys_h = crate::platform::to_physical_px(h, scale);
884
885 let options = baseview::WindowOpenOptions {
886 title: String::from("truce"),
887 size: baseview::Size::new(lw, lh),
888 scale: baseview::WindowScalePolicy::SystemScaleFactor,
889 };
890
891 let parent_wrapper = crate::platform::ParentWindow(parent);
892 let editor_addr = ptr::from_mut::<BuiltinEditor<P>>(self) as usize;
893
894 let shared_backend: SharedBackend = Arc::new(Mutex::new(None));
899 self.blit_backend = Some(shared_backend.clone());
900 let shared_for_handler = shared_backend;
901
902 let window = baseview::Window::open_parented(
903 &parent_wrapper,
904 options,
905 move |window: &mut baseview::Window| {
906 let mut backend = create_wgpu_backend(window, phys_w, phys_h);
907
908 let editor = unsafe { &mut *(editor_addr as *mut BuiltinEditor<P>) };
915 editor.render();
916 if let Some(pixels) = editor.pixel_data() {
917 let BlitBackend {
918 device,
919 queue,
920 surface,
921 blit,
922 ..
923 } = &mut backend;
924 blit.update(queue, pixels);
925 if let wgpu::CurrentSurfaceTexture::Success(frame)
926 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) =
927 surface.get_current_texture()
928 {
929 let view = frame
930 .texture
931 .create_view(&wgpu::TextureViewDescriptor::default());
932 let mut encoder =
933 device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
934 label: None,
935 });
936 blit.render(&mut encoder, &view);
937 queue.submit(std::iter::once(encoder.finish()));
938 frame.present();
939 }
940 }
941
942 if let Ok(mut guard) = shared_for_handler.lock() {
949 *guard = Some(backend);
950 }
951
952 BuiltinWindowHandler {
953 editor: editor_addr as *mut BuiltinEditor<P>,
954 backend: shared_for_handler.clone(),
955 translator: crate::interaction::BaseviewTranslator::default(),
956 last_applied_scale: scale_f32,
957 }
958 },
959 );
960
961 self.window = Some(window);
962 }
963
964 fn set_scale_factor(&mut self, factor: f64) {
965 self.scale.set(factor);
970 }
971
972 fn close(&mut self) {
973 #[cfg(target_os = "macos")]
980 let pool = unsafe {
981 unsafe extern "C" {
982 fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void;
983 }
984 objc_autoreleasePoolPush()
985 };
986
987 if let Some(shared) = self.blit_backend.take()
996 && let Ok(mut guard) = shared.lock()
997 && let Some(backend) = guard.take()
998 {
999 let BlitBackend {
1000 blit,
1001 surface,
1002 surface_config,
1003 queue,
1004 device,
1005 } = backend;
1006 drop(surface_config);
1007 drop(blit);
1008 drop(surface);
1009 drop(queue);
1010 drop(device);
1011 }
1012
1013 if let Some(mut window) = self.window.take() {
1014 window.close();
1015 }
1016 self.context = None;
1017 self.backend = None;
1018
1019 #[cfg(target_os = "macos")]
1020 unsafe {
1021 unsafe extern "C" {
1022 fn objc_autoreleasePoolPop(pool: *mut std::ffi::c_void);
1023 }
1024 objc_autoreleasePoolPop(pool);
1025 }
1026 }
1027
1028 fn idle(&mut self) {
1029 if self.window.is_none() {
1033 self.render();
1034 }
1035 }
1036
1037 fn screenshot(
1038 &mut self,
1039 _params: Arc<dyn truce_params::Params>,
1040 ) -> Option<(Vec<u8>, u32, u32)> {
1041 let (lw, lh) = self.size();
1048 let scale = self.scale.get_f32();
1049 let mut backend = CpuBackend::new(lw, lh, scale)?;
1050 self.render_to(&mut backend);
1051 let pixels = backend.data().to_vec();
1052 let (phys_w, phys_h) = (backend.width(), backend.height());
1053 Some((pixels, phys_w, phys_h))
1054 }
1055}
1056
1057#[cfg(feature = "cpu")]
1058impl<P: Params + 'static> Drop for BuiltinEditor<P> {
1059 fn drop(&mut self) {
1060 Editor::close(self);
1072 }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 #![allow(clippy::float_cmp, clippy::cast_precision_loss)]
1080
1081 use super::*;
1082 use crate::layout::{GridLayout, GridWidget, Layout, section, widgets};
1083 use crate::widgets::WidgetType;
1084 use std::sync::Arc;
1085 use std::sync::atomic::{AtomicU64, Ordering};
1086 use truce_params::{ParamFlags, ParamInfo, ParamRange, ParamUnit, ParamValueKind, Params};
1087
1088 struct TestParams {
1091 values: [AtomicU64; 2],
1092 }
1093
1094 impl TestParams {
1095 fn new() -> Self {
1096 Self {
1097 values: [
1098 AtomicU64::new(0.0f64.to_bits()),
1099 AtomicU64::new(0.0f64.to_bits()),
1100 ],
1101 }
1102 }
1103 }
1104
1105 impl truce_params::__private::Sealed for TestParams {}
1106 impl Params for TestParams {
1107 fn param_infos(&self) -> Vec<ParamInfo> {
1108 vec![
1109 ParamInfo {
1110 id: 0,
1111 name: "Mode",
1112 short_name: "Mode",
1113 group: "",
1114 range: ParamRange::Enum { count: 4 },
1115 default_plain: 0.0,
1116 flags: ParamFlags::AUTOMATABLE,
1117 unit: ParamUnit::None,
1118 kind: ParamValueKind::Enum,
1119 },
1120 ParamInfo {
1121 id: 1,
1122 name: "Gain",
1123 short_name: "Gain",
1124 group: "",
1125 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1126 default_plain: 0.5,
1127 flags: ParamFlags::AUTOMATABLE,
1128 unit: ParamUnit::None,
1129 kind: ParamValueKind::Float,
1130 },
1131 ]
1132 }
1133
1134 fn count(&self) -> usize {
1135 2
1136 }
1137
1138 fn get_normalized(&self, id: u32) -> Option<f64> {
1139 self.values
1140 .get(id as usize)
1141 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1142 }
1143
1144 fn set_normalized(&self, id: u32, value: f64) {
1145 if let Some(v) = self.values.get(id as usize) {
1146 v.store(value.to_bits(), Ordering::Relaxed);
1147 }
1148 }
1149
1150 fn get_plain(&self, id: u32) -> Option<f64> {
1151 let norm = self.get_normalized(id)?;
1152 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1153 Some(info.range.denormalize(norm))
1154 }
1155
1156 fn set_plain(&self, id: u32, value: f64) {
1157 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1158 self.set_normalized(id, info.range.normalize(value));
1159 }
1160 }
1161
1162 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1163 Some(format!("{value:.0}"))
1164 }
1165
1166 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1167 None
1168 }
1169 fn snap_smoothers(&self) {}
1170 fn set_sample_rate(&self, _: f64) {}
1171
1172 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1173 let ids = vec![0, 1];
1174 let vals: Vec<f64> = ids
1175 .iter()
1176 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1177 .collect();
1178 (ids, vals)
1179 }
1180
1181 fn restore_values(&self, values: &[(u32, f64)]) {
1182 for &(id, val) in values {
1183 self.set_plain(id, val);
1184 }
1185 }
1186 }
1187
1188 impl Default for TestParams {
1189 fn default() -> Self {
1190 Self::new()
1191 }
1192 }
1193
1194 fn make_editor() -> BuiltinEditor<TestParams> {
1198 let params = Arc::new(TestParams::new());
1199 let layout = GridLayout::build(vec![widgets(vec![
1200 GridWidget::dropdown(0u32, "Mode"),
1201 GridWidget::knob(1u32, "Gain"),
1202 ])]);
1203 let mut editor = BuiltinEditor::new_grid(params, layout);
1204 if let Layout::Grid(ref gl) = editor.layout {
1206 editor.interaction.build_regions_grid(gl);
1207 for (idx, gw) in gl.widgets.iter().enumerate() {
1208 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1209 region.widget_type =
1210 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1211 }
1212 }
1213 }
1214 editor.render();
1216 editor
1217 }
1218
1219 fn make_editor_with_sections() -> BuiltinEditor<TestParams> {
1221 let params = Arc::new(TestParams::new());
1222 let layout = GridLayout::build(vec![
1223 section(
1224 "SECTION A",
1225 vec![
1226 GridWidget::knob(1u32, "Gain"),
1227 GridWidget::knob(1u32, "Gain 2"),
1228 ],
1229 ),
1230 section(
1231 "SECTION B",
1232 vec![
1233 GridWidget::dropdown(0u32, "Mode"),
1234 GridWidget::knob(1u32, "Gain 3"),
1235 ],
1236 ),
1237 ]);
1238 let mut editor = BuiltinEditor::new_grid(params, layout);
1239 if let Layout::Grid(ref gl) = editor.layout {
1240 editor.interaction.build_regions_grid(gl);
1241 for (idx, gw) in gl.widgets.iter().enumerate() {
1242 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1243 region.widget_type =
1244 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1245 }
1246 }
1247 }
1248 editor.render();
1249 editor
1250 }
1251
1252 fn dropdown_center(editor: &BuiltinEditor<TestParams>) -> (f32, f32) {
1254 let region = editor
1255 .interaction
1256 .knob_regions
1257 .iter()
1258 .find(|r| r.widget_type == WidgetType::Dropdown)
1259 .expect("no dropdown in layout");
1260 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1261 }
1262
1263 #[test]
1266 fn dropdown_click_opens() {
1267 let mut editor = make_editor();
1268 let (dx, dy) = dropdown_center(&editor);
1269
1270 editor.on_mouse_down(dx, dy);
1271 assert!(editor.interaction.dropdown_is_open());
1272 }
1273
1274 #[test]
1275 fn dropdown_click_toggles_closed() {
1276 let mut editor = make_editor();
1277 let (dx, dy) = dropdown_center(&editor);
1278
1279 editor.on_mouse_down(dx, dy);
1281 editor.on_mouse_up(dx, dy);
1282 assert!(editor.interaction.dropdown_is_open());
1283
1284 editor.on_mouse_down(dx, dy);
1286 assert!(!editor.interaction.dropdown_is_open());
1287 }
1288
1289 #[test]
1290 fn dropdown_click_outside_closes() {
1291 let mut editor = make_editor();
1292 let (dx, dy) = dropdown_center(&editor);
1293
1294 editor.on_mouse_down(dx, dy);
1295 editor.on_mouse_up(dx, dy);
1296 assert!(editor.interaction.dropdown_is_open());
1297
1298 editor.on_mouse_down(0.0, 0.0);
1300 assert!(!editor.interaction.dropdown_is_open());
1301 }
1302
1303 #[test]
1304 fn dropdown_click_option_selects_and_closes() {
1305 let mut editor = make_editor();
1306 let (dx, dy) = dropdown_center(&editor);
1307
1308 editor.on_mouse_down(dx, dy);
1309 editor.on_mouse_up(dx, dy);
1310 assert!(editor.interaction.dropdown_is_open());
1311
1312 let dd = editor.interaction.dropdown.as_ref().unwrap();
1314 let (px, py, _, _) = dd.popup_rect;
1315 let item_h = 18.0f32;
1316 let padding = 4.0f32;
1317 let option_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, option_y);
1323 editor.on_mouse_up(px + 10.0, option_y);
1324
1325 assert!(!editor.interaction.dropdown_is_open());
1326 let norm = editor.params.get_normalized(0).unwrap();
1328 let expected = 1.0 / 3.0;
1329 assert!(
1330 (norm - expected).abs() < 0.01,
1331 "expected {expected:.4}, got {norm}"
1332 );
1333 }
1334
1335 #[test]
1338 fn dropdown_anchor_set_after_render() {
1339 let editor = make_editor();
1340 let region = editor
1341 .interaction
1342 .knob_regions
1343 .iter()
1344 .find(|r| r.widget_type == WidgetType::Dropdown)
1345 .unwrap();
1346
1347 assert!(
1349 region.dropdown_anchor_y > region.y,
1350 "anchor {} should be below region.y {}",
1351 region.dropdown_anchor_y,
1352 region.y
1353 );
1354 assert!(
1355 region.dropdown_anchor_y < region.y + region.h,
1356 "anchor {} should be above region bottom {}",
1357 region.dropdown_anchor_y,
1358 region.y + region.h
1359 );
1360 }
1361
1362 #[test]
1363 fn dropdown_popup_uses_anchor() {
1364 let mut editor = make_editor();
1365 let (dx, dy) = dropdown_center(&editor);
1366
1367 editor.on_mouse_down(dx, dy);
1368 editor.on_mouse_up(dx, dy);
1369
1370 let dd = editor.interaction.dropdown.as_ref().unwrap();
1371 let region = &editor.interaction.knob_regions[dd.region_idx];
1372
1373 assert_eq!(dd.popup_rect.1, region.dropdown_anchor_y);
1377 }
1378
1379 #[test]
1380 fn dropdown_anchor_gap_stable_with_sections() {
1381 let editor_plain = make_editor();
1382 let editor_sections = make_editor_with_sections();
1383
1384 let r_plain = editor_plain
1385 .interaction
1386 .knob_regions
1387 .iter()
1388 .find(|r| r.widget_type == WidgetType::Dropdown)
1389 .unwrap();
1390 let r_sections = editor_sections
1391 .interaction
1392 .knob_regions
1393 .iter()
1394 .find(|r| r.widget_type == WidgetType::Dropdown)
1395 .unwrap();
1396
1397 let gap_plain = r_plain.dropdown_anchor_y - (r_plain.y + r_plain.h / 2.0);
1400 let gap_sections = r_sections.dropdown_anchor_y - (r_sections.y + r_sections.h / 2.0);
1401 assert!(
1402 (gap_plain - gap_sections).abs() < 0.1,
1403 "gap_plain={gap_plain}, gap_sections={gap_sections}"
1404 );
1405 }
1406
1407 struct ManyOptionParams {
1410 values: [AtomicU64; 2],
1411 }
1412
1413 impl ManyOptionParams {
1414 fn new() -> Self {
1415 Self {
1416 values: [
1417 AtomicU64::new(0.0f64.to_bits()),
1418 AtomicU64::new(0.0f64.to_bits()),
1419 ],
1420 }
1421 }
1422 }
1423
1424 impl truce_params::__private::Sealed for ManyOptionParams {}
1425 impl Params for ManyOptionParams {
1426 fn param_infos(&self) -> Vec<ParamInfo> {
1427 vec![
1428 ParamInfo {
1429 id: 0,
1430 name: "Note",
1431 short_name: "Note",
1432 group: "",
1433 range: ParamRange::Enum { count: 20 },
1434 default_plain: 0.0,
1435 flags: ParamFlags::AUTOMATABLE,
1436 unit: ParamUnit::None,
1437 kind: ParamValueKind::Enum,
1438 },
1439 ParamInfo {
1440 id: 1,
1441 name: "Gain",
1442 short_name: "Gain",
1443 group: "",
1444 range: ParamRange::Linear { min: 0.0, max: 1.0 },
1445 default_plain: 0.5,
1446 flags: ParamFlags::AUTOMATABLE,
1447 unit: ParamUnit::None,
1448 kind: ParamValueKind::Float,
1449 },
1450 ]
1451 }
1452
1453 fn count(&self) -> usize {
1454 2
1455 }
1456
1457 fn get_normalized(&self, id: u32) -> Option<f64> {
1458 self.values
1459 .get(id as usize)
1460 .map(|v| f64::from_bits(v.load(Ordering::Relaxed)))
1461 }
1462
1463 fn set_normalized(&self, id: u32, value: f64) {
1464 if let Some(v) = self.values.get(id as usize) {
1465 v.store(value.to_bits(), Ordering::Relaxed);
1466 }
1467 }
1468
1469 fn get_plain(&self, id: u32) -> Option<f64> {
1470 let norm = self.get_normalized(id)?;
1471 let info = self.param_infos().iter().find(|i| i.id == id).copied()?;
1472 Some(info.range.denormalize(norm))
1473 }
1474
1475 fn set_plain(&self, id: u32, value: f64) {
1476 if let Some(info) = self.param_infos().iter().find(|i| i.id == id).copied() {
1477 self.set_normalized(id, info.range.normalize(value));
1478 }
1479 }
1480
1481 fn format_value(&self, _id: u32, value: f64) -> Option<String> {
1482 Some(format!("{value:.0}"))
1483 }
1484
1485 fn parse_value(&self, _id: u32, _text: &str) -> Option<f64> {
1486 None
1487 }
1488 fn snap_smoothers(&self) {}
1489 fn set_sample_rate(&self, _: f64) {}
1490
1491 fn collect_values(&self) -> (Vec<u32>, Vec<f64>) {
1492 let ids = vec![0, 1];
1493 let vals: Vec<f64> = ids
1494 .iter()
1495 .map(|&id| self.get_plain(id).unwrap_or(0.0))
1496 .collect();
1497 (ids, vals)
1498 }
1499
1500 fn restore_values(&self, values: &[(u32, f64)]) {
1501 for &(id, val) in values {
1502 self.set_plain(id, val);
1503 }
1504 }
1505 }
1506
1507 impl Default for ManyOptionParams {
1508 fn default() -> Self {
1509 Self::new()
1510 }
1511 }
1512
1513 fn make_editor_bottom_dropdown() -> BuiltinEditor<TestParams> {
1517 let params = Arc::new(TestParams::new());
1518 let layout = GridLayout::build(vec![widgets(vec![
1520 GridWidget::knob(1u32, "K1"),
1521 GridWidget::knob(1u32, "K2"),
1522 GridWidget::knob(1u32, "K3"),
1523 GridWidget::knob(1u32, "K4"),
1524 GridWidget::dropdown(0u32, "Mode"),
1525 GridWidget::knob(1u32, "K5"),
1526 ])])
1527 .with_cols(2);
1528 let mut editor = BuiltinEditor::new_grid(params, layout);
1529 if let Layout::Grid(ref gl) = editor.layout {
1530 editor.interaction.build_regions_grid(gl);
1531 for (idx, gw) in gl.widgets.iter().enumerate() {
1532 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1533 region.widget_type =
1534 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1535 }
1536 }
1537 }
1538 editor.render();
1539 editor
1540 }
1541
1542 fn make_editor_two_dropdowns() -> BuiltinEditor<TestParams> {
1544 let params = Arc::new(TestParams::new());
1545 let layout = GridLayout::build(vec![widgets(vec![
1546 GridWidget::dropdown(0u32, "Mode A"),
1547 GridWidget::dropdown(0u32, "Mode B"),
1548 ])]);
1549 let mut editor = BuiltinEditor::new_grid(params, layout);
1550 if let Layout::Grid(ref gl) = editor.layout {
1551 editor.interaction.build_regions_grid(gl);
1552 for (idx, gw) in gl.widgets.iter().enumerate() {
1553 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1554 region.widget_type =
1555 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1556 }
1557 }
1558 }
1559 editor.render();
1560 editor
1561 }
1562
1563 fn make_editor_many_options() -> BuiltinEditor<ManyOptionParams> {
1565 let params = Arc::new(ManyOptionParams::new());
1566 let layout = GridLayout::build(vec![widgets(vec![
1567 GridWidget::dropdown(0u32, "Note"),
1568 GridWidget::knob(1u32, "Gain"),
1569 ])]);
1570 let mut editor = BuiltinEditor::new_grid(params, layout);
1571 if let Layout::Grid(ref gl) = editor.layout {
1572 editor.interaction.build_regions_grid(gl);
1573 for (idx, gw) in gl.widgets.iter().enumerate() {
1574 if let Some(region) = editor.interaction.knob_regions.get_mut(idx) {
1575 region.widget_type =
1576 resolve_widget_type(gw.widget, gw.param_id, &*editor.params);
1577 }
1578 }
1579 }
1580 editor.render();
1581 editor
1582 }
1583
1584 fn dropdown_center_many(editor: &BuiltinEditor<ManyOptionParams>) -> (f32, f32) {
1585 let region = editor
1586 .interaction
1587 .knob_regions
1588 .iter()
1589 .find(|r| r.widget_type == WidgetType::Dropdown)
1590 .expect("no dropdown in layout");
1591 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1592 }
1593
1594 #[test]
1597 fn dropdown_anchors_below_button_scrolls_when_tight() {
1598 let mut editor = make_editor_bottom_dropdown();
1599 let (dx, dy) = {
1600 let region = editor
1601 .interaction
1602 .knob_regions
1603 .iter()
1604 .find(|r| r.widget_type == WidgetType::Dropdown)
1605 .unwrap();
1606 (region.x + region.w / 2.0, region.y + region.h / 2.0)
1607 };
1608
1609 editor.on_mouse_down(dx, dy);
1610 editor.on_mouse_up(dx, dy);
1611 assert!(editor.interaction.dropdown_is_open());
1612
1613 let dd = editor.interaction.dropdown.as_ref().unwrap();
1614 let region = &editor.interaction.knob_regions[dd.region_idx];
1615 let (_, popup_y, _, popup_h) = dd.popup_rect;
1616 let window_h = editor.layout.height() as f32;
1617
1618 assert_eq!(
1623 popup_y, region.dropdown_anchor_y,
1624 "popup must anchor at dropdown_anchor_y, got popup_y={popup_y}"
1625 );
1626 assert!(
1628 popup_y + popup_h <= window_h + 1.0,
1629 "popup bottom {} exceeds window height {window_h}",
1630 popup_y + popup_h
1631 );
1632 }
1633
1634 #[test]
1635 fn dropdown_clamps_horizontal_near_right_edge() {
1636 let mut editor = make_editor_two_dropdowns();
1637 let region = &editor.interaction.knob_regions[1];
1639 assert_eq!(region.widget_type, WidgetType::Dropdown);
1640 let dx = region.x + region.w / 2.0;
1641 let dy = region.y + region.h / 2.0;
1642
1643 editor.on_mouse_down(dx, dy);
1644 editor.on_mouse_up(dx, dy);
1645 assert!(editor.interaction.dropdown_is_open());
1646
1647 let dd = editor.interaction.dropdown.as_ref().unwrap();
1648 let (popup_x, _, popup_w, _) = dd.popup_rect;
1649 let window_w = editor.layout.width() as f32;
1650
1651 assert!(
1652 popup_x + popup_w <= window_w + 1.0,
1653 "popup right edge {} exceeds window width {window_w}",
1654 popup_x + popup_w
1655 );
1656 assert!(popup_x >= 0.0, "popup_x={popup_x} is negative");
1657 }
1658
1659 #[test]
1660 fn dropdown_scroll_long_list() {
1661 let mut editor = make_editor_many_options();
1662 let (dx, dy) = dropdown_center_many(&editor);
1663
1664 editor.on_mouse_down(dx, dy);
1665 editor.on_mouse_up(dx, dy);
1666 assert!(editor.interaction.dropdown_is_open());
1667
1668 let dd = editor.interaction.dropdown.as_ref().unwrap();
1669 assert!(
1671 dd.options.len() > dd.visible_count,
1672 "expected scroll: {} options, {} visible",
1673 dd.options.len(),
1674 dd.visible_count
1675 );
1676 assert_eq!(dd.scroll_offset, 0);
1677 }
1678
1679 #[test]
1680 fn dropdown_scroll_clamps_to_bounds() {
1681 let mut editor = make_editor_many_options();
1682 let (dx, dy) = dropdown_center_many(&editor);
1683
1684 editor.on_mouse_down(dx, dy);
1685 editor.on_mouse_up(dx, dy);
1686
1687 editor.interaction.dropdown_scroll(-10);
1689 assert_eq!(
1690 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
1691 0
1692 );
1693
1694 editor.interaction.dropdown_scroll(1000);
1696 let dd = editor.interaction.dropdown.as_ref().unwrap();
1697 let max_offset = dd.options.len().saturating_sub(dd.visible_count);
1698 assert_eq!(dd.scroll_offset, max_offset);
1699 }
1700
1701 #[test]
1702 fn dropdown_selected_item_visible_on_open() {
1703 let mut editor = make_editor_many_options();
1704 editor.params.set_normalized(0, 15.0 / 18.0);
1706
1707 let (dx, dy) = dropdown_center_many(&editor);
1708 editor.on_mouse_down(dx, dy);
1709 editor.on_mouse_up(dx, dy);
1710
1711 let dd = editor.interaction.dropdown.as_ref().unwrap();
1712 let selected = dd.selected;
1713 assert!(
1715 selected >= dd.scroll_offset && selected < dd.scroll_offset + dd.visible_count,
1716 "selected={selected} not in visible range {}..{}",
1717 dd.scroll_offset,
1718 dd.scroll_offset + dd.visible_count
1719 );
1720 }
1721
1722 #[test]
1723 fn dropdown_scroll_then_select_correct_index() {
1724 let mut editor = make_editor_many_options();
1725 let (dx, dy) = dropdown_center_many(&editor);
1726
1727 editor.on_mouse_down(dx, dy);
1728 editor.on_mouse_up(dx, dy);
1729
1730 editor.interaction.dropdown_scroll(3);
1732 assert_eq!(
1733 editor.interaction.dropdown.as_ref().unwrap().scroll_offset,
1734 3
1735 );
1736
1737 let dd = editor.interaction.dropdown.as_ref().unwrap();
1739 let (px, py, _, _) = dd.popup_rect;
1740 let item_h = 18.0f32;
1741 let padding = 4.0f32;
1742 let click_y = py + padding + item_h + item_h / 2.0; editor.on_mouse_down(px + 10.0, click_y);
1745 editor.on_mouse_up(px + 10.0, click_y);
1746
1747 assert!(!editor.interaction.dropdown_is_open());
1748 let norm = editor.params.get_normalized(0).unwrap();
1751 let expected = 4.0 / 19.0;
1752 assert!(
1753 (norm - expected).abs() < 0.01,
1754 "expected {expected:.4}, got {norm:.4}"
1755 );
1756 }
1757
1758 #[test]
1759 fn dropdown_click_different_dropdown_closes_first() {
1760 let mut editor = make_editor_two_dropdowns();
1761 let r0 = &editor.interaction.knob_regions[0];
1762 let r1 = &editor.interaction.knob_regions[1];
1763 let (ax, ay) = (r0.x + r0.w / 2.0, r0.y + r0.h / 2.0);
1764 let (bx, by) = (r1.x + r1.w / 2.0, r1.y + r1.h / 2.0);
1765
1766 editor.on_mouse_down(ax, ay);
1768 editor.on_mouse_up(ax, ay);
1769 assert!(editor.interaction.dropdown_is_open());
1770 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 0);
1771
1772 editor.on_mouse_down(bx, by);
1774 editor.on_mouse_up(bx, by);
1775 assert!(editor.interaction.dropdown_is_open());
1776 assert_eq!(editor.interaction.dropdown.as_ref().unwrap().region_idx, 1);
1777 }
1778
1779 #[test]
1780 fn dropdown_hover_tracks_correct_option() {
1781 let mut editor = make_editor();
1782 let (dx, dy) = dropdown_center(&editor);
1783
1784 editor.on_mouse_down(dx, dy);
1785 editor.on_mouse_up(dx, dy);
1786
1787 let dd = editor.interaction.dropdown.as_ref().unwrap();
1788 let (px, py, pw, _) = dd.popup_rect;
1789 let item_h = 18.0f32;
1790 let padding = 4.0f32;
1791 let last_visible = dd.visible_count - 1;
1792
1793 let hover_y = py + padding + last_visible as f32 * item_h + item_h / 2.0;
1795 editor.on_mouse_moved(px + pw / 2.0, hover_y);
1796
1797 let dd = editor.interaction.dropdown.as_ref().unwrap();
1798 assert_eq!(
1799 dd.hover_option,
1800 Some(last_visible),
1801 "expected hover on last visible option"
1802 );
1803
1804 editor.on_mouse_moved(0.0, 0.0);
1806 let dd = editor.interaction.dropdown.as_ref().unwrap();
1807 assert_eq!(dd.hover_option, None, "hover should clear outside popup");
1808 }
1809
1810 #[test]
1811 fn dropdown_popup_within_window_bounds() {
1812 let mut editor = make_editor();
1814 let (dx, dy) = dropdown_center(&editor);
1815
1816 editor.on_mouse_down(dx, dy);
1817 editor.on_mouse_up(dx, dy);
1818
1819 let dd = editor.interaction.dropdown.as_ref().unwrap();
1820 let (px, py, pw, ph) = dd.popup_rect;
1821 let window_w = editor.layout.width() as f32;
1822 let window_h = editor.layout.height() as f32;
1823
1824 assert!(px >= 0.0, "popup left edge {px} < 0");
1825 assert!(py >= 0.0, "popup top edge {py} < 0");
1826 assert!(
1827 px + pw <= window_w + 1.0,
1828 "popup right {} > window {window_w}",
1829 px + pw
1830 );
1831 assert!(
1832 py + ph <= window_h + 1.0,
1833 "popup bottom {} > window {window_h}",
1834 py + ph
1835 );
1836 }
1837}