1#![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}