data_table.rs

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