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}