Skip to main content

ruviz_gpui/
lib.rs

1//! GPUI component adapter for [`ruviz`](https://docs.rs/ruviz).
2//!
3//! `ruviz-gpui` embeds a shared `ruviz` interactive plot session inside a GPUI
4//! application. It is intended for native desktop apps that want the plot
5//! interaction model from `ruviz` while keeping presentation and layout inside a
6//! GPUI view tree.
7//!
8//! # What This Crate Provides
9//!
10//! - `RuvizPlot` for embedding static or interactive plots in GPUI views
11//! - configurable presentation modes for image-backed and hybrid rendering
12//! - built-in pan, zoom, hover, selection, and context-menu behavior
13//! - clipboard and PNG save helpers routed through the host platform
14//!
15//! # Platform Support
16//!
17//! `ruviz-gpui` currently supports macOS, Linux, and Windows. Unsupported
18//! targets fail at compile time.
19//!
20//! # Recommended Usage
21//!
22//! Add both crates to your application:
23//!
24//! ```toml
25//! [dependencies]
26//! ruviz = "0.4.19"
27//! ruviz-gpui = "0.4.19"
28//! ```
29//!
30//! Then build a normal `ruviz::Plot` or `PreparedPlot` and hand it to the GPUI
31//! component. See the repository examples under
32//! `crates/ruviz-gpui/examples/` for end-to-end setups.
33//!
34//! # Documentation
35//!
36//! - Repository README: <https://github.com/Ameyanagi/ruviz/blob/main/README.md>
37//! - Adapter README: <https://github.com/Ameyanagi/ruviz/tree/main/crates/ruviz-gpui>
38
39#[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    /// Presentation backend for the GPUI embed.
146    ///
147    /// `Hybrid` is the default presentation path. When GPU support is unavailable,
148    /// it falls back to the stable image-backed renderer.
149    #[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    /// Layout policy for the embedded plot.
159    #[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    /// Image-fit policy used by the image-backed presentation mode.
170    #[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    /// Time and interaction settings for the embedded plot.
189    #[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        /// Controls whether the "Save PNG..." context menu item and built-in `Cmd/Ctrl+S`
247        /// shortcut are available.
248        pub show_save_png: bool,
249        /// Controls whether the "Copy Image" context menu item and built-in `Cmd/Ctrl+C`
250        /// shortcut are available.
251        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        /// Snapshot of the current visible plot image, captured eagerly when the action context
283        /// is built. Cached image-backed frames are reused when available; otherwise this may
284        /// trigger a fresh image render.
285        pub image: RuvizImage,
286    }
287
288    /// Performance tuning options for the shared interactive session.
289    #[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    /// Top-level configuration for the GPUI plot component.
333    #[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    /// Presentation cadence measured from GPUI's paint phase.
360    ///
361    /// This is distinct from [`FrameStats`], which tracks how quickly `ruviz`
362    /// produces frames. `PresentationStats` approximates how often GPUI is
363    /// actually painting this component.
364    #[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    /// GPUI view that renders a `ruviz` plot into a cached `RenderImage`.
705    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(&copy),
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::*;