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