1use std::sync::{Arc, RwLock};
2use std::time::{Duration, Instant};
3
4use gpui::prelude::*;
5use gpui::{
6 MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent,
7 StatefulInteractiveElement, Window, canvas, div, px,
8};
9
10use crate::geom::{Point as DataPoint, ScreenPoint, ScreenRect};
11use crate::interaction::{
12 HitRegion, pan_viewport, toggle_pin, zoom_factor_from_drag, zoom_to_rect, zoom_viewport,
13};
14use crate::plot::Plot;
15use crate::transform::Transform;
16use crate::view::{Range, Viewport};
17
18use super::config::PlotViewConfig;
19use super::constants::DOUBLE_CLICK_PIN_GRACE_MS;
20use super::frame::build_frame;
21use super::geometry::{distance_sq, normalized_rect};
22use super::hover::{compute_hover_target, hover_target_within_threshold};
23use super::link::{LinkBinding, PlotLinkGroup, PlotLinkOptions, ViewSyncKind};
24use super::paint::{paint_frame, to_hsla};
25use super::state::{ClickState, DragMode, DragState, PinToggle, PlotUiState};
26
27#[derive(Debug, Clone)]
32pub struct PlotView {
33 plot: Arc<RwLock<Plot>>,
34 state: Arc<RwLock<PlotUiState>>,
35 config: PlotViewConfig,
36 link: Option<LinkBinding>,
37}
38
39impl PlotView {
40 pub fn new(plot: Plot) -> Self {
44 Self {
45 plot: Arc::new(RwLock::new(plot)),
46 state: Arc::new(RwLock::new(PlotUiState::default())),
47 config: PlotViewConfig::default(),
48 link: None,
49 }
50 }
51
52 pub fn with_config(plot: Plot, config: PlotViewConfig) -> Self {
54 Self {
55 plot: Arc::new(RwLock::new(plot)),
56 state: Arc::new(RwLock::new(PlotUiState::default())),
57 config,
58 link: None,
59 }
60 }
61
62 pub fn with_link_group(mut self, group: PlotLinkGroup, options: PlotLinkOptions) -> Self {
66 self.link = Some(LinkBinding {
67 member_id: group.register_member(),
68 group,
69 options,
70 });
71 self
72 }
73
74 pub fn plot_handle(&self) -> PlotHandle {
78 PlotHandle {
79 plot: Arc::clone(&self.plot),
80 }
81 }
82
83 fn publish_manual_view_link(&self, viewport: Viewport) {
84 let Some(link) = self.link.as_ref() else {
85 return;
86 };
87 link.group.publish_manual_view(
88 link.member_id,
89 viewport,
90 link.options.link_x,
91 link.options.link_y,
92 );
93 }
94
95 fn publish_reset_link(&self) {
96 let Some(link) = self.link.as_ref() else {
97 return;
98 };
99 if link.options.link_reset {
100 link.group.publish_reset(link.member_id);
101 }
102 }
103
104 fn publish_cursor_link(&self, x: Option<f64>) {
105 let Some(link) = self.link.as_ref() else {
106 return;
107 };
108 if link.options.link_cursor {
109 link.group.publish_cursor_x(link.member_id, x);
110 }
111 }
112
113 fn publish_brush_link(&self, x_range: Option<Range>) {
114 let Some(link) = self.link.as_ref() else {
115 return;
116 };
117 if link.options.link_brush {
118 link.group.publish_brush_x(link.member_id, x_range);
119 }
120 }
121
122 fn apply_manual_view_with_link(
123 &self,
124 plot: &mut Plot,
125 state: &mut PlotUiState,
126 rect: ScreenRect,
127 viewport: Viewport,
128 ) {
129 apply_manual_view(plot, state, rect, viewport);
130 state.linked_brush_x = None;
131 self.publish_manual_view_link(viewport);
132 self.publish_brush_link(None);
133 }
134
135 fn on_mouse_down(&mut self, ev: &MouseDownEvent, cx: &mut Context<Self>) {
136 let pos = screen_point(ev.position);
137 let mut state = self.state.write().expect("plot state lock");
138 state.last_cursor = Some(pos);
139
140 if let Some(series_id) = state.legend_hit(pos) {
141 if ev.button == MouseButton::Left && ev.click_count == 1 {
142 if let Ok(mut plot) = self.plot.write() {
143 if let Some(series) = plot
144 .series_mut()
145 .iter_mut()
146 .find(|series| series.id() == series_id)
147 {
148 series.set_visible(!series.is_visible());
149 }
150 }
151 }
152 state.clear_interaction();
153 state.hover = None;
154 state.hover_target = None;
155 cx.notify();
156 return;
157 }
158
159 let region = state.regions.hit_test(pos);
160 if ev.button == MouseButton::Left && ev.click_count >= 2 && region == HitRegion::Plot {
161 let last_toggle = state.last_pin_toggle.take();
162 if let Ok(mut plot) = self.plot.write() {
163 if let Some(last_toggle) = last_toggle {
164 if last_toggle.at.elapsed() <= Duration::from_millis(DOUBLE_CLICK_PIN_GRACE_MS)
165 && distance_sq(last_toggle.screen_pos, pos)
166 <= self.config.pin_threshold_px.powi(2)
167 {
168 revert_pin_toggle(&mut plot, last_toggle);
169 }
170 }
171 plot.reset_view();
172 state.linked_brush_x = None;
173 self.publish_reset_link();
174 self.publish_brush_link(None);
175 }
176 state.clear_interaction();
177 cx.notify();
178 return;
179 }
180
181 state.pending_click = Some(ClickState {
182 region,
183 button: ev.button,
184 });
185
186 match (ev.button, region) {
187 (MouseButton::Left, HitRegion::XAxis) => {
188 state.drag = Some(DragState::new(DragMode::ZoomX, pos, true));
189 }
190 (MouseButton::Left, HitRegion::YAxis) => {
191 state.drag = Some(DragState::new(DragMode::ZoomY, pos, true));
192 }
193 (MouseButton::Left, HitRegion::Plot) => {
194 state.drag = Some(DragState::new(DragMode::Pan, pos, false));
195 }
196 (MouseButton::Right, HitRegion::Plot) => {
197 state.drag = Some(DragState::new(DragMode::ZoomRect, pos, true));
198 state.selection_rect = Some(ScreenRect::new(pos, pos));
199 }
200 _ => {}
201 }
202
203 cx.notify();
204 }
205
206 fn on_mouse_move(&mut self, ev: &MouseMoveEvent, cx: &mut Context<Self>) {
207 let pos = screen_point(ev.position);
208 let mut state = self.state.write().expect("plot state lock");
209 state.last_cursor = Some(pos);
210
211 if state.legend_hit(pos).is_some() {
212 state.hover = None;
213 } else if state.regions.hit_test(pos) == HitRegion::Plot {
214 state.hover = Some(pos);
215 } else {
216 state.hover = None;
217 }
218 let linked_cursor_x = state.hover.and_then(|_| {
219 state
220 .transform
221 .as_ref()
222 .and_then(|transform| transform.screen_to_data(pos))
223 .map(|point| point.x)
224 });
225 self.publish_cursor_link(linked_cursor_x);
226
227 let Some(mut drag) = state.drag.clone() else {
228 cx.notify();
229 return;
230 };
231
232 if !is_drag_button_held(drag.mode, ev.pressed_button) {
233 state.clear_interaction();
234 self.publish_cursor_link(None);
235 cx.notify();
236 return;
237 }
238
239 let moved_sq = distance_sq(drag.start, pos);
240 if !drag.active && moved_sq > self.config.drag_threshold_px.powi(2) {
241 drag.active = true;
242 }
243
244 if !drag.active {
245 state.drag = Some(drag);
246 cx.notify();
247 return;
248 }
249
250 let delta = ScreenPoint::new(pos.x - drag.last.x, pos.y - drag.last.y);
251 let plot_rect = state.plot_rect;
252 let transform = state.transform.clone();
253
254 match drag.mode {
255 DragMode::Pan => {
256 if let (Some(rect), Some(transform)) = (plot_rect, transform) {
257 if let Ok(mut plot) = self.plot.write() {
258 if let Some(viewport) = plot.viewport() {
259 if let Some(next) = pan_viewport(viewport, delta, &transform) {
260 self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
261 }
262 }
263 }
264 }
265 }
266 DragMode::ZoomRect => {
267 state.selection_rect = Some(ScreenRect::new(drag.start, pos));
268 }
269 DragMode::ZoomX => {
270 if let (Some(rect), Some(transform)) = (plot_rect, transform) {
271 let axis_pixels = rect.width().max(1.0);
272 let factor = zoom_factor_from_drag(delta.x, axis_pixels);
273 if let Ok(mut plot) = self.plot.write() {
274 if let Some(viewport) = plot.viewport() {
275 let center = transform
276 .screen_to_data(pos)
277 .unwrap_or_else(|| viewport.x_center());
278 let next = zoom_viewport(viewport, center, factor, 1.0);
279 self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
280 }
281 }
282 }
283 }
284 DragMode::ZoomY => {
285 if let (Some(rect), Some(transform)) = (plot_rect, transform) {
286 let axis_pixels = rect.height().max(1.0);
287 let factor = zoom_factor_from_drag(-delta.y, axis_pixels);
288 if let Ok(mut plot) = self.plot.write() {
289 if let Some(viewport) = plot.viewport() {
290 let center = transform
291 .screen_to_data(pos)
292 .unwrap_or_else(|| viewport.y_center());
293 let next = zoom_viewport(viewport, center, 1.0, factor);
294 self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
295 }
296 }
297 }
298 }
299 }
300
301 drag.last = pos;
302 state.drag = Some(drag);
303 state.pending_click = None;
304 cx.notify();
305 }
306
307 fn on_hover_state_change(&mut self, hovered: bool, window: &Window, cx: &mut Context<Self>) {
308 if hovered {
309 return;
310 }
311
312 let cursor = screen_point(window.mouse_position());
313 let mut state = self.state.write().expect("plot state lock");
314 let still_inside = state.legend_hit(cursor).is_some()
315 || state.regions.hit_test(cursor) != HitRegion::Outside;
316 if still_inside {
317 return;
318 }
319
320 let changed = state.hover.take().is_some() || state.hover_target.take().is_some();
321 state.last_cursor = None;
322 drop(state);
323
324 self.publish_cursor_link(None);
325 if changed {
326 cx.notify();
327 }
328 }
329
330 fn on_mouse_up(&mut self, ev: &MouseUpEvent, cx: &mut Context<Self>) {
331 let pos = screen_point(ev.position);
332 let mut state = self.state.write().expect("plot state lock");
333 let drag = state.drag.clone();
334
335 if let Some(drag_state) = drag.as_ref() {
336 if drag_state.active && drag_state.mode == DragMode::ZoomRect {
337 if let (Some(rect), Some(transform)) =
338 (state.selection_rect.take(), state.transform.clone())
339 {
340 let rect = normalized_rect(rect);
341 if let Ok(mut plot) = self.plot.write() {
342 if let Some(viewport) = plot.viewport() {
343 if let Some(next) = zoom_to_rect(viewport, rect, &transform) {
344 self.apply_manual_view_with_link(
345 &mut plot,
346 &mut state,
347 transform.screen(),
348 next,
349 );
350 self.publish_brush_link(Some(next.x));
351 }
352 }
353 }
354 }
355 }
356 }
357
358 let click = state.pending_click.take();
359 let should_toggle = click.as_ref().is_some_and(|click| {
360 click.button == MouseButton::Left && click.region == HitRegion::Plot
361 }) && drag.as_ref().is_none_or(|drag| !drag.active)
362 && ev.click_count == 1;
363
364 if should_toggle {
365 if let Some(transform) = state.transform.clone() {
366 if let Ok(mut plot) = self.plot.write() {
367 let target = state
368 .hover_target
369 .filter(|target| hover_target_within_threshold(target, pos, &self.config))
370 .or_else(|| {
371 compute_hover_target(
372 &plot,
373 &transform,
374 pos,
375 state.plot_rect,
376 self.config.pin_threshold_px,
377 self.config.unpin_threshold_px,
378 )
379 });
380
381 if let Some(target) = target {
382 let added = toggle_pin(plot.pins_mut(), target.pin);
383 let now = Instant::now();
384 state.last_pin_toggle = Some(PinToggle {
385 pin: target.pin,
386 added,
387 at: now,
388 screen_pos: target.screen,
389 });
390 }
391 }
392 }
393 } else if ev.click_count > 1 {
394 state.last_pin_toggle = None;
395 }
396
397 state.drag = None;
398 state.selection_rect = None;
399 self.publish_cursor_link(None);
400 cx.notify();
401 }
402
403 fn on_mouse_up_out(&mut self, _ev: &MouseUpEvent, cx: &mut Context<Self>) {
404 let mut state = self.state.write().expect("plot state lock");
405 state.clear_interaction();
406 self.publish_cursor_link(None);
407 cx.notify();
408 }
409
410 fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &Window, cx: &mut Context<Self>) {
411 let pos = screen_point(ev.position);
412 let mut state = self.state.write().expect("plot state lock");
413 if state.legend_hit(pos).is_some() {
414 return;
415 }
416 let region = state.regions.hit_test(pos);
417 let Some(transform) = state.transform.clone() else {
418 return;
419 };
420
421 let line_height = px(16.0);
422 let delta = ev.delta.pixel_delta(line_height);
423 let factor = scroll_zoom_factor(f32::from(delta.y));
424 if factor == 1.0 {
425 return;
426 }
427
428 if let Ok(mut plot) = self.plot.write() {
429 if let Some(viewport) = plot.viewport() {
430 let center = transform
431 .screen_to_data(pos)
432 .unwrap_or_else(|| viewport.center());
433 let (factor_x, factor_y) = match region {
434 HitRegion::XAxis => (factor, 1.0),
435 HitRegion::YAxis => (1.0, factor),
436 HitRegion::Plot => (factor, factor),
437 HitRegion::Outside => (1.0, 1.0),
438 };
439 if factor_x != 1.0 || factor_y != 1.0 {
440 let next = zoom_viewport(viewport, center, factor_x, factor_y);
441 if let Some(rect) = state.plot_rect {
442 self.apply_manual_view_with_link(&mut plot, &mut state, rect, next);
443 }
444 }
445 }
446 }
447
448 cx.notify();
449 }
450}
451
452impl Render for PlotView {
453 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
454 let plot = Arc::clone(&self.plot);
455 let state = Arc::clone(&self.state);
456 let config = self.config.clone();
457 let link = self.link.clone();
458 let base_theme = plot.read().expect("plot lock").theme().clone();
459 #[cfg(feature = "gpui_component_theme")]
460 let theme = resolve_theme(base_theme, cx);
461 #[cfg(not(feature = "gpui_component_theme"))]
462 let theme = base_theme;
463 let hover_region_id = Arc::as_ptr(&self.state) as usize;
464
465 div()
466 .id(("gpui-plot-view", hover_region_id))
467 .size_full()
468 .bg(to_hsla(theme.background))
469 .child(
470 canvas(
471 move |bounds, window, _app| {
472 let mut plot = plot.write().expect("plot lock");
473 let mut state = state.write().expect("plot state lock");
474 #[cfg(feature = "gpui_component_theme")]
475 if let Some(theme) = resolve_gpui_component_theme(_app) {
476 plot.set_theme(theme);
477 }
478 if let Some(link) = &link {
479 apply_link_updates(link, &mut plot, &mut state);
480 }
481 build_frame(&mut plot, &mut state, &config, bounds, window)
482 },
483 move |_, frame, window, cx| {
484 paint_frame(&frame, window, cx);
485 },
486 )
487 .size_full(),
488 )
489 .on_mouse_down(
490 MouseButton::Left,
491 cx.listener(|this, ev, _, cx| {
492 this.on_mouse_down(ev, cx);
493 }),
494 )
495 .on_mouse_down(
496 MouseButton::Right,
497 cx.listener(|this, ev, _, cx| {
498 this.on_mouse_down(ev, cx);
499 }),
500 )
501 .on_mouse_move(cx.listener(|this, ev, _, cx| {
502 this.on_mouse_move(ev, cx);
503 }))
504 .on_hover(cx.listener(|this, hovered, window, cx| {
505 this.on_hover_state_change(*hovered, window, cx);
506 }))
507 .on_mouse_up(
508 MouseButton::Left,
509 cx.listener(|this, ev, _, cx| {
510 this.on_mouse_up(ev, cx);
511 }),
512 )
513 .on_mouse_up(
514 MouseButton::Right,
515 cx.listener(|this, ev, _, cx| {
516 this.on_mouse_up(ev, cx);
517 }),
518 )
519 .on_mouse_up_out(
520 MouseButton::Left,
521 cx.listener(|this, ev, _, cx| {
522 this.on_mouse_up_out(ev, cx);
523 }),
524 )
525 .on_mouse_up_out(
526 MouseButton::Right,
527 cx.listener(|this, ev, _, cx| {
528 this.on_mouse_up_out(ev, cx);
529 }),
530 )
531 .on_scroll_wheel(cx.listener(|this, ev, window, cx| {
532 this.on_scroll(ev, window, cx);
533 }))
534 }
535}
536
537#[cfg(feature = "gpui_component_theme")]
538fn resolve_gpui_component_theme(cx: &gpui::App) -> Option<crate::style::Theme> {
539 if cx.has_global::<gpui_component::Theme>() {
540 Some(crate::style::Theme::from_gpui_component_theme(
541 gpui_component::Theme::global(cx),
542 ))
543 } else {
544 None
545 }
546}
547
548#[cfg(feature = "gpui_component_theme")]
549fn resolve_theme(base: crate::style::Theme, cx: &gpui::App) -> crate::style::Theme {
550 resolve_gpui_component_theme(cx).unwrap_or(base)
551}
552
553#[derive(Debug, Clone)]
557pub struct PlotHandle {
558 plot: Arc<RwLock<Plot>>,
559}
560
561impl PlotHandle {
562 pub fn read<R>(&self, f: impl FnOnce(&Plot) -> R) -> R {
566 let plot = self.plot.read().expect("plot lock");
567 f(&plot)
568 }
569
570 pub fn write<R>(&self, f: impl FnOnce(&mut Plot) -> R) -> R {
574 let mut plot = self.plot.write().expect("plot lock");
575 f(&mut plot)
576 }
577}
578
579fn apply_link_updates(link: &LinkBinding, plot: &mut Plot, state: &mut PlotUiState) {
580 if let Some(update) = link.group.latest_view_update()
581 && update.seq > state.link_view_seq
582 {
583 state.link_view_seq = update.seq;
584 if update.source != link.member_id {
585 match update.kind {
586 ViewSyncKind::Reset => {
587 if link.options.link_reset {
588 plot.reset_view();
589 state.viewport = None;
590 state.transform = None;
591 state.linked_brush_x = None;
592 }
593 }
594 ViewSyncKind::Manual {
595 viewport,
596 sync_x,
597 sync_y,
598 } => {
599 let mut next = plot
600 .viewport()
601 .or_else(|| plot.data_bounds())
602 .unwrap_or(viewport);
603 let mut changed = false;
604 if sync_x && link.options.link_x {
605 next.x = viewport.x;
606 changed = true;
607 }
608 if sync_y && link.options.link_y {
609 next.y = viewport.y;
610 changed = true;
611 }
612 if changed {
613 plot.set_manual_view(next);
614 state.viewport = Some(next);
615 if let Some(rect) = state.plot_rect {
616 state.transform = Transform::new(next, rect);
617 }
618 }
619 }
620 }
621 }
622 }
623
624 if let Some(update) = link.group.latest_cursor_update()
625 && update.seq > state.link_cursor_seq
626 {
627 state.link_cursor_seq = update.seq;
628 if update.source != link.member_id && link.options.link_cursor {
629 state.linked_cursor_x = update.x;
630 }
631 }
632
633 if let Some(update) = link.group.latest_brush_update()
634 && update.seq > state.link_brush_seq
635 {
636 state.link_brush_seq = update.seq;
637 if update.source != link.member_id && link.options.link_brush {
638 state.linked_brush_x = update.x_range;
639 if let Some(x_range) = update.x_range {
640 let y_range = plot
641 .viewport()
642 .or_else(|| plot.data_bounds())
643 .map(|viewport| viewport.y)
644 .unwrap_or_else(|| Range::new(0.0, 1.0));
645 let next = Viewport::new(x_range, y_range);
646 plot.set_manual_view(next);
647 state.viewport = Some(next);
648 if let Some(rect) = state.plot_rect {
649 state.transform = Transform::new(next, rect);
650 }
651 }
652 }
653 }
654}
655
656fn screen_point(point: Point<Pixels>) -> ScreenPoint {
657 ScreenPoint::new(f32::from(point.x), f32::from(point.y))
658}
659
660fn apply_manual_view(
661 plot: &mut Plot,
662 state: &mut PlotUiState,
663 rect: ScreenRect,
664 viewport: Viewport,
665) {
666 plot.set_manual_view(viewport);
667 state.viewport = Some(viewport);
668 state.transform = Transform::new(viewport, rect);
669}
670
671fn revert_pin_toggle(plot: &mut Plot, toggle: PinToggle) {
672 let pins = plot.pins_mut();
673 if toggle.added {
674 if let Some(index) = pins.iter().position(|pin| *pin == toggle.pin) {
675 pins.swap_remove(index);
676 }
677 } else if !pins.contains(&toggle.pin) {
678 pins.push(toggle.pin);
679 }
680}
681
682fn is_drag_button_held(mode: DragMode, pressed_button: Option<MouseButton>) -> bool {
683 let expected = match mode {
684 DragMode::ZoomRect => MouseButton::Right,
685 DragMode::Pan | DragMode::ZoomX | DragMode::ZoomY => MouseButton::Left,
686 };
687 pressed_button == Some(expected)
688}
689
690fn scroll_zoom_factor(delta_y: f32) -> f64 {
691 if delta_y.abs() < 0.01 {
692 return 1.0;
693 }
694
695 (1.0 - (delta_y as f64 * 0.002)).clamp(0.1, 10.0)
696}
697
698trait ViewportCenter {
699 fn center(&self) -> DataPoint;
700 fn x_center(&self) -> DataPoint;
701 fn y_center(&self) -> DataPoint;
702}
703
704impl ViewportCenter for Viewport {
705 fn center(&self) -> DataPoint {
706 DataPoint::new(
707 (self.x.min + self.x.max) * 0.5,
708 (self.y.min + self.y.max) * 0.5,
709 )
710 }
711
712 fn x_center(&self) -> DataPoint {
713 DataPoint::new(
714 (self.x.min + self.x.max) * 0.5,
715 (self.y.min + self.y.max) * 0.5,
716 )
717 }
718
719 fn y_center(&self) -> DataPoint {
720 DataPoint::new(
721 (self.x.min + self.x.max) * 0.5,
722 (self.y.min + self.y.max) * 0.5,
723 )
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::{DragMode, MouseButton, is_drag_button_held, scroll_zoom_factor};
730
731 #[test]
732 fn drag_requires_matching_button() {
733 assert!(is_drag_button_held(DragMode::Pan, Some(MouseButton::Left)));
734 assert!(is_drag_button_held(
735 DragMode::ZoomX,
736 Some(MouseButton::Left)
737 ));
738 assert!(is_drag_button_held(
739 DragMode::ZoomY,
740 Some(MouseButton::Left)
741 ));
742 assert!(is_drag_button_held(
743 DragMode::ZoomRect,
744 Some(MouseButton::Right)
745 ));
746 assert!(!is_drag_button_held(
747 DragMode::Pan,
748 Some(MouseButton::Right)
749 ));
750 assert!(!is_drag_button_held(DragMode::ZoomRect, None));
751 }
752
753 #[test]
754 fn positive_scroll_delta_zooms_in_after_reversal() {
755 let factor = scroll_zoom_factor(120.0);
756 assert!(factor < 1.0, "expected zoom-in factor, got {factor}");
757 }
758
759 #[test]
760 fn negative_scroll_delta_zooms_out_after_reversal() {
761 let factor = scroll_zoom_factor(-120.0);
762 assert!(factor > 1.0, "expected zoom-out factor, got {factor}");
763 }
764}