Skip to main content

kcan_viewer/
lib.rs

1//! Scrin pane-wall dashboard components for KCAN viewers.
2//!
3//! ```
4//! # fn main() -> kcan_core::Result<()> {
5//! use kcan_core::{CanFrame, CanId};
6//! use kcan_viewer::{PaneWall, ViewerState};
7//!
8//! let frame = CanFrame::new(CanId::extended(0x501)?, &[0x01, 0x02])?;
9//! let state = ViewerState::from_frames("Bench", &[frame]);
10//! let output = PaneWall::new().render_plain(&state, 80, 20);
11//!
12//! assert!(output.contains("Bench"));
13//! # Ok(())
14//! # }
15//! ```
16
17#![forbid(unsafe_code)]
18
19use kcan_core::CanFrame;
20use scrin::widgets::Widget;
21use scrin::{Buffer, Color, Rect};
22use scrin_widgets::AislingPalette;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum PaneHealth {
26    Nominal,
27    Warning,
28    Fault,
29    Stale,
30}
31
32impl PaneHealth {
33    pub const fn label(self) -> &'static str {
34        match self {
35            Self::Nominal => "NOMINAL",
36            Self::Warning => "WARNING",
37            Self::Fault => "FAULT",
38            Self::Stale => "STALE",
39        }
40    }
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct PaneMetric {
45    pub label: String,
46    pub value: String,
47    pub unit: String,
48    pub health: PaneHealth,
49}
50
51impl PaneMetric {
52    pub fn new(
53        label: impl Into<String>,
54        value: impl Into<String>,
55        unit: impl Into<String>,
56    ) -> Self {
57        Self {
58            label: label.into(),
59            value: value.into(),
60            unit: unit.into(),
61            health: PaneHealth::Nominal,
62        }
63    }
64
65    pub const fn with_health(mut self, health: PaneHealth) -> Self {
66        self.health = health;
67        self
68    }
69}
70
71#[derive(Debug, Clone, PartialEq)]
72pub struct ActuatorPane {
73    pub title: String,
74    pub subtitle: String,
75    pub health: PaneHealth,
76    pub metrics: Vec<PaneMetric>,
77    pub message: Option<String>,
78}
79
80impl ActuatorPane {
81    pub fn new(title: impl Into<String>, subtitle: impl Into<String>) -> Self {
82        Self {
83            title: title.into(),
84            subtitle: subtitle.into(),
85            health: PaneHealth::Nominal,
86            metrics: Vec::new(),
87            message: None,
88        }
89    }
90
91    pub const fn with_health(mut self, health: PaneHealth) -> Self {
92        self.health = health;
93        self
94    }
95
96    pub fn with_metric(mut self, metric: PaneMetric) -> Self {
97        self.metrics.push(metric);
98        self
99    }
100
101    pub fn with_message(mut self, message: impl Into<String>) -> Self {
102        self.message = Some(message.into());
103        self
104    }
105
106    pub fn from_frame(index: usize, frame: &CanFrame) -> Self {
107        Self::new(format!("frame #{index}"), frame.id().to_string())
108            .with_metric(PaneMetric::new("format", frame.id().kind_name(), ""))
109            .with_metric(PaneMetric::new("len", frame.len().to_string(), "bytes"))
110            .with_message(format!("data {}", hex_bytes(frame.data())))
111    }
112}
113
114#[derive(Debug, Clone, PartialEq)]
115pub struct ViewerState {
116    pub title: String,
117    pub bus_line: String,
118    pub panes: Vec<ActuatorPane>,
119    pub selected: usize,
120}
121
122impl ViewerState {
123    pub fn new(title: impl Into<String>) -> Self {
124        Self {
125            title: title.into(),
126            bus_line: "waiting for bus data".to_owned(),
127            panes: Vec::new(),
128            selected: 0,
129        }
130    }
131
132    pub fn with_bus_line(mut self, bus_line: impl Into<String>) -> Self {
133        self.bus_line = bus_line.into();
134        self
135    }
136
137    pub fn with_pane(mut self, pane: ActuatorPane) -> Self {
138        self.panes.push(pane);
139        self
140    }
141
142    pub fn from_frames(title: impl Into<String>, frames: &[CanFrame]) -> Self {
143        let mut state = Self::new(title).with_bus_line(format!("frames={}", frames.len()));
144        for (index, frame) in frames.iter().enumerate() {
145            state = state.with_pane(ActuatorPane::from_frame(index, frame));
146        }
147        state
148    }
149
150    pub fn select(&mut self, index: usize) {
151        if !self.panes.is_empty() {
152            self.selected = index.min(self.panes.len() - 1);
153        }
154    }
155}
156
157#[derive(Debug, Clone)]
158pub struct PaneWall {
159    palette: AislingPalette,
160}
161
162impl PaneWall {
163    pub fn new() -> Self {
164        Self {
165            palette: AislingPalette::cypherpunk(),
166        }
167    }
168
169    pub fn with_palette(mut self, palette: AislingPalette) -> Self {
170        self.palette = palette;
171        self
172    }
173
174    pub fn render_plain(&self, state: &ViewerState, width: usize, height: usize) -> String {
175        let mut buffer = Buffer::new(width, height);
176        self.render_to_buffer(state, &mut buffer);
177        buffer.to_plain_string()
178    }
179
180    pub fn render_to_buffer(&self, state: &ViewerState, buffer: &mut Buffer) {
181        let area = Rect::new(
182            0,
183            0,
184            buffer.width().min(usize::from(u16::MAX)) as u16,
185            buffer.height().min(usize::from(u16::MAX)) as u16,
186        );
187        self.render(state, buffer, area);
188    }
189
190    pub fn render(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
191        if area.is_empty() {
192            return;
193        }
194
195        buffer.fill(area, ' ', self.palette.low, Some(Color::BLACK));
196        let header_height = area.height.min(4);
197        let footer_height = if area.height >= 9 { 2 } else { 0 };
198        let grid_height = area
199            .height
200            .saturating_sub(header_height)
201            .saturating_sub(footer_height);
202
203        let header = Rect::new(area.x, area.y, area.width, header_height);
204        let grid = Rect::new(
205            area.x,
206            area.y.saturating_add(header_height),
207            area.width,
208            grid_height,
209        );
210        let footer = Rect::new(
211            area.x,
212            area.bottom().saturating_sub(footer_height),
213            area.width,
214            footer_height,
215        );
216
217        self.render_header(state, buffer, header);
218        self.render_grid(state, buffer, grid);
219        self.render_footer(state, buffer, footer);
220    }
221
222    fn render_header(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
223        if area.is_empty() {
224            return;
225        }
226        let block = self.palette.block("KCAN Viewer");
227        let inner = block.inner(area);
228        block.render(buffer, area);
229        draw_line(buffer, inner, 0, &state.title, self.palette.high);
230        draw_line(buffer, inner, 1, &state.bus_line, self.palette.pulse);
231    }
232
233    fn render_grid(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
234        if area.is_empty() {
235            return;
236        }
237        if state.panes.is_empty() {
238            draw_line(
239                buffer,
240                area,
241                0,
242                "no actuator panes configured",
243                self.palette.mid,
244            );
245            return;
246        }
247
248        let columns = if area.width >= 120 {
249            3
250        } else if area.width >= 80 {
251            2
252        } else {
253            1
254        };
255        let rows = state.panes.len().div_ceil(columns);
256        let pane_width = area.width / columns as u16;
257        let pane_height = (area.height / rows.max(1) as u16).max(5);
258
259        for (index, pane) in state.panes.iter().enumerate() {
260            let column = index % columns;
261            let row = index / columns;
262            let x = area
263                .x
264                .saturating_add(pane_width.saturating_mul(column as u16));
265            let y = area
266                .y
267                .saturating_add(pane_height.saturating_mul(row as u16));
268            if y >= area.bottom() {
269                break;
270            }
271            let width = if column + 1 == columns {
272                area.right().saturating_sub(x)
273            } else {
274                pane_width
275            };
276            let height = pane_height.min(area.bottom().saturating_sub(y));
277            self.render_pane(
278                pane,
279                index == state.selected,
280                buffer,
281                Rect::new(x, y, width, height),
282            );
283        }
284    }
285
286    fn render_pane(&self, pane: &ActuatorPane, selected: bool, buffer: &mut Buffer, area: Rect) {
287        if area.is_empty() {
288            return;
289        }
290        let title = if selected { "Selected" } else { "Actuator" };
291        let block = self
292            .palette
293            .block(title)
294            .with_title_right(pane.health.label());
295        let inner = block.inner(area);
296        block.render(buffer, area);
297
298        let color = health_color(pane.health, self.palette);
299        draw_line(buffer, inner, 0, &pane.title, color);
300        draw_line(buffer, inner, 1, &pane.subtitle, self.palette.mid);
301
302        for (row, metric) in pane
303            .metrics
304            .iter()
305            .take(inner.height.saturating_sub(4) as usize)
306            .enumerate()
307        {
308            let line = format!("{:<10} {:>10} {}", metric.label, metric.value, metric.unit);
309            draw_line(
310                buffer,
311                inner,
312                row as u16 + 2,
313                &line,
314                health_color(metric.health, self.palette),
315            );
316        }
317
318        if let Some(message) = &pane.message {
319            draw_line(
320                buffer,
321                inner,
322                inner.height.saturating_sub(1),
323                message,
324                self.palette.pulse,
325            );
326        }
327    }
328
329    fn render_footer(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
330        if area.is_empty() {
331            return;
332        }
333        let selected = state
334            .panes
335            .get(state.selected)
336            .map(|pane| pane.title.as_str())
337            .unwrap_or("none");
338        draw_line(
339            buffer,
340            area,
341            0,
342            &format!("panes={} selected={selected}", state.panes.len()),
343            self.palette.low,
344        );
345    }
346}
347
348impl Default for PaneWall {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354fn health_color(health: PaneHealth, palette: AislingPalette) -> Color {
355    match health {
356        PaneHealth::Nominal => palette.high,
357        PaneHealth::Warning => palette.pulse,
358        PaneHealth::Fault => palette.pulse,
359        PaneHealth::Stale => palette.mid,
360    }
361}
362
363fn draw_line(buffer: &mut Buffer, area: Rect, row: u16, text: &str, color: Color) {
364    if row >= area.height || area.width == 0 {
365        return;
366    }
367    let clipped = text
368        .chars()
369        .take(usize::from(area.width))
370        .collect::<String>();
371    buffer.set_str(
372        usize::from(area.x),
373        usize::from(area.y.saturating_add(row)),
374        &clipped,
375        color,
376        Some(Color::BLACK),
377    );
378}
379
380fn hex_bytes(data: &[u8]) -> String {
381    data.iter()
382        .map(|byte| format!("{byte:02X}"))
383        .collect::<Vec<_>>()
384        .join(" ")
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn pane_wall_renders_multiple_actuators() {
393        let state = ViewerState::new("Bench")
394            .with_bus_line("frames=42 rate=120Hz")
395            .with_pane(
396                ActuatorPane::new("left-knee", "CubeMars AK60-6 id=0x03")
397                    .with_metric(PaneMetric::new("pos", "12.5", "deg"))
398                    .with_metric(PaneMetric::new("current", "1.2", "A")),
399            )
400            .with_pane(
401                ActuatorPane::new("right-hip", "RobStride RS-01 id=0x01")
402                    .with_health(PaneHealth::Warning)
403                    .with_metric(
404                        PaneMetric::new("torque", "4.2", "Nm").with_health(PaneHealth::Warning),
405                    )
406                    .with_message("watch temperature"),
407            );
408
409        let output = PaneWall::new().render_plain(&state, 120, 32);
410
411        assert!(output.contains("KCAN Viewer"));
412        assert!(output.contains("left-knee"));
413        assert!(output.contains("right-hip"));
414        assert!(output.contains("watch temperature"));
415    }
416
417    #[test]
418    fn viewer_state_can_be_built_from_core_frames() {
419        let frames = [
420            CanFrame::new(kcan_core::CanId::extended(0x501).unwrap(), &[0x01, 0x02]).unwrap(),
421            CanFrame::new(kcan_core::CanId::standard(0x123).unwrap(), &[0x0A]).unwrap(),
422        ];
423
424        let state = ViewerState::from_frames("Monitor", &frames);
425        let output = PaneWall::new().render_plain(&state, 100, 24);
426
427        assert_eq!(state.panes.len(), 2);
428        assert_eq!(state.bus_line, "frames=2");
429        assert!(output.contains("Monitor"));
430        assert!(output.contains("extended 0x501"));
431        assert!(output.contains("standard 0x123"));
432        assert!(output.contains("data 01 02"));
433    }
434}