data_table.rs

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