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 div()
384 .flex()
385 .flex_col()
386 .gap_2()
387 .w_full()
388 .p_4()
389 .rounded_md()
390 .bg(rgb(PANEL_BG))
391 .border_1()
392 .border_color(rgb(BLUE))
393 .child(header)
394 .child(body)
395 }
396}
397
398fn chart_panel(
400 title: &str,
401 current: f32,
402 peak: f32,
403 samples: &[f32],
404 color: gpui::Rgba,
405 active: bool,
406) -> impl IntoElement {
407 let max = samples
408 .iter()
409 .copied()
410 .fold(1.0_f32, f32::max)
411 .max(peak)
412 .max(1.0);
413
414 let border = if active { color } else { rgb(PANEL_BORDER) };
415
416 let bars = samples.iter().map(move |&s| {
417 let h = (s / max * CHART_HEIGHT).clamp(2.0, CHART_HEIGHT);
418 div().w(px(5.)).h(px(h)).bg(color).rounded_t_sm()
419 });
420
421 div()
422 .flex()
423 .flex_col()
424 .flex_1()
425 .gap_2()
426 .p_4()
427 .rounded_md()
428 .bg(rgb(PANEL_BG))
429 .border_1()
430 .border_color(border)
431 .child(
432 div()
433 .flex()
434 .flex_row()
435 .items_center()
436 .justify_between()
437 .child(div().text_color(color).child(title.to_string()))
438 .child(
439 div()
440 .text_xs()
441 .text_color(rgb(MUTED))
442 .child(format!("peak {:.1}", peak)),
443 ),
444 )
445 .child(
446 div()
447 .text_2xl()
448 .text_color(rgb(TEXT))
449 .child(format!("{:.1} Mbps", current)),
450 )
451 .child(
452 div()
453 .flex()
454 .flex_row()
455 .items_end()
456 .gap(px(1.))
457 .h(px(CHART_HEIGHT))
458 .w_full()
459 .overflow_hidden()
460 .children(bars),
461 )
462}
463
464fn metric(label: &str, value: String, color: gpui::Rgba) -> impl IntoElement {
465 div()
466 .flex()
467 .flex_col()
468 .gap_1()
469 .child(
470 div()
471 .text_xs()
472 .text_color(rgb(MUTED))
473 .child(label.to_string()),
474 )
475 .child(div().text_xl().text_color(color).child(value))
476}
477
478fn kv(label: &str, value: String) -> impl IntoElement {
479 div()
480 .flex()
481 .flex_row()
482 .gap_2()
483 .child(div().text_color(rgb(MUTED)).child(format!("{label}:")))
484 .child(div().text_color(rgb(TEXT)).child(value))
485}
486
487fn ctrl_pill(id: &'static str, label: impl Into<String>) -> gpui::Stateful<gpui::Div> {
489 div()
490 .id(id)
491 .px_2()
492 .py_1()
493 .rounded_md()
494 .border_1()
495 .border_color(rgb(PANEL_BORDER))
496 .text_color(rgb(CYAN))
497 .cursor_pointer()
498 .hover(|s| s.bg(rgb(PANEL_BORDER)))
499 .child(label.into())
500}
501
502fn setting_group(name: &str, controls: impl IntoElement) -> impl IntoElement {
504 div()
505 .flex()
506 .flex_col()
507 .gap_1()
508 .child(
509 div()
510 .text_xs()
511 .text_color(rgb(MUTED))
512 .child(name.to_string()),
513 )
514 .child(controls)
515}
516
517fn history_row(r: &netrunner_core::SpeedTestResult) -> impl IntoElement {
519 let when = r.timestamp.format("%m-%d %H:%M").to_string();
520 div()
521 .flex()
522 .flex_row()
523 .items_center()
524 .gap_4()
525 .w_full()
526 .px_2()
527 .py_1()
528 .rounded_sm()
529 .hover(|s| s.bg(rgb(PANEL_BORDER)))
530 .child(
531 div()
532 .w(px(96.))
533 .text_sm()
534 .text_color(rgb(MUTED))
535 .child(when),
536 )
537 .child(
538 div()
539 .w(px(92.))
540 .text_color(rgb(GREEN))
541 .child(format!("↓ {:.1}", r.download_mbps)),
542 )
543 .child(
544 div()
545 .w(px(92.))
546 .text_color(rgb(CYAN))
547 .child(format!("↑ {:.1}", r.upload_mbps)),
548 )
549 .child(
550 div()
551 .w(px(72.))
552 .text_color(rgb(BLUE))
553 .child(format!("{:.0} ms", r.ping_ms)),
554 )
555 .child(
556 div()
557 .text_color(quality_color(r.quality))
558 .child(r.quality.to_string()),
559 )
560}