Skip to main content

csi_webclient/ui/
stream.rs

1use crate::state::{DeviceAction, DeviceState};
2
3/// Max height of the per-device frame list (keeps multi-device view usable).
4const FRAME_LIST_HEIGHT: f32 = 220.0;
5
6/// Render the stream inspection view for one device.
7///
8/// Recording start/stop are queued into `actions`. The shared export directory
9/// is edited once at the top of the Stream tab (see [`render_export_dir`]).
10pub fn render(ui: &mut egui::Ui, device: &mut DeviceState, actions: &mut Vec<DeviceAction>) {
11    ui.add(
12        egui::Label::new(format!("Stream — {}", device.id))
13            .wrap(),
14    );
15    ui.add_space(8.0);
16
17    ui.horizontal_wrapped(|ui| {
18        ui.checkbox(&mut device.auto_scroll_stream, "Auto-scroll");
19        ui.label(format!("Frames: {}", device.frames_received));
20        ui.label(format!("Bytes: {}", device.bytes_received));
21    });
22
23    render_recording_controls(ui, device, actions);
24
25    ui.add_space(8.0);
26    ui.strong("Recent frames");
27    ui.add_space(4.0);
28
29    egui::ScrollArea::vertical()
30        .id_salt("stream_frames_scroll")
31        .auto_shrink([false, false])
32        .max_height(FRAME_LIST_HEIGHT)
33        .stick_to_bottom(device.auto_scroll_stream)
34        .show(ui, |ui| {
35            ui.set_width(ui.available_width());
36            if device.recent_frames.is_empty() {
37                ui.label("No frames yet — connect the WebSocket and start collection.");
38                return;
39            }
40
41            ui.horizontal_wrapped(|ui| {
42                ui.strong("Time");
43                ui.label("·");
44                ui.strong("Len");
45                ui.label("·");
46                ui.strong("Preview (hex)");
47            });
48            ui.separator();
49
50            for frame in device.recent_frames.iter().rev().take(250) {
51                ui.horizontal_wrapped(|ui| {
52                    ui.label(&frame.timestamp);
53                    ui.label(format!("{} B", frame.length));
54                    ui.add(
55                        egui::Label::new(
56                            egui::RichText::new(&frame.preview_hex).monospace(),
57                        )
58                        .wrap(),
59                    );
60                });
61                ui.add_space(2.0);
62            }
63        });
64}
65
66/// Shared Parquet output directory (shown once above all device panels).
67pub fn render_export_dir(ui: &mut egui::Ui, export_dir: &mut String) {
68    ui.strong("Export to Parquet");
69    ui.horizontal_wrapped(|ui| {
70        ui.label("Output dir");
71        let field_w = (ui.available_width() - 80.0).clamp(120.0, 480.0);
72        ui.add(egui::TextEdit::singleline(export_dir).desired_width(field_w));
73    });
74}
75
76/// Per-device recording start/stop and status.
77fn render_recording_controls(
78    ui: &mut egui::Ui,
79    device: &DeviceState,
80    actions: &mut Vec<DeviceAction>,
81) {
82    ui.add_space(6.0);
83    ui.strong("Recording");
84    ui.add_space(4.0);
85
86    let chip_known = device
87        .latest_info
88        .as_ref()
89        .and_then(|i| i.chip.as_deref())
90        .is_some();
91
92    ui.horizontal_wrapped(|ui| {
93        if device.recording {
94            if ui.button("⏹ Stop Recording").clicked() {
95                actions.push(DeviceAction::StopRecording);
96            }
97            ui.colored_label(
98                egui::Color32::from_rgb(220, 80, 80),
99                format!("● Recording — {} frame(s)", device.recorded_frames),
100            );
101            if device.record_decode_errors > 0 {
102                ui.label(format!("({} undecodable)", device.record_decode_errors));
103            }
104        } else {
105            let start = ui.add_enabled(chip_known, egui::Button::new("⏺ Start Recording"));
106            if start.clicked() {
107                actions.push(DeviceAction::StartRecording);
108            }
109            if !chip_known {
110                ui.label("— chip unknown; Fetch Info first");
111            } else if !device.ws_connected {
112                ui.label("— connect WebSocket to capture");
113            }
114        }
115    });
116
117    if let Some(path) = &device.record_path {
118        let label = if device.recording {
119            format!("Writing: {path}")
120        } else {
121            format!("Saved: {path}")
122        };
123        ui.add(egui::Label::new(label).wrap());
124    }
125}