#![forbid(unsafe_code)]
use kcan_core::CanFrame;
use scrin::widgets::Widget;
use scrin::{Buffer, Color, Rect};
use scrin_widgets::AislingPalette;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneHealth {
Nominal,
Warning,
Fault,
Stale,
}
impl PaneHealth {
pub const fn label(self) -> &'static str {
match self {
Self::Nominal => "NOMINAL",
Self::Warning => "WARNING",
Self::Fault => "FAULT",
Self::Stale => "STALE",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PaneMetric {
pub label: String,
pub value: String,
pub unit: String,
pub health: PaneHealth,
}
impl PaneMetric {
pub fn new(
label: impl Into<String>,
value: impl Into<String>,
unit: impl Into<String>,
) -> Self {
Self {
label: label.into(),
value: value.into(),
unit: unit.into(),
health: PaneHealth::Nominal,
}
}
pub const fn with_health(mut self, health: PaneHealth) -> Self {
self.health = health;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ActuatorPane {
pub title: String,
pub subtitle: String,
pub health: PaneHealth,
pub metrics: Vec<PaneMetric>,
pub message: Option<String>,
}
impl ActuatorPane {
pub fn new(title: impl Into<String>, subtitle: impl Into<String>) -> Self {
Self {
title: title.into(),
subtitle: subtitle.into(),
health: PaneHealth::Nominal,
metrics: Vec::new(),
message: None,
}
}
pub const fn with_health(mut self, health: PaneHealth) -> Self {
self.health = health;
self
}
pub fn with_metric(mut self, metric: PaneMetric) -> Self {
self.metrics.push(metric);
self
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
pub fn from_frame(index: usize, frame: &CanFrame) -> Self {
Self::new(format!("frame #{index}"), frame.id().to_string())
.with_metric(PaneMetric::new("format", frame.id().kind_name(), ""))
.with_metric(PaneMetric::new("len", frame.len().to_string(), "bytes"))
.with_message(format!("data {}", hex_bytes(frame.data())))
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewerState {
pub title: String,
pub bus_line: String,
pub panes: Vec<ActuatorPane>,
pub selected: usize,
}
impl ViewerState {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
bus_line: "waiting for bus data".to_owned(),
panes: Vec::new(),
selected: 0,
}
}
pub fn with_bus_line(mut self, bus_line: impl Into<String>) -> Self {
self.bus_line = bus_line.into();
self
}
pub fn with_pane(mut self, pane: ActuatorPane) -> Self {
self.panes.push(pane);
self
}
pub fn from_frames(title: impl Into<String>, frames: &[CanFrame]) -> Self {
let mut state = Self::new(title).with_bus_line(format!("frames={}", frames.len()));
for (index, frame) in frames.iter().enumerate() {
state = state.with_pane(ActuatorPane::from_frame(index, frame));
}
state
}
pub fn select(&mut self, index: usize) {
if !self.panes.is_empty() {
self.selected = index.min(self.panes.len() - 1);
}
}
}
#[derive(Debug, Clone)]
pub struct PaneWall {
palette: AislingPalette,
}
impl PaneWall {
pub fn new() -> Self {
Self {
palette: AislingPalette::cypherpunk(),
}
}
pub fn with_palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
pub fn render_plain(&self, state: &ViewerState, width: usize, height: usize) -> String {
let mut buffer = Buffer::new(width, height);
self.render_to_buffer(state, &mut buffer);
buffer.to_plain_string()
}
pub fn render_to_buffer(&self, state: &ViewerState, buffer: &mut Buffer) {
let area = Rect::new(
0,
0,
buffer.width().min(usize::from(u16::MAX)) as u16,
buffer.height().min(usize::from(u16::MAX)) as u16,
);
self.render(state, buffer, area);
}
pub fn render(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
buffer.fill(area, ' ', self.palette.low, Some(Color::BLACK));
let header_height = area.height.min(4);
let footer_height = if area.height >= 9 { 2 } else { 0 };
let grid_height = area
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let header = Rect::new(area.x, area.y, area.width, header_height);
let grid = Rect::new(
area.x,
area.y.saturating_add(header_height),
area.width,
grid_height,
);
let footer = Rect::new(
area.x,
area.bottom().saturating_sub(footer_height),
area.width,
footer_height,
);
self.render_header(state, buffer, header);
self.render_grid(state, buffer, grid);
self.render_footer(state, buffer, footer);
}
fn render_header(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let block = self.palette.block("KCAN Viewer");
let inner = block.inner(area);
block.render(buffer, area);
draw_line(buffer, inner, 0, &state.title, self.palette.high);
draw_line(buffer, inner, 1, &state.bus_line, self.palette.pulse);
}
fn render_grid(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
if state.panes.is_empty() {
draw_line(
buffer,
area,
0,
"no actuator panes configured",
self.palette.mid,
);
return;
}
let columns = if area.width >= 120 {
3
} else if area.width >= 80 {
2
} else {
1
};
let rows = state.panes.len().div_ceil(columns);
let pane_width = area.width / columns as u16;
let pane_height = (area.height / rows.max(1) as u16).max(5);
for (index, pane) in state.panes.iter().enumerate() {
let column = index % columns;
let row = index / columns;
let x = area
.x
.saturating_add(pane_width.saturating_mul(column as u16));
let y = area
.y
.saturating_add(pane_height.saturating_mul(row as u16));
if y >= area.bottom() {
break;
}
let width = if column + 1 == columns {
area.right().saturating_sub(x)
} else {
pane_width
};
let height = pane_height.min(area.bottom().saturating_sub(y));
self.render_pane(
pane,
index == state.selected,
buffer,
Rect::new(x, y, width, height),
);
}
}
fn render_pane(&self, pane: &ActuatorPane, selected: bool, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let title = if selected { "Selected" } else { "Actuator" };
let block = self
.palette
.block(title)
.with_title_right(pane.health.label());
let inner = block.inner(area);
block.render(buffer, area);
let color = health_color(pane.health, self.palette);
draw_line(buffer, inner, 0, &pane.title, color);
draw_line(buffer, inner, 1, &pane.subtitle, self.palette.mid);
for (row, metric) in pane
.metrics
.iter()
.take(inner.height.saturating_sub(4) as usize)
.enumerate()
{
let line = format!("{:<10} {:>10} {}", metric.label, metric.value, metric.unit);
draw_line(
buffer,
inner,
row as u16 + 2,
&line,
health_color(metric.health, self.palette),
);
}
if let Some(message) = &pane.message {
draw_line(
buffer,
inner,
inner.height.saturating_sub(1),
message,
self.palette.pulse,
);
}
}
fn render_footer(&self, state: &ViewerState, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
let selected = state
.panes
.get(state.selected)
.map(|pane| pane.title.as_str())
.unwrap_or("none");
draw_line(
buffer,
area,
0,
&format!("panes={} selected={selected}", state.panes.len()),
self.palette.low,
);
}
}
impl Default for PaneWall {
fn default() -> Self {
Self::new()
}
}
fn health_color(health: PaneHealth, palette: AislingPalette) -> Color {
match health {
PaneHealth::Nominal => palette.high,
PaneHealth::Warning => palette.pulse,
PaneHealth::Fault => palette.pulse,
PaneHealth::Stale => palette.mid,
}
}
fn draw_line(buffer: &mut Buffer, area: Rect, row: u16, text: &str, color: Color) {
if row >= area.height || area.width == 0 {
return;
}
let clipped = text
.chars()
.take(usize::from(area.width))
.collect::<String>();
buffer.set_str(
usize::from(area.x),
usize::from(area.y.saturating_add(row)),
&clipped,
color,
Some(Color::BLACK),
);
}
fn hex_bytes(data: &[u8]) -> String {
data.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pane_wall_renders_multiple_actuators() {
let state = ViewerState::new("Bench")
.with_bus_line("frames=42 rate=120Hz")
.with_pane(
ActuatorPane::new("left-knee", "CubeMars AK60-6 id=0x03")
.with_metric(PaneMetric::new("pos", "12.5", "deg"))
.with_metric(PaneMetric::new("current", "1.2", "A")),
)
.with_pane(
ActuatorPane::new("right-hip", "RobStride RS-01 id=0x01")
.with_health(PaneHealth::Warning)
.with_metric(
PaneMetric::new("torque", "4.2", "Nm").with_health(PaneHealth::Warning),
)
.with_message("watch temperature"),
);
let output = PaneWall::new().render_plain(&state, 120, 32);
assert!(output.contains("KCAN Viewer"));
assert!(output.contains("left-knee"));
assert!(output.contains("right-hip"));
assert!(output.contains("watch temperature"));
}
#[test]
fn viewer_state_can_be_built_from_core_frames() {
let frames = [
CanFrame::new(kcan_core::CanId::extended(0x501).unwrap(), &[0x01, 0x02]).unwrap(),
CanFrame::new(kcan_core::CanId::standard(0x123).unwrap(), &[0x0A]).unwrap(),
];
let state = ViewerState::from_frames("Monitor", &frames);
let output = PaneWall::new().render_plain(&state, 100, 24);
assert_eq!(state.panes.len(), 2);
assert_eq!(state.bus_line, "frames=2");
assert!(output.contains("Monitor"));
assert!(output.contains("extended 0x501"));
assert!(output.contains("standard 0x123"));
assert!(output.contains("data 01 02"));
}
}