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}