gpui: Add data table example (#24373)

Jason Lee created

Release Notes:

- N/A

As https://github.com/zed-industries/zed/discussions/24260 I mentioned
issue.

Make a complex data table example to test the text rendering
performance.

This example also can be an example to show how to build a large data
table.

```bash
cargo run -p gpui --example data_table
```

<img width="2004" alt="image"
src="https://github.com/user-attachments/assets/653771e5-ef08-4d76-97b9-90ea4b78be59"
/>

----

I will try to do some test. 

For example: With a threshold for the hold number of caches in
`FrameCache`, and only when the threshold is greater than a certain
number, some caches are released, or when a certain time has passed. I
am not sure if this is feasible.

This example is added to help us to test.

Change summary

crates/gpui/examples/data_table.rs | 479 ++++++++++++++++++++++++++++++++
1 file changed, 479 insertions(+)

Detailed changes

crates/gpui/examples/data_table.rs 🔗

@@ -0,0 +1,479 @@
+use std::{
+    ops::Range,
+    rc::Rc,
+    time::{Duration, Instant},
+};
+
+use gpui::{
+    canvas, div, point, prelude::*, px, rgb, size, uniform_list, App, Application, Bounds, Context,
+    MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, SharedString,
+    UniformListScrollHandle, Window, WindowBounds, WindowOptions,
+};
+
+const TOTAL_ITEMS: usize = 10000;
+const SCROLLBAR_THUMB_WIDTH: Pixels = px(8.);
+const SCROLLBAR_THUMB_HEIGHT: Pixels = px(100.);
+
+pub struct Quote {
+    name: SharedString,
+    symbol: SharedString,
+    last_done: f64,
+    prev_close: f64,
+    open: f64,
+    high: f64,
+    low: f64,
+    timestamp: Instant,
+    volume: i64,
+    turnover: f64,
+    ttm: f64,
+    market_cap: f64,
+    float_cap: f64,
+    shares: f64,
+    pb: f64,
+    pe: f64,
+    eps: f64,
+    dividend: f64,
+    dividend_yield: f64,
+    dividend_per_share: f64,
+    dividend_date: SharedString,
+    dividend_payment: f64,
+}
+
+impl Quote {
+    pub fn random() -> Self {
+        use rand::Rng;
+        let mut rng = rand::thread_rng();
+        // simulate a base price in a realistic range
+        let prev_close = rng.gen_range(100.0..200.0);
+        let change = rng.gen_range(-5.0..5.0);
+        let last_done = prev_close + change;
+        let open = prev_close + rng.gen_range(-3.0..3.0);
+        let high = (prev_close + rng.gen_range::<f64, _>(0.0..10.0)).max(open);
+        let low = (prev_close - rng.gen_range::<f64, _>(0.0..10.0)).min(open);
+        // Randomize the timestamp in the past 24 hours
+        let timestamp = Instant::now() - Duration::from_secs(rng.gen_range(0..86400));
+        let volume = rng.gen_range(1_000_000..100_000_000);
+        let turnover = last_done * volume as f64;
+        let symbol = {
+            let mut ticker = String::new();
+            if rng.gen_bool(0.5) {
+                ticker.push_str(&format!(
+                    "{:03}.{}",
+                    rng.gen_range(100..1000),
+                    rng.gen_range(0..10)
+                ));
+            } else {
+                ticker.push_str(&format!(
+                    "{}{}",
+                    rng.gen_range('A'..='Z'),
+                    rng.gen_range('A'..='Z')
+                ));
+            }
+            ticker.push_str(&format!(".{}", rng.gen_range('A'..='Z')));
+            ticker
+        };
+        let name = format!(
+            "{} {} - #{}",
+            symbol,
+            rng.gen_range(1..100),
+            rng.gen_range(10000..100000)
+        );
+        let ttm = rng.gen_range(0.0..10.0);
+        let market_cap = rng.gen_range(1_000_000.0..10_000_000.0);
+        let float_cap = market_cap + rng.gen_range(1_000.0..10_000.0);
+        let shares = rng.gen_range(100.0..1000.0);
+        let pb = market_cap / shares;
+        let pe = market_cap / shares;
+        let eps = market_cap / shares;
+        let dividend = rng.gen_range(0.0..10.0);
+        let dividend_yield = rng.gen_range(0.0..10.0);
+        let dividend_per_share = rng.gen_range(0.0..10.0);
+        let dividend_date = SharedString::new(format!(
+            "{}-{}-{}",
+            rng.gen_range(2000..2023),
+            rng.gen_range(1..12),
+            rng.gen_range(1..28)
+        ));
+        let dividend_payment = rng.gen_range(0.0..10.0);
+
+        Self {
+            name: name.into(),
+            symbol: symbol.into(),
+            last_done,
+            prev_close,
+            open,
+            high,
+            low,
+            timestamp,
+            volume,
+            turnover,
+            pb,
+            pe,
+            eps,
+            ttm,
+            market_cap,
+            float_cap,
+            shares,
+            dividend,
+            dividend_yield,
+            dividend_per_share,
+            dividend_date,
+            dividend_payment,
+        }
+    }
+
+    fn change(&self) -> f64 {
+        (self.last_done - self.prev_close) / self.prev_close * 100.0
+    }
+
+    fn change_color(&self) -> gpui::Hsla {
+        if self.change() > 0.0 {
+            gpui::green()
+        } else {
+            gpui::red()
+        }
+    }
+
+    fn turnover_ratio(&self) -> f64 {
+        self.volume as f64 / self.turnover * 100.0
+    }
+}
+
+#[derive(IntoElement)]
+struct TableRow {
+    ix: usize,
+    quote: Rc<Quote>,
+}
+impl TableRow {
+    fn new(ix: usize, quote: Rc<Quote>) -> Self {
+        Self { ix, quote }
+    }
+
+    fn render_cell(&self, key: &str, width: Pixels, color: gpui::Hsla) -> impl IntoElement {
+        div()
+            .whitespace_nowrap()
+            .truncate()
+            .w(width)
+            .px_1()
+            .child(match key {
+                "id" => div().child(format!("{}", self.ix)),
+                "symbol" => div().child(self.quote.symbol.clone()),
+                "name" => div().child(self.quote.name.clone()),
+                "last_done" => div()
+                    .text_color(color)
+                    .child(format!("{:.3}", self.quote.last_done)),
+                "prev_close" => div()
+                    .text_color(color)
+                    .child(format!("{:.3}", self.quote.prev_close)),
+                "change" => div()
+                    .text_color(color)
+                    .child(format!("{:.2}%", self.quote.change())),
+                "timestamp" => div()
+                    .text_color(color)
+                    .child(format!("{:?}", self.quote.timestamp.elapsed().as_secs())),
+                "open" => div()
+                    .text_color(color)
+                    .child(format!("{:.2}", self.quote.open)),
+                "low" => div()
+                    .text_color(color)
+                    .child(format!("{:.2}", self.quote.low)),
+                "high" => div()
+                    .text_color(color)
+                    .child(format!("{:.2}", self.quote.high)),
+                "ttm" => div()
+                    .text_color(color)
+                    .child(format!("{:.2}", self.quote.ttm)),
+                "eps" => div()
+                    .text_color(color)
+                    .child(format!("{:.2}", self.quote.eps)),
+                "market_cap" => {
+                    div().child(format!("{:.2} M", self.quote.market_cap / 1_000_000.0))
+                }
+                "float_cap" => div().child(format!("{:.2} M", self.quote.float_cap / 1_000_000.0)),
+                "turnover" => div().child(format!("{:.2} M", self.quote.turnover / 1_000_000.0)),
+                "volume" => div().child(format!("{:.2} M", self.quote.volume as f64 / 1_000_000.0)),
+                "turnover_ratio" => div().child(format!("{:.2}%", self.quote.turnover_ratio())),
+                "pe" => div().child(format!("{:.2}", self.quote.pe)),
+                "pb" => div().child(format!("{:.2}", self.quote.pb)),
+                "shares" => div().child(format!("{:.2}", self.quote.shares)),
+                "dividend" => div().child(format!("{:.2}", self.quote.dividend)),
+                "yield" => div().child(format!("{:.2}%", self.quote.dividend_yield)),
+                "dividend_per_share" => {
+                    div().child(format!("{:.2}", self.quote.dividend_per_share))
+                }
+                "dividend_date" => div().child(format!("{}", self.quote.dividend_date)),
+                "dividend_payment" => div().child(format!("{:.2}", self.quote.dividend_payment)),
+                _ => div().child("--"),
+            })
+    }
+}
+
+const FIELDS: [(&str, f32); 24] = [
+    ("id", 64.),
+    ("symbol", 64.),
+    ("name", 180.),
+    ("last_done", 80.),
+    ("prev_close", 80.),
+    ("open", 80.),
+    ("low", 80.),
+    ("high", 80.),
+    ("ttm", 50.),
+    ("market_cap", 96.),
+    ("float_cap", 96.),
+    ("turnover", 120.),
+    ("volume", 100.),
+    ("turnover_ratio", 96.),
+    ("pe", 64.),
+    ("pb", 64.),
+    ("eps", 64.),
+    ("shares", 96.),
+    ("dividend", 64.),
+    ("yield", 64.),
+    ("dividend_per_share", 64.),
+    ("dividend_date", 96.),
+    ("dividend_payment", 64.),
+    ("timestamp", 120.),
+];
+
+impl RenderOnce for TableRow {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let color = self.quote.change_color();
+        div()
+            .flex()
+            .flex_row()
+            .border_b_1()
+            .border_color(rgb(0xE0E0E0))
+            .bg(if self.ix % 2 == 0 {
+                rgb(0xFFFFFF)
+            } else {
+                rgb(0xFAFAFA)
+            })
+            .py_0p5()
+            .px_2()
+            .children(FIELDS.map(|(key, width)| self.render_cell(key, px(width), color)))
+    }
+}
+
+struct DataTable {
+    /// Use `Rc` to share the same quote data across multiple items, avoid cloning.
+    quotes: Vec<Rc<Quote>>,
+    visible_range: Range<usize>,
+    scroll_handle: UniformListScrollHandle,
+    /// The position in thumb bounds when dragging start mouse down.
+    drag_position: Option<Point<Pixels>>,
+}
+
+impl DataTable {
+    fn new() -> Self {
+        Self {
+            quotes: Vec::new(),
+            visible_range: 0..0,
+            scroll_handle: UniformListScrollHandle::new(),
+            drag_position: None,
+        }
+    }
+
+    fn generate(&mut self) {
+        self.quotes = (0..TOTAL_ITEMS).map(|_| Rc::new(Quote::random())).collect();
+    }
+
+    fn table_bounds(&self) -> Bounds<Pixels> {
+        self.scroll_handle.0.borrow().base_handle.bounds()
+    }
+
+    fn scroll_top(&self) -> Pixels {
+        self.scroll_handle.0.borrow().base_handle.offset().y
+    }
+
+    fn scroll_height(&self) -> Pixels {
+        self.scroll_handle
+            .0
+            .borrow()
+            .last_item_size
+            .unwrap_or_default()
+            .contents
+            .height
+    }
+
+    fn render_scrollbar(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let scroll_height = self.scroll_height();
+        let table_bounds = self.table_bounds();
+        let table_height = table_bounds.size.height;
+        if table_height == px(0.) {
+            return div().id("scrollbar");
+        }
+
+        let percentage = -self.scroll_top() / scroll_height;
+        let offset_top = (table_height * percentage).clamp(
+            px(4.),
+            (table_height - SCROLLBAR_THUMB_HEIGHT - px(4.)).max(px(4.)),
+        );
+        let entity = cx.entity();
+        let scroll_handle = self.scroll_handle.0.borrow().base_handle.clone();
+
+        div()
+            .id("scrollbar")
+            .absolute()
+            .top(offset_top)
+            .right_1()
+            .h(SCROLLBAR_THUMB_HEIGHT)
+            .w(SCROLLBAR_THUMB_WIDTH)
+            .bg(rgb(0xC0C0C0))
+            .hover(|this| this.bg(rgb(0xA0A0A0)))
+            .rounded_lg()
+            .child(
+                canvas(
+                    |_, _, _| (),
+                    move |thumb_bounds, _, window, _| {
+                        window.on_mouse_event({
+                            let entity = entity.clone();
+                            move |ev: &MouseDownEvent, _, _, cx| {
+                                if !thumb_bounds.contains(&ev.position) {
+                                    return;
+                                }
+
+                                entity.update(cx, |this, _| {
+                                    this.drag_position = Some(
+                                        ev.position - thumb_bounds.origin - table_bounds.origin,
+                                    );
+                                })
+                            }
+                        });
+                        window.on_mouse_event({
+                            let entity = entity.clone();
+                            move |_: &MouseUpEvent, _, _, cx| {
+                                entity.update(cx, |this, _| {
+                                    this.drag_position = None;
+                                })
+                            }
+                        });
+
+                        window.on_mouse_event(move |ev: &MouseMoveEvent, _, _, cx| {
+                            if !ev.dragging() {
+                                return;
+                            }
+
+                            let Some(drag_pos) = entity.read(cx).drag_position else {
+                                return;
+                            };
+
+                            let inside_offset = drag_pos.y;
+                            let percentage = ((ev.position.y - table_bounds.origin.y
+                                + inside_offset)
+                                / (table_bounds.size.height))
+                                .clamp(0., 1.);
+
+                            let offset_y = ((scroll_height - table_bounds.size.height)
+                                * percentage)
+                                .clamp(px(0.), scroll_height - SCROLLBAR_THUMB_HEIGHT);
+                            scroll_handle.set_offset(point(px(0.), -offset_y));
+                            cx.notify(entity.entity_id());
+                        })
+                    },
+                )
+                .size_full(),
+            )
+    }
+}
+
+impl Render for DataTable {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let entity = cx.entity();
+
+        div()
+            .font_family(".SystemUIFont")
+            .bg(gpui::white())
+            .text_sm()
+            .size_full()
+            .p_4()
+            .gap_2()
+            .flex()
+            .flex_col()
+            .child(format!(
+                "Total {} items, visible range: {:?}",
+                self.quotes.len(),
+                self.visible_range
+            ))
+            .child(
+                div()
+                    .flex()
+                    .flex_col()
+                    .flex_1()
+                    .overflow_hidden()
+                    .border_1()
+                    .border_color(rgb(0xE0E0E0))
+                    .rounded_md()
+                    .child(
+                        div()
+                            .flex()
+                            .flex_row()
+                            .w_full()
+                            .overflow_hidden()
+                            .border_b_1()
+                            .border_color(rgb(0xE0E0E0))
+                            .text_color(rgb(0x555555))
+                            .bg(rgb(0xF0F0F0))
+                            .py_1()
+                            .px_2()
+                            .text_xs()
+                            .children(FIELDS.map(|(key, width)| {
+                                div()
+                                    .whitespace_nowrap()
+                                    .flex_shrink_0()
+                                    .truncate()
+                                    .px_1()
+                                    .w(px(width))
+                                    .child(key.replace("_", " ").to_uppercase())
+                            })),
+                    )
+                    .child(
+                        div()
+                            .relative()
+                            .size_full()
+                            .child(
+                                uniform_list(entity, "items", self.quotes.len(), {
+                                    move |this, range, _, _| {
+                                        this.visible_range = range.clone();
+                                        let mut items = Vec::with_capacity(range.end - range.start);
+                                        for i in range {
+                                            if let Some(quote) = this.quotes.get(i) {
+                                                items.push(TableRow::new(i, quote.clone()));
+                                            }
+                                        }
+                                        items
+                                    }
+                                })
+                                .size_full()
+                                .track_scroll(self.scroll_handle.clone()),
+                            )
+                            .child(self.render_scrollbar(window, cx)),
+                    ),
+            )
+    }
+}
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        cx.open_window(
+            WindowOptions {
+                focus: true,
+                window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
+                    None,
+                    size(px(1280.0), px(1000.0)),
+                    cx,
+                ))),
+                ..Default::default()
+            },
+            |_, cx| {
+                cx.new(|_| {
+                    let mut table = DataTable::new();
+                    table.generate();
+                    table
+                })
+            },
+        )
+        .unwrap();
+
+        cx.activate(true);
+    });
+}