use std::rc::Rc; use gpui::{ App, Bounds, Div, Entity, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, Pixels, Point, Stateful, StatefulInteractiveElement, Styled, Window, canvas, fill, prelude::*, size, }; use super::{ geometry::{Dot, Geometry, Ink, Rect}, paint::{Pen, at, finish, paint, place, ring}, plot::{Pick, Plot, State}, scale::Band, }; /// What the pointer at a place picks: a category's band, the nearest category, or the nearest dot within reach. pub(crate) fn pick_at( pick: Pick, place: (f32, f32), frame: Rect, horizontal: bool, span: (usize, usize), dots: &[Dot], reach: f32, ) -> Option { if !frame.contains(place) { return None; } let count = span.1 - span.0 + 1; let (along, start, length) = if horizontal { (place.1, frame.y, frame.h) } else { (place.0, frame.x, frame.w) }; match pick { Pick::Band => Band { count, range: (start, start + length), padding: 0.0, } .index(along) .map(|ix| ix + span.0), Pick::Point if count < 2 => Some(span.0), Pick::Point => { let ix = ((along - start) / (length / (count - 1) as f32)).round() as usize; Some(ix.min(count - 1) + span.0) } Pick::Dot => dots .iter() .map(|dot| (dot, (dot.center.0 - place.0).hypot(dot.center.1 - place.1))) .filter(|(dot, far)| *far <= dot.radius.max(reach)) .min_by(|a, b| a.1.total_cmp(&b.1)) .map(|(dot, _)| dot.key), } } /// What the picked index lights: its band, its points on each line, or its dot. pub(crate) enum Focus { Band(Rect), Line(f32, Vec<(Ink, (f32, f32))>), Dot(Dot), } /// What the picked index lights: its band, its points on each line, or its dot. pub(crate) fn focus( plot: &Plot, geometry: &Geometry, hover: Option, (rect, span): (Rect, (usize, usize)), ) -> Option { let ix = hover?; match plot.pick { Pick::Dot => geometry .dots .iter() .find(|dot| dot.key == ix) .map(|dot| Focus::Dot(*dot)), Pick::Band => { let range = if plot.horizontal { (rect.y, rect.y + rect.h) } else { (rect.x, rect.x + rect.w) }; let lanes = Band { count: span.1 - span.0 + 1, range, padding: 0.0, }; let (start, width) = lanes.slot(ix - span.0); Some(Focus::Band(if plot.horizontal { Rect { x: rect.x, y: start, w: rect.w, h: width, } } else { Rect { x: start, y: rect.y, w: width, h: rect.h, } })) } Pick::Point => { let x = geometry.labels.get(ix - span.0)?.0; let points = geometry .lines .iter() .filter_map(|line| Some((line.ink, *line.points.get(ix - span.0)?))) .collect(); Some(Focus::Line(x, points)) } } } /// The plot's canvas: the band under the pointer, the marks, dashed rules and note lines, the crosshair or the lit dot, and a brush. pub(crate) fn drawing( geometry: Rc, pen: Pen, rect: Rect, (focus, brush): (Option, Option<(f32, f32)>), (rules, notes): (Vec, Vec), ) -> impl IntoElement { canvas( |_, _, _| {}, move |bounds, _, window, _| { let origin = bounds.origin; let colors = &pen.colors; if let Some(Focus::Band(band)) = &focus { window.paint_quad(fill(place(origin, *band), colors.hover)); } paint(&geometry, rect, &pen, origin, window); let dash = [pen.stroke * 2.0, pen.stroke * 2.0]; for level in &rules { let mut rule = PathBuilder::stroke(pen.hairline).dash_array(&dash); let (from, to) = if pen.horizontal { ((*level, rect.y), (*level, rect.y + rect.h)) } else { ((rect.x, *level), (rect.x + rect.w, *level)) }; rule.move_to(at(origin, from)); rule.line_to(at(origin, to)); finish(rule, colors.fg_muted, window); } let upright = |x: f32| { Bounds::new( at(origin, (x, rect.y)), size(pen.hairline, Pixels::from(rect.h)), ) }; for x in ¬es { window.paint_quad(fill(upright(*x), colors.fg_subtle.opacity(0.6))); } match &focus { Some(Focus::Line(x, points)) => { window.paint_quad(fill(upright(*x), colors.border_strong)); for (ink, point) in points { let ends = (colors.bg, ink.color(colors)); ring( at(origin, *point), pen.stroke * 2.0, ends, pen.stroke, window, ); } } Some(Focus::Dot(dot)) => { let ends = (dot.ink.color(colors), colors.bg); ring( at(origin, dot.center), Pixels::from(dot.radius), ends, pen.stroke, window, ); } _ => {} } if let Some((a, b)) = brush { let band = Rect { x: a.min(b), y: rect.y, w: (a - b).abs(), h: rect.h, }; window.paint_quad(fill(place(origin, band), colors.selection)); } }, ) .absolute() .inset_0() } /// A place in window pixels as the plot's own. fn local(position: Point, bounds: Bounds) -> (f32, f32) { let offset = position - bounds.origin; (f32::from(offset.x), f32::from(offset.y)) } /// Wires the pointer: hover picks, and on a zoomable plot a drag brushes a span and a double press shows all. pub(crate) fn answer( root: Stateful
, state: &Entity, (pick, horizontal, zoomable): (Pick, bool, bool), (rect, span, reach): (Rect, (usize, usize), f32), geometry: Rc, ) -> Stateful
{ let (moved, left, pressed, released) = (state.clone(), state.clone(), state.clone(), state.clone()); let end_brush = move |_: &MouseUpEvent, _: &mut Window, cx: &mut App| { released.update(cx, |state, cx| { let Some((a, b)) = state.brush.take() else { return; }; let middle = rect.y + rect.h / 2.0; let end = |x: f32| { let place = (x.clamp(rect.x, rect.x + rect.w), middle); pick_at(pick, place, rect, false, span, &[], 0.0) }; if let (Some(from), Some(to)) = (end(a.min(b)), end(a.max(b))) && to > from { state.span = Some((from, to)); log::info!("chart: zoomed to {from}..={to}"); } cx.notify(); }) }; root.on_mouse_move(move |event: &MouseMoveEvent, _, cx| { let place = local(event.position, moved.read(cx).bounds); moved.update(cx, |state, cx| { if event.pressed_button == Some(MouseButton::Left) && let Some(brush) = state.brush.as_mut() { brush.1 = place.0.clamp(rect.x, rect.x + rect.w); cx.notify(); return; } let next = pick_at(pick, place, rect, horizontal, span, &geometry.dots, reach); if state.hover != next || (next.is_some() && pick != Pick::Dot) { state.hover = next; state.pointer = place; cx.notify(); } }) }) .on_hover(move |inside, _, cx| { if !*inside { left.update(cx, |state, cx| { state.hover = None; cx.notify(); }) } }) .when(zoomable, |root| { root.cursor_crosshair() .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { let place = local(event.position, pressed.read(cx).bounds); pressed.update(cx, |state, cx| { if event.click_count == 2 { state.span = None; log::info!("chart: zoom reset"); } else if rect.contains(place) { state.brush = Some((place.0, place.0)); } cx.notify(); }) }) .on_mouse_up(MouseButton::Left, end_brush.clone()) .on_mouse_up_out(MouseButton::Left, end_brush) }) } #[cfg(test)] mod tests { use super::*; #[test] fn the_pointer_picks_bands_points_and_dots() { let frame = Rect { x: 0.0, y: 0.0, w: 100.0, h: 50.0, }; assert_eq!( pick_at(Pick::Band, (30.0, 10.0), frame, false, (0, 3), &[], 0.0), Some(1) ); assert_eq!( pick_at(Pick::Point, (40.0, 10.0), frame, false, (2, 4), &[], 0.0), Some(3), "the nearest of three, past the zoomed start" ); assert_eq!( pick_at(Pick::Band, (30.0, 60.0), frame, false, (0, 3), &[], 0.0), None, "below the frame" ); assert_eq!( pick_at(Pick::Band, (10.0, 30.0), frame, true, (0, 4), &[], 0.0), Some(3), "sideways bands run down" ); let dots = [Dot { ink: Ink::Series(0), center: (10.0, 10.0), radius: 2.0, key: 7, }]; assert_eq!( pick_at(Pick::Dot, (14.0, 10.0), frame, false, (0, 0), &dots, 6.0), Some(7) ); assert_eq!( pick_at(Pick::Dot, (30.0, 10.0), frame, false, (0, 0), &dots, 6.0), None, "out of reach" ); } }