Skip to main content

netrunner_gui/
view.rs

1//! GPUI rendering for the netrunner desktop app.
2
3use 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                    // Auto-run toggle — persisted to settings.json on click.
92                    .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        // Download/upload trend charts across past runs (oldest → newest).
384        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
433/// A single labelled live throughput chart.
434fn 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
522/// A small clickable control pill (stepper / cycle button). Attach `.on_click`.
523fn 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
537/// A labelled settings control group (small caption above its controls).
538fn 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
552/// A compact bar chart of a metric across past runs (oldest → newest).
553fn 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
599/// A single row in the history list: date, download, upload, ping, quality.
600fn 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}