diff --git a/crates/base/src/plot/appear.rs b/crates/base/src/plot/appear.rs index d7c17221ac..182ced3bb8 100644 --- a/crates/base/src/plot/appear.rs +++ b/crates/base/src/plot/appear.rs @@ -4,7 +4,17 @@ //! 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}; +//! +//! The appear lives in the plot's element state, so a plot that stops being +//! painted — a row a virtual list scrolled away — forgets it and draws in again +//! when it comes back. A [`PlotAppearScope`] around the list remembers which +//! plots have finished, for as long as the scope itself is painted. +use std::{cell::RefCell, collections::HashMap, panic::Location, rc::Rc}; + +use gpui::{ + AnyElement, App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + LayoutId, Pixels, Window, +}; use crate::{ Theme, @@ -69,12 +79,160 @@ impl PlotAppear { /// The element-state key of a plot's appear, within the plot's scope. const APPEAR: &str = "__plot-appear"; +/// The plots a [`PlotAppearScope`] has seen finish appearing, by global id, +/// with the generation that finished. +type Appeared = Rc>>; + +thread_local! { + /// The scopes being laid out or painted, innermost last. Only non-empty + /// while a [`PlotAppearScope`] is drawing its child. + static SCOPES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Remembers which plots inside it have finished appearing, so a plot that is +/// painted again after a gap — a row a virtual list scrolled out of view and +/// back — shows its data whole instead of drawing in again. +/// +/// Wrap the list, or whatever region repaints its plots on and off, in one: +/// +/// ```ignore +/// PlotAppearScope::new(("transcript", conversation_id), list(state, render_row).flex_1()) +/// ``` +/// +/// A plot is remembered by its global element id and its +/// [`Plot::appear_generation`](super::Plot::appear_generation), once its appear +/// has finished; a new generation still replays it, and one taken away +/// mid-appear draws in again from the start. The memory is the scope's own +/// element state: it lasts while the scope is painted every frame and goes with +/// it, so a view that is closed, or a scope whose id changes — name it after the +/// content, such as a conversation — draws its plots in afresh. The innermost +/// scope wins. Without one, every plot draws in each time it is painted anew. +/// +/// The scope takes no part in layout: it hands on its child's layout, so a +/// child that sizes itself, such as a `list`, keeps doing so. +pub struct PlotAppearScope { + id: ElementId, + child: Option, +} + +impl PlotAppearScope { + /// Remember the appears of the plots in `child` under `id`, unique among + /// its siblings. + pub fn new(id: impl Into, child: impl IntoElement) -> Self { + Self { + id: id.into(), + child: Some(child.into_any_element()), + } + } + + /// Run `f` with `appeared` as the innermost scope. + fn within(appeared: &Appeared, f: impl FnOnce() -> R) -> R { + SCOPES.with_borrow_mut(|scopes| scopes.push(appeared.clone())); + let result = f(); + SCOPES.with_borrow_mut(|scopes| scopes.pop()); + result + } +} + +impl IntoElement for PlotAppearScope { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for PlotAppearScope { + type RequestLayoutState = (Option, Appeared); + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let appeared: Appeared = match global_id { + Some(global_id) => window.with_element_state(global_id, |appeared, _| { + let appeared: Appeared = appeared.unwrap_or_default(); + (appeared.clone(), appeared) + }), + None => Appeared::default(), + }; + let mut child = self.child.take(); + let layout_id = Self::within(&appeared, || match child.as_mut() { + Some(child) => child.request_layout(window, cx), + None => window.request_layout(Default::default(), None, cx), + }); + (layout_id, (child, appeared)) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + (child, appeared): &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + // A virtual list lays its rows out here, so their plots appear here. + if let Some(child) = child { + Self::within(appeared, || { + child.prepaint(window, cx); + }); + } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + (child, appeared): &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(child) = child { + Self::within(appeared, || child.paint(window, cx)); + } + } +} + +/// The innermost scope, if any, cloned out so no borrow outlives the lookup. +fn current_scope() -> Option { + SCOPES.with_borrow(|scopes| scopes.last().cloned()) +} + /// Sample the appear of the plot painting under the window's current element -/// id. A new `generation` starts it over. Called by +/// id, `global_id`. A new `generation` starts it over, and one the innermost +/// [`PlotAppearScope`] saw finish is complete at once. 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 { +pub(super) fn track_appear( + global_id: &GlobalElementId, + generation: u64, + window: &mut Window, + cx: &mut App, +) -> PlotAppear { + let scope = current_scope(); + if scope + .as_ref() + .is_some_and(|scope| scope.borrow().get(global_id) == Some(&generation)) + { + return PlotAppear::complete(); + } let Some(policy) = cx .try_global::() .map(|theme| theme.plot.motion().appear().clone()) @@ -87,6 +245,11 @@ pub(super) fn track_appear(generation: u64, window: &mut Window, cx: &mut App) - let sample = Presence::new((ElementId::Integer(generation), APPEAR), true) .transition(policy.easing(Easing::Linear)) .sample(window, cx); + if sample.progress >= 1. + && let Some(scope) = scope + { + scope.borrow_mut().insert(global_id.clone(), generation); + } PlotAppear { time: sample.progress, easing, @@ -98,8 +261,8 @@ mod tests { use std::{cell::RefCell, rc::Rc, time::Duration}; use gpui::{ - Bounds, Context, ElementId, IntoElement, Pixels, Render, TestAppContext, WindowHandle, px, - size, + Bounds, Context, ElementId, IntoElement, ParentElement as _, Pixels, Render, Styled as _, + TestAppContext, WindowHandle, px, size, }; use super::*; @@ -158,10 +321,30 @@ mod tests { } } - fn open( - cx: &mut TestAppContext, - generation: Option, - ) -> (WindowHandle, Rc>>) { + /// A recorder that can be taken out of the tree and put back, inside a + /// [`PlotAppearScope`] named `scope` or none. + struct ScopedView { + samples: Rc>>, + scope: Option, + mounted: bool, + generation: u64, + } + + impl Render for ScopedView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let plot = self.mounted.then(|| Recorder { + samples: self.samples.clone(), + generation: Some(self.generation), + }); + let body = gpui::div().size_full().children(plot); + match self.scope { + Some(scope) => PlotAppearScope::new(("scope", scope), body).into_any_element(), + None => body.into_any_element(), + } + } + } + + fn set_theme(cx: &mut TestAppContext) { cx.update(|cx| { cx.set_global(Theme::default()); Theme::global_mut(cx).plot = PlotTheme::new().with_motion( @@ -169,6 +352,60 @@ mod tests { .with_appear(Transition::new(Duration::from_millis(100)).ease(|t| t)), ); }); + } + + fn open_scoped( + cx: &mut TestAppContext, + scope: Option, + ) -> (WindowHandle, Rc>>) { + set_theme(cx); + let samples = Rc::new(RefCell::new(Vec::new())); + let window = cx.open_window(size(px(100.), px(100.)), { + let samples = samples.clone(); + move |_, _| ScopedView { + samples, + scope, + mounted: true, + generation: 0, + } + }); + cx.run_until_parked(); + (window, samples) + } + + /// Change the view, then return the progress the plot was handed on the + /// frame that follows, if it was painted. + fn update_scoped( + window: WindowHandle, + cx: &mut TestAppContext, + f: impl FnOnce(&mut ScopedView), + ) -> Option { + window + .update(cx, |view, _, cx| { + f(view); + view.samples.borrow_mut().clear(); + cx.notify(); + }) + .unwrap(); + cx.run_until_parked(); + window + .update(cx, |view, _, _| view.samples.borrow().last().copied()) + .unwrap() + } + + fn finish_appear(window: WindowHandle, cx: &mut TestAppContext) { + cx.executor().advance_clock(Duration::from_millis(100)); + window + .update(cx, |_, window, cx| window.simulate_next_frame(cx)) + .unwrap(); + cx.run_until_parked(); + } + + fn open( + cx: &mut TestAppContext, + generation: Option, + ) -> (WindowHandle, Rc>>) { + set_theme(cx); let samples = Rc::new(RefCell::new(Vec::new())); let window = cx.open_window(size(px(100.), px(100.)), { let samples = samples.clone(); @@ -244,6 +481,78 @@ mod tests { assert_eq!(samples.borrow().last(), Some(&0.)); } + /// Inside a scope, a plot painted again after a gap is whole at once and + /// asks for no frames. + #[gpui::test] + fn test_scope_keeps_a_finished_appear_across_a_remount(cx: &mut TestAppContext) { + let (window, samples) = open_scoped(cx, Some(0)); + assert_eq!(samples.borrow().last(), Some(&0.)); + finish_appear(window, cx); + assert_eq!(samples.borrow().last(), Some(&1.)); + + assert_eq!(update_scoped(window, cx, |view| view.mounted = false), None); + assert_eq!( + update_scoped(window, cx, |view| view.mounted = true), + Some(1.) + ); + let frames = window + .update(cx, |_, window, cx| window.simulate_next_frame(cx)) + .unwrap(); + assert_eq!(frames, 0); + + // A new generation still replays. + assert_eq!( + update_scoped(window, cx, |view| view.generation = 1), + Some(0.) + ); + } + + /// A plot taken away before its appear finished draws in again. + #[gpui::test] + fn test_scope_replays_an_unfinished_appear(cx: &mut TestAppContext) { + let (window, _) = open_scoped(cx, Some(0)); + assert_eq!(update_scoped(window, cx, |view| view.mounted = false), None); + assert_eq!( + update_scoped(window, cx, |view| view.mounted = true), + Some(0.) + ); + } + + /// The memory goes with the scope: a scope under a new id, or one that + /// stops being painted, draws its plots in afresh. + #[gpui::test] + fn test_scope_forgets_when_it_goes(cx: &mut TestAppContext) { + let (window, _) = open_scoped(cx, Some(0)); + finish_appear(window, cx); + assert_eq!( + update_scoped(window, cx, |view| view.scope = Some(1)), + Some(0.) + ); + + finish_appear(window, cx); + assert_eq!( + update_scoped(window, cx, |view| view.scope = None), + Some(0.) + ); + finish_appear(window, cx); + assert_eq!( + update_scoped(window, cx, |view| view.scope = Some(1)), + Some(0.) + ); + } + + /// Without a scope, a plot painted again after a gap draws in again. + #[gpui::test] + fn test_without_a_scope_a_remount_replays(cx: &mut TestAppContext) { + let (window, _) = open_scoped(cx, None); + finish_appear(window, cx); + assert_eq!(update_scoped(window, cx, |view| view.mounted = false), None); + assert_eq!( + update_scoped(window, cx, |view| view.mounted = true), + Some(0.) + ); + } + fn at(time: f32) -> PlotAppear { PlotAppear { time, diff --git a/crates/base/src/plot/element.rs b/crates/base/src/plot/element.rs index 893acbbb4f..8834351073 100644 --- a/crates/base/src/plot/element.rs +++ b/crates/base/src/plot/element.rs @@ -102,7 +102,7 @@ impl Element for PlotElement

{ }; let appear = match self.0.appear_generation() { - Some(generation) => track_appear(generation, window, cx), + Some(generation) => track_appear(global_id, generation, window, cx), None => PlotAppear::complete(), }; let appearing = appear.is_appearing(); diff --git a/crates/base/src/plot/mod.rs b/crates/base/src/plot/mod.rs index f764c1a069..ab50d8cb2c 100644 --- a/crates/base/src/plot/mod.rs +++ b/crates/base/src/plot/mod.rs @@ -23,7 +23,7 @@ use gpui::{ use crate::{Spring, motion::Transition}; -pub use appear::PlotAppear; +pub use appear::{PlotAppear, PlotAppearScope}; #[allow(deprecated)] pub use axis::AXIS_GAP; pub use axis::{AxisLabelPlacement, AxisLabelSide, AxisText, PlotAxis, axis_gutter}; diff --git a/release-notes.md b/release-notes.md index 7731c83183..2e43d4fb99 100644 --- a/release-notes.md +++ b/release-notes.md @@ -69,8 +69,18 @@ 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 +pub struct PlotAppearScope // remembers finished appears across remounts +impl PlotAppearScope { + pub fn new(id: impl Into, child: impl IntoElement) -> Self +} ``` +A chart that stops being painted forgets its appear, so one in a virtual list +draws in again whenever it scrolls back into view. Wrap the list in a +`PlotAppearScope` and each chart inside draws in once; the memory lasts while +the scope is painted, so closing the view or renaming the scope draws the +charts in afresh. + 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 diff --git a/website/base/plot.md b/website/base/plot.md index ffe2968d61..bb0c70320f 100644 --- a/website/base/plot.md +++ b/website/base/plot.md @@ -145,4 +145,14 @@ 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. +### Appear scope + +The appear lives in the plot's element state, so a plot that stops being painted — a row a virtual list scrolled away — draws in again when it comes back. `PlotAppearScope` remembers which plots inside it have finished, by global element id and appear generation, and hands those a complete appear at once, with no frames asked for: + +```rust +PlotAppearScope::new(("transcript", conversation_id), list(state, render_row).flex_1()) +``` + +A new generation still replays, and a plot taken away mid-appear draws in again from the start. The memory is the scope's own element state, so it lasts while the scope is painted every frame: close the view, or give the scope another id, and its plots draw in afresh. The innermost scope wins. The scope takes no part in layout, so a child that sizes itself, such as a `list`, keeps doing so. + 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 d20c90438b..9e100cf611 100644 --- a/website/component/chart.md +++ b/website/component/chart.md @@ -784,7 +784,13 @@ The appear runs once per id. New data paints in place, so a chart fed live quote 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: +A chart that stops being painted forgets its appear, so one in a virtual list draws in again every time it scrolls back into view. Wrap the list in a [`PlotAppearScope`](../base/plot.md#appear-scope) to have each chart draw in once and show whole when it returns; name the scope after what the list shows, such as a conversation or a watchlist, so moving to another one, or closing the view, draws its charts in afresh: + +```rust +PlotAppearScope::new(("rows", list_id), list(state, render_row).flex_1()) +``` + +Where a chart should never draw in, turn the appear off: ```rust LineChart::new(intraday).interactive(false).appear(false) diff --git a/website/zh-CN/base/plot.md b/website/zh-CN/base/plot.md index b59237efb0..ad34d05925 100644 --- a/website/zh-CN/base/plot.md +++ b/website/zh-CN/base/plot.md @@ -145,4 +145,14 @@ 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。 +### Appear scope + +入场状态存在 plot 的元素状态里,所以 plot 一旦不再绘制(比如虚拟列表滚走的一行),回来时会重新入场。`PlotAppearScope` 按全局元素 id 和入场 generation 记下其中已经入场完毕的 plot,之后直接交给它们完成态的入场,不请求帧: + +```rust +PlotAppearScope::new(("transcript", conversation_id), list(state, render_row).flex_1()) +``` + +generation 变化仍会重播;入场到一半被移走的 plot 回来后从头入场。记录存在作用域自己的元素状态里,只要作用域每帧都在绘制就一直保留:关闭视图,或给作用域换一个 id,其中的 plot 就会重新入场。嵌套时最内层的作用域生效。作用域不参与布局,自带尺寸的子元素(比如 `list`)照常生效。 + GPUI Component 会在主题变化时把自己的 motion token 投射到这里。动效遵循操作系统的“减少动态效果”设置,开启后所有值都会立即采用目标值。 diff --git a/website/zh-CN/component/chart.md b/website/zh-CN/component/chart.md index 41fd8420e1..71c90c3b74 100644 --- a/website/zh-CN/component/chart.md +++ b/website/zh-CN/component/chart.md @@ -758,7 +758,13 @@ AreaChart::new(range).interactive(false) // 拖拽手柄下面的底图 LineChart::new(candles).appear_key((&symbol, period)) ``` -每次滚入视野都会重新绘制的图表(比如长列表每一行里的图表),每次都会重播入场。这种场景把入场关掉: +图表一旦不再绘制就会忘掉自己的入场,所以虚拟列表里的图表每次滚回视野都会重播。把列表包进 [`PlotAppearScope`](../base/plot.md#appear-scope),每张图表只入场一次,滚回来时直接完整显示。作用域按列表展示的内容命名(比如一段对话、一个自选列表),切到别的内容或关闭视图后,图表会重新入场: + +```rust +PlotAppearScope::new(("rows", list_id), list(state, render_row).flex_1()) +``` + +图表完全不需要入场时,把它关掉: ```rust LineChart::new(intraday).interactive(false).appear(false)