1#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
40compile_error!("ruviz-gpui currently supports macOS, Linux, and Windows only.");
41
42#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
43mod platform_impl {
44 mod interaction;
45 mod presentation;
46
47 use arboard::{Clipboard, ImageData};
48 #[cfg(all(feature = "gpu", target_os = "macos"))]
49 use core_foundation::{
50 base::{CFType, TCFType},
51 boolean::CFBoolean,
52 dictionary::CFDictionary,
53 string::CFString,
54 };
55 #[cfg(all(feature = "gpu", target_os = "macos"))]
56 use core_video::pixel_buffer::{
57 CVPixelBuffer, CVPixelBufferKeys, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
58 };
59 use futures::{
60 StreamExt,
61 channel::mpsc::{UnboundedReceiver, unbounded},
62 executor::block_on,
63 };
64 use gpui::{
65 AnyElement, App, Bounds, Context, Corners, Entity, FocusHandle, Focusable,
66 InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
67 MouseUpEvent, ObjectFit, Pixels, Point, Render, RenderImage, ScrollDelta, ScrollWheelEvent,
68 Task, Window, canvas, div, point, prelude::*, px, rgb, rgba, size,
69 };
70 use image::{Frame, ImageBuffer, Rgba};
71 use ruviz::{
72 core::plot::Image as RuvizImage,
73 core::{
74 FramePacing, FrameStats, ImageTarget, InteractivePlotSession, Plot, PlotInputEvent,
75 PlottingError, PreparedPlot, QualityPolicy, ReactiveSubscription, RenderTargetKind,
76 Result, SurfaceCapability, SurfaceTarget, ViewportPoint, ViewportRect,
77 },
78 export::write_rgba_png_atomic,
79 };
80 use smallvec::smallvec;
81 use std::{
82 borrow::Cow,
83 sync::{
84 Arc, Mutex,
85 atomic::{AtomicBool, Ordering},
86 },
87 time::{Duration, Instant},
88 };
89
90 use self::interaction::*;
91 use self::presentation::*;
92
93 pub use gpui;
94 pub use ruviz;
95
96 const DRAG_THRESHOLD_PX: f64 = 3.0;
97 const LINE_SCROLL_DELTA_PX: f32 = 50.0;
98 const MENU_MIN_WIDTH_PX: f32 = 220.0;
99 const MENU_ITEM_HEIGHT_PX: f32 = 30.0;
100 const MENU_SEPARATOR_HEIGHT_PX: f32 = 10.0;
101 const MENU_PADDING_X_PX: f32 = 14.0;
102 const MENU_PADDING_Y_PX: f32 = 8.0;
103 const MENU_EDGE_MARGIN_PX: f32 = 8.0;
104
105 type ContextMenuActionHandler =
106 Arc<dyn Fn(GpuiContextMenuActionContext) -> Result<()> + Send + Sync>;
107
108 pub trait IntoPlotSession {
109 fn into_plot_session(self) -> InteractivePlotSession;
110 }
111
112 impl IntoPlotSession for InteractivePlotSession {
113 fn into_plot_session(self) -> InteractivePlotSession {
114 self
115 }
116 }
117
118 impl IntoPlotSession for PreparedPlot {
119 fn into_plot_session(self) -> InteractivePlotSession {
120 self.into_interactive()
121 }
122 }
123
124 impl IntoPlotSession for Plot {
125 fn into_plot_session(self) -> InteractivePlotSession {
126 self.prepare_interactive()
127 }
128 }
129
130 #[deprecated(note = "Use IntoPlotSession instead.")]
131 pub trait IntoRuvizSession {
132 fn into_session(self) -> InteractivePlotSession;
133 }
134
135 #[allow(deprecated)]
136 impl<T> IntoRuvizSession for T
137 where
138 T: IntoPlotSession,
139 {
140 fn into_session(self) -> InteractivePlotSession {
141 self.into_plot_session()
142 }
143 }
144
145 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
150 pub enum PresentationMode {
151 Image,
152 #[default]
153 Hybrid,
154 #[deprecated(note = "Use Hybrid; this alias falls back to Hybrid/Image.")]
155 SurfaceExperimental,
156 }
157
158 #[derive(Clone, Debug, Default, Eq, PartialEq)]
160 pub enum SizingPolicy {
161 #[default]
162 Fill,
163 FixedPixels {
164 width: u32,
165 height: u32,
166 },
167 }
168
169 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
171 pub enum ImageFit {
172 #[default]
173 Contain,
174 Cover,
175 Fill,
176 }
177
178 impl ImageFit {
179 fn into_gpui(self) -> ObjectFit {
180 match self {
181 Self::Contain => ObjectFit::Contain,
182 Self::Cover => ObjectFit::Cover,
183 Self::Fill => ObjectFit::Fill,
184 }
185 }
186 }
187
188 #[derive(Clone, Copy, Debug, PartialEq)]
190 pub struct InteractionOptions {
191 pub time_seconds: f64,
192 pub image_fit: ImageFit,
193 pub pan: bool,
194 pub zoom: bool,
195 pub hover: bool,
196 pub selection: bool,
197 pub tooltips: bool,
198 }
199
200 impl Default for InteractionOptions {
201 fn default() -> Self {
202 Self {
203 time_seconds: 0.0,
204 image_fit: ImageFit::Contain,
205 pan: true,
206 zoom: true,
207 hover: true,
208 selection: true,
209 tooltips: true,
210 }
211 }
212 }
213
214 #[derive(Clone, Debug, PartialEq, Eq)]
215 pub struct GpuiContextMenuItem {
216 pub id: String,
217 pub label: String,
218 pub enabled: bool,
219 }
220
221 impl GpuiContextMenuItem {
222 pub fn new<I, L>(id: I, label: L) -> Self
223 where
224 I: Into<String>,
225 L: Into<String>,
226 {
227 Self {
228 id: id.into(),
229 label: label.into(),
230 enabled: true,
231 }
232 }
233
234 pub fn enabled(mut self, enabled: bool) -> Self {
235 self.enabled = enabled;
236 self
237 }
238 }
239
240 #[derive(Clone, Debug, PartialEq, Eq)]
241 pub struct GpuiContextMenuConfig {
242 pub enabled: bool,
243 pub show_reset_view: bool,
244 pub show_set_home_view: bool,
245 pub show_go_to_home_view: bool,
246 pub show_save_png: bool,
249 pub show_copy_image: bool,
252 pub show_copy_cursor_coordinates: bool,
253 pub show_copy_visible_bounds: bool,
254 pub custom_items: Vec<GpuiContextMenuItem>,
255 }
256
257 impl Default for GpuiContextMenuConfig {
258 fn default() -> Self {
259 Self {
260 enabled: true,
261 show_reset_view: true,
262 show_set_home_view: true,
263 show_go_to_home_view: true,
264 show_save_png: true,
265 show_copy_image: true,
266 show_copy_cursor_coordinates: true,
267 show_copy_visible_bounds: true,
268 custom_items: Vec::new(),
269 }
270 }
271 }
272
273 #[derive(Clone, Debug)]
274 pub struct GpuiContextMenuActionContext {
275 pub action_id: String,
276 pub visible_bounds: ViewportRect,
277 pub plot_area_px: ViewportRect,
278 pub frame_size_px: (u32, u32),
279 pub scale_factor: f32,
280 pub cursor_position_px: ViewportPoint,
281 pub cursor_data_position: Option<ViewportPoint>,
282 pub image: RuvizImage,
286 }
287
288 #[derive(Clone, Copy, Debug, PartialEq)]
290 pub struct PerformanceOptions {
291 pub frame_pacing: FramePacing,
292 pub quality_policy: QualityPolicy,
293 pub prefer_gpu: bool,
294 }
295
296 impl Default for PerformanceOptions {
297 fn default() -> Self {
298 Self {
299 frame_pacing: FramePacing::Display,
300 quality_policy: QualityPolicy::Balanced,
301 prefer_gpu: cfg!(feature = "gpu"),
302 }
303 }
304 }
305
306 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
307 pub enum PerformancePreset {
308 Interactive,
309 #[default]
310 Balanced,
311 Publication,
312 }
313
314 impl PerformancePreset {
315 fn into_options(self) -> PerformanceOptions {
316 match self {
317 Self::Interactive => PerformanceOptions {
318 frame_pacing: FramePacing::Display,
319 quality_policy: QualityPolicy::Interactive,
320 prefer_gpu: cfg!(feature = "gpu"),
321 },
322 Self::Balanced => PerformanceOptions::default(),
323 Self::Publication => PerformanceOptions {
324 frame_pacing: FramePacing::Display,
325 quality_policy: QualityPolicy::Publication,
326 prefer_gpu: false,
327 },
328 }
329 }
330 }
331
332 #[derive(Clone, Debug, Default, PartialEq)]
334 pub struct RuvizPlotOptions {
335 pub presentation_mode: PresentationMode,
336 pub sizing_policy: SizingPolicy,
337 pub performance: PerformanceOptions,
338 pub interaction: InteractionOptions,
339 pub context_menu: GpuiContextMenuConfig,
340 }
341
342 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
343 pub enum ActiveBackend {
344 #[default]
345 Idle,
346 Image,
347 HybridFallback,
348 HybridFastPath,
349 }
350
351 #[derive(Clone, Debug, PartialEq)]
352 pub struct PlotStats {
353 pub render: FrameStats,
354 pub presentation: PresentationStats,
355 pub dropped_frames: u64,
356 pub active_backend: ActiveBackend,
357 }
358
359 #[derive(Clone, Debug, Default, PartialEq)]
365 pub struct PresentationStats {
366 pub frame_count: u64,
367 pub last_present_interval: Duration,
368 pub average_present_interval: Duration,
369 pub current_fps: f64,
370 }
371
372 #[derive(Debug, Default)]
373 struct PresentationClock {
374 stats: PresentationStats,
375 last_presented_at: Option<Instant>,
376 average_present_interval_secs: f64,
377 }
378
379 impl PresentationClock {
380 fn stats(&self) -> PresentationStats {
381 self.stats.clone()
382 }
383
384 fn reset(&mut self) {
385 *self = Self::default();
386 }
387
388 fn record_now(&mut self) {
389 self.record_at(Instant::now());
390 }
391
392 fn record_at(&mut self, now: Instant) {
393 self.stats.frame_count = self.stats.frame_count.saturating_add(1);
394
395 if let Some(previous) = self.last_presented_at {
396 let interval = now.saturating_duration_since(previous);
397 let interval_secs = interval.as_secs_f64();
398 self.stats.last_present_interval = interval;
399 self.average_present_interval_secs = if self.stats.frame_count <= 2 {
400 interval_secs
401 } else {
402 let sample_count = self.stats.frame_count - 1;
403 let previous_sample_count = (sample_count - 1) as f64;
404 (self.average_present_interval_secs * previous_sample_count + interval_secs)
405 / sample_count as f64
406 };
407 self.stats.average_present_interval =
408 Duration::from_secs_f64(self.average_present_interval_secs);
409 self.stats.current_fps = if interval.is_zero() {
410 0.0
411 } else {
412 1.0 / interval_secs
413 };
414 }
415
416 self.last_presented_at = Some(now);
417 }
418 }
419
420 #[derive(Clone, Debug, Eq, PartialEq)]
421 struct RenderRequest {
422 size_px: (u32, u32),
423 scale_bits: u32,
424 time_bits: u64,
425 presentation_mode: PresentationMode,
426 }
427
428 impl RenderRequest {
429 fn new(
430 size_px: (u32, u32),
431 scale_factor: f32,
432 time_seconds: f64,
433 presentation_mode: PresentationMode,
434 ) -> Self {
435 Self {
436 size_px: (size_px.0.max(1), size_px.1.max(1)),
437 scale_bits: sanitize_scale_factor(scale_factor).to_bits(),
438 time_bits: time_seconds.to_bits(),
439 presentation_mode,
440 }
441 }
442
443 fn scale_factor(&self) -> f32 {
444 f32::from_bits(self.scale_bits)
445 }
446
447 fn time_seconds(&self) -> f64 {
448 f64::from_bits(self.time_bits)
449 }
450
451 fn is_dirty(&self, session: &InteractivePlotSession) -> bool {
452 let dirty = session.dirty_domains();
453 dirty.layout || dirty.data || dirty.overlay || dirty.temporal || dirty.interaction
454 }
455 }
456
457 fn fill_backing_dimension_px(logical_px: u32, scale_factor: f32) -> u32 {
458 if logical_px == 0 {
459 return 0;
460 }
461 let backing_scale = sanitize_scale_factor(scale_factor).max(1.0);
462 ((logical_px as f32) * backing_scale).ceil().max(1.0) as u32
463 }
464
465 #[derive(Clone)]
466 enum PrimaryFrame {
467 Image(Arc<RenderImage>),
468 #[cfg(all(feature = "gpu", target_os = "macos"))]
469 Surface(CVPixelBuffer),
470 }
471
472 #[derive(Clone)]
473 enum RenderedPrimary {
474 Image(Arc<RenderImage>),
475 #[cfg(all(feature = "gpu", target_os = "macos"))]
476 Surface(Arc<RuvizImage>),
477 }
478
479 #[derive(Clone)]
480 struct CachedFrame {
481 request: RenderRequest,
482 primary: PrimaryFrame,
483 overlay_image: Option<Arc<RenderImage>>,
484 stats: FrameStats,
485 target: RenderTargetKind,
486 }
487
488 struct RenderedFrame {
489 primary: Option<RenderedPrimary>,
490 overlay_image: Option<Arc<RenderImage>>,
491 stats: FrameStats,
492 target: RenderTargetKind,
493 }
494
495 #[derive(Clone, Debug, Eq, PartialEq)]
496 struct ScheduledRender {
497 generation: u64,
498 request: RenderRequest,
499 }
500
501 #[derive(Debug, Default)]
502 struct RenderScheduler {
503 latest_requested_generation: u64,
504 in_flight: Option<ScheduledRender>,
505 queued: Option<ScheduledRender>,
506 dropped_frames: u64,
507 }
508
509 impl RenderScheduler {
510 fn schedule(&mut self, request: RenderRequest) -> Option<ScheduledRender> {
511 self.latest_requested_generation = self.latest_requested_generation.saturating_add(1);
512 let scheduled = ScheduledRender {
513 generation: self.latest_requested_generation,
514 request,
515 };
516
517 if self.in_flight.is_some() {
518 if self.queued.replace(scheduled).is_some() {
519 self.dropped_frames = self.dropped_frames.saturating_add(1);
520 }
521 None
522 } else {
523 Some(scheduled)
524 }
525 }
526
527 fn start(&mut self, scheduled: ScheduledRender) {
528 self.in_flight = Some(scheduled);
529 }
530
531 fn finish(&mut self, scheduled: &ScheduledRender) -> bool {
532 if self.in_flight.as_ref() != Some(scheduled) {
533 return false;
534 }
535 self.in_flight = None;
536 true
537 }
538
539 fn take_queued(&mut self) -> Option<ScheduledRender> {
540 self.queued.take()
541 }
542
543 fn reset(&mut self) {
544 *self = Self::default();
545 }
546 }
547
548 pub struct RuvizPlotBuilder<P> {
549 plot: P,
550 options: RuvizPlotOptions,
551 context_menu_action_handler: Option<ContextMenuActionHandler>,
552 }
553
554 impl<P> RuvizPlotBuilder<P>
555 where
556 P: IntoPlotSession + 'static,
557 {
558 fn new(plot: P) -> Self {
559 Self {
560 plot,
561 options: RuvizPlotOptions::default(),
562 context_menu_action_handler: None,
563 }
564 }
565
566 pub fn interactive(mut self) -> Self {
567 self.options.interaction = InteractionOptions::default();
568 self
569 }
570
571 pub fn static_view(mut self) -> Self {
572 self.options.interaction = InteractionOptions {
573 time_seconds: self.options.interaction.time_seconds,
574 image_fit: self.options.interaction.image_fit,
575 pan: false,
576 zoom: false,
577 hover: false,
578 selection: false,
579 tooltips: false,
580 };
581 self
582 }
583
584 pub fn performance_preset(mut self, preset: PerformancePreset) -> Self {
585 self.options.performance = preset.into_options();
586 self
587 }
588
589 pub fn presentation(mut self, presentation_mode: PresentationMode) -> Self {
590 self.options.presentation_mode = presentation_mode;
591 self
592 }
593
594 pub fn fill(mut self) -> Self {
595 self.options.sizing_policy = SizingPolicy::Fill;
596 self
597 }
598
599 pub fn fixed_pixels(mut self, width: u32, height: u32) -> Self {
600 self.options.sizing_policy = SizingPolicy::FixedPixels { width, height };
601 self
602 }
603
604 pub fn interaction_options(mut self, interaction: InteractionOptions) -> Self {
605 self.options.interaction = interaction;
606 self
607 }
608
609 pub fn performance_options(mut self, performance: PerformanceOptions) -> Self {
610 self.options.performance = performance;
611 self
612 }
613
614 pub fn context_menu(mut self, context_menu: GpuiContextMenuConfig) -> Self {
615 self.options.context_menu = context_menu;
616 self
617 }
618
619 pub fn on_context_menu_action<F>(mut self, handler: F) -> Self
620 where
621 F: Fn(GpuiContextMenuActionContext) -> Result<()> + Send + Sync + 'static,
622 {
623 self.context_menu_action_handler = Some(Arc::new(handler));
624 self
625 }
626
627 fn validate(&self) -> Result<()> {
628 if self.options.context_menu.enabled
629 && !self.options.context_menu.custom_items.is_empty()
630 && self.context_menu_action_handler.is_none()
631 {
632 return Err(PlottingError::InvalidInput(
633 "GPUI context menu custom items require on_context_menu_action(...) before build()"
634 .to_string(),
635 ));
636 }
637 Ok(())
638 }
639
640 pub fn try_build<V>(self, cx: &mut Context<V>) -> Result<Entity<RuvizPlot>>
641 where
642 V: 'static,
643 {
644 self.validate()?;
645 let options = self.options;
646 let plot = self.plot;
647 let context_menu_action_handler = self.context_menu_action_handler;
648 Ok(cx.new(move |cx| {
649 RuvizPlot::from_options_impl(plot, options, context_menu_action_handler, cx)
650 }))
651 }
652
653 pub fn build<V>(self, cx: &mut Context<V>) -> Entity<RuvizPlot>
654 where
655 V: 'static,
656 {
657 self.try_build(cx)
658 .unwrap_or_else(|err| panic!("failed to build RuvizPlot: {err}"))
659 }
660 }
661
662 pub fn plot<P, V>(plot: P, cx: &mut Context<V>) -> Entity<RuvizPlot>
663 where
664 P: IntoPlotSession + 'static,
665 V: 'static,
666 {
667 plot_builder(plot).build(cx)
668 }
669
670 pub fn plot_builder<P>(plot: P) -> RuvizPlotBuilder<P>
671 where
672 P: IntoPlotSession + 'static,
673 {
674 RuvizPlotBuilder::new(plot)
675 }
676
677 #[derive(Clone)]
678 struct InteractionLayout {
679 component_bounds: Bounds<Pixels>,
680 content_bounds: Bounds<Pixels>,
681 frame_size_px: (u32, u32),
682 }
683
684 #[derive(Clone)]
685 struct PaintFrame {
686 primary: PrimaryFrame,
687 overlay_image: Option<Arc<RenderImage>>,
688 }
689
690 #[cfg(all(feature = "gpu", target_os = "macos"))]
691 struct SurfaceUploadState {
692 pixel_buffer_options: CFDictionary<CFString, CFType>,
693 }
694
695 #[cfg(all(feature = "gpu", target_os = "macos"))]
696 impl Default for SurfaceUploadState {
697 fn default() -> Self {
698 Self {
699 pixel_buffer_options: make_surface_pixel_buffer_options(),
700 }
701 }
702 }
703
704 pub struct RuvizPlot {
706 session: InteractivePlotSession,
707 subscription: ReactiveSubscription,
708 reactive_notify_pending: Arc<AtomicBool>,
709 reactive_receiver: Option<UnboundedReceiver<()>>,
710 reactive_watcher: Option<Task<()>>,
711 presentation_clock: Arc<Mutex<PresentationClock>>,
712 options: RuvizPlotOptions,
713 cached_frame: Option<CachedFrame>,
714 retired_images: Vec<Arc<RenderImage>>,
715 #[cfg(all(feature = "gpu", target_os = "macos"))]
716 surface_upload: SurfaceUploadState,
717 scheduler: RenderScheduler,
718 in_flight_render: Option<Task<()>>,
719 last_layout: Option<InteractionLayout>,
720 focus_handle: FocusHandle,
721 interaction_state: InteractionState,
722 context_menu_action_handler: Option<ContextMenuActionHandler>,
723 }
724
725 impl RuvizPlot {
726 fn from_options_impl<P>(
727 plot: P,
728 options: RuvizPlotOptions,
729 context_menu_action_handler: Option<ContextMenuActionHandler>,
730 cx: &mut Context<Self>,
731 ) -> Self
732 where
733 P: IntoPlotSession,
734 {
735 let session = plot.into_plot_session();
736 apply_performance_options(&session, options.performance);
737 let (reactive_notify_pending, reactive_receiver, subscription) =
738 bind_reactive_session(&session);
739 Self {
740 session,
741 subscription,
742 reactive_notify_pending,
743 reactive_receiver: Some(reactive_receiver),
744 reactive_watcher: None,
745 presentation_clock: Arc::new(Mutex::new(PresentationClock::default())),
746 options,
747 cached_frame: None,
748 retired_images: Vec::new(),
749 #[cfg(all(feature = "gpu", target_os = "macos"))]
750 surface_upload: SurfaceUploadState::default(),
751 scheduler: RenderScheduler::default(),
752 in_flight_render: None,
753 last_layout: None,
754 focus_handle: cx.focus_handle(),
755 interaction_state: InteractionState::default(),
756 context_menu_action_handler,
757 }
758 }
759
760 #[deprecated(note = "Use ruviz_gpui::plot(...) or ruviz_gpui::plot_builder(...).")]
761 pub fn new<P>(plot: P, cx: &mut Context<Self>) -> Self
762 where
763 P: IntoPlotSession,
764 {
765 Self::from_options_impl(plot, RuvizPlotOptions::default(), None, cx)
766 }
767
768 #[deprecated(note = "Use ruviz_gpui::plot_builder(...).build(cx).")]
769 pub fn with_options<P>(plot: P, options: RuvizPlotOptions, _cx: &mut Context<Self>) -> Self
770 where
771 P: IntoPlotSession,
772 {
773 Self::from_options_impl(plot, options, None, _cx)
774 }
775
776 pub fn interactive_session(&self) -> &InteractivePlotSession {
777 &self.session
778 }
779
780 pub fn prepared_plot(&self) -> &PreparedPlot {
781 self.session.prepared_plot()
782 }
783
784 pub fn presentation_mode(&self) -> PresentationMode {
785 self.options.presentation_mode
786 }
787
788 pub fn sizing_policy(&self) -> &SizingPolicy {
789 &self.options.sizing_policy
790 }
791
792 pub fn performance_options(&self) -> &PerformanceOptions {
793 &self.options.performance
794 }
795
796 pub fn interaction_options(&self) -> &InteractionOptions {
797 &self.options.interaction
798 }
799
800 pub fn context_menu_config(&self) -> &GpuiContextMenuConfig {
801 &self.options.context_menu
802 }
803
804 pub fn stats(&self) -> PlotStats {
805 PlotStats {
806 render: self.frame_stats(),
807 presentation: self.presentation_stats(),
808 dropped_frames: self.scheduler.dropped_frames,
809 active_backend: self
810 .cached_frame
811 .as_ref()
812 .map(active_backend_for_frame)
813 .unwrap_or_default(),
814 }
815 }
816
817 pub fn frame_stats(&self) -> FrameStats {
818 self.cached_frame
819 .as_ref()
820 .map(|frame| frame.stats.clone())
821 .unwrap_or_else(|| self.session.stats())
822 }
823
824 pub fn presentation_stats(&self) -> PresentationStats {
825 self.presentation_clock
826 .lock()
827 .expect("RuvizPlot presentation clock lock poisoned")
828 .stats()
829 }
830
831 pub fn set_plot<P>(&mut self, plot: P, cx: &mut Context<Self>)
832 where
833 P: IntoPlotSession,
834 {
835 self.replace_session(plot.into_plot_session());
836 cx.notify();
837 }
838
839 pub fn set_time(&mut self, time_seconds: f64, cx: &mut Context<Self>) {
840 self.options.interaction.time_seconds = time_seconds;
841 self.session
842 .apply_input(PlotInputEvent::SetTime { time_seconds });
843 cx.notify();
844 }
845
846 pub fn reset_view(&mut self, cx: &mut Context<Self>) {
847 self.session.apply_input(PlotInputEvent::ResetView);
848 self.reset_pointer_state();
849 self.invalidate_frame_state();
850 cx.notify();
851 }
852
853 pub fn set_current_view_as_home(&mut self, cx: &mut Context<Self>) -> Result<()> {
854 self.interaction_state.home_view_bounds =
855 Some(self.session.viewport_snapshot()?.visible_bounds);
856 cx.notify();
857 Ok(())
858 }
859
860 pub fn go_to_home_view(&mut self, cx: &mut Context<Self>) -> Result<()> {
861 let Some(home_view_bounds) = self.interaction_state.home_view_bounds else {
862 return Ok(());
863 };
864 if self.session.restore_visible_bounds(home_view_bounds) {
865 self.reset_pointer_state();
866 self.invalidate_frame_state();
867 cx.notify();
868 }
869 Ok(())
870 }
871
872 pub fn save_png(&mut self, window: &Window, _cx: &mut Context<Self>) -> Result<()> {
873 let image = self.capture_visible_view_image(window)?;
874 self.spawn_save_png_dialog(image)
875 }
876
877 pub fn copy_image(&mut self, window: &Window, _cx: &mut Context<Self>) -> Result<()> {
878 let image = self.capture_visible_view_image(window)?;
879 self.copy_image_to_clipboard(&image)
880 }
881
882 pub fn copy_cursor_coordinates(&self) -> Result<()> {
883 let cursor_position_px = self.current_cursor_position_px().ok_or_else(|| {
884 PlottingError::InvalidInput(
885 "cursor coordinates are unavailable until the pointer enters the plot area"
886 .to_string(),
887 )
888 })?;
889 self.copy_cursor_coordinates_at(cursor_position_px)
890 }
891
892 pub(crate) fn copy_cursor_coordinates_at(
893 &self,
894 cursor_position_px: ViewportPoint,
895 ) -> Result<()> {
896 let snapshot = self.session.viewport_snapshot()?;
897 let cursor_data_position = cursor_data_position(
898 snapshot.visible_bounds,
899 snapshot.plot_area,
900 cursor_position_px,
901 )
902 .ok_or_else(|| {
903 PlottingError::InvalidInput("cursor is outside the plotted data area".to_string())
904 })?;
905 self.copy_text_to_clipboard(&format!(
906 "x={:.6}, y={:.6}",
907 cursor_data_position.x, cursor_data_position.y
908 ))
909 }
910
911 pub fn copy_visible_bounds(&self) -> Result<()> {
912 let snapshot = self.session.viewport_snapshot()?;
913 self.copy_text_to_clipboard(&format!(
914 "x=[{:.6}, {:.6}], y=[{:.6}, {:.6}]",
915 snapshot.visible_bounds.min.x,
916 snapshot.visible_bounds.max.x,
917 snapshot.visible_bounds.min.y,
918 snapshot.visible_bounds.max.y
919 ))
920 }
921
922 #[deprecated(note = "Use ruviz_gpui::plot_builder(...).presentation(...).build(cx).")]
923 pub fn set_presentation_mode(
924 &mut self,
925 presentation_mode: PresentationMode,
926 cx: &mut Context<Self>,
927 ) {
928 self.options.presentation_mode = presentation_mode;
929 self.invalidate_frame_state();
930 cx.notify();
931 }
932
933 #[deprecated(
934 note = "Use ruviz_gpui::plot_builder(...).fill()/fixed_pixels(...).build(cx)."
935 )]
936 pub fn set_sizing_policy(&mut self, sizing_policy: SizingPolicy, cx: &mut Context<Self>) {
937 self.options.sizing_policy = sizing_policy;
938 self.invalidate_frame_state();
939 cx.notify();
940 }
941
942 #[deprecated(
943 note = "Use ruviz_gpui::plot_builder(...).performance_options(...).build(cx)."
944 )]
945 pub fn set_performance_options(
946 &mut self,
947 performance: PerformanceOptions,
948 cx: &mut Context<Self>,
949 ) {
950 self.options.performance = performance;
951 apply_performance_options(&self.session, performance);
952 self.invalidate_frame_state();
953 cx.notify();
954 }
955
956 #[deprecated(
957 note = "Use ruviz_gpui::plot_builder(...).interaction_options(...).build(cx)."
958 )]
959 pub fn set_interaction_options(
960 &mut self,
961 interaction: InteractionOptions,
962 cx: &mut Context<Self>,
963 ) {
964 self.options.interaction = interaction;
965 self.invalidate_frame_state();
966 cx.notify();
967 }
968
969 fn invalidate_frame_state(&mut self) {
970 self.retire_cached_frame();
971 self.session.invalidate();
972 self.scheduler.reset();
973 self.in_flight_render = None;
974 }
975
976 fn replace_session(&mut self, session: InteractivePlotSession) {
977 self.session = session;
978 apply_performance_options(&self.session, self.options.performance);
979 let (reactive_notify_pending, reactive_receiver, subscription) =
980 bind_reactive_session(&self.session);
981 self.subscription = subscription;
982 self.reactive_notify_pending = reactive_notify_pending;
983 self.reactive_receiver = Some(reactive_receiver);
984 self.reactive_watcher = None;
985 self.presentation_clock
986 .lock()
987 .expect("RuvizPlot presentation clock lock poisoned")
988 .reset();
989 self.reset_pointer_state();
990 self.retire_cached_frame();
991 self.scheduler.reset();
992 self.in_flight_render = None;
993 self.last_layout = None;
994 self.interaction_state.reset();
995 }
996 }
997
998 impl Render for RuvizPlot {
999 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1000 let entity = cx.entity();
1001 self.ensure_reactive_watcher(entity.clone(), window, cx);
1002
1003 let plot_canvas = canvas::<Option<PaintFrame>>(
1004 {
1005 let entity = entity.clone();
1006 move |bounds, window, cx| {
1007 let entity_for_prepaint = entity.clone();
1008 entity.update(cx, move |view, cx| {
1009 view.prepaint(entity_for_prepaint, bounds, window, cx)
1010 })
1011 }
1012 },
1013 {
1014 let image_fit = self.options.interaction.image_fit;
1015 let presentation_clock = Arc::clone(&self.presentation_clock);
1016 move |bounds, image: Option<PaintFrame>, window: &mut Window, _cx| {
1017 presentation_clock
1018 .lock()
1019 .expect("RuvizPlot presentation clock lock poisoned")
1020 .record_now();
1021 if let Some(image) = image {
1022 let fitted_bounds = match image.primary {
1023 PrimaryFrame::Image(primary_image) => {
1024 let image_size = primary_image.size(0);
1025 let fitted_bounds =
1026 image_fit.into_gpui().get_bounds(bounds, image_size);
1027 let _ = window.paint_image(
1028 fitted_bounds,
1029 Corners::default(),
1030 primary_image,
1031 0,
1032 false,
1033 );
1034 fitted_bounds
1035 }
1036 #[cfg(all(feature = "gpu", target_os = "macos"))]
1037 PrimaryFrame::Surface(surface) => {
1038 let image_size = size(
1039 surface.get_width().into(),
1040 surface.get_height().into(),
1041 );
1042 let fitted_bounds =
1043 image_fit.into_gpui().get_bounds(bounds, image_size);
1044 window.paint_surface(fitted_bounds, surface);
1045 fitted_bounds
1046 }
1047 };
1048 if let Some(overlay_image) = image.overlay_image {
1049 let _ = window.paint_image(
1050 fitted_bounds,
1051 Corners::default(),
1052 overlay_image,
1053 0,
1054 false,
1055 );
1056 }
1057 }
1058 }
1059 },
1060 )
1061 .size_full();
1062
1063 let mut root = div()
1064 .relative()
1065 .track_focus(&self.focus_handle)
1066 .child(plot_canvas)
1067 .when_some(self.zoom_overlay_bounds(), |this, bounds| {
1068 this.child(self.render_zoom_overlay(bounds))
1069 })
1070 .when_some(self.render_context_menu_overlay(), |this, menu_overlay| {
1071 this.child(menu_overlay)
1072 })
1073 .on_mouse_down(MouseButton::Left, {
1074 let entity = entity.clone();
1075 move |event, window, cx| {
1076 entity.update(cx, |view, cx| {
1077 if let Err(err) = view.handle_left_mouse_down(event, window, cx) {
1078 log_interaction_error("left mouse down", &err);
1079 }
1080 });
1081 }
1082 })
1083 .on_mouse_down(MouseButton::Right, {
1084 let entity = entity.clone();
1085 move |event, window, cx| {
1086 entity.update(cx, |view, cx| {
1087 if let Err(err) = view.handle_right_mouse_down(event, window, cx) {
1088 log_interaction_error("right mouse down", &err);
1089 }
1090 });
1091 }
1092 })
1093 .on_mouse_move({
1094 let entity = entity.clone();
1095 move |event, _, cx| {
1096 entity.update(cx, |view, cx| {
1097 if let Err(err) = view.handle_mouse_move(event, cx) {
1098 log_interaction_error("mouse move", &err);
1099 }
1100 });
1101 }
1102 })
1103 .on_mouse_up(MouseButton::Left, {
1104 let entity = entity.clone();
1105 move |event, _, cx| {
1106 entity.update(cx, |view, cx| {
1107 if let Err(err) = view.handle_left_mouse_up(event, cx) {
1108 log_interaction_error("left mouse up", &err);
1109 }
1110 });
1111 }
1112 })
1113 .on_mouse_up_out(MouseButton::Left, {
1114 let entity = entity.clone();
1115 move |event, _, cx| {
1116 entity.update(cx, |view, cx| {
1117 if let Err(err) = view.handle_left_mouse_up(event, cx) {
1118 log_interaction_error("left mouse up-out", &err);
1119 }
1120 });
1121 }
1122 })
1123 .on_mouse_up(MouseButton::Right, {
1124 let entity = entity.clone();
1125 move |event, _, cx| {
1126 entity.update(cx, |view, cx| {
1127 if let Err(err) = view.handle_right_mouse_up(event, cx) {
1128 log_interaction_error("right mouse up", &err);
1129 }
1130 });
1131 }
1132 })
1133 .on_mouse_up_out(MouseButton::Right, {
1134 let entity = entity.clone();
1135 move |event, _, cx| {
1136 entity.update(cx, |view, cx| {
1137 if let Err(err) = view.handle_right_mouse_up(event, cx) {
1138 log_interaction_error("right mouse up-out", &err);
1139 }
1140 });
1141 }
1142 })
1143 .on_scroll_wheel({
1144 let entity = entity.clone();
1145 move |event, _, cx| {
1146 entity.update(cx, |view, cx| {
1147 if let Err(err) = view.handle_scroll_wheel(event, cx) {
1148 log_interaction_error("scroll handling", &err);
1149 }
1150 });
1151 }
1152 })
1153 .on_key_down({
1154 let entity = entity.clone();
1155 move |event, window, cx| {
1156 entity.update(cx, |view, cx| {
1157 match view.handle_key_down(event, window, cx) {
1158 Ok(true) => cx.stop_propagation(),
1159 Ok(false) => {}
1160 Err(err) => log_interaction_error("key handling", &err),
1161 }
1162 });
1163 }
1164 });
1165
1166 root.interactivity().on_hover({
1167 let entity = entity.clone();
1168 move |hovered, _, cx| {
1169 entity.update(cx, |view, cx| {
1170 view.handle_hover_change(*hovered, cx);
1171 });
1172 }
1173 });
1174
1175 match self.options.sizing_policy {
1176 SizingPolicy::Fill => {
1177 root = root.size_full();
1178 }
1179 SizingPolicy::FixedPixels { width, height } => {
1180 root = root.w(px(width as f32)).h(px(height as f32));
1181 }
1182 }
1183
1184 root
1185 }
1186 }
1187
1188 impl Focusable for RuvizPlot {
1189 fn focus_handle(&self, _: &App) -> FocusHandle {
1190 self.focus_handle.clone()
1191 }
1192 }
1193
1194 #[cfg(test)]
1195 mod tests {
1196 use super::*;
1197 use gpui::{Modifiers, MouseButton, TestAppContext};
1198 use ruviz::{data::Observable, prelude::Plot};
1199
1200 #[test]
1201 fn test_rgba_to_bgra_conversion() {
1202 let mut pixels = vec![1, 2, 3, 255, 10, 20, 30, 128];
1203 rgba_to_bgra_in_place(&mut pixels);
1204 assert_eq!(pixels, vec![3, 2, 1, 255, 30, 20, 10, 128]);
1205 }
1206
1207 #[test]
1208 fn test_render_image_to_ruviz_round_trips_pixels() {
1209 let original = ruviz::core::plot::Image::new(2, 1, vec![1, 2, 3, 255, 10, 20, 30, 128]);
1210 let render = render_image_from_ruviz(original.clone());
1211 let restored = render_image_to_ruviz(render.as_ref())
1212 .expect("render image should decode back into RGBA pixels");
1213 assert_eq!(restored.width, original.width);
1214 assert_eq!(restored.height, original.height);
1215 assert_eq!(restored.pixels, original.pixels);
1216 }
1217
1218 #[test]
1219 fn test_cached_frame_capture_reuses_cached_image_and_overlay() {
1220 let cx = TestAppContext::single();
1221 let focus_handle = cx.update(|cx| cx.focus_handle());
1222 let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
1223 let session = plot.prepare_interactive();
1224 let (pending, receiver, subscription) = bind_reactive_session(&session);
1225
1226 let base = ruviz::core::plot::Image::new(1, 1, vec![20, 40, 60, 255]);
1227 let overlay = ruviz::core::plot::Image::new(1, 1, vec![200, 100, 50, 128]);
1228 let mut expected = base.pixels.clone();
1229 blend_rgba_into_rgba(&overlay.pixels, &mut expected);
1230
1231 let view = RuvizPlot {
1232 session,
1233 subscription,
1234 reactive_notify_pending: pending,
1235 reactive_receiver: Some(receiver),
1236 reactive_watcher: None,
1237 presentation_clock: Arc::new(Mutex::new(PresentationClock::default())),
1238 options: RuvizPlotOptions::default(),
1239 cached_frame: Some(CachedFrame {
1240 request: RenderRequest::new((1, 1), 1.0, 0.0, PresentationMode::Hybrid),
1241 primary: PrimaryFrame::Image(render_image_from_ruviz(base)),
1242 overlay_image: Some(render_image_from_ruviz(overlay)),
1243 stats: FrameStats::default(),
1244 target: RenderTargetKind::Surface,
1245 }),
1246 retired_images: Vec::new(),
1247 #[cfg(all(feature = "gpu", target_os = "macos"))]
1248 surface_upload: SurfaceUploadState::default(),
1249 scheduler: RenderScheduler::default(),
1250 in_flight_render: None,
1251 last_layout: None,
1252 focus_handle,
1253 interaction_state: InteractionState::default(),
1254 context_menu_action_handler: None,
1255 };
1256
1257 let captured = view
1258 .capture_visible_view_image_from_cache()
1259 .expect("cached frame should provide a visible image");
1260 assert_eq!(captured.width, 1);
1261 assert_eq!(captured.height, 1);
1262 assert_eq!(captured.pixels, expected);
1263 }
1264
1265 #[test]
1266 fn test_hybrid_mode_falls_back_without_gpu_feature() {
1267 #[cfg(not(feature = "gpu"))]
1268 assert_eq!(
1269 resolve_presentation_mode(PresentationMode::Hybrid),
1270 PresentationMode::Image
1271 );
1272 }
1273
1274 #[test]
1275 #[allow(deprecated)]
1276 fn test_surface_mode_alias_resolves() {
1277 #[cfg(feature = "gpu")]
1278 assert_eq!(
1279 resolve_presentation_mode(PresentationMode::SurfaceExperimental),
1280 PresentationMode::Hybrid
1281 );
1282
1283 #[cfg(not(feature = "gpu"))]
1284 assert_eq!(
1285 resolve_presentation_mode(PresentationMode::SurfaceExperimental),
1286 PresentationMode::Image
1287 );
1288 }
1289
1290 #[test]
1291 fn test_render_request_is_dirty_before_first_frame_and_clean_after_render() {
1292 let plot: Plot = Plot::new().line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0]).into();
1293 let session = plot.prepare_interactive();
1294 let request = RenderRequest::new((320, 240), 1.0, 0.0, PresentationMode::Image);
1295
1296 assert!(request.is_dirty(&session));
1297 session
1298 .render_to_image(ImageTarget {
1299 size_px: request.size_px,
1300 scale_factor: request.scale_factor(),
1301 time_seconds: request.time_seconds(),
1302 })
1303 .expect("interactive session should render");
1304 assert!(!request.is_dirty(&session));
1305 }
1306
1307 #[test]
1308 fn test_fill_backing_dimension_uses_display_scale_without_shrinking() {
1309 assert_eq!(fill_backing_dimension_px(320, 1.0), 320);
1310 assert_eq!(fill_backing_dimension_px(320, 2.0), 640);
1311 assert_eq!(fill_backing_dimension_px(320, 1.5), 480);
1312 assert_eq!(fill_backing_dimension_px(320, 0.5), 320);
1313 assert_eq!(fill_backing_dimension_px(0, 2.0), 0);
1314 }
1315
1316 #[test]
1317 fn test_fill_sizing_fits_backing_size_to_figure_aspect() {
1318 let plot = Plot::new().size(4.0, 3.0);
1319 let session = plot.prepare_interactive();
1320
1321 let frame_size =
1322 frame_size_px_for_policy(&session, &SizingPolicy::Fill, (400, 250), 2.0);
1323
1324 assert_eq!(frame_size, Some((666, 500)));
1325 }
1326
1327 #[test]
1328 fn test_fixed_pixels_sizing_preserves_exact_requested_size() {
1329 let plot = Plot::new().size(4.0, 3.0);
1330 let session = plot.prepare_interactive();
1331
1332 let frame_size = frame_size_px_for_policy(
1333 &session,
1334 &SizingPolicy::FixedPixels {
1335 width: 800,
1336 height: 500,
1337 },
1338 (400, 250),
1339 2.0,
1340 );
1341
1342 assert_eq!(frame_size, Some((800, 500)));
1343 }
1344
1345 #[test]
1346 fn test_render_request_becomes_dirty_after_observable_update() {
1347 let y = Observable::new(vec![0.0, 1.0, 4.0]);
1348 let plot: Plot = Plot::new()
1349 .line_source(vec![0.0, 1.0, 2.0], y.clone())
1350 .into();
1351 let session = plot.prepare_interactive();
1352 let request = RenderRequest::new((320, 240), 1.0, 0.0, PresentationMode::Image);
1353
1354 session
1355 .render_to_image(ImageTarget {
1356 size_px: request.size_px,
1357 scale_factor: request.scale_factor(),
1358 time_seconds: request.time_seconds(),
1359 })
1360 .expect("interactive session should render");
1361 assert!(!request.is_dirty(&session));
1362
1363 y.set(vec![0.0, 1.0, 9.0]);
1364 assert!(request.is_dirty(&session));
1365 }
1366
1367 #[test]
1368 fn test_render_request_becomes_dirty_after_session_invalidate() {
1369 let plot: Plot = Plot::new().line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0]).into();
1370 let session = plot.prepare_interactive();
1371 let request = RenderRequest::new((320, 240), 1.0, 0.0, PresentationMode::Hybrid);
1372
1373 session
1374 .render_to_surface(SurfaceTarget {
1375 size_px: request.size_px,
1376 scale_factor: request.scale_factor(),
1377 time_seconds: request.time_seconds(),
1378 })
1379 .expect("interactive session should render");
1380 assert!(!request.is_dirty(&session));
1381
1382 session.invalidate();
1383 assert!(request.is_dirty(&session));
1384 }
1385
1386 #[test]
1387 fn test_bind_reactive_session_coalesces_notifications() {
1388 let y = Observable::new(vec![0.0, 1.0, 4.0]);
1389 let plot: Plot = Plot::new()
1390 .line_source(vec![0.0, 1.0, 2.0], y.clone())
1391 .into();
1392 let session = plot.prepare_interactive();
1393 let (pending, mut receiver, _subscription) = bind_reactive_session(&session);
1394
1395 y.set(vec![1.0, 2.0, 3.0]);
1396 y.set(vec![2.0, 3.0, 4.0]);
1397
1398 assert!(pending.load(Ordering::Acquire));
1399 assert!(receiver.try_recv().is_ok());
1400 }
1401
1402 #[test]
1403 fn test_replace_session_retires_cached_frame() {
1404 let cx = TestAppContext::single();
1405 let focus_handle = cx.update(|cx| cx.focus_handle());
1406 let initial_plot: Plot = Plot::new().line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0]).into();
1407 let initial_session = initial_plot.prepare_interactive();
1408 let (pending, receiver, subscription) = bind_reactive_session(&initial_session);
1409 let cached_primary =
1410 render_image_from_ruviz(ruviz::core::plot::Image::new(1, 1, vec![0, 0, 0, 255]));
1411 let cached_frame = CachedFrame {
1412 request: RenderRequest::new((320, 240), 1.0, 0.0, PresentationMode::Hybrid),
1413 primary: PrimaryFrame::Image(cached_primary),
1414 overlay_image: None,
1415 stats: FrameStats::default(),
1416 target: RenderTargetKind::Surface,
1417 };
1418 let mut view = RuvizPlot {
1419 session: initial_session,
1420 subscription,
1421 reactive_notify_pending: pending,
1422 reactive_receiver: Some(receiver),
1423 reactive_watcher: None,
1424 presentation_clock: Arc::new(Mutex::new(PresentationClock::default())),
1425 options: RuvizPlotOptions::default(),
1426 cached_frame: Some(cached_frame),
1427 retired_images: Vec::new(),
1428 #[cfg(all(feature = "gpu", target_os = "macos"))]
1429 surface_upload: SurfaceUploadState::default(),
1430 scheduler: RenderScheduler::default(),
1431 in_flight_render: None,
1432 last_layout: Some(InteractionLayout {
1433 component_bounds: Bounds::default(),
1434 content_bounds: Bounds::default(),
1435 frame_size_px: (320, 240),
1436 }),
1437 focus_handle,
1438 interaction_state: InteractionState {
1439 last_pointer_px: Some(ViewportPoint::new(2.0, 2.0)),
1440 active_drag: ActiveDrag::LeftPan {
1441 anchor_px: ViewportPoint::new(1.0, 1.0),
1442 last_px: ViewportPoint::new(2.0, 2.0),
1443 crossed_threshold: true,
1444 },
1445 context_menu: None,
1446 home_view_bounds: None,
1447 },
1448 context_menu_action_handler: None,
1449 };
1450
1451 let replacement_plot: Plot = Plot::new().line(&[0.0, 1.0], &[1.0, 2.0]).into();
1452 let replacement_session = replacement_plot.prepare_interactive();
1453 view.replace_session(replacement_session);
1454
1455 assert!(view.cached_frame.is_none());
1456 assert_eq!(view.retired_images.len(), 1);
1457 assert!(view.last_layout.is_none());
1458 assert_eq!(view.interaction_state.active_drag, ActiveDrag::None);
1459 assert!(view.interaction_state.last_pointer_px.is_none());
1460 }
1461
1462 #[test]
1463 fn test_secondary_shortcut_maps_to_builtins() {
1464 let view = RuvizPlot {
1465 session: {
1466 let plot: Plot = Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).into();
1467 plot.prepare_interactive()
1468 },
1469 subscription: ReactiveSubscription::default(),
1470 reactive_notify_pending: Arc::new(AtomicBool::new(false)),
1471 reactive_receiver: None,
1472 reactive_watcher: None,
1473 presentation_clock: Arc::new(Mutex::new(PresentationClock::default())),
1474 options: RuvizPlotOptions::default(),
1475 cached_frame: None,
1476 retired_images: Vec::new(),
1477 #[cfg(all(feature = "gpu", target_os = "macos"))]
1478 surface_upload: SurfaceUploadState::default(),
1479 scheduler: RenderScheduler::default(),
1480 in_flight_render: None,
1481 last_layout: None,
1482 focus_handle: TestAppContext::single().update(|cx| cx.focus_handle()),
1483 interaction_state: InteractionState::default(),
1484 context_menu_action_handler: None,
1485 };
1486
1487 let save = KeyDownEvent {
1488 keystroke: gpui::Keystroke {
1489 modifiers: Modifiers::secondary_key(),
1490 key: "s".to_string(),
1491 key_char: None,
1492 },
1493 is_held: false,
1494 prefer_character_input: false,
1495 };
1496 let copy = KeyDownEvent {
1497 keystroke: gpui::Keystroke {
1498 modifiers: Modifiers::secondary_key(),
1499 key: "c".to_string(),
1500 key_char: None,
1501 },
1502 is_held: false,
1503 prefer_character_input: false,
1504 };
1505
1506 assert_eq!(
1507 view.builtin_shortcut_action_for_keystroke(&save),
1508 Some(BuiltinContextMenuAction::SavePng)
1509 );
1510 assert_eq!(
1511 view.builtin_shortcut_action_for_keystroke(©),
1512 Some(BuiltinContextMenuAction::CopyImage)
1513 );
1514 }
1515
1516 #[test]
1517 fn test_right_mouse_up_far_from_anchor_zooms_without_move_events() {
1518 let mut app = TestAppContext::single();
1519 let (view, cx) = app.add_window_view(|_, cx| {
1520 let plot: Plot = Plot::new()
1521 .line(&[0.0, 1.0, 2.0, 3.0], &[0.0, 1.0, 0.5, 1.5])
1522 .into();
1523 RuvizPlot::from_options_impl(plot, RuvizPlotOptions::default(), None, cx)
1524 });
1525
1526 cx.refresh().expect("window refresh should succeed");
1527 cx.run_until_parked();
1528
1529 let (start, end, initial_bounds) = cx.read(|app| {
1530 app.read_entity(&view, |view, _| {
1531 let layout = view
1532 .last_layout
1533 .clone()
1534 .expect("plot should have a resolved layout");
1535 let start = point(
1536 layout.content_bounds.origin.x + layout.content_bounds.size.width * 0.25,
1537 layout.content_bounds.origin.y + layout.content_bounds.size.height * 0.25,
1538 );
1539 let end = point(
1540 layout.content_bounds.origin.x + layout.content_bounds.size.width * 0.75,
1541 layout.content_bounds.origin.y + layout.content_bounds.size.height * 0.75,
1542 );
1543 (
1544 start,
1545 end,
1546 view.session
1547 .viewport_snapshot()
1548 .expect("viewport snapshot should succeed")
1549 .visible_bounds,
1550 )
1551 })
1552 });
1553
1554 cx.simulate_mouse_down(start, MouseButton::Right, Modifiers::default());
1555 cx.simulate_mouse_up(end, MouseButton::Right, Modifiers::default());
1556 cx.run_until_parked();
1557
1558 let (context_menu_open, final_bounds) = cx.read(|app| {
1559 app.read_entity(&view, |view, _| {
1560 (
1561 view.interaction_state.context_menu.is_some(),
1562 view.session
1563 .viewport_snapshot()
1564 .expect("viewport snapshot should succeed")
1565 .visible_bounds,
1566 )
1567 })
1568 });
1569
1570 assert!(!context_menu_open);
1571 assert_ne!(final_bounds, initial_bounds);
1572 }
1573
1574 #[test]
1575 fn test_cursor_data_position_maps_plot_area() {
1576 let visible = ViewportRect::from_points(
1577 ViewportPoint::new(0.0, 0.0),
1578 ViewportPoint::new(10.0, 20.0),
1579 );
1580 let plot_area = ViewportRect::from_points(
1581 ViewportPoint::new(100.0, 50.0),
1582 ViewportPoint::new(300.0, 250.0),
1583 );
1584 let cursor = ViewportPoint::new(200.0, 150.0);
1585
1586 let data = cursor_data_position(visible, plot_area, cursor)
1587 .expect("cursor inside plot area should map to data coordinates");
1588 assert!((data.x - 5.0).abs() < 1e-6);
1589 assert!((data.y - 10.0).abs() < 1e-6);
1590 }
1591
1592 #[test]
1593 fn test_presentation_clock_tracks_paint_cadence() {
1594 let start = Instant::now();
1595 let mut clock = PresentationClock::default();
1596
1597 clock.record_at(start);
1598 clock.record_at(start + Duration::from_millis(16));
1599 clock.record_at(start + Duration::from_millis(32));
1600
1601 let stats = clock.stats();
1602 assert_eq!(stats.frame_count, 3);
1603 assert!(stats.last_present_interval >= Duration::from_millis(16));
1604 assert!(stats.average_present_interval >= Duration::from_millis(16));
1605 assert!(stats.current_fps > 50.0 && stats.current_fps < 70.0);
1606 }
1607
1608 #[test]
1609 fn test_presentation_clock_preserves_average_precision() {
1610 let start = Instant::now();
1611 let mut clock = PresentationClock::default();
1612
1613 clock.record_at(start);
1614 clock.record_at(start + Duration::from_nanos(1));
1615 clock.record_at(start + Duration::from_nanos(3));
1616 clock.record_at(start + Duration::from_nanos(6));
1617
1618 let stats = clock.stats();
1619 assert_eq!(stats.average_present_interval, Duration::from_nanos(2));
1620 assert!((clock.average_present_interval_secs - 2e-9).abs() < 1e-18);
1621 }
1622
1623 #[test]
1624 fn test_render_scheduler_coalesces_queued_requests() {
1625 let mut scheduler = RenderScheduler::default();
1626 let first = RenderRequest::new((320, 240), 1.0, 0.0, PresentationMode::Hybrid);
1627 let second = RenderRequest::new((320, 240), 1.0, 1.0, PresentationMode::Hybrid);
1628 let third = RenderRequest::new((640, 480), 1.0, 1.0, PresentationMode::Hybrid);
1629
1630 let scheduled = scheduler
1631 .schedule(first.clone())
1632 .expect("first request should start");
1633 scheduler.start(scheduled.clone());
1634 assert!(scheduler.schedule(second).is_none());
1635 assert!(scheduler.schedule(third).is_none());
1636 assert_eq!(scheduler.dropped_frames, 1);
1637
1638 assert!(scheduler.finish(&scheduled));
1639 let queued = scheduler
1640 .take_queued()
1641 .expect("queued request should remain");
1642 assert_eq!(queued.request.size_px, (640, 480));
1643 }
1644
1645 #[test]
1646 fn test_surface_primary_only_enabled_for_fast_path_frames() {
1647 #[allow(deprecated)]
1648 let deprecated_hybrid = PresentationMode::SurfaceExperimental;
1649
1650 #[cfg(all(feature = "gpu", target_os = "macos"))]
1651 {
1652 assert!(should_use_surface_primary(
1653 PresentationMode::Hybrid,
1654 RenderTargetKind::Surface,
1655 SurfaceCapability::FastPath,
1656 ));
1657 assert!(should_use_surface_primary(
1658 deprecated_hybrid,
1659 RenderTargetKind::Surface,
1660 SurfaceCapability::FastPath,
1661 ));
1662 }
1663
1664 #[cfg(not(all(feature = "gpu", target_os = "macos")))]
1665 {
1666 assert!(!should_use_surface_primary(
1667 PresentationMode::Hybrid,
1668 RenderTargetKind::Surface,
1669 SurfaceCapability::FastPath,
1670 ));
1671 assert!(!should_use_surface_primary(
1672 deprecated_hybrid,
1673 RenderTargetKind::Surface,
1674 SurfaceCapability::FastPath,
1675 ));
1676 }
1677
1678 assert!(!should_use_surface_primary(
1679 PresentationMode::Hybrid,
1680 RenderTargetKind::Surface,
1681 SurfaceCapability::FallbackImage,
1682 ));
1683 assert!(!should_use_surface_primary(
1684 PresentationMode::Image,
1685 RenderTargetKind::Image,
1686 SurfaceCapability::Unsupported,
1687 ));
1688 }
1689
1690 #[test]
1691 fn test_active_backend_reports_fallback_for_image_backed_surface_frames() {
1692 let frame = CachedFrame {
1693 request: RenderRequest::new((320, 240), 1.0, 0.0, PresentationMode::Hybrid),
1694 primary: PrimaryFrame::Image(render_image_from_ruviz(
1695 ruviz::core::plot::Image::new(1, 1, vec![0, 0, 0, 255]),
1696 )),
1697 overlay_image: None,
1698 stats: FrameStats::default(),
1699 target: RenderTargetKind::Surface,
1700 };
1701
1702 assert_eq!(
1703 active_backend_for_frame(&frame),
1704 ActiveBackend::HybridFallback
1705 );
1706 }
1707
1708 #[cfg(all(feature = "gpu", target_os = "macos"))]
1709 #[test]
1710 fn test_active_backend_reports_fast_path_for_surface_backed_frames() {
1711 let mut upload = SurfaceUploadState::default();
1712 let surface = upload
1713 .update(
1714 None,
1715 &ruviz::core::plot::Image::new(
1716 2,
1717 2,
1718 vec![
1719 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
1720 ],
1721 ),
1722 )
1723 .expect("surface upload should succeed");
1724 let frame = CachedFrame {
1725 request: RenderRequest::new((320, 240), 1.0, 0.0, PresentationMode::Hybrid),
1726 primary: PrimaryFrame::Surface(surface),
1727 overlay_image: None,
1728 stats: FrameStats::default(),
1729 target: RenderTargetKind::Surface,
1730 };
1731
1732 assert_eq!(
1733 active_backend_for_frame(&frame),
1734 ActiveBackend::HybridFastPath
1735 );
1736 }
1737
1738 #[cfg(all(feature = "gpu", target_os = "macos"))]
1739 #[test]
1740 fn test_surface_upload_reuses_pixel_buffer_when_size_is_stable() {
1741 let mut upload = SurfaceUploadState::default();
1742 let first = upload
1743 .update(
1744 None,
1745 &ruviz::core::plot::Image::new(2, 1, vec![1, 2, 3, 255, 4, 5, 6, 255]),
1746 )
1747 .expect("first surface upload should succeed");
1748 let reused = upload
1749 .update(
1750 Some(&first),
1751 &ruviz::core::plot::Image::new(2, 1, vec![10, 20, 30, 255, 40, 50, 60, 255]),
1752 )
1753 .expect("second surface upload should succeed");
1754
1755 assert_eq!(first, reused);
1756 assert_eq!(reused.get_width(), 2);
1757 assert_eq!(reused.get_height(), 1);
1758 assert_eq!(
1759 reused.get_pixel_format(),
1760 kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
1761 );
1762 assert!(reused.is_planar());
1763 assert_eq!(reused.get_plane_count(), 2);
1764 }
1765 }
1766}
1767
1768#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
1769pub use gpui;
1770
1771#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
1772pub use platform_impl::*;