diff --git a/crates/base/src/motion.rs b/crates/base/src/motion.rs index 1ac947cc68..2de123ab49 100644 --- a/crates/base/src/motion.rs +++ b/crates/base/src/motion.rs @@ -143,6 +143,12 @@ impl Transition { self.easing.sample(progress) } + /// The easing curve, for a caller that samples one timeline at several + /// offsets, such as a staggered plot appear. + pub(crate) fn curve(&self) -> &Easing { + &self.easing + } + fn progress(&self, elapsed: Duration, duration: Duration) -> (f32, MotionStatus) { let Some(active_elapsed) = self.delay.active_elapsed(elapsed) else { return (0.0, MotionStatus::Delayed); diff --git a/crates/base/src/plot/appear.rs b/crates/base/src/plot/appear.rs new file mode 100644 index 0000000000..d7c17221ac --- /dev/null +++ b/crates/base/src/plot/appear.rs @@ -0,0 +1,282 @@ +//! The appear motion shared by every [`Plot`](super::Plot): how far its data +//! marks have drawn in since the plot was first painted. +//! +//! This is behavior only. A plot decides what appearing looks like — a line +//! revealed from the left, bars growing from zero — and a styled layer +//! projects the timing through [`PlotMotion`](crate::PlotMotion). +use gpui::{App, ElementId, Window}; + +use crate::{ + Theme, + motion::{Easing, Presence}, +}; + +/// How far a plot's data marks have appeared this frame, handed to +/// [`Plot::appear`](super::Plot::appear). +/// +/// The appear starts on the first frame a plot's id is painted and runs over +/// the active [`PlotMotion`](crate::PlotMotion)'s appear. Base's default +/// duration is zero, and reduced motion skips it, so a plot is then complete +/// from its first frame. +#[derive(Clone)] +pub struct PlotAppear { + /// Linear time through the appear, from `0` to `1`. + time: f32, + easing: Easing, +} + +impl PlotAppear { + /// A finished appear: every mark is complete. + pub fn complete() -> Self { + Self { + time: 1., + easing: Easing::Linear, + } + } + + /// How far the whole plot has appeared, from `0` to `1`, eased. + pub fn progress(&self) -> f32 { + // Charts read this per mark on every frame, long after the appear is + // done, so a finished appear skips sampling the curve. + if self.time >= 1. { + return 1.; + } + self.easing.sample(self.time) + } + + /// Whether the appear is still running. + pub fn is_appearing(&self) -> bool { + self.time < 1. + } + + /// How far mark `index` of `count` has appeared, from `0` to `1`, eased. + /// + /// The marks start one after another across the first `spread` of the + /// appear (`0..1`) and each runs for the rest of it, so the last mark + /// still finishes with the appear however many marks there are. A `spread` + /// of `0` moves every mark together. + pub fn staggered(&self, index: usize, count: usize, spread: f32) -> f32 { + if count <= 1 || self.time >= 1. { + return self.progress(); + } + let spread = spread.clamp(0., 0.95); + let start = spread * index.min(count - 1) as f32 / (count - 1) as f32; + let time = ((self.time - start) / (1. - spread)).clamp(0., 1.); + self.easing.sample(time) + } +} + +/// The element-state key of a plot's appear, within the plot's scope. +const APPEAR: &str = "__plot-appear"; + +/// Sample the appear of the plot painting under the window's current element +/// id. A new `generation` starts it over. Called by +/// [`PlotElement`](super::PlotElement) within the plot's element scope on every +/// frame, so it borrows the theme rather than cloning it and builds its key +/// without allocating. +pub(super) fn track_appear(generation: u64, window: &mut Window, cx: &mut App) -> PlotAppear { + let Some(policy) = cx + .try_global::() + .map(|theme| theme.plot.motion().appear().clone()) + else { + return PlotAppear::complete(); + }; + let easing = policy.curve().clone(); + // Presence keeps the linear time so the marks can each ease over their + // own slice of it; see `PlotAppear::staggered`. + let sample = Presence::new((ElementId::Integer(generation), APPEAR), true) + .transition(policy.easing(Easing::Linear)) + .sample(window, cx); + PlotAppear { + time: sample.progress, + easing, + } +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc, time::Duration}; + + use gpui::{ + Bounds, Context, ElementId, IntoElement, Pixels, Render, TestAppContext, WindowHandle, px, + size, + }; + + use super::*; + use crate::{ + PlotMotion, PlotTheme, + motion::Transition, + plot::{Plot, PlotElement}, + }; + + /// A plot that records the appear progress it is handed each frame. + struct Recorder { + samples: Rc>>, + generation: Option, + } + + impl IntoElement for Recorder { + type Element = PlotElement; + + fn into_element(self) -> Self::Element { + PlotElement::new(self) + } + } + + impl Plot for Recorder { + fn paint(&mut self, _: Bounds, _: &mut Window, _: &mut App) {} + + fn id(&self) -> Option { + Some("recorder".into()) + } + + // Appear motion rides on the id alone, without the interactive layer. + fn interactive(&self) -> bool { + false + } + + fn appear(&mut self, appear: PlotAppear, _: &mut Window, _: &mut App) { + self.samples.borrow_mut().push(appear.progress()); + } + + fn appear_generation(&self) -> Option { + self.generation + } + } + + struct RecorderView { + samples: Rc>>, + generation: Option, + } + + impl Render for RecorderView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Recorder { + samples: self.samples.clone(), + generation: self.generation, + } + } + } + + fn open( + cx: &mut TestAppContext, + generation: Option, + ) -> (WindowHandle, Rc>>) { + cx.update(|cx| { + cx.set_global(Theme::default()); + Theme::global_mut(cx).plot = PlotTheme::new().with_motion( + PlotMotion::default() + .with_appear(Transition::new(Duration::from_millis(100)).ease(|t| t)), + ); + }); + let samples = Rc::new(RefCell::new(Vec::new())); + let window = cx.open_window(size(px(100.), px(100.)), { + let samples = samples.clone(); + move |_, _| RecorderView { + samples, + generation, + } + }); + cx.run_until_parked(); + (window, samples) + } + + fn next_frame(window: WindowHandle, cx: &mut TestAppContext) -> usize { + let frames = window + .update(cx, |_, window, cx| window.simulate_next_frame(cx)) + .unwrap(); + cx.run_until_parked(); + frames + } + + #[gpui::test] + fn test_plot_appears_once_over_the_theme_duration(cx: &mut TestAppContext) { + let (window, samples) = open(cx, Some(0)); + assert_eq!(samples.borrow().last(), Some(&0.)); + + cx.executor().advance_clock(Duration::from_millis(50)); + next_frame(window, cx); + assert_eq!(samples.borrow().last(), Some(&0.5)); + + cx.executor().advance_clock(Duration::from_millis(50)); + next_frame(window, cx); + assert_eq!(samples.borrow().last(), Some(&1.)); + + // Once whole, the plot stops asking for frames and stays whole. + assert_eq!(next_frame(window, cx), 0); + window.update(cx, |_, window, _| window.refresh()).unwrap(); + cx.run_until_parked(); + assert_eq!(samples.borrow().last(), Some(&1.)); + } + + #[gpui::test] + fn test_reduced_motion_skips_the_appear(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); + let (window, samples) = open(cx, Some(0)); + assert_eq!(samples.borrow().first(), Some(&1.)); + assert_eq!(next_frame(window, cx), 0); + } + + /// A plot that does not opt in is whole at once and asks for no frames, + /// even with an appear duration in the theme. + #[gpui::test] + fn test_plot_without_a_generation_does_not_appear(cx: &mut TestAppContext) { + let (window, samples) = open(cx, None); + assert_eq!(samples.borrow().first(), Some(&1.)); + assert_eq!(next_frame(window, cx), 0); + } + + /// A new generation starts the appear over. + #[gpui::test] + fn test_new_generation_replays_the_appear(cx: &mut TestAppContext) { + let (window, samples) = open(cx, Some(0)); + cx.executor().advance_clock(Duration::from_millis(100)); + next_frame(window, cx); + assert_eq!(samples.borrow().last(), Some(&1.)); + + window + .update(cx, |view, _, cx| { + view.generation = Some(1); + cx.notify(); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(samples.borrow().last(), Some(&0.)); + } + + fn at(time: f32) -> PlotAppear { + PlotAppear { + time, + easing: Easing::Linear, + } + } + + #[test] + fn test_complete_appear() { + let appear = PlotAppear::complete(); + assert!(!appear.is_appearing()); + assert_eq!(appear.progress(), 1.); + assert_eq!(appear.staggered(3, 10, 0.5), 1.); + } + + #[test] + fn test_staggered_marks_share_the_appear() { + // The first mark starts at once, the last once the spread has passed. + assert_eq!(at(0.).staggered(0, 5, 0.5), 0.); + assert_eq!(at(0.25).staggered(0, 5, 0.5), 0.5); + assert_eq!(at(0.5).staggered(4, 5, 0.5), 0.); + assert_eq!(at(0.75).staggered(4, 5, 0.5), 0.5); + // Every mark finishes with the appear. + for index in 0..5 { + assert_eq!(at(1.).staggered(index, 5, 0.5), 1.); + } + } + + #[test] + fn test_staggered_without_spread_moves_together() { + assert_eq!(at(0.4).staggered(0, 3, 0.), 0.4); + assert_eq!(at(0.4).staggered(2, 3, 0.), 0.4); + // A lone mark ignores the spread. + assert_eq!(at(0.4).staggered(0, 1, 0.5), 0.4); + } +} diff --git a/crates/base/src/plot/element.rs b/crates/base/src/plot/element.rs index 9e8939d7d3..893acbbb4f 100644 --- a/crates/base/src/plot/element.rs +++ b/crates/base/src/plot/element.rs @@ -1,5 +1,5 @@ -//! The element behind every [`Plot`]: layout, hover tracking and the overlay -//! the plot returns from [`Plot::tooltip`]. +//! The element behind every [`Plot`]: layout, appear and hover tracking and +//! the overlay the plot returns from [`Plot::tooltip`]. use std::{cell::Cell, rc::Rc}; use gpui::{ @@ -8,10 +8,11 @@ use gpui::{ Style, TouchPhase, Window, }; -use super::{Plot, hover::track_hover}; +use super::{Plot, PlotAppear, appear::track_appear, hover::track_hover}; -/// Paints a [`Plot`] filling its container, with hover tracking and the plot's -/// tooltip overlay when the plot has an [`Plot::id`]. +/// Paints a [`Plot`] filling its container, with appear tracking when the plot +/// has an [`Plot::id`], and hover tracking and the plot's tooltip overlay when +/// it is also [`Plot::interactive`]. /// /// A plot becomes an element through this type: /// @@ -60,7 +61,8 @@ impl Element for PlotElement

{ type PrepaintState = (Option, Vec, Option); fn id(&self) -> Option { - // `Some` opts the plot in to interactive tooltips. + // `Some` gives the plot element state: appear motion and, when + // interactive, tooltips. self.0.id() } @@ -99,6 +101,17 @@ impl Element for PlotElement

{ return (None, children, None); }; + let appear = match self.0.appear_generation() { + Some(generation) => track_appear(generation, window, cx), + None => PlotAppear::complete(), + }; + let appearing = appear.is_appearing(); + self.0.appear(appear, window, cx); + + if !self.0.interactive() { + return (None, children, None); + } + // `Hitbox::is_hovered` is false while an open popup or modal covers the plot. let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); @@ -109,7 +122,11 @@ impl Element for PlotElement

{ .map(|_| window.mouse_position()) .filter(|mouse| bounds.contains(mouse)) .map(|mouse| mouse - bounds.origin); - let live = cursor.and_then(|position| self.0.tooltip_state(position, bounds, cx)); + // No tooltip while the marks draw in: its dots would land on data not + // painted yet. + let live = cursor + .filter(|_| !appearing) + .and_then(|position| self.0.tooltip_state(position, bounds, cx)); // The datum under the cursor, or the last one while its hover fades out. let hover = track_hover(live, cursor, window, cx); diff --git a/crates/base/src/plot/mod.rs b/crates/base/src/plot/mod.rs index d1e7c58288..f764c1a069 100644 --- a/crates/base/src/plot/mod.rs +++ b/crates/base/src/plot/mod.rs @@ -2,7 +2,9 @@ //! hover tracking behind every [`Plot`]. //! //! Colors are always handed in by the caller. A styled layer supplies chart -//! defaults, the tooltip overlay, and hover timing through [`PlotMotion`]. +//! defaults, the tooltip overlay, and hover and appear timing through +//! [`PlotMotion`]. +mod appear; mod axis; mod element; mod grid; @@ -21,6 +23,7 @@ use gpui::{ use crate::{Spring, motion::Transition}; +pub use appear::PlotAppear; #[allow(deprecated)] pub use axis::AXIS_GAP; pub use axis::{AxisLabelPlacement, AxisLabelSide, AxisText, PlotAxis, axis_gutter}; @@ -31,8 +34,9 @@ pub use label::PlotLabel; pub use path_cache::{PathCache, PathCaches, ShapeKey}; pub use scale::PlotValue; -/// The timing of a plot's hover: how its progress fades in and out, and the -/// spring a pointer follows the hovered datum with. +/// The timing of a plot's motion: how its data marks appear when it is first +/// painted, how its hover progress fades in and out, and the spring a pointer +/// follows the hovered datum with. /// /// Base installs no motion of its own: every duration defaults to zero, so the /// hover appears, fades and glides at once. Product timing belongs to the @@ -42,6 +46,7 @@ pub struct PlotMotion { pointer: Spring, enter: Transition, exit: Transition, + appear: Transition, } impl Default for PlotMotion { @@ -50,6 +55,7 @@ impl Default for PlotMotion { pointer: Spring::new(Duration::ZERO), enter: Transition::new(Duration::ZERO), exit: Transition::new(Duration::ZERO), + appear: Transition::new(Duration::ZERO), } } } @@ -74,6 +80,13 @@ impl PlotMotion { self } + /// How a plot's data marks appear the first time it is painted; see + /// [`PlotAppear`]. + pub fn with_appear(mut self, appear: Transition) -> Self { + self.appear = appear; + self + } + pub fn pointer(&self) -> Spring { self.pointer } @@ -85,6 +98,10 @@ impl PlotMotion { pub fn exit(&self) -> &Transition { &self.exit } + + pub fn appear(&self) -> &Transition { + &self.appear + } } pub trait Plot: IntoElement { @@ -110,11 +127,13 @@ pub trait Plot: IntoElement { fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App); - /// A stable element id that enables interactive tooltip support for this plot. + /// A stable element id that keeps this plot's state across frames. /// - /// Return `Some(id)` to opt in to tooltips and hover motion; the id must be unique - /// among sibling elements. Returning `None` (the default for a hand-written plot) - /// disables all tooltip behavior, leaving the plot a pure, non-interactive element. + /// Return `Some(id)` to opt in to appear motion and, unless + /// [`Plot::interactive`] says otherwise, tooltips and hover motion; the id + /// must be unique among sibling elements. Returning `None` (the default for + /// a hand-written plot) leaves the plot a pure element that neither appears + /// nor tracks hover. /// /// The charts in GPUI Component always return `Some`: their id defaults to the /// source location they were constructed at, and `id` renames it. @@ -122,6 +141,33 @@ pub trait Plot: IntoElement { None } + /// Whether a plot with an [`Plot::id`] tracks hover and shows its tooltip. + /// + /// `false` keeps the id's state — appear motion and path caches — without + /// the hitbox, hover tracking or overlay. The default is `true`, so a plot + /// opts in to tooltips by returning an id. + fn interactive(&self) -> bool { + true + } + + /// Receive how far the plot's data marks have appeared this frame, before + /// [`Plot::hover`] and [`Plot::paint`] run. + /// + /// Called on every frame the plot has an [`Plot::id`]; see [`PlotAppear`]. + /// Without an [`Plot::appear_generation`] the appear is always complete. + /// The default ignores it. + fn appear(&mut self, _appear: PlotAppear, _window: &mut Window, _cx: &mut App) {} + + /// Opt in to appear motion: `Some` draws the plot in the first time its + /// id is painted, and again whenever the value changes, such as when a + /// chart switches to another symbol or period. + /// + /// The default, `None`, tracks no appear, keeps no state for it and asks + /// for no frames, so a plot that does not draw in costs nothing. + fn appear_generation(&self) -> Option { + None + } + /// Map the cursor to the tooltip state to display. /// /// `position` is the cursor position relative to the plot's top-left origin (already diff --git a/crates/component/src/chart/area_chart.rs b/crates/component/src/chart/area_chart.rs index bffde5044e..26128ed364 100644 --- a/crates/component/src/chart/area_chart.rs +++ b/crates/component/src/chart/area_chart.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{hash::Hash, rc::Rc}; use gpui::{ AnyElement, App, Background, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, @@ -9,7 +9,7 @@ use gpui_component_macros::IntoPlot; use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, Curve, PathCaches, Plot, PlotAxis, + AxisLabelPlacement, Curve, PathCaches, Plot, PlotAppear, PlotAxis, scale::{PlotValue, Scale, ScaleLinear, ScalePoint}, shape::Area, tooltip::{CrossLine, Dot, Tooltip, TooltipState}, @@ -17,9 +17,9 @@ use crate::{ }; use super::{ - AXIS_GAP, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, + AXIS_GAP, ChartAppear, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask, - point_range, point_value_scale, + point_range, point_value_scale, reveal_mask, }; #[derive(IntoPlot)] @@ -45,6 +45,7 @@ where axes: PointAxes, id: ElementId, interactive: bool, + appear: ChartAppear, } impl AreaChart @@ -74,6 +75,7 @@ where axes: PointAxes::default(), id: caller_id(), interactive: true, + appear: ChartAppear::default(), } } @@ -94,13 +96,32 @@ where /// and a dot per series mark the hovered point, and a tooltip shows a row /// each. Turn it off for a chart that only decorates, or one an element above /// it wants the cursor for: without a hitbox it neither answers the mouse nor - /// takes the hover from what sits over it. A chart that is off also drops its - /// path cache, which is keyed on the same id. + /// takes the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the name of the most recently added series, shown in its tooltip row. /// /// Call after the matching [`AreaChart::y`] (e.g. `.y(..).stroke(..).name("Desktop")`). @@ -458,11 +479,11 @@ where .y_domain .is_some() .then(|| pinned_plot_mask(bounds, height)); + // The areas draw in from the left under a mask, so their shapes, and + // the cached paths, stay the same on every frame of the appear. + let reveal = reveal_mask(bounds, left, self.appear.get().progress()); window.with_content_mask(mask, |window| { - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off tessellates afresh each paint. - if self.interactive { + window.with_content_mask(reveal, |window| { let caches = PathCaches::for_paint("areas", window, cx); caches.update(cx, |caches, _| { for (i, area) in areas.enumerate() { @@ -470,11 +491,7 @@ where area.paint_cached(&bounds, fill, line, window); } }); - } else { - for area in areas { - area.paint(&bounds, window); - } - } + }); }); self.axes @@ -483,7 +500,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/bar_chart.rs b/crates/component/src/chart/bar_chart.rs index c9e96d2f8f..b18055362f 100644 --- a/crates/component/src/chart/bar_chart.rs +++ b/crates/component/src/chart/bar_chart.rs @@ -9,7 +9,7 @@ use gpui_component_macros::IntoPlot; use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel, + AxisLabelPlacement, AxisLabelSide, AxisText, Grid, Plot, PlotAppear, PlotAxis, PlotLabel, label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width}, scale::{PlotValue, Scale, ScaleBand, ScaleLinear}, shape::{Bar, BarAlignment}, @@ -18,8 +18,8 @@ use crate::{ }; use super::{ - AXIS_GAP, MAX_BAND_WIDTH, TickFormat, TooltipContent, VALUE_AXIS_GAP, build_band_labels, - caller_id, format_tick, labeled_items, value_axis_gap, + AXIS_GAP, ChartAppear, MAX_BAND_WIDTH, TickFormat, TooltipContent, VALUE_AXIS_GAP, + build_band_labels, caller_id, format_tick, labeled_items, value_axis_gap, }; /// How much the bars away from the hovered one fade, as a share of their opacity. @@ -68,6 +68,7 @@ where min_length: f32, id: ElementId, interactive: bool, + appear: ChartAppear, name: Option, tooltip_content: TooltipContent, /// The label gaps of horizontal bars, measured in `prepaint` for the frame, @@ -115,6 +116,7 @@ where min_length: 0., id: caller_id(), interactive: true, + appear: ChartAppear::default(), name: None, tooltip_content: TooltipContent::default(), horizontal_gaps: (0., 0.), @@ -140,13 +142,32 @@ where /// marks the hovered band, and a tooltip shows its category and value. Turn /// it off for a chart that only decorates, or one an element above it wants /// the cursor for: without a hitbox it neither answers the mouse nor takes - /// the hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); @@ -963,6 +984,10 @@ where 1. - HOVER_DIM * hover.focus * distance }; + // Every bar grows out of the zero line together as the chart appears, + // the way Chart.js draws bars in. + let appear = self.appear.get().progress(); + let mut bar = Bar::new() .data(&self.data) .alignment(alignment) @@ -970,13 +995,14 @@ where .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset)) .base(move |_| zero_pixel) .value(move |d| { - bar_end( + let end = bar_end( &value_scale, value_fn_cloned(d), zero_pixel, alignment, min_length, - ) + )?; + Some(zero_pixel + (end - zero_pixel) * appear) }) .corner_radii(self.corner_radii); @@ -1004,7 +1030,11 @@ where BarAlignment::Right => TextAlign::Right, }; bar = bar.label(move |d, p| { - let color = label_color_fn.as_ref().map_or(label_color, |f| f(d)); + // A value label rides the end of its bar and fades in with it. + let color = label_color_fn + .as_ref() + .map_or(label_color, |f| f(d)) + .opacity(appear); vec![Text::new(label(d), p, color).align(text_align)] }); } @@ -1016,7 +1046,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/candlestick_chart.rs b/crates/component/src/chart/candlestick_chart.rs index acce514bd8..0b64671569 100644 --- a/crates/component/src/chart/candlestick_chart.rs +++ b/crates/component/src/chart/candlestick_chart.rs @@ -10,14 +10,15 @@ use rust_i18n::t; use crate::{ ActiveTheme, plot::{ - Grid, Plot, PlotAxis, origin_point, + Grid, Plot, PlotAppear, PlotAxis, origin_point, scale::{PlotValue, Scale, ScaleBand, ScaleLinear}, tooltip::{CrossLine, Tooltip, TooltipState}, }, }; use super::{ - AXIS_GAP, MAX_BAND_WIDTH, TooltipContent, build_band_labels, caller_id, labeled_items, + AXIS_GAP, ChartAppear, MAX_BAND_WIDTH, TooltipContent, build_band_labels, caller_id, + labeled_items, reveal_mask, }; #[derive(IntoPlot)] @@ -42,6 +43,7 @@ where bearish: Option, id: ElementId, interactive: bool, + appear: ChartAppear, tooltip_content: TooltipContent, } @@ -71,6 +73,7 @@ where bearish: None, id: caller_id(), interactive: true, + appear: ChartAppear::default(), tooltip_content: TooltipContent::default(), } } @@ -92,13 +95,32 @@ where /// band marks the hovered candle, and a tooltip shows its open, high, low and /// close. Turn it off for a chart that only decorates, or one an element /// above it wants the cursor for: without a hitbox it neither answers the - /// mouse nor takes the hover from what sits over it. A chart that is off also - /// drops its path cache, which is keyed on the same id. + /// mouse nor takes the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the hover tooltip's title for a datum, instead of its x value. pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self { self.tooltip_content.set_title(title); @@ -315,68 +337,84 @@ where let low_fn = low_fn.clone(); let close_fn = close_fn.clone(); - for d in &self.data { - let x_tick = x.tick(&x_fn(d)); - let Some(x_tick) = x_tick else { - continue; - }; - - // Get OHLC values for the current data point - let open = open_fn(d); - let high = high_fn(d); - let low = low_fn(d); - let close = close_fn(d); - - // Convert values to pixel coordinates - let open_y = y.tick(&open); - let high_y = y.tick(&high); - let low_y = y.tick(&low); - let close_y = y.tick(&close); - - let (Some(open_y), Some(high_y), Some(low_y), Some(close_y)) = - (open_y, high_y, low_y, close_y) - else { - continue; - }; - - // Determine if bullish (close > open) or bearish (close < open) - let is_bullish = close > open; - let color = if is_bullish { bullish } else { bearish }; - - // Calculate candlestick body dimensions - let center_x = x_tick + band_width / 2.; - let body_width = band_width * self.body_width_ratio; - let body_left = center_x - body_width / 2.; - let body_right = center_x + body_width / 2.; - - // Draw wick (high to low line): a 1px quad, so no stroke to tessellate. - let (wick_top, wick_bottom) = (high_y.min(low_y), high_y.max(low_y)); - let wick_bounds = Bounds::from_corners( - origin_point(px(center_x - 0.5), px(wick_top), origin), - origin_point(px(center_x + 0.5), px(wick_bottom), origin), - ); - window.paint_quad(fill(wick_bounds, color)); - - // Draw body (open to close rectangle) - // For bullish: top is close, bottom is open - // For bearish: top is open, bottom is close - let (top, bottom) = if is_bullish { - (close_y, open_y) - } else { - (open_y, close_y) - }; - - let body_bounds = Bounds::from_corners( - origin_point(px(body_left), px(top), origin), - origin_point(px(body_right), px(bottom), origin), - ); - - window.paint_quad(fill(body_bounds, color)); - } + // The candles draw in from the left under a mask. + let reveal = reveal_mask(bounds, 0., self.appear.get().progress()); + window.with_content_mask(reveal, |window| { + for d in &self.data { + let x_tick = x.tick(&x_fn(d)); + let Some(x_tick) = x_tick else { + continue; + }; + + // Get OHLC values for the current data point + let open = open_fn(d); + let high = high_fn(d); + let low = low_fn(d); + let close = close_fn(d); + + // Convert values to pixel coordinates + let open_y = y.tick(&open); + let high_y = y.tick(&high); + let low_y = y.tick(&low); + let close_y = y.tick(&close); + + let (Some(open_y), Some(high_y), Some(low_y), Some(close_y)) = + (open_y, high_y, low_y, close_y) + else { + continue; + }; + + // Determine if bullish (close > open) or bearish (close < open) + let is_bullish = close > open; + let color = if is_bullish { bullish } else { bearish }; + + // Calculate candlestick body dimensions + let center_x = x_tick + band_width / 2.; + let body_width = band_width * self.body_width_ratio; + let body_left = center_x - body_width / 2.; + let body_right = center_x + body_width / 2.; + + // Draw wick (high to low line): a 1px quad, so no stroke to tessellate. + let (wick_top, wick_bottom) = (high_y.min(low_y), high_y.max(low_y)); + let wick_bounds = Bounds::from_corners( + origin_point(px(center_x - 0.5), px(wick_top), origin), + origin_point(px(center_x + 0.5), px(wick_bottom), origin), + ); + window.paint_quad(fill(wick_bounds, color)); + + // Draw body (open to close rectangle) + // For bullish: top is close, bottom is open + // For bearish: top is open, bottom is close + let (top, bottom) = if is_bullish { + (close_y, open_y) + } else { + (open_y, close_y) + }; + + let body_bounds = Bounds::from_corners( + origin_point(px(body_left), px(top), origin), + origin_point(px(body_right), px(bottom), origin), + ); + + window.paint_quad(fill(body_bounds, color)); + } + }); } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/line_chart.rs b/crates/component/src/chart/line_chart.rs index 8d8096824f..59b965648f 100644 --- a/crates/component/src/chart/line_chart.rs +++ b/crates/component/src/chart/line_chart.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{hash::Hash, rc::Rc}; use gpui::{ AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Size, @@ -9,7 +9,7 @@ use gpui_component_macros::IntoPlot; use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, Curve, PathCaches, Plot, PlotAxis, + AxisLabelPlacement, Curve, PathCaches, Plot, PlotAppear, PlotAxis, scale::{PlotValue, Scale, ScaleLinear, ScalePoint}, shape::Line, tooltip::{CrossLine, Dot, Tooltip, TooltipState}, @@ -17,9 +17,9 @@ use crate::{ }; use super::{ - AXIS_GAP, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, + AXIS_GAP, ChartAppear, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent, axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask, - point_range, point_value_scale, + point_range, point_value_scale, reveal_mask, }; #[derive(IntoPlot)] @@ -43,6 +43,7 @@ where axes: PointAxes, id: ElementId, interactive: bool, + appear: ChartAppear, name: Option, tooltip_content: TooltipContent, } @@ -72,6 +73,7 @@ where axes: PointAxes::default(), id: caller_id(), interactive: true, + appear: ChartAppear::default(), name: None, tooltip_content: TooltipContent::default(), } @@ -94,13 +96,32 @@ where /// and a dot mark the hovered point, and a tooltip shows its value. Turn it /// off for a chart that only decorates, or one an element above it wants the /// cursor for: without a hitbox it neither answers the mouse nor takes the - /// hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); @@ -439,18 +460,16 @@ where .y_domain .is_some() .then(|| pinned_plot_mask(bounds, height)); + // The line draws in from the left under a mask, so its shape, and the + // cached path, stay the same on every frame of the appear. + let reveal = reveal_mask(bounds, left, self.appear.get().progress()); window.with_content_mask(mask, |window| { - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off tessellates afresh each paint. - if self.interactive { + window.with_content_mask(reveal, |window| { let caches = PathCaches::for_paint("line", window, cx); caches.update(cx, |caches, _| { line.paint_cached(&bounds, caches.slot(0), window); }); - } else { - line.paint(&bounds, window); - } + }); }); self.axes @@ -459,7 +478,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/mod.rs b/crates/component/src/chart/mod.rs index 54fbf3464e..97a81ac4c5 100644 --- a/crates/component/src/chart/mod.rs +++ b/crates/component/src/chart/mod.rs @@ -14,7 +14,11 @@ pub use pie_chart::PieChart; pub use radar_chart::{RadarChart, RadarLabel}; pub use sankey_chart::{SankeyChart, SankeyLabel}; -use std::{hash::Hash, panic::Location, rc::Rc}; +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + panic::Location, + rc::Rc, +}; use gpui::{ AnyElement, App, Bounds, ContentMask, ElementId, Hsla, IntoElement, ParentElement as _, Pixels, @@ -24,7 +28,7 @@ use gpui::{ use crate::{ ActiveTheme, plot::{ - AxisLabelPlacement, AxisText, Grid, PlotLabel, + AxisLabelPlacement, AxisText, Grid, PlotAppear, PlotLabel, label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width}, scale::{PlotValue, Scale, ScaleBand, ScaleLinear, ScalePoint}, tooltip::Tooltip, @@ -50,6 +54,76 @@ pub(crate) fn caller_id() -> ElementId { ElementId::CodeLocation(*Location::caller()) } +/// A chart's appear: whether its data draws in the first time it is painted, +/// the key that replays it, and how far it has drawn in this frame. +pub(crate) struct ChartAppear { + enabled: bool, + generation: u64, + current: PlotAppear, +} + +impl Default for ChartAppear { + fn default() -> Self { + Self { + enabled: true, + generation: 0, + current: PlotAppear::complete(), + } + } +} + +impl ChartAppear { + pub(crate) fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + pub(crate) fn set_key(&mut self, key: impl Hash) { + let mut hasher = DefaultHasher::new(); + key.hash(&mut hasher); + self.generation = hasher.finish(); + } + + /// The generation a chart hands [`Plot::appear_generation`], or `None` + /// when it opted out, so no appear is tracked and no frames are asked for. + /// + /// [`Plot::appear_generation`]: crate::plot::Plot::appear_generation + pub(crate) fn generation(&self) -> Option { + self.enabled.then_some(self.generation) + } + + pub(crate) fn update(&mut self, appear: PlotAppear) { + self.current = appear; + } + + pub(crate) fn get(&self) -> &PlotAppear { + &self.current + } +} + +/// The mask a chart that draws in from the left paints its series under while +/// it appears: everything left of `progress` of the way across the plot, which +/// starts `left` into `bounds`. It bleeds by half a hover dot, so a dot on the +/// plot's first point shows whole as soon as the reveal passes it, and there +/// is no mask once the appear is done. +pub(crate) fn reveal_mask( + bounds: Bounds, + left: f32, + progress: f32, +) -> Option> { + if progress >= 1. { + return None; + } + let bleed = HOVER_DOT_SIZE / 2.; + let start = bounds.left() + px(left) - bleed; + let end = start + (bounds.right() + bleed - start) * progress.max(0.); + Some(ContentMask { + bounds: Bounds::from_corners( + gpui::point(bounds.left() - bleed, bounds.top() - bleed), + gpui::point(end, bounds.bottom() + bleed), + ), + }) +} + /// The size of the dot marking the hovered data point. pub(crate) const HOVER_DOT_SIZE: Pixels = px(8.); @@ -610,12 +684,30 @@ mod tests { assert_eq!(Plot::id(&chart()), Plot::id(&chart())); } - /// The escape hatch: no id means no hitbox, so nothing above the chart has - /// to fight it for the cursor. + /// The escape hatch: a chart turned off has no hitbox, so nothing above it + /// has to fight it for the cursor, but it keeps its id for its appear and + /// its caches. + #[test] + fn a_chart_turned_off_keeps_its_id_but_not_its_hitbox() { + let off = chart().interactive(false); + assert!(!Plot::interactive(&off)); + assert!(Plot::id(&off).is_some()); + assert_eq!( + Plot::id(&chart().id("pie").interactive(false)), + Some("pie".into()) + ); + } + + /// Without a key a chart appears once; a key names the generation that + /// replays it. #[test] - fn a_chart_turned_off_has_no_id_to_key_anything_on() { - assert!(Plot::id(&chart().interactive(false)).is_none()); - assert!(Plot::id(&chart().id("pie").interactive(false)).is_none()); + fn an_appear_key_replays_the_appear() { + assert_eq!(Plot::appear_generation(&chart()), Some(0)); + assert_eq!(Plot::appear_generation(&chart().appear(false)), None); + let a = Plot::appear_generation(&chart().appear_key("AAPL.US")); + let b = Plot::appear_generation(&chart().appear_key("TSLA.US")); + assert_ne!(a, b); + assert_eq!(a, Plot::appear_generation(&chart().appear_key("AAPL.US"))); } /// Only a label on the axis's last point hugs the right edge; the last item diff --git a/crates/component/src/chart/pie_chart.rs b/crates/component/src/chart/pie_chart.rs index ffde4c82a6..07af7f63b8 100644 --- a/crates/component/src/chart/pie_chart.rs +++ b/crates/component/src/chart/pie_chart.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{hash::Hash, rc::Rc}; use gpui::{ AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, TextAlign, @@ -8,11 +8,11 @@ use gpui_base::motion::spring; use gpui_component_macros::IntoPlot; use num_traits::Zero; -use super::caller_id; +use super::{ChartAppear, caller_id}; use crate::{ ActiveTheme, plot::{ - PathCaches, Plot, + PathCaches, Plot, PlotAppear, label::{PlotLabel, TEXT_HEIGHT, TEXT_SIZE, Text}, polygon, shape::{Arc, ArcData, Pie}, @@ -29,6 +29,9 @@ const HOVER_LIFT: f32 = 6.; /// How much the slices other than the hovered one fade, as a share of their opacity. const HOVER_DIM: f32 = 0.35; +/// How far into the appear the leader-line labels start fading in. +const LABEL_APPEAR_START: f32 = 0.7; + /// The hover a pie chart paints, sampled once per frame in [`Plot::hover`]. struct PieHover { /// How far each datum's slice has lifted, `0..=1`, springing up on the @@ -56,6 +59,7 @@ pub struct PieChart { tooltip_value: Option SharedString + 'static>>, id: ElementId, interactive: bool, + appear: ChartAppear, name: Option, hover: Option, } @@ -83,6 +87,7 @@ impl PieChart { tooltip_value: None, id: caller_id(), interactive: true, + appear: ChartAppear::default(), name: None, hover: None, } @@ -105,13 +110,32 @@ impl PieChart { /// slice lifts out of the ring, and a tooltip shows its value and share. Turn /// it off for a chart that only decorates, or one an element above it wants /// the cursor for: without a hitbox it neither answers the mouse nor takes - /// the hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the series name shown in the hover tooltip row (e.g. "Desktop"). pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); @@ -291,13 +315,25 @@ impl Plot for PieChart { let outer_radius = self.resolve_outer_radius(&bounds); let arcs = self.arcs(); - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off tessellates afresh each paint. - let caches = self - .interactive - .then(|| PathCaches::for_paint("slices", window, cx)); - for (ix, a) in arcs.iter().enumerate() { + // The ring sweeps clockwise from its first slice as the chart appears. + // Every frame of the sweep is a new shape, so the slices tessellate + // afresh until it ends rather than churn the cache. + let appear = self.appear.get().progress(); + let swept; + let slices = if appear < 1. { + let mut arcs = self.arcs(); + let start = arcs.first().map_or(0., |a| a.start_angle); + for a in &mut arcs { + a.start_angle = start + (a.start_angle - start) * appear; + a.end_angle = start + (a.end_angle - start) * appear; + } + swept = arcs; + &swept + } else { + &arcs + }; + let caches = (appear >= 1.).then(|| PathCaches::for_paint("slices", window, cx)); + for (ix, a) in slices.iter().enumerate() { let inner_radius = self.get_inner_radius(a); // The hovered slice lifts out of the ring while the others fade behind it. let (lift, opacity) = self.slice_emphasis(a.index); @@ -326,7 +362,16 @@ impl Plot for PieChart { .inner_radius(label_radius) .outer_radius(label_radius); - let label_color = self.label_color.unwrap_or(cx.theme().foreground); + // Labels fade in over the end of the sweep, once their slices are + // mostly drawn. + let label_opacity = ((appear - LABEL_APPEAR_START) / (1. - LABEL_APPEAR_START)).max(0.); + if label_opacity <= 0. { + return; + } + let label_color = self + .label_color + .unwrap_or(cx.theme().foreground) + .opacity(label_opacity); let default_line_color = cx.theme().border; // First pass: collect a layout candidate per visible slice, split by @@ -355,7 +400,8 @@ impl Plot for PieChart { .label_line_color .as_ref() .map(|f| f(a.data)) - .unwrap_or(default_line_color); + .unwrap_or(default_line_color) + .opacity(label_opacity); let layout = LabelLayout { arc_x: edge.x, @@ -408,7 +454,19 @@ impl Plot for PieChart { } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/radar_chart.rs b/crates/component/src/chart/radar_chart.rs index aa05a2f2d7..2253e6f5a1 100644 --- a/crates/component/src/chart/radar_chart.rs +++ b/crates/component/src/chart/radar_chart.rs @@ -1,5 +1,6 @@ use std::{ f32::consts::{PI, TAU}, + hash::Hash, rc::Rc, }; @@ -13,7 +14,7 @@ use num_traits::Zero; use crate::{ ActiveTheme, plot::{ - Plot, + Plot, PlotAppear, label::{PlotLabel, TEXT_SIZE, Text}, polygon, scale::{PlotValue, Scale, ScaleLinear}, @@ -22,7 +23,7 @@ use crate::{ }, }; -use super::{HOVER_DOT_SIZE, HOVER_HALO_SIZE, TooltipContent, caller_id}; +use super::{ChartAppear, HOVER_DOT_SIZE, HOVER_HALO_SIZE, TooltipContent, caller_id}; const HALF_PI: f32 = PI / 2.; @@ -98,6 +99,7 @@ where dot: bool, id: ElementId, interactive: bool, + appear: ChartAppear, } impl RadarChart @@ -127,6 +129,7 @@ where dot: false, id: caller_id(), interactive: true, + appear: ChartAppear::default(), } } @@ -147,13 +150,32 @@ where /// series marks the hovered dimension, and a tooltip shows a row each. Turn /// it off for a chart that only decorates, or one an element above it wants /// the cursor for: without a hitbox it neither answers the mouse nor takes - /// the hover from what sits over it. A chart that is off also drops its path - /// cache, which is keyed on the same id. + /// the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the name of the most recently added series, shown in its tooltip row. /// /// Call after the matching [`RadarChart::value`] @@ -510,7 +532,8 @@ where } } - // Draw series + // Draw series. They grow out of the center as the chart appears. + let appear = self.appear.get().progress(); for (i, value_fn) in self.values.iter().enumerate() { let stroke = self.series_stroke(i, cx); let fill = self @@ -524,7 +547,7 @@ where let mut line = RadialLine::new() .data(&self.data) .angle(move |_, i| Some(i as f32 * angle_step)) - .radius(move |d, _| scale.tick(&value_fn(d))) + .radius(move |d, _| scale.tick(&value_fn(d)).map(|r| r * appear)) .closed() .fill(fill) .stroke(stroke) @@ -571,7 +594,19 @@ where } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/chart/sankey_chart.rs b/crates/component/src/chart/sankey_chart.rs index 03adfb9bb5..ef100a75b4 100644 --- a/crates/component/src/chart/sankey_chart.rs +++ b/crates/component/src/chart/sankey_chart.rs @@ -9,11 +9,11 @@ use gpui::{ }; use gpui_component_macros::IntoPlot; -use super::caller_id; +use super::{ChartAppear, caller_id, reveal_mask}; use crate::{ ActiveTheme, plot::{ - PathCaches, Plot, ShapeKey, + PathCaches, Plot, PlotAppear, ShapeKey, label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width, truncate_text_to_width}, origin_point, shape::{ @@ -133,6 +133,7 @@ pub struct SankeyChart { tooltip_value: Option SharedString + 'static>>, id: ElementId, interactive: bool, + appear: ChartAppear, /// The placement for this frame, resolved in `prepaint` (measuring labels /// needs the window) and read by `tooltip_state` and `paint`. frame: Option>, @@ -168,6 +169,7 @@ impl SankeyChart { tooltip_value: None, id: caller_id(), interactive: true, + appear: ChartAppear::default(), frame: None, hover: None, } @@ -190,13 +192,32 @@ impl SankeyChart { /// node's links stand out from the rest, and a tooltip shows its label and /// throughput. Turn it off for a chart that only decorates, or one an element /// above it wants the cursor for: without a hitbox it neither answers the - /// mouse nor takes the hover from what sits over it. A chart that is off also - /// drops its path cache, which is keyed on the same id. + /// mouse nor takes the hover from what sits over it. pub fn interactive(mut self, interactive: bool) -> Self { self.interactive = interactive; self } + /// Draw the data in the first time this chart is painted. On by default. + /// + /// The theme sets how long it takes, and the system's reduced-motion + /// setting skips it. Turn it off for a chart that is painted again and + /// again as it scrolls in and out of view, such as one in each row of a + /// long list, where it would draw in every time. + pub fn appear(mut self, appear: bool) -> Self { + self.appear.set_enabled(appear); + self + } + + /// Draw the data in again whenever `key` changes, such as the symbol or + /// period a chart shows. + /// + /// Without one the data draws in once, and later data paints in place. + pub fn appear_key(mut self, key: impl Hash) -> Self { + self.appear.set_key(key); + self + } + /// Set the node rectangle width. Defaults to 10. pub fn node_width(mut self, node_width: f32) -> Self { self.node_width = node_width; @@ -523,26 +544,18 @@ impl Plot for SankeyChart { let node_labels = self.node_labels(cx); - // Caching hangs off the chart's own id, which only an interactive chart - // puts on the stack; without one, siblings would share a slot and thrash - // it, so a chart that is off places itself afresh each paint. - self.frame = if self.interactive { - let key = self.frame_key(bounds, &node_labels); - let cache = - window.use_keyed_state("sankey-frame", cx, |_, _| SankeyFrameCache::default()); - let cached = cache.read(cx); - if cached.key == Some(key) { - cached.frame.clone() - } else { - let frame = self.place(bounds, node_labels, window).map(Rc::new); - cache.update(cx, |cache, _| { - cache.key = Some(key); - cache.frame = frame.clone(); - }); - frame - } + let key = self.frame_key(bounds, &node_labels); + let cache = window.use_keyed_state("sankey-frame", cx, |_, _| SankeyFrameCache::default()); + let cached = cache.read(cx); + self.frame = if cached.key == Some(key) { + cached.frame.clone() } else { - self.place(bounds, node_labels, window).map(Rc::new) + let frame = self.place(bounds, node_labels, window).map(Rc::new); + cache.update(cx, |cache, _| { + cache.key = Some(key); + cache.frame = frame.clone(); + }); + frame }; vec![] @@ -583,22 +596,23 @@ impl Plot for SankeyChart { // Links first, under the nodes. The links of the hovered node keep their // opacity while the rest fade behind them. // - // Hovering changes only a ribbon's opacity, so an interactive chart keeps - // each tessellated ribbon, slotted by the link's index in the graph so a - // skipped zero-value link doesn't shift the others. Without an id, siblings - // would share the slots, so a chart that is off tessellates afresh. + // Hovering changes only a ribbon's opacity, so the chart keeps each + // tessellated ribbon, slotted by the link's index in the graph so a + // skipped zero-value link doesn't shift the others. + // + // Links and nodes draw in from the left under a mask as the chart + // appears, which leaves the cached ribbons whole; the labels fade in. let min_width = self.min_link_width; - let caches = self - .interactive - .then(|| PathCaches::for_paint("links", window, cx)); - for (ix, link) in graph.links.iter().enumerate() { - if link.value <= 0. { - continue; - } - let source = &graph.nodes[link.source]; - let target = &graph.nodes[link.target]; - let path = match caches.as_ref() { - Some(caches) => caches.update(cx, |caches, _| { + let caches = PathCaches::for_paint("links", window, cx); + let appear = self.appear.get().progress(); + window.with_content_mask(reveal_mask(bounds, 0., appear), |window| { + for (ix, link) in graph.links.iter().enumerate() { + if link.value <= 0. { + continue; + } + let source = &graph.nodes[link.source]; + let target = &graph.nodes[link.target]; + let path = caches.update(cx, |caches, _| { let key = ShapeKey::new(()) .f32(source.x1) .f32(target.x0) @@ -610,37 +624,36 @@ impl Plot for SankeyChart { caches.slot(ix).get(key, bounds.origin, || { sankey_link_path(source, target, link, min_width, Point::default()) }) - }), - None => sankey_link_path(source, target, link, min_width, bounds.origin), - }; - let Some(path) = path else { - continue; - }; - let opacity = match self.hover { - Some(hover) if !Self::is_attached(link, hover.node) => { - self.link_opacity * (1. - HOVER_DIM * hover.focus) - } - _ => self.link_opacity, - }; - window.paint_path( - path, - linear_gradient( - 90., - linear_color_stop(colors[link.source].opacity(opacity), 0.), - linear_color_stop(colors[link.target].opacity(opacity), 1.), - ), - ); - } + }); + let Some(path) = path else { + continue; + }; + let opacity = match self.hover { + Some(hover) if !Self::is_attached(link, hover.node) => { + self.link_opacity * (1. - HOVER_DIM * hover.focus) + } + _ => self.link_opacity, + }; + window.paint_path( + path, + linear_gradient( + 90., + linear_color_stop(colors[link.source].opacity(opacity), 0.), + linear_color_stop(colors[link.target].opacity(opacity), 1.), + ), + ); + } - let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default()); - for node in &graph.nodes { - let node_bounds = Bounds::from_corners( - origin_point(px(node.x0), px(node.y0), bounds.origin), - // Keep tiny nodes visible with a minimum 1px height. - origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin), - ); - window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii)); - } + let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default()); + for node in &graph.nodes { + let node_bounds = Bounds::from_corners( + origin_point(px(node.x0), px(node.y0), bounds.origin), + // Keep tiny nodes visible with a minimum 1px height. + origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin), + ); + window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii)); + } + }); let mut texts = Vec::new(); for node in &graph.nodes { @@ -695,7 +708,7 @@ impl Plot for SankeyChart { Text::new( text, point(px(x), px(y)), - line.color.unwrap_or(cx.theme().foreground), + line.color.unwrap_or(cx.theme().foreground).opacity(appear), ) .font_size(font_size) .align(align), @@ -707,7 +720,19 @@ impl Plot for SankeyChart { } fn id(&self) -> Option { - self.interactive.then(|| self.id.clone()) + Some(self.id.clone()) + } + + fn interactive(&self) -> bool { + self.interactive + } + + fn appear(&mut self, appear: PlotAppear, _window: &mut Window, _cx: &mut App) { + self.appear.update(appear); + } + + fn appear_generation(&self) -> Option { + self.appear.generation() } fn tooltip_state( diff --git a/crates/component/src/theme/mod.rs b/crates/component/src/theme/mod.rs index 83a53cbc53..facc3f5a25 100644 --- a/crates/component/src/theme/mod.rs +++ b/crates/component/src/theme/mod.rs @@ -61,6 +61,23 @@ fn default_true() -> bool { /// renderer, so this is simply "as round as it goes". const RADIUS_FULL: Pixels = px(9999.); +/// How long a chart's data takes to draw in the first time it is painted. +/// +/// The mainstream chart libraries agree on about a second: ECharts, Chart.js +/// and Highcharts all default to 1000 ms, ApexCharts to 800 ms and Recharts to +/// 1500 ms. +const PLOT_APPEAR: Duration = Duration::from_millis(1000); + +/// The curve a chart's data draws in on: Chart.js' default `easeOutQuart`. +/// +/// Not the design system's enter curve. That one is an exponential ease-out +/// built for a popover, done nine-tenths of the way in the first quarter, which +/// makes a second of drawing in read as a flash. The quartic still leads with +/// most of the motion but leaves the data a visible glide into place. +fn plot_appear_easing(t: f32) -> f32 { + 1. - (1. - t).powi(4) +} + /// How long the scrollbar stays visible after the last scroll, drag, or hover. const SCROLLBAR_IDLE: Duration = Duration::from_secs(2); /// How long the scrollbar takes to appear. @@ -93,6 +110,7 @@ const SCROLLBAR_THUMB_INSET: Pixels = px(4.); /// the way there in the first third. The fast tier as a critically damped /// response lands in the same place, and the tolerance is sub-pixel so the /// spring rests once nothing visible moves. The hover fades on the same tier. +/// The data draws in over [`PLOT_APPEAR`] on [`plot_appear_easing`]. fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::PlotMotion::default() .with_pointer(gpui_base::Spring::new(motion.duration_fast).with_epsilon(0.1)) @@ -104,6 +122,7 @@ fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion { gpui_base::motion::Transition::new(motion.duration_fast) .easing(motion.easing_exit.clone()), ) + .with_appear(gpui_base::motion::Transition::new(PLOT_APPEAR).ease(plot_appear_easing)) } /// The scrollbar motion this design system projects onto Base. diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index ab14f3a78d..b29b80217b 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -4,6 +4,7 @@ use gpui_kit::assets::IconName; use gpui_kit::base::ElementExt as _; use gpui_kit::component::{ ActiveTheme, Icon, StyledExt, + button::Button, chart::{ AreaChart, BarChart, CandlestickChart, LineChart, PieChart, RadarChart, SankeyChart, SankeyLabel, @@ -16,15 +17,15 @@ use gpui_kit::component::{ v_flex, }; use gpui_kit::{ - AnyElement, App, AppContext, Background, Context, Corners, Entity, FocusHandle, Focusable, - FontWeight, Hsla, IntoElement, ListAlignment, ListState, ParentElement, Pixels, Render, Rgba, - SharedString, Styled, Window, div, linear_color_stop, linear_gradient, list, - prelude::FluentBuilder, px, + AnyElement, App, AppContext, Background, Context, Corners, ElementId, Entity, FocusHandle, + Focusable, FontWeight, Hsla, InteractiveElement as _, IntoElement, ListAlignment, ListState, + ParentElement, Pixels, Render, Rgba, SharedString, Styled, Window, div, linear_color_stop, + linear_gradient, list, prelude::FluentBuilder, px, }; use serde::Deserialize; use super::StackedBarChart; -use crate::Story; +use crate::{Story, story_toolbar_group}; /// The height of one chart card, and the list's overdraw: the virtual list /// keeps one row of cards live on either side of the viewport. @@ -1475,6 +1476,9 @@ pub struct ChartStory { /// the last prepaint. columns: usize, list_state: ListState, + /// Bumped by the replay button. The gallery is keyed on it, so every chart + /// gets fresh element state and draws in again. + appear_generation: u64, } fn fixture Deserialize<'de>>(json: &str) -> T { @@ -1571,6 +1575,7 @@ impl ChartStory { sections, columns, list_state, + appear_generation: 0, } } @@ -1669,39 +1674,69 @@ impl Render for ChartStory { let data = self.data.clone(); let story = cx.entity(); - div() + v_flex() .size_full() .bg(cx.theme().background) .on_prepaint(move |bounds, _, cx| { story.update(cx, |this, cx| this.measure(bounds.size.width, cx)); }) + // The toolbar stays put while the gallery scrolls under it, so the + // gap below it belongs to the toolbar, not to the list's padding. + .child( + div() + .px(CONTENT_INSET) + .pt(CONTENT_INSET) + .pb(CARD_GAP) + .child( + story_toolbar_group().child( + Button::new("chart-replay") + .icon(IconName::RotateCw) + .label("Replay") + .on_click(cx.listener(|this, _, _, cx| { + this.appear_generation += 1; + cx.notify(); + })), + ), + ), + ) .child( - list(self.list_state.clone(), move |index, _, cx| { - let Some(row) = rows.get(index) else { - return div().into_any_element(); - }; - - div() - .w_full() - .px(CONTENT_INSET) - // Spacing between rows only, like a CSS gap. - .when(index + 1 < rows.len(), |this| this.pb(CARD_GAP)) - .child(match row { - ChartRow::Rule => Separator::horizontal().into_any_element(), - ChartRow::Cards(cards) => h_flex() + div() + .id(ElementId::NamedInteger( + "chart-gallery".into(), + self.appear_generation, + )) + .flex_1() + .min_h_0() + .w_full() + .child( + list(self.list_state.clone(), move |index, _, cx| { + let Some(row) = rows.get(index) else { + return div().into_any_element(); + }; + + div() .w_full() - .gap(CARD_GAP) - .children(cards.iter().map(|card| card.render(&data, cx))) - .into_any_element(), + .px(CONTENT_INSET) + // Spacing between rows only, like a CSS gap. + .when(index + 1 < rows.len(), |this| this.pb(CARD_GAP)) + .child(match row { + ChartRow::Rule => Separator::horizontal().into_any_element(), + ChartRow::Cards(cards) => h_flex() + .w_full() + .gap(CARD_GAP) + .children(cards.iter().map(|card| card.render(&data, cx))) + .into_any_element(), + }) + .into_any_element() }) - .into_any_element() - }) - .size_full() - // The list's own style honours vertical padding only, so the - // horizontal inset rides on each row above. - .py(CONTENT_INSET), + .size_full() + // The list's own style honours vertical padding only, so the + // horizontal inset rides on each row above; the toolbar + // above holds the top gap. + .pb(CONTENT_INSET), + ) + .vertical_scrollbar(&self.list_state), ) - .vertical_scrollbar(&self.list_state) } } diff --git a/release-notes.md b/release-notes.md index 9d01fc2044..7731c83183 100644 --- a/release-notes.md +++ b/release-notes.md @@ -47,6 +47,36 @@ motionless by default, and `gpui-component` projects its motion tokens onto `gpui_base::Theme::plot` whenever its theme is applied. The `decimal` feature moves to `gpui-base`; `gpui-component`'s `decimal` feature forwards to it. +#### Added: chart appear motion + +Charts draw their data in the first time they are painted, over 1000 ms on +`easeOutQuart`: line, area, candlestick and sankey charts reveal from the left, +bars grow out of the zero line together, a pie sweeps clockwise +and a radar grows out of its center. Axes, grids and labels are there from the +first frame, the tooltip waits until the data is whole, and reduced motion +skips it. New data paints in place, so a chart fed live quotes does not replay. + +```rust +pub fn appear(self, appear: bool) -> Self // every chart: opt out, e.g. in list rows +pub fn appear_key(self, key: impl Hash) -> Self // every chart: replay when the key changes +``` + +Custom plots opt in through `gpui_base::plot`: + +```rust +pub struct PlotAppear // progress(), staggered(index, count, spread), is_appearing(), complete() +fn Plot::appear(&mut self, appear: PlotAppear, window: &mut Window, cx: &mut App) +fn Plot::appear_generation(&self) -> Option // Some opts in; a new value replays +fn Plot::interactive(&self) -> bool // hover and tooltip, apart from the id +pub fn PlotMotion::with_appear(self, appear: Transition) -> Self +``` + +Every new `Plot` method has a default, so existing plots compile and behave as +before: `Plot::interactive` is `true`, and without an `appear_generation` a plot +tracks no appear and asks for no frames. A chart with `interactive(false)` now +returns its id from `Plot::id`, keeping its appear and path caches but still no +hitbox. + #### Breaking changes ##### Plot API diff --git a/website/base/plot.md b/website/base/plot.md index 9e48d1e830..ffe2968d61 100644 --- a/website/base/plot.md +++ b/website/base/plot.md @@ -112,7 +112,7 @@ Shapes that repaint every frame can keep their tessellated paths across frames w ## Hover and tooltips -A plot opts into hover by returning an id from `Plot::id`. `PlotElement` then tracks the cursor each frame, occlusion-aware so an open popup above the plot clears it, and asks the plot three questions: +A plot opts into hover by returning an id from `Plot::id`; one that returns `false` from `Plot::interactive` keeps the id's state and its appear, but no hitbox or hover. `PlotElement` then tracks the cursor each frame, occlusion-aware so an open popup above the plot clears it, and asks the plot three questions: 1. `tooltip_state` — map the cursor to a [`TooltipState`]: the hovered index, the crosshair point and the data dots, or `None`. 2. `hover` — receive the hovered [`PlotHover`] before painting. It lingers after the cursor leaves while `progress()` eases back to zero, so emphasis fades out over the last datum instead of vanishing. `is_entering()` is true on the first hovered frame. @@ -138,8 +138,11 @@ use gpui_kit::base::{PlotMotion, PlotTheme, Spring, Theme, motion::Transition}; let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) - .with_exit(Transition::new(Duration::from_millis(120))); + .with_exit(Transition::new(Duration::from_millis(120))) + .with_appear(Transition::new(Duration::from_millis(1000))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` +`with_appear` sets how a plot's data draws in the first time its id is painted. A plot receives the progress in `Plot::appear`, before `Plot::hover` and `Plot::paint`; `PlotAppear::staggered` gives each of several marks its own slice of it. A plot opts in by returning `Some` from `Plot::appear_generation`, and a new value replays the appear; the default `None` tracks nothing and asks for no frames. A plot with an id that is not `Plot::interactive` still appears, and hover tracking waits until the appear is done. + GPUI Component projects its motion tokens here whenever its theme changes. Motion honors the operating system's reduced-motion preference, under which every value adopts its target at once. diff --git a/website/component/chart.md b/website/component/chart.md index 167f3fd6f2..d20c90438b 100644 --- a/website/component/chart.md +++ b/website/component/chart.md @@ -768,17 +768,37 @@ AreaChart::new(range).interactive(false) // A backdrop under drag handles The second matters because a plain hitbox does not block the one behind it: an element painted over an interactive chart is hovered *and so is the chart*, so the crosshair keeps tracking under it. The chart has to stand down. +A chart that is off keeps its id, so it still draws in and keeps its cache. + ### Motion The emphasis is animated with the styled layer's motion tokens (`cx.theme().motion_tokens()`), which the theme projects onto gpui-base as its [`PlotMotion`](../base/plot.md#motion): pointers — crosshair, band, dots — follow the hovered datum on a fast spring, a pie slice lifts on the control spring, and the whole overlay fades in when the cursor lands on a datum and out after it leaves. The motion honors the operating system's reduced-motion preference, under which every value adopts its target at once. +### Appear + +The data draws in the first time a chart is painted, over 1000 ms on `easeOutQuart`, Chart.js' default curve: lines, areas, candlesticks and a sankey diagram are revealed from the left the way ECharts, Highcharts and Recharts draw them, bars grow out of the zero line together, a pie sweeps clockwise from its first slice, and a radar grows out of its center. Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. + +The appear runs once per id. New data paints in place, so a chart fed live quotes does not draw in again on every tick. To replay it when the chart starts showing something else, such as another symbol or period, hand it a key: + +```rust +LineChart::new(candles).appear_key((&symbol, period)) +``` + +A chart that is painted again each time it scrolls into view — one in each row of a long list — draws in every time it does. Turn the appear off there: + +```rust +LineChart::new(intraday).interactive(false).appear(false) +``` + +Reduced motion skips the appear. + ### Caching -A chart also keeps its heavy geometry across frames, since it repaints on every frame it is on screen: line and area strokes and pie slices stay tessellated while their projected points are unchanged, and a sankey diagram keeps its placement while its data, settings and size are unchanged. This cache hangs off the same id, so charts sharing one share the cache and thrash it — another reason to name siblings apart — and a chart with `interactive(false)`, having no id of its own, rebuilds its geometry on each paint. +A chart also keeps its heavy geometry across frames, since it repaints on every frame it is on screen: line and area strokes and pie slices stay tessellated while their projected points are unchanged, and a sankey diagram keeps its placement while its data, settings and size are unchanged. This cache hangs off the same id, so charts sharing one share the cache and thrash it — another reason to name siblings apart. A pie or radar being drawn in is a new shape on every frame, so it tessellates afresh until the appear ends. ### Custom Plots -A custom [`Plot`] opts in by hand — `Plot::id` defaults to `None` there. The trait, `PlotElement` and hover tracking come from [gpui-base](../base/plot.md), so a plot written against `gpui_kit::base::plot` works here unchanged. Return an id from `Plot::id`, resolve the datum under the cursor in `Plot::tooltip_state`, and build the overlay in `Plot::tooltip`. The `Tooltip` returned there animates the hover on its own, the same way the built-in charts do: the whole overlay fades with the hover, the crosshair and dots glide to each hovered datum on the pointer spring, adopting it on the frame the cursor lands, and a dot's `halo` grows as the hover fades in. A crosshair glides along the axis it marks only, so a line that also follows the cursor keeps up with it. Pass the data point itself; the tooltip does the rest: +A custom [`Plot`] opts in by hand — `Plot::id` defaults to `None` there. The trait, `PlotElement` and hover tracking come from [gpui-base](../base/plot.md), so a plot written against `gpui_kit::base::plot` works here unchanged. Return an id from `Plot::id`, resolve the datum under the cursor in `Plot::tooltip_state`, and build the overlay in `Plot::tooltip`. To draw in, keep the `PlotAppear` that `Plot::appear` hands over on every frame and paint with its progress. The `Tooltip` returned there animates the hover on its own, the same way the built-in charts do: the whole overlay fades with the hover, the crosshair and dots glide to each hovered datum on the pointer spring, adopting it on the frame the cursor lands, and a dot's `halo` grows as the hover fades in. A crosshair glides along the axis it marks only, so a line that also follows the cursor keeps up with it. Pass the data point itself; the tooltip does the rest: ```rust fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option { diff --git a/website/zh-CN/base/plot.md b/website/zh-CN/base/plot.md index 6cd8d4e1e7..b59237efb0 100644 --- a/website/zh-CN/base/plot.md +++ b/website/zh-CN/base/plot.md @@ -112,7 +112,7 @@ Line::new() ## Hover 与 tooltip -Plot 在 `Plot::id` 中返回 id 即可启用 hover。之后 `PlotElement` 每帧跟踪光标(会识别遮挡,Plot 上方打开的弹出层会清除 hover),并依次询问 Plot: +Plot 在 `Plot::id` 中返回 id 即可启用 hover;若 `Plot::interactive` 返回 `false`,则保留 id 对应的状态和入场,但没有 hitbox 和 hover。之后 `PlotElement` 每帧跟踪光标(会识别遮挡,Plot 上方打开的弹出层会清除 hover),并依次询问 Plot: 1. `tooltip_state`:把光标映射成 [`TooltipState`],包括悬停的索引、十字线位置和数据点,或返回 `None`。 2. `hover`:在绘制前接收当前的 [`PlotHover`]。光标离开后它会继续保留,同时 `progress()` 逐渐回落到零,让强调效果在最后一个数据项上淡出,而不是突然消失。首个悬停帧上 `is_entering()` 为 true。 @@ -138,8 +138,11 @@ use gpui_kit::base::{PlotMotion, PlotTheme, Spring, Theme, motion::Transition}; let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) - .with_exit(Transition::new(Duration::from_millis(120))); + .with_exit(Transition::new(Duration::from_millis(120))) + .with_appear(Transition::new(Duration::from_millis(1000))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` +`with_appear` 设置 plot 的 id 第一次绘制时数据画出来的方式。plot 在 `Plot::appear` 里拿到进度,调用时机在 `Plot::hover` 和 `Plot::paint` 之前;`PlotAppear::staggered` 把进度切给多个图形,每个错开一点开始。plot 在 `Plot::appear_generation` 返回 `Some` 即开启入场,值变化时重播;默认的 `None` 不跟踪任何状态,也不请求帧。有 id 但 `Plot::interactive` 为 false 的 plot 同样会入场;入场结束前不跟踪 hover。 + GPUI Component 会在主题变化时把自己的 motion token 投射到这里。动效遵循操作系统的“减少动态效果”设置,开启后所有值都会立即采用目标值。 diff --git a/website/zh-CN/component/chart.md b/website/zh-CN/component/chart.md index 73209f2dfb..41fd8420e1 100644 --- a/website/zh-CN/component/chart.md +++ b/website/zh-CN/component/chart.md @@ -742,17 +742,37 @@ AreaChart::new(range).interactive(false) // 拖拽手柄下面的底图 第二种尤其要注意:普通 hitbox **不会挡住它后面的 hitbox**,盖在图表上的元素被悬停时,图表**同样**算被悬停,十字线会在它下面继续跟着跑。只能让图表让位。 +关闭交互的图表仍保留自己的 id,所以依然有入场动画,也依然保留缓存。 + ### 动效 强调效果使用样式层的 motion tokens(`cx.theme().motion_tokens()`)驱动,主题会把它们作为 [`PlotMotion`](../base/plot.md) 投射到 gpui-base:十字线、高亮条、圆点等指示器以快速弹簧跟随悬停的数据,饼图扇区以 control 弹簧抬起,整个覆盖层在光标落到数据上时淡入、离开后淡出。动效遵循操作系统的减弱动态效果偏好,开启后所有值立即到达目标。 +### 入场 + +图表第一次绘制时,数据会按 `easeOutQuart` 曲线(Chart.js 的默认曲线)在 1000 ms 内画出来:折线、面积、K 线和桑基图像 ECharts、Highcharts、Recharts 那样从左往右展开,柱子从零线同时长出,饼图从第一个扇区顺时针扫开,雷达图从中心向外放大。坐标轴、网格线和刻度文字从第一帧起就完整显示,tooltip 等数据画完才出现。 + +每个 id 只播放一次入场。之后数据变化会原地重绘,所以接实时行情的图表不会每次推送都重播。图表换成展示别的内容(比如换了股票或周期)时,传一个 key 让它重播: + +```rust +LineChart::new(candles).appear_key((&symbol, period)) +``` + +每次滚入视野都会重新绘制的图表(比如长列表每一行里的图表),每次都会重播入场。这种场景把入场关掉: + +```rust +LineChart::new(intraday).interactive(false).appear(false) +``` + +开启减弱动态效果时跳过入场。 + ### 缓存 -图表还会跨帧保留较重的几何计算,因为它在屏幕上的每一帧都会重绘:折线与面积的描边、饼图扇区在投影点不变时保持已细分的路径,桑基图在数据、设置和尺寸不变时保留布局。这份缓存挂在同一个 id 上,因此共用 id 的图表会互相冲刷缓存——这是同级图表需要分别命名的另一个理由;而 `interactive(false)` 的图表没有自己的 id,每次绘制都会重算几何。 +图表还会跨帧保留较重的几何计算,因为它在屏幕上的每一帧都会重绘:折线与面积的描边、饼图扇区在投影点不变时保持已细分的路径,桑基图在数据、设置和尺寸不变时保留布局。这份缓存挂在同一个 id 上,因此共用 id 的图表会互相冲刷缓存——这是同级图表需要分别命名的另一个理由。饼图和雷达图入场时每一帧都是新的形状,所以入场结束前每帧都重新细分路径。 ### 自定义 Plot -自定义 [`Plot`] 需要手动接入——那里的 `Plot::id` 仍默认返回 `None`。这个 trait、`PlotElement` 和 hover 跟踪都来自 [gpui-base](../base/plot.md),因此基于 `gpui_kit::base::plot` 编写的 Plot 可以直接在这里使用。在 `Plot::id` 返回 id,在 `Plot::tooltip_state` 解析光标所在的数据,在 `Plot::tooltip` 构建覆盖层。这里返回的 `Tooltip` 会自己为悬停加动画,和内置图表一样:整个覆盖层随悬停淡入淡出;十字线和圆点按指针 spring 滑到每个悬停的数据点,光标落下的那一帧直接就位;圆点的 `halo` 随悬停淡入逐渐放大。十字线只沿它标记的那条轴滑动,所以同时跟随光标的那条线不会滞后。传入数据点本身即可,其余交给 tooltip: +自定义 [`Plot`] 需要手动接入——那里的 `Plot::id` 仍默认返回 `None`。这个 trait、`PlotElement` 和 hover 跟踪都来自 [gpui-base](../base/plot.md),因此基于 `gpui_kit::base::plot` 编写的 Plot 可以直接在这里使用。在 `Plot::id` 返回 id,在 `Plot::tooltip_state` 解析光标所在的数据,在 `Plot::tooltip` 构建覆盖层。要做入场,就保存 `Plot::appear` 每帧传入的 `PlotAppear`,用它的进度来绘制。这里返回的 `Tooltip` 会自己为悬停加动画,和内置图表一样:整个覆盖层随悬停淡入淡出;十字线和圆点按指针 spring 滑到每个悬停的数据点,光标落下的那一帧直接就位;圆点的 `halo` 随悬停淡入逐渐放大。十字线只沿它标记的那条轴滑动,所以同时跟随光标的那条线不会滞后。传入数据点本身即可,其余交给 tooltip: ```rust fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option {