data_table.rs

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