Skip to main content

gpui_kit_grid/state/
progress.rs

1//! Progress: the progress shown over the body and its overlay.
2//!
3//! Two sources: the view ([`LiveGridState::set_progress`], UI thread) and
4//! the store (`GridWriter::set_progress`, any thread, in order with the
5//! data). The view's progress wins; an application that wants another rule
6//! combines both itself. The store's progress is ordered with the data:
7//! when the store clears it at version V while the order on screen is still
8//! built from an older version, the view keeps showing it until the order
9//! covers V (the coverage `flush` waits for). So the bar does not leave
10//! before the rows it announced are shown.
11//!
12//! The overlay is absolute over the body, so it moves no row when it comes
13//! or goes, and it has no hitbox: presses and the wheel reach the body.
14
15use gpui_kit::base::TestSupportExt as _;
16use gpui_kit::component::{ActiveTheme as _, Sizable as _, progress::Progress};
17use gpui_kit::{
18    AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _,
19    SharedString, Styled as _, Window, div, prelude::FluentBuilder as _,
20};
21use rust_i18n::t;
22
23use super::{LiveGridDelegate, LiveGridState};
24use crate::store::GridProgress;
25
26/// The element id of the overlay's container.
27const OVERLAY_ID: &str = "live-grid-progress";
28/// The element id of the default overlay's label on an empty grid.
29const LABEL_ID: &str = "live-grid-progress-label";
30
31/// What [`LiveGridDelegate::render_progress`] knows about the grid.
32///
33/// [`LiveGridDelegate::render_progress`]: crate::LiveGridDelegate::render_progress
34pub struct ProgressContext {
35    empty: bool,
36}
37
38impl ProgressContext {
39    /// Returns true if the grid shows no data rows, as during a first load.
40    pub fn is_empty(&self) -> bool {
41        self.empty
42    }
43
44    /// The default presentation: a 4 px bar (gpui-kit's `Progress`,
45    /// `xsmall`) across the grid right under the header, over the pinned
46    /// rows and the data rows; indeterminate when the length of the work is
47    /// unknown. On an empty grid the progress label, or "Loading…", is
48    /// centered in the body as muted text. With data shown there is no
49    /// label: the previous rows stay readable while they refresh (Design
50    /// Guides, "Loading: preserve context").
51    ///
52    /// The label is also the bar's accessible name. gpui-kit's `Progress`
53    /// honors reduced motion: an indeterminate bar does not animate then.
54    pub fn default_overlay(
55        &self,
56        progress: &GridProgress,
57        _window: &mut Window,
58        cx: &mut App,
59    ) -> AnyElement {
60        let label = progress_label(progress);
61        div()
62            .relative()
63            .size_full()
64            .child(
65                Progress::new("live-grid-progress-bar")
66                    .xsmall()
67                    .loading(progress.is_indeterminate())
68                    .value(progress.fraction().unwrap_or_default() * 100.)
69                    .accessibility_label(label.clone())
70                    // Edge to edge under the header's line.
71                    .rounded_none()
72                    .absolute()
73                    .top_0()
74                    .left_0()
75                    .right_0(),
76            )
77            .when(self.empty, |this| {
78                this.child(
79                    div()
80                        .id(LABEL_ID)
81                        .test_support()
82                        .absolute()
83                        .size_full()
84                        .flex()
85                        .items_center()
86                        .justify_center()
87                        .text_color(cx.theme().muted_foreground)
88                        .child(label),
89                )
90            })
91            .into_any_element()
92    }
93}
94
95/// The text of a progress: its label, or "Loading…".
96fn progress_label(progress: &GridProgress) -> SharedString {
97    match progress.label() {
98        Some(label) => SharedString::from(label.to_owned()),
99        None => SharedString::from(t!("live_grid.progress.loading").into_owned()),
100    }
101}
102
103/// The progress of the view and the store's progress as shown.
104#[derive(Default)]
105pub(super) struct ProgressSlot {
106    /// [`LiveGridState::set_progress`].
107    view: Option<GridProgress>,
108    /// The store's progress as shown: kept after the store cleared it,
109    /// until the order on screen covers the clearing.
110    store: Option<GridProgress>,
111    /// The store version that cleared `store`, while it is still shown.
112    cleared_at: Option<u64>,
113}
114
115impl ProgressSlot {
116    /// A slot that shows the store's progress `store` from the start.
117    pub(super) fn new(store: Option<&GridProgress>) -> Self {
118        Self {
119            view: None,
120            store: store.cloned(),
121            cleared_at: None,
122        }
123    }
124
125    /// The progress shown: the view's, else the store's.
126    fn shown(&self) -> Option<&GridProgress> {
127        self.view.as_ref().or(self.store.as_ref())
128    }
129
130    /// A snapshot of `version` changed the store's progress to `progress`.
131    /// New progress shows at once; cleared progress stays until
132    /// [`Self::release`] sees the order cover `version`.
133    pub(super) fn follow_store(&mut self, progress: Option<&GridProgress>, version: u64) {
134        match progress {
135            Some(progress) => {
136                if self.store.as_ref() != Some(progress) {
137                    self.store = Some(progress.clone());
138                }
139                self.cleared_at = None;
140            }
141            None if self.store.is_some() && self.cleared_at.is_none() => {
142                self.cleared_at = Some(version);
143            }
144            None => {}
145        }
146    }
147
148    /// The order on screen covers `coverage`. Returns true if a cleared
149    /// store progress left with it.
150    pub(super) fn release(&mut self, coverage: u64) -> bool {
151        match self.cleared_at {
152            Some(version) if coverage >= version => {
153                self.store = None;
154                self.cleared_at = None;
155                true
156            }
157            _ => false,
158        }
159    }
160}
161
162impl<D: LiveGridDelegate> LiveGridState<D> {
163    /// Shows the view's own progress over the store's; `None` shows the
164    /// store's again. For work the view waits for itself; a producer writes
165    /// `GridWriter::set_progress` instead, in order with its data.
166    pub fn set_progress(&mut self, progress: Option<GridProgress>, cx: &mut Context<Self>) {
167        if self.progress.view != progress {
168            self.progress.view = progress;
169            cx.notify();
170        }
171    }
172
173    /// The progress shown now: the view's ([`Self::set_progress`]), else the
174    /// store's. Progress the store cleared stays shown until the order on
175    /// screen reflects every write before the clearing, so the bar does not
176    /// leave before the new rows show.
177    pub fn progress(&self) -> Option<&GridProgress> {
178        self.progress.shown()
179    }
180
181    /// The overlay of the shown progress, from the delegate's
182    /// `render_progress`: called in `render`, only while progress is shown.
183    /// Absolute over the body, below the header.
184    pub(super) fn render_progress_overlay(
185        &self,
186        window: &mut Window,
187        cx: &mut App,
188    ) -> Option<impl IntoElement + use<D>> {
189        let progress = self.progress()?;
190        let context = ProgressContext {
191            empty: self.projection.row_count() == 0,
192        };
193        let overlay = self
194            .delegate
195            .render_progress(progress, &context, window, cx);
196        Some(
197            div()
198                .id(OVERLAY_ID)
199                .test_support()
200                .absolute()
201                // Below the group panel too.
202                .top(self.body_top())
203                .left_0()
204                .right_0()
205                .bottom_0()
206                .child(overlay),
207        )
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::{GridProgress, ProgressSlot, progress_label};
214
215    #[test]
216    fn test_progress_slot() {
217        let loading = GridProgress::indeterminate().with_label("Loading trades");
218        let mut slot = ProgressSlot::new(None);
219        assert_eq!(slot.shown(), None);
220
221        // The store's progress shows at once.
222        slot.follow_store(Some(&loading), 3);
223        assert_eq!(slot.shown(), Some(&loading));
224        // Cleared at version 5: shown until the order covers 5.
225        slot.follow_store(None, 5);
226        assert!(!slot.release(4));
227        assert_eq!(slot.shown(), Some(&loading));
228        assert!(slot.release(5));
229        assert_eq!(slot.shown(), None);
230        assert!(!slot.release(6));
231
232        // New progress replaces a cleared one waiting for the order.
233        let half = GridProgress::new(0.5);
234        slot.follow_store(Some(&loading), 7);
235        slot.follow_store(None, 8);
236        slot.follow_store(Some(&half), 9);
237        assert!(!slot.release(9));
238        assert_eq!(slot.shown(), Some(&half));
239
240        // The view's progress wins over the store's.
241        slot.view = Some(loading.clone());
242        assert_eq!(slot.shown(), Some(&loading));
243        slot.view = None;
244        assert_eq!(slot.shown(), Some(&half));
245
246        assert_eq!(ProgressSlot::new(Some(&half)).shown(), Some(&half));
247        assert_eq!(progress_label(&loading), "Loading trades");
248        assert_eq!(
249            &*progress_label(&half),
250            &*rust_i18n::t!("live_grid.progress.loading")
251        );
252    }
253}