1use gpui::{div, prelude::*, px, rgb, Context, Window};
4
5use crate::app::{Phase, SpeedApp};
6use crate::theme::*;
7
8const CHART_HEIGHT: f32 = 170.0;
9
10impl Render for SpeedApp {
11 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
12 div()
13 .id("root")
14 .flex()
15 .flex_col()
16 .size_full()
17 .overflow_y_scroll()
18 .bg(rgb(BG))
19 .text_color(rgb(TEXT))
20 .font_family("monospace")
21 .p_5()
22 .gap_4()
23 .child(self.header(cx))
24 .child(self.status_bar())
25 .child(
26 div()
27 .flex()
28 .flex_row()
29 .gap_4()
30 .w_full()
31 .child(chart_panel(
32 "⬇ DOWNLOAD",
33 self.download_mbps,
34 self.peak_download,
35 &self.download_samples,
36 download_color(),
37 self.phase == Phase::Download,
38 ))
39 .child(chart_panel(
40 "⬆ UPLOAD",
41 self.upload_mbps,
42 self.peak_upload,
43 &self.upload_samples,
44 upload_color(),
45 self.phase == Phase::Upload,
46 )),
47 )
48 .child(self.summary())
49 .child(self.settings_panel(cx))
50 .child(self.history_panel(cx))
51 }
52}
53
54impl SpeedApp {
55 fn header(&self, cx: &mut Context<Self>) -> impl IntoElement {
56 let running = self.running;
57 let auto_run = self.settings.auto_run;
58 let button_label = if running {
59 "RUNNING…"
60 } else {
61 "▶ RUN TEST"
62 };
63
64 div()
65 .flex()
66 .flex_row()
67 .items_center()
68 .justify_between()
69 .w_full()
70 .child(
71 div()
72 .flex()
73 .flex_row()
74 .items_center()
75 .gap_2()
76 .child(div().text_2xl().text_color(rgb(MAGENTA)).child("⟨⟨⟨"))
77 .child(div().text_2xl().text_color(rgb(CYAN)).child("NETRUNNER"))
78 .child(
79 div()
80 .text_sm()
81 .text_color(rgb(MUTED))
82 .child("// SPEED TEST"),
83 ),
84 )
85 .child(
86 div()
87 .flex()
88 .flex_row()
89 .items_center()
90 .gap_2()
91 .child(
93 div()
94 .id("autorun")
95 .px_3()
96 .py_2()
97 .rounded_md()
98 .border_1()
99 .border_color(rgb(if auto_run { GREEN } else { PANEL_BORDER }))
100 .text_color(rgb(if auto_run { GREEN } else { MUTED }))
101 .bg(rgb(PANEL_BG))
102 .cursor_pointer()
103 .hover(|s| s.bg(rgb(PANEL_BORDER)))
104 .on_click(cx.listener(|app, _ev, _window, cx| {
105 app.toggle_auto_run();
106 cx.notify();
107 }))
108 .child(format!("Auto-run: {}", if auto_run { "ON" } else { "OFF" })),
109 )
110 .child(
111 div()
112 .id("run")
113 .px_4()
114 .py_2()
115 .rounded_md()
116 .border_1()
117 .border_color(rgb(if running { MUTED } else { GREEN }))
118 .text_color(rgb(if running { MUTED } else { GREEN }))
119 .bg(rgb(PANEL_BG))
120 .cursor_pointer()
121 .hover(|s| s.bg(rgb(PANEL_BORDER)))
122 .on_click(cx.listener(|app, _ev, _window, cx| app.start(cx)))
123 .child(button_label),
124 ),
125 )
126 }
127
128 fn status_bar(&self) -> impl IntoElement {
129 div()
130 .flex()
131 .flex_row()
132 .items_center()
133 .gap_3()
134 .w_full()
135 .px_3()
136 .py_2()
137 .rounded_md()
138 .bg(rgb(PANEL_BG))
139 .border_1()
140 .border_color(rgb(PANEL_BORDER))
141 .child(
142 div()
143 .text_color(rgb(YELLOW))
144 .child(self.phase.label().to_string()),
145 )
146 .child(div().text_color(rgb(MUTED)).child("·"))
147 .child(div().text_color(rgb(TEXT)).child(self.status.to_string()))
148 }
149
150 fn summary(&self) -> impl IntoElement {
151 let location = self
152 .location
153 .as_ref()
154 .map(|s| s.to_string())
155 .unwrap_or_else(|| "—".to_string());
156 let isp = self
157 .isp
158 .as_ref()
159 .map(|s| s.to_string())
160 .unwrap_or_else(|| "—".to_string());
161 let server = self
162 .server
163 .as_ref()
164 .map(|s| s.to_string())
165 .unwrap_or_else(|| "—".to_string());
166
167 let quality = self
168 .result
169 .as_ref()
170 .map(|r| r.quality.to_string())
171 .unwrap_or_else(|| "—".to_string());
172 let quality_col = self
173 .result
174 .as_ref()
175 .map(|r| quality_color(r.quality))
176 .unwrap_or_else(|| rgb(MUTED));
177
178 div()
179 .flex()
180 .flex_col()
181 .gap_2()
182 .w_full()
183 .p_4()
184 .rounded_md()
185 .bg(rgb(PANEL_BG))
186 .border_1()
187 .border_color(rgb(MAGENTA))
188 .child(
189 div()
190 .flex()
191 .flex_row()
192 .gap_6()
193 .child(metric("Ping", format!("{:.0} ms", self.ping_ms), rgb(BLUE)))
194 .child(metric(
195 "Download",
196 format!("{:.1} Mbps", self.download_mbps),
197 rgb(GREEN),
198 ))
199 .child(metric(
200 "Upload",
201 format!("{:.1} Mbps", self.upload_mbps),
202 rgb(CYAN),
203 ))
204 .child(metric("Quality", quality, quality_col)),
205 )
206 .child(
207 div()
208 .flex()
209 .flex_row()
210 .gap_6()
211 .text_sm()
212 .child(kv("Location", location))
213 .child(kv("ISP", isp))
214 .child(kv("Server", server)),
215 )
216 }
217
218 fn settings_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
219 let s = &self.settings;
220 let server_host = s
221 .server_url
222 .trim_start_matches("https://")
223 .trim_start_matches("http://")
224 .to_string();
225
226 div()
227 .flex()
228 .flex_col()
229 .gap_2()
230 .w_full()
231 .p_4()
232 .rounded_md()
233 .bg(rgb(PANEL_BG))
234 .border_1()
235 .border_color(rgb(PANEL_BORDER))
236 .child(div().text_color(rgb(YELLOW)).child("⚙ SETTINGS"))
237 .child(
238 div()
239 .flex()
240 .flex_row()
241 .flex_wrap()
242 .items_end()
243 .gap_6()
244 .child(setting_group(
245 "Server",
246 div()
247 .flex()
248 .flex_row()
249 .items_center()
250 .gap_2()
251 .child(div().text_color(rgb(TEXT)).child(server_host))
252 .child(ctrl_pill("server-cycle", "↺").on_click(cx.listener(
253 |a, _e, _w, cx| {
254 a.cycle_server();
255 cx.notify();
256 },
257 ))),
258 ))
259 .child(setting_group(
260 "Size (MB)",
261 div()
262 .flex()
263 .flex_row()
264 .items_center()
265 .gap_2()
266 .child(ctrl_pill("size-dec", "−").on_click(cx.listener(
267 |a, _e, _w, cx| {
268 a.adjust_size(-1);
269 cx.notify();
270 },
271 )))
272 .child(
273 div()
274 .w(px(44.))
275 .text_color(rgb(TEXT))
276 .child(format!("{}", s.test_size_mb)),
277 )
278 .child(ctrl_pill("size-inc", "+").on_click(cx.listener(
279 |a, _e, _w, cx| {
280 a.adjust_size(1);
281 cx.notify();
282 },
283 ))),
284 ))
285 .child(setting_group(
286 "Timeout (s)",
287 div()
288 .flex()
289 .flex_row()
290 .items_center()
291 .gap_2()
292 .child(ctrl_pill("to-dec", "−").on_click(cx.listener(
293 |a, _e, _w, cx| {
294 a.adjust_timeout(-5);
295 cx.notify();
296 },
297 )))
298 .child(
299 div()
300 .w(px(44.))
301 .text_color(rgb(TEXT))
302 .child(format!("{}", s.timeout_seconds)),
303 )
304 .child(ctrl_pill("to-inc", "+").on_click(cx.listener(
305 |a, _e, _w, cx| {
306 a.adjust_timeout(5);
307 cx.notify();
308 },
309 ))),
310 ))
311 .child(setting_group(
312 "Detail",
313 div()
314 .flex()
315 .flex_row()
316 .items_center()
317 .gap_2()
318 .child(
319 div()
320 .text_color(rgb(TEXT))
321 .child(s.detail_level.to_string()),
322 )
323 .child(ctrl_pill("detail-cycle", "↺").on_click(cx.listener(
324 |a, _e, _w, cx| {
325 a.cycle_detail();
326 cx.notify();
327 },
328 ))),
329 )),
330 )
331 }
332
333 fn history_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
334 let has_history = !self.history.is_empty();
335
336 let header = div()
337 .flex()
338 .flex_row()
339 .items_center()
340 .justify_between()
341 .child(
342 div()
343 .text_color(rgb(MAGENTA))
344 .child(format!("🗂 HISTORY ({})", self.history.len())),
345 )
346 .child(
347 div()
348 .id("clear-history")
349 .px_3()
350 .py_1()
351 .rounded_md()
352 .border_1()
353 .border_color(rgb(PANEL_BORDER))
354 .text_color(rgb(MUTED))
355 .cursor_pointer()
356 .hover(|s| s.bg(rgb(PANEL_BORDER)).text_color(rgb(RED)))
357 .on_click(cx.listener(|app, _ev, _window, cx| {
358 app.clear_history();
359 cx.notify();
360 }))
361 .child("Clear"),
362 );
363
364 let body: gpui::AnyElement = if has_history {
365 let rows = self.history.iter().map(history_row).collect::<Vec<_>>();
366 div()
367 .id("history-list")
368 .flex()
369 .flex_col()
370 .gap_1()
371 .max_h(px(200.))
372 .overflow_y_scroll()
373 .children(rows)
374 .into_any_element()
375 } else {
376 div()
377 .text_sm()
378 .text_color(rgb(MUTED))
379 .child("No previous runs yet — run a test to start building history.")
380 .into_any_element()
381 };
382
383 let charts: gpui::AnyElement = if has_history {
385 let downloads: Vec<f32> = self
386 .history
387 .iter()
388 .rev()
389 .map(|r| r.download_mbps as f32)
390 .collect();
391 let uploads: Vec<f32> = self
392 .history
393 .iter()
394 .rev()
395 .map(|r| r.upload_mbps as f32)
396 .collect();
397 div()
398 .flex()
399 .flex_row()
400 .gap_4()
401 .w_full()
402 .child(history_trend_chart(
403 "⬇ Download trend",
404 &downloads,
405 download_color(),
406 ))
407 .child(history_trend_chart(
408 "⬆ Upload trend",
409 &uploads,
410 upload_color(),
411 ))
412 .into_any_element()
413 } else {
414 div().into_any_element()
415 };
416
417 div()
418 .flex()
419 .flex_col()
420 .gap_2()
421 .w_full()
422 .p_4()
423 .rounded_md()
424 .bg(rgb(PANEL_BG))
425 .border_1()
426 .border_color(rgb(BLUE))
427 .child(header)
428 .child(charts)
429 .child(body)
430 }
431}
432
433fn chart_panel(
435 title: &str,
436 current: f32,
437 peak: f32,
438 samples: &[f32],
439 color: gpui::Rgba,
440 active: bool,
441) -> impl IntoElement {
442 let max = samples
443 .iter()
444 .copied()
445 .fold(1.0_f32, f32::max)
446 .max(peak)
447 .max(1.0);
448
449 let border = if active { color } else { rgb(PANEL_BORDER) };
450
451 let bars = samples.iter().map(move |&s| {
452 let h = (s / max * CHART_HEIGHT).clamp(2.0, CHART_HEIGHT);
453 div().w(px(5.)).h(px(h)).bg(color).rounded_t_sm()
454 });
455
456 div()
457 .flex()
458 .flex_col()
459 .flex_1()
460 .gap_2()
461 .p_4()
462 .rounded_md()
463 .bg(rgb(PANEL_BG))
464 .border_1()
465 .border_color(border)
466 .child(
467 div()
468 .flex()
469 .flex_row()
470 .items_center()
471 .justify_between()
472 .child(div().text_color(color).child(title.to_string()))
473 .child(
474 div()
475 .text_xs()
476 .text_color(rgb(MUTED))
477 .child(format!("peak {:.1}", peak)),
478 ),
479 )
480 .child(
481 div()
482 .text_2xl()
483 .text_color(rgb(TEXT))
484 .child(format!("{:.1} Mbps", current)),
485 )
486 .child(
487 div()
488 .flex()
489 .flex_row()
490 .items_end()
491 .gap(px(1.))
492 .h(px(CHART_HEIGHT))
493 .w_full()
494 .overflow_hidden()
495 .children(bars),
496 )
497}
498
499fn metric(label: &str, value: String, color: gpui::Rgba) -> impl IntoElement {
500 div()
501 .flex()
502 .flex_col()
503 .gap_1()
504 .child(
505 div()
506 .text_xs()
507 .text_color(rgb(MUTED))
508 .child(label.to_string()),
509 )
510 .child(div().text_xl().text_color(color).child(value))
511}
512
513fn kv(label: &str, value: String) -> impl IntoElement {
514 div()
515 .flex()
516 .flex_row()
517 .gap_2()
518 .child(div().text_color(rgb(MUTED)).child(format!("{label}:")))
519 .child(div().text_color(rgb(TEXT)).child(value))
520}
521
522fn ctrl_pill(id: &'static str, label: impl Into<String>) -> gpui::Stateful<gpui::Div> {
524 div()
525 .id(id)
526 .px_2()
527 .py_1()
528 .rounded_md()
529 .border_1()
530 .border_color(rgb(PANEL_BORDER))
531 .text_color(rgb(CYAN))
532 .cursor_pointer()
533 .hover(|s| s.bg(rgb(PANEL_BORDER)))
534 .child(label.into())
535}
536
537fn setting_group(name: &str, controls: impl IntoElement) -> impl IntoElement {
539 div()
540 .flex()
541 .flex_col()
542 .gap_1()
543 .child(
544 div()
545 .text_xs()
546 .text_color(rgb(MUTED))
547 .child(name.to_string()),
548 )
549 .child(controls)
550}
551
552fn history_trend_chart(title: &str, values: &[f32], color: gpui::Rgba) -> impl IntoElement {
554 const H: f32 = 64.0;
555 let max = values.iter().copied().fold(1.0_f32, f32::max).max(1.0);
556 let latest = values.last().copied().unwrap_or(0.0);
557 let bars = values.iter().map(move |&v| {
558 let bh = (v / max * H).clamp(2.0, H);
559 div().w(px(7.)).h(px(bh)).bg(color).rounded_t_sm()
560 });
561
562 div()
563 .flex()
564 .flex_col()
565 .flex_1()
566 .gap_1()
567 .child(
568 div()
569 .flex()
570 .flex_row()
571 .justify_between()
572 .child(
573 div()
574 .text_xs()
575 .text_color(rgb(MUTED))
576 .child(title.to_string()),
577 )
578 .child(
579 div()
580 .text_xs()
581 .text_color(color)
582 .child(format!("{latest:.1} Mbps")),
583 ),
584 )
585 .child(
586 div()
587 .id(gpui::ElementId::Name(title.to_string().into()))
588 .flex()
589 .flex_row()
590 .items_end()
591 .gap(px(2.))
592 .h(px(H))
593 .w_full()
594 .overflow_x_scroll()
595 .children(bars),
596 )
597}
598
599fn history_row(r: &netrunner_core::SpeedTestResult) -> impl IntoElement {
601 let when = r.timestamp.format("%m-%d %H:%M").to_string();
602 div()
603 .flex()
604 .flex_row()
605 .items_center()
606 .gap_4()
607 .w_full()
608 .px_2()
609 .py_1()
610 .rounded_sm()
611 .hover(|s| s.bg(rgb(PANEL_BORDER)))
612 .child(
613 div()
614 .w(px(96.))
615 .text_sm()
616 .text_color(rgb(MUTED))
617 .child(when),
618 )
619 .child(
620 div()
621 .w(px(92.))
622 .text_color(rgb(GREEN))
623 .child(format!("↓ {:.1}", r.download_mbps)),
624 )
625 .child(
626 div()
627 .w(px(92.))
628 .text_color(rgb(CYAN))
629 .child(format!("↑ {:.1}", r.upload_mbps)),
630 )
631 .child(
632 div()
633 .w(px(72.))
634 .text_color(rgb(BLUE))
635 .child(format!("{:.0} ms", r.ping_ms)),
636 )
637 .child(
638 div()
639 .text_color(quality_color(r.quality))
640 .child(r.quality.to_string()),
641 )
642}