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        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
398/// A single labelled live throughput chart.
399fn 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
487/// A small clickable control pill (stepper / cycle button). Attach `.on_click`.
488fn 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
502/// A labelled settings control group (small caption above its controls).
503fn 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
517/// A single row in the history list: date, download, upload, ping, quality.
518fn 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}