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}