data_table.rs

  1#![cfg_attr(target_family = "wasm", no_main)]
  2
  3use std::{ops::Range, rc::Rc, time::Duration};
  4
  5use gpui::{
  6    App, Bounds, Context, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Render,
  7    SharedString, UniformListScrollHandle, Window, WindowBounds, WindowOptions, canvas, div, point,
  8    prelude::*, px, rgb, size, uniform_list,
  9};
 10use gpui_platform::application;
 11
 12const TOTAL_ITEMS: usize = 10000;
 13const SCROLLBAR_THUMB_WIDTH: Pixels = px(8.);
 14const SCROLLBAR_THUMB_HEIGHT: Pixels = px(100.);
 15
 16pub struct Quote {
 17    name: SharedString,
 18    symbol: SharedString,
 19    last_done: f64,
 20    prev_close: f64,
 21    open: f64,
 22    high: f64,
 23    low: f64,
 24    timestamp: Duration,
 25    volume: i64,
 26    turnover: f64,
 27    ttm: f64,
 28    market_cap: f64,
 29    float_cap: f64,
 30    shares: f64,
 31    pb: f64,
 32    pe: f64,
 33    eps: f64,
 34    dividend: f64,
 35    dividend_yield: f64,
 36    dividend_per_share: f64,
 37    dividend_date: SharedString,
 38    dividend_payment: f64,
 39}
 40
 41impl Quote {
 42    pub fn random() -> Self {
 43        use rand::Rng;
 44        let mut rng = rand::rng();
 45        // simulate a base price in a realistic range
 46        let prev_close = rng.random_range(100.0..200.0);
 47        let change = rng.random_range(-5.0..5.0);
 48        let last_done = prev_close + change;
 49        let open = prev_close + rng.random_range(-3.0..3.0);
 50        let high = (prev_close + rng.random_range::<f64, _>(0.0..10.0)).max(open);
 51        let low = (prev_close - rng.random_range::<f64, _>(0.0..10.0)).min(open);
 52        let timestamp = Duration::from_secs(rng.random_range(0..86400));
 53        let volume = rng.random_range(1_000_000..100_000_000);
 54        let turnover = last_done * volume as f64;
 55        let symbol = {
 56            let mut ticker = String::new();
 57            if rng.random_bool(0.5) {
 58                ticker.push_str(&format!(
 59                    "{:03}.{}",
 60                    rng.random_range(100..1000),
 61                    rng.random_range(0..10)
 62                ));
 63            } else {
 64                ticker.push_str(&format!(
 65                    "{}{}",
 66                    rng.random_range('A'..='Z'),
 67                    rng.random_range('A'..='Z')
 68                ));
 69            }
 70            ticker.push_str(&format!(".{}", rng.random_range('A'..='Z')));
 71            ticker
 72        };
 73        let name = format!(
 74            "{} {} - #{}",
 75            symbol,
 76            rng.random_range(1..100),
 77            rng.random_range(10000..100000)
 78        );
 79        let ttm = rng.random_range(0.0..10.0);
 80        let market_cap = rng.random_range(1_000_000.0..10_000_000.0);
 81        let float_cap = market_cap + rng.random_range(1_000.0..10_000.0);
 82        let shares = rng.random_range(100.0..1000.0);
 83        let pb = market_cap / shares;
 84        let pe = market_cap / shares;
 85        let eps = market_cap / shares;
 86        let dividend = rng.random_range(0.0..10.0);
 87        let dividend_yield = rng.random_range(0.0..10.0);
 88        let dividend_per_share = rng.random_range(0.0..10.0);
 89        let dividend_date = SharedString::new(format!(
 90            "{}-{}-{}",
 91            rng.random_range(2000..2023),
 92            rng.random_range(1..12),
 93            rng.random_range(1..28)
 94        ));
 95        let dividend_payment = rng.random_range(0.0..10.0);
 96
 97        Self {
 98            name: name.into(),
 99            symbol: symbol.into(),
100            last_done,
101            prev_close,
102            open,
103            high,
104            low,
105            timestamp,
106            volume,
107            turnover,
108            pb,
109            pe,
110            eps,
111            ttm,
112            market_cap,
113            float_cap,
114            shares,
115            dividend,
116            dividend_yield,
117            dividend_per_share,
118            dividend_date,
119            dividend_payment,
120        }
121    }
122
123    fn change(&self) -> f64 {
124        (self.last_done - self.prev_close) / self.prev_close * 100.0
125    }
126
127    fn change_color(&self) -> gpui::Hsla {
128        if self.change() > 0.0 {
129            gpui::green()
130        } else {
131            gpui::red()
132        }
133    }
134
135    fn turnover_ratio(&self) -> f64 {
136        self.volume as f64 / self.turnover * 100.0
137    }
138}
139
140#[derive(IntoElement)]
141struct TableRow {
142    ix: usize,
143    quote: Rc<Quote>,
144}
145impl TableRow {
146    fn new(ix: usize, quote: Rc<Quote>) -> Self {
147        Self { ix, quote }
148    }
149
150    fn render_cell(&self, key: &str, width: Pixels, color: gpui::Hsla) -> impl IntoElement {
151        div()
152            .whitespace_nowrap()
153            .truncate()
154            .w(width)
155            .px_1()
156            .child(match key {
157                "id" => div().child(format!("{}", self.ix)),
158                "symbol" => div().child(self.quote.symbol.clone()),
159                "name" => div().child(self.quote.name.clone()),
160                "last_done" => div()
161                    .text_color(color)
162                    .child(format!("{:.3}", self.quote.last_done)),
163                "prev_close" => div()
164                    .text_color(color)
165                    .child(format!("{:.3}", self.quote.prev_close)),
166                "change" => div()
167                    .text_color(color)
168                    .child(format!("{:.2}%", self.quote.change())),
169                "timestamp" => div()
170                    .text_color(color)
171                    .child(format!("{:?}", self.quote.timestamp.as_secs())),
172                "open" => div()
173                    .text_color(color)
174                    .child(format!("{:.2}", self.quote.open)),
175                "low" => div()
176                    .text_color(color)
177                    .child(format!("{:.2}", self.quote.low)),
178                "high" => div()
179                    .text_color(color)
180                    .child(format!("{:.2}", self.quote.high)),
181                "ttm" => div()
182                    .text_color(color)
183                    .child(format!("{:.2}", self.quote.ttm)),
184                "eps" => div()
185                    .text_color(color)
186                    .child(format!("{:.2}", self.quote.eps)),
187                "market_cap" => {
188                    div().child(format!("{:.2} M", self.quote.market_cap / 1_000_000.0))
189                }
190                "float_cap" => div().child(format!("{:.2} M", self.quote.float_cap / 1_000_000.0)),
191                "turnover" => div().child(format!("{:.2} M", self.quote.turnover / 1_000_000.0)),
192                "volume" => div().child(format!("{:.2} M", self.quote.volume as f64 / 1_000_000.0)),
193                "turnover_ratio" => div().child(format!("{:.2}%", self.quote.turnover_ratio())),
194                "pe" => div().child(format!("{:.2}", self.quote.pe)),
195                "pb" => div().child(format!("{:.2}", self.quote.pb)),
196                "shares" => div().child(format!("{:.2}", self.quote.shares)),
197                "dividend" => div().child(format!("{:.2}", self.quote.dividend)),
198                "yield" => div().child(format!("{:.2}%", self.quote.dividend_yield)),
199                "dividend_per_share" => {
200                    div().child(format!("{:.2}", self.quote.dividend_per_share))
201                }
202                "dividend_date" => div().child(format!("{}", self.quote.dividend_date)),
203                "dividend_payment" => div().child(format!("{:.2}", self.quote.dividend_payment)),
204                _ => div().child("--"),
205            })
206    }
207}
208
209const FIELDS: [(&str, f32); 24] = [
210    ("id", 64.),
211    ("symbol", 64.),
212    ("name", 180.),
213    ("last_done", 80.),
214    ("prev_close", 80.),
215    ("open", 80.),
216    ("low", 80.),
217    ("high", 80.),
218    ("ttm", 50.),
219    ("market_cap", 96.),
220    ("float_cap", 96.),
221    ("turnover", 120.),
222    ("volume", 100.),
223    ("turnover_ratio", 96.),
224    ("pe", 64.),
225    ("pb", 64.),
226    ("eps", 64.),
227    ("shares", 96.),
228    ("dividend", 64.),
229    ("yield", 64.),
230    ("dividend_per_share", 64.),
231    ("dividend_date", 96.),
232    ("dividend_payment", 64.),
233    ("timestamp", 120.),
234];
235
236impl RenderOnce for TableRow {
237    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
238        let color = self.quote.change_color();
239        div()
240            .flex()
241            .flex_row()
242            .border_b_1()
243            .border_color(rgb(0xE0E0E0))
244            .bg(if self.ix.is_multiple_of(2) {
245                rgb(0xFFFFFF)
246            } else {
247                rgb(0xFAFAFA)
248            })
249            .py_0p5()
250            .px_2()
251            .children(FIELDS.map(|(key, width)| self.render_cell(key, px(width), color)))
252    }
253}
254
255struct DataTable {
256    /// Use `Rc` to share the same quote data across multiple items, avoid cloning.
257    quotes: Vec<Rc<Quote>>,
258    visible_range: Range<usize>,
259    scroll_handle: UniformListScrollHandle,
260    /// The position in thumb bounds when dragging start mouse down.
261    drag_position: Option<Point<Pixels>>,
262}
263
264impl DataTable {
265    fn new() -> Self {
266        Self {
267            quotes: Vec::new(),
268            visible_range: 0..0,
269            scroll_handle: UniformListScrollHandle::new(),
270            drag_position: None,
271        }
272    }
273
274    fn generate(&mut self) {
275        self.quotes = (0..TOTAL_ITEMS).map(|_| Rc::new(Quote::random())).collect();
276    }
277
278    fn table_bounds(&self) -> Bounds<Pixels> {
279        self.scroll_handle.0.borrow().base_handle.bounds()
280    }
281
282    fn scroll_top(&self) -> Pixels {
283        self.scroll_handle.0.borrow().base_handle.offset().y
284    }
285
286    fn scroll_height(&self) -> Pixels {
287        self.scroll_handle
288            .0
289            .borrow()
290            .last_item_size
291            .unwrap_or_default()
292            .contents
293            .height
294    }
295
296    fn render_scrollbar(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
297        let scroll_height = self.scroll_height();
298        let table_bounds = self.table_bounds();
299        let table_height = table_bounds.size.height;
300        if table_height == px(0.) {
301            return div().id("scrollbar");
302        }
303
304        let percentage = -self.scroll_top() / scroll_height;
305        let offset_top = (table_height * percentage).clamp(
306            px(4.),
307            (table_height - SCROLLBAR_THUMB_HEIGHT - px(4.)).max(px(4.)),
308        );
309        let entity = cx.entity();
310        let scroll_handle = self.scroll_handle.0.borrow().base_handle.clone();
311
312        div()
313            .id("scrollbar")
314            .absolute()
315            .top(offset_top)
316            .right_1()
317            .h(SCROLLBAR_THUMB_HEIGHT)
318            .w(SCROLLBAR_THUMB_WIDTH)
319            .bg(rgb(0xC0C0C0))
320            .hover(|this| this.bg(rgb(0xA0A0A0)))
321            .rounded_lg()
322            .child(
323                canvas(
324                    |_, _, _| (),
325                    move |thumb_bounds, _, window, _| {
326                        window.on_mouse_event({
327                            let entity = entity.clone();
328                            move |ev: &MouseDownEvent, _, _, cx| {
329                                if !thumb_bounds.contains(&ev.position) {
330                                    return;
331                                }
332
333                                entity.update(cx, |this, _| {
334                                    this.drag_position = Some(
335                                        ev.position - thumb_bounds.origin - table_bounds.origin,
336                                    );
337                                })
338                            }
339                        });
340                        window.on_mouse_event({
341                            let entity = entity.clone();
342                            move |_: &MouseUpEvent, _, _, cx| {
343                                entity.update(cx, |this, _| {
344                                    this.drag_position = None;
345                                })
346                            }
347                        });
348
349                        window.on_mouse_event(move |ev: &MouseMoveEvent, _, _, cx| {
350                            if !ev.dragging() {
351                                return;
352                            }
353
354                            let Some(drag_pos) = entity.read(cx).drag_position else {
355                                return;
356                            };
357
358                            let inside_offset = drag_pos.y;
359                            let percentage = ((ev.position.y - table_bounds.origin.y
360                                + inside_offset)
361                                / (table_bounds.size.height))
362                                .clamp(0., 1.);
363
364                            let offset_y = ((scroll_height - table_bounds.size.height)
365                                * percentage)
366                                .clamp(px(0.), scroll_height - SCROLLBAR_THUMB_HEIGHT);
367                            scroll_handle.set_offset(point(px(0.), -offset_y));
368                            cx.notify(entity.entity_id());
369                        })
370                    },
371                )
372                .size_full(),
373            )
374    }
375}
376
377impl Render for DataTable {
378    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
379        div()
380            .bg(gpui::white())
381            .text_sm()
382            .size_full()
383            .p_4()
384            .gap_2()
385            .flex()
386            .flex_col()
387            .child(format!(
388                "Total {} items, visible range: {:?}",
389                self.quotes.len(),
390                self.visible_range
391            ))
392            .child(
393                div()
394                    .flex()
395                    .flex_col()
396                    .flex_1()
397                    .overflow_hidden()
398                    .border_1()
399                    .border_color(rgb(0xE0E0E0))
400                    .rounded_sm()
401                    .child(
402                        div()
403                            .flex()
404                            .flex_row()
405                            .w_full()
406                            .overflow_hidden()
407                            .border_b_1()
408                            .border_color(rgb(0xE0E0E0))
409                            .text_color(rgb(0x555555))
410                            .bg(rgb(0xF0F0F0))
411                            .py_1()
412                            .px_2()
413                            .text_xs()
414                            .children(FIELDS.map(|(key, width)| {
415                                div()
416                                    .whitespace_nowrap()
417                                    .flex_shrink_0()
418                                    .truncate()
419                                    .px_1()
420                                    .w(px(width))
421                                    .child(key.replace("_", " ").to_uppercase())
422                            })),
423                    )
424                    .child(
425                        div()
426                            .relative()
427                            .size_full()
428                            .child(
429                                uniform_list(
430                                    "items",
431                                    self.quotes.len(),
432                                    cx.processor(move |this, range: Range<usize>, _, _| {
433                                        this.visible_range = range.clone();
434                                        let mut items = Vec::with_capacity(range.end - range.start);
435                                        for i in range {
436                                            if let Some(quote) = this.quotes.get(i) {
437                                                items.push(TableRow::new(i, quote.clone()));
438                                            }
439                                        }
440                                        items
441                                    }),
442                                )
443                                .size_full()
444                                .track_scroll(&self.scroll_handle),
445                            )
446                            .child(self.render_scrollbar(window, cx)),
447                    ),
448            )
449    }
450}
451
452fn run_example() {
453    application().run(|cx: &mut App| {
454        cx.open_window(
455            WindowOptions {
456                focus: true,
457                window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
458                    None,
459                    size(px(1280.0), px(1000.0)),
460                    cx,
461                ))),
462                ..Default::default()
463            },
464            |_, cx| {
465                cx.new(|_| {
466                    let mut table = DataTable::new();
467                    table.generate();
468                    table
469                })
470            },
471        )
472        .unwrap();
473
474        cx.activate(true);
475    });
476}
477
478#[cfg(not(target_family = "wasm"))]
479fn main() {
480    run_example();
481}
482
483#[cfg(target_family = "wasm")]
484#[wasm_bindgen::prelude::wasm_bindgen(start)]
485pub fn start() {
486    gpui_platform::web_init();
487    run_example();
488}