use crate::config::MergedConfig;
use crate::parser::{SensorData, get_color_for_index, parse_sensor_data};
use crate::rtt_reader::RttDefmtReader;
use crate::serial::{
get_timestamp, parse_data_bits, parse_flow_control, parse_parity, parse_stop_bits,
};
use crossterm::{
event::{self, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use inline_colorization::*;
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
prelude::*,
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::*,
};
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::{self, BufWriter, Read, Write};
use std::time::{Duration, Instant};
use ratatui_wireframe::WireframeWidget;
#[cfg(feature = "ratty")]
use ratatui_ratty::{ObjectFormat, RattyGraphic, RattyGraphicSettings};
#[derive(PartialEq)]
enum ActiveTab {
Chart2D,
Wireframe3D,
}
fn detect_terminal() -> String {
if std::env::var("TERM_PROGRAM").is_ok_and(|v| v.to_lowercase() == "ratty") {
return if cfg!(feature = "ratty") {
"Ratty (GPU 3D)".to_string()
} else {
"Ratty (Braille)".to_string()
};
}
if std::env::var("WEZTERM_EXECUTABLE").is_ok() {
return "WezTerm (Braille)".to_string();
}
if let Ok(prog) = std::env::var("TERM_PROGRAM") {
let prog_lower = prog.to_lowercase();
if prog_lower == "ghostty" {
return "Ghostty (Braille)".to_string();
} else if prog_lower == "kitty" {
return "Kitty (Braille)".to_string();
}
}
if std::env::var("TERM").is_ok_and(|v| v.to_lowercase() == "foot") {
return "Foot (Braille)".to_string();
}
"Standard TTY (Braille)".to_string()
}
struct PlotterState {
sensors: HashMap<String, SensorData>,
sensor_order: Vec<String>,
x: f64,
global_y_min: f64,
global_y_max: f64,
paused: bool,
total_samples: u64,
samples_this_second: u32,
sample_rate: u32,
last_rate_update: Instant,
start_time: Instant,
lines_discarded: usize,
receive_buf: String,
last_error: Option<String>,
export_data: HashMap<String, Vec<(f64, f64)>>,
pub export_limit: usize,
pub csv_streamer: Option<crate::export::CsvStreamer>,
active_tab: ActiveTab,
terminal_type: String,
custom_model: Option<ratatui_wireframe::model::Model>,
display_pitch: f64,
display_yaw: f64,
display_roll: f64,
last_frame_time: Instant,
#[cfg(feature = "ratty")]
ratty_engines: Option<RattyGraphic<'static>>,
}
const DISCARD_FIRST_LINES: usize = 3;
impl PlotterState {
fn new(
export_limit: usize,
csv_streamer: Option<crate::export::CsvStreamer>,
_obj_file: Option<String>,
) -> Self {
let now = Instant::now();
#[cfg(feature = "ratty")]
let ratty_engines = {
let is_ratty = std::env::var("TERM_PROGRAM")
.map(|v| v.to_lowercase() == "ratty")
.unwrap_or(false);
if is_ratty {
let default_obj = b"v -1 -1 1\nv 1 -1 1\nv -1 1 1\nv 1 1 1\nv -1 -1 -1\nv 1 -1 -1\nv -1 1 -1\nv 1 1 -1\nvn 0 0 1\nvn 0 0 -1\nvn 0 1 0\nvn 0 -1 0\nvn 1 0 0\nvn -1 0 0\ns off\nf 1//1 2//1 4//1\nf 1//1 4//1 3//1\nf 6//2 5//2 7//2\nf 6//2 7//2 8//2\nf 3//3 4//3 8//3\nf 3//3 8//3 7//3\nf 5//4 6//4 2//4\nf 5//4 2//4 1//4\nf 2//5 6//5 8//5\nf 2//5 8//5 4//5\nf 5//6 1//6 3//6\nf 5//6 3//6 7//6\n";
let (name, payload) = if let Some(path) = _obj_file {
match std::fs::read(&path) {
Ok(bytes) => (path, bytes),
Err(err) => {
eprintln!(
"Failed to read .obj file '{}': {err}. Falling back to default cube.",
path
);
("cube.obj".to_string(), default_obj.to_vec())
}
}
} else {
("cube.obj".to_string(), default_obj.to_vec())
};
let name_static: &'static str = Box::leak(name.into_boxed_str());
let graphic = RattyGraphic::new(
RattyGraphicSettings::new(name_static)
.id(1)
.format(ObjectFormat::Obj)
.scale(0.25)
.depth(3.0)
.brightness(1.8)
.animate(false),
);
let _ = graphic.register_payload(&payload);
let _ = graphic.update();
Some(graphic)
} else {
None
}
};
PlotterState {
sensors: HashMap::new(),
sensor_order: Vec::new(),
x: 0.0,
global_y_min: f64::INFINITY,
global_y_max: f64::NEG_INFINITY,
paused: false,
total_samples: 0,
samples_this_second: 0,
sample_rate: 0,
last_rate_update: now,
start_time: now,
lines_discarded: 0,
receive_buf: String::new(),
last_error: None,
export_data: HashMap::new(),
export_limit,
csv_streamer,
active_tab: ActiveTab::Chart2D,
terminal_type: detect_terminal(),
custom_model: None,
display_pitch: 0.0,
display_yaw: 0.0,
display_roll: 0.0,
last_frame_time: now,
#[cfg(feature = "ratty")]
ratty_engines,
}
}
fn get_or_create_sensor(&mut self, name: &str) -> &mut SensorData {
if !self.sensors.contains_key(name) {
let color = get_color_for_index(self.sensor_order.len());
self.sensors
.insert(name.to_string(), SensorData::new(name.to_string(), color));
self.sensor_order.push(name.to_string());
}
self.sensors.get_mut(name).unwrap()
}
fn ingest_line(&mut self, line: &str, max_points: usize) {
let clean = line.trim();
if self.lines_discarded < DISCARD_FIRST_LINES {
self.lines_discarded += 1;
return;
}
if self.paused {
return;
}
let readings = parse_sensor_data(clean);
if readings.is_empty() {
return;
}
if let Some(streamer) = &mut self.csv_streamer {
let _ = streamer.write_row(&readings);
}
for (name, value) in readings {
let x = self.x;
let sensor = self.get_or_create_sensor(name.as_ref());
sensor.add_point(x, value, max_points);
let series = self.export_data.entry(name.to_string()).or_default();
series.push((x, value));
if series.len() > self.export_limit {
let drop_count = self.export_limit / 10; series.drain(0..drop_count);
}
if value < self.global_y_min {
self.global_y_min = value;
}
if value > self.global_y_max {
self.global_y_max = value;
}
}
self.x += 1.0;
self.total_samples += 1;
self.samples_this_second += 1;
let elapsed = self.last_rate_update.elapsed();
if elapsed >= Duration::from_secs(1) {
self.sample_rate = self.samples_this_second;
self.samples_this_second = 0;
self.last_rate_update = Instant::now();
}
}
fn x_bounds(&self) -> [f64; 2] {
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
for sensor in self.sensors.values() {
if let (Some(first), Some(last)) = (sensor.data.first(), sensor.data.last()) {
if first.0 < min_x {
min_x = first.0;
}
if last.0 > max_x {
max_x = last.0;
}
}
}
if min_x.is_finite() && max_x.is_finite() {
[min_x, max_x]
} else {
[0.0, 10.0]
}
}
fn y_bounds(&self) -> [f64; 2] {
if self.global_y_min.is_finite() && self.global_y_max.is_finite() {
if self.global_y_min == self.global_y_max {
[self.global_y_min - 1.0, self.global_y_max + 1.0]
} else {
let padding = (self.global_y_max - self.global_y_min) * 0.1;
[self.global_y_min - padding, self.global_y_max + padding]
}
} else {
[-1.0, 1.0]
}
}
fn uptime_str(&self) -> String {
let secs = self.start_time.elapsed().as_secs();
format!(
"{:02}:{:02}:{:02}",
secs / 3600,
(secs % 3600) / 60,
secs % 60
)
}
}
pub fn run_plotter_mode(
config: MergedConfig,
port_name: String,
passed_port: Option<Box<dyn serialport::SerialPort>>,
passed_rtt: Option<crate::rtt_reader::RttDefmtReader>,
) -> Result<crate::AppExitState, Box<dyn std::error::Error>> {
let mut port = if let Some(p) = passed_port {
Some(p)
} else if config.simulate || config.replay_file.is_some() || config.rtt {
None
} else {
let data_bits = parse_data_bits(config.data_bits)?;
let stop_bits = parse_stop_bits(config.stop_bits)?;
let parity = parse_parity(&config.parity)?;
let flow_control = parse_flow_control(&config.flow_control)?;
Some(
serialport::new(&port_name, config.baud)
.timeout(Duration::from_millis(config.timeout_ms))
.data_bits(data_bits)
.stop_bits(stop_bits)
.parity(parity)
.flow_control(flow_control)
.open()?,
)
};
let mut rtt_reader = if let Some(r) = passed_rtt {
Some(r)
} else if config.rtt {
let elf = config.elf.as_deref().unwrap_or("");
if elf.is_empty() {
return Err("RTT mode requires an ELF file. Use --elf <path>".into());
}
Some(RttDefmtReader::new(elf, config.chip.clone())?)
} else {
None
};
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut log_writer: Option<BufWriter<std::fs::File>> =
if let Some(ref log_path) = config.log_file {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
Some(BufWriter::new(file))
} else {
None
};
std::thread::sleep(Duration::from_millis(config.reset_delay_ms));
if let Some(p) = port.as_mut() {
let _ = p.clear(serialport::ClearBuffer::Input);
}
let csv_streamer = config
.csv_file
.as_ref()
.and_then(|path| crate::export::CsvStreamer::new(path).ok());
let mut session_replayer = if let Some(ref path) = config.replay_file {
Some(
crate::replay::SessionReplayer::new(path)
.map_err(|e| format!("Failed to open replay file '{}' : {}", path, e))?,
)
} else {
None
};
let mut state = PlotterState::new(config.export_limit, csv_streamer, config.obj_file);
let mut serial_buf = [0u8; 1024];
if let crate::config::BrailleModel::Custom(ref path) = config.braille {
if path.to_lowercase().ends_with(".obj") {
#[cfg(feature = "ratty")]
{
match std::fs::read_to_string(path) {
Ok(obj_data) => match ratatui_wireframe::model::Model::from_obj(&obj_data) {
Ok(render_model) => state.custom_model = Some(render_model),
Err(e) => {
state.last_error = Some(format!("OBJ Parse Error in {}: {}", path, e))
}
},
Err(e) => state.last_error = Some(format!("Failed to read {}: {}", path, e)),
}
}
#[cfg(not(feature = "ratty"))]
{
state.last_error = Some(
"OBJ parsing requires the ratty feature. Recompile with --features ratty"
.to_string(),
);
}
} else {
match wrfm::WrfmModel::from_file(path) {
Ok(parsed_wrfm) => {
let render_model = ratatui_wireframe::model::Model::from_wrfm(parsed_wrfm);
state.custom_model = Some(render_model);
}
Err(e) => {
state.last_error = Some(format!("Failed to load {}: {}", path, e));
}
}
}
}
loop {
if event::poll(Duration::from_millis(5))?
&& let event::Event::Key(key) = event::read()?
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
return Ok(crate::AppExitState::SwitchToMonitor { port, rtt_reader });
}
KeyCode::Char(' ') => {
state.paused = !state.paused;
}
KeyCode::Char('1') => {
state.active_tab = ActiveTab::Chart2D;
}
KeyCode::Char('2') => {
state.active_tab = ActiveTab::Wireframe3D;
}
KeyCode::Tab => {
state.active_tab = match state.active_tab {
ActiveTab::Chart2D => ActiveTab::Wireframe3D,
ActiveTab::Wireframe3D => ActiveTab::Chart2D,
}
}
KeyCode::Char('c') => {
state.sensors.clear();
state.sensor_order.clear();
state.export_data.clear();
state.x = 0.0;
state.global_y_min = f64::INFINITY;
state.global_y_max = f64::NEG_INFINITY;
state.total_samples = 0;
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let timestamp = get_timestamp().replace(":", "-");
let filename = format!("comchan_plot_{}.svg", timestamp);
match crate::export::export_to_svg(
&state.export_data,
&filename,
&state.sensor_order,
&config.plot_title,
config.dark_mode,
) {
Ok(_) => {
state.last_error = Some(format!("✅ Exported to {}", filename));
}
Err(e) => {
state.last_error = Some(format!("❌ Export failed: {}", e));
}
}
}
_ => {}
}
}
if config.simulate {
let t = state.x * 0.1;
let pitch = (t * 0.5).sin() * 45.0;
let roll = (t * 0.8).cos() * 30.0;
let yaw = (t * 2.0) % 360.0;
state.ingest_line(&format!("Pitch: {:.2}", pitch), config.plot_points);
state.ingest_line(&format!("Roll: {:.2}", roll), config.plot_points);
state.ingest_line(&format!("Yaw: {:.2}", yaw), config.plot_points);
std::thread::sleep(Duration::from_millis(50));
} else if let Some(ref mut replayer) = session_replayer {
match replayer.next_payload() {
crate::replay::ReplayEvent::Payload(payload) => {
state.ingest_line(&payload, config.plot_points);
}
crate::replay::ReplayEvent::Waiting => {}
crate::replay::ReplayEvent::Eof => {
std::thread::sleep(Duration::from_millis(100));
}
}
} else if let Some(reader) = rtt_reader.as_mut() {
match reader.poll_logs() {
Ok(logs) => {
for line in logs {
if let Some(ref mut writer) = log_writer {
let _ =
writeln!(writer, "RX [{}]: {}", get_timestamp(), line.trim_end());
let _ = writer.flush();
}
state.ingest_line(&line, config.plot_points);
}
}
Err(e) => {
state.last_error = Some(format!("RTT lost: {}. Reconnecting...", e));
let elf = config.elf.as_deref().unwrap_or("");
match RttDefmtReader::new(elf, config.chip.clone()) {
Ok(new_reader) => {
*reader = new_reader;
state.last_error = Some("RTT re-connected!".to_string());
}
Err(err) => {
state.last_error = Some(format!("RTT re-connect failed: {}", err));
std::thread::sleep(Duration::from_millis(500));
}
}
}
}
} else if let Some(p) = port.as_mut() {
let mut drain_iters = 0;
const MAX_DRAIN_SIZE: usize = 10;
loop {
if drain_iters >= MAX_DRAIN_SIZE {
break;
}
drain_iters += 1;
match p.bytes_to_read() {
Ok(avail) if avail > 0 => match p.read(&mut serial_buf) {
Ok(n) if n > 0 => {
let chunk = String::from_utf8_lossy(&serial_buf[..n]);
state.receive_buf.push_str(&chunk);
while let Some(pos) = state.receive_buf.find('\n') {
let line = state.receive_buf.drain(..=pos).collect::<String>();
if let Some(ref mut writer) = log_writer {
let _ = writeln!(
writer,
"RX [{}]: {}",
get_timestamp(),
line.trim_end()
);
let _ = writer.flush();
}
state.ingest_line(&line, config.plot_points);
}
}
Ok(_) => break,
Err(ref e) if e.kind() == io::ErrorKind::TimedOut => break,
Err(e) => {
state.last_error = Some(format!("Read error: {}", e));
break;
}
},
Ok(_) => break, Err(e) => {
state.last_error = Some(format!("Read error: {}", e));
break;
}
}
}
}
let now = Instant::now();
let dt = now.duration_since(state.last_frame_time).as_secs_f64();
state.last_frame_time = now;
let target_pitch = state.sensors.get("Pitch").map_or(0.0, |s| s.current_value);
let target_yaw = state.sensors.get("Yaw").map_or(0.0, |s| s.current_value);
let target_roll = state.sensors.get("Roll").map_or(0.0, |s| s.current_value);
let lerp_speed = 15.0;
let amount = (lerp_speed * dt).clamp(0.0, 1.0);
let pitch_diff = (target_pitch - state.display_pitch + 180.0).rem_euclid(360.0) - 180.0;
let yaw_diff = (target_yaw - state.display_yaw + 180.0).rem_euclid(360.0) - 180.0;
let roll_diff = (target_roll - state.display_roll + 180.0).rem_euclid(360.0) - 180.0;
state.display_pitch = (state.display_pitch + pitch_diff * amount).rem_euclid(360.0);
state.display_yaw = (state.display_yaw + yaw_diff * amount).rem_euclid(360.0);
state.display_roll = (state.display_roll + roll_diff * amount).rem_euclid(360.0);
let x_bounds = state.x_bounds();
let y_bounds = state.y_bounds();
let x_labels = [
format!("{:.0}", x_bounds[0]),
format!("{:.0}", (x_bounds[0] + x_bounds[1]) / 2.0),
format!("{:.0}", x_bounds[1]),
];
let y_labels = [
format!("{:.2}", y_bounds[0]),
format!("{:.2}", (y_bounds[0] + y_bounds[1]) / 2.0),
format!("{:.2}", y_bounds[1]),
];
let pause_indicator = if state.paused { " ⏸ PAUSED" } else { "" };
let uptime = state.uptime_str();
let sample_rate = state.sample_rate;
let total_samples = state.total_samples;
let sensor_count = state.sensors.len();
let last_error = state.last_error.clone();
let baud = config.baud;
let port_name_disp = port_name.clone();
let sidebar_rows: Vec<(String, Color, f64, f64, f64)> = state
.sensor_order
.iter()
.filter_map(|name| {
state.sensors.get(name).map(|s| {
(
s.name.clone(),
s.color,
s.current_value,
s.min_value,
s.max_value,
)
})
})
.collect();
let datasets: Vec<Dataset> = state
.sensor_order
.iter()
.filter_map(|name| state.sensors.get(name))
.filter(|s| s.has_data())
.map(|sensor| {
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(sensor.color))
.data(&sensor.data)
})
.collect();
terminal.draw(|f| {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(f.area());
let main_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Length(28)])
.split(outer[0]);
match state.active_tab {
ActiveTab::Chart2D => {
let chart_title = format!(
" ComChan Plotter {} {} {} sensors{}",
port_name_disp, baud, sensor_count, pause_indicator
);
let chart = Chart::new(datasets)
.block(
Block::default()
.title(Span::styled(
chart_title,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.x_axis(
Axis::default()
.title(Span::styled("Sample", Style::default().fg(Color::Gray)))
.style(Style::default().fg(Color::DarkGray))
.bounds(x_bounds)
.labels(vec![
x_labels[0].as_str(),
x_labels[1].as_str(),
x_labels[2].as_str(),
]),
)
.y_axis(
Axis::default()
.title(Span::styled("Value", Style::default().fg(Color::Gray)))
.style(Style::default().fg(Color::DarkGray))
.bounds(y_bounds)
.labels(vec![
y_labels[0].as_str(),
y_labels[1].as_str(),
y_labels[2].as_str(),
]),
);
f.render_widget(chart, main_row[0]);
#[cfg(feature = "ratty")]
if let Some(graphic) = &mut state.ratty_engines {
graphic.settings_mut().scale = 0.0;
f.render_widget(
&*graphic,
ratatui::layout::Rect {
x: main_row[0].x,
y: main_row[0].y,
width: 1,
height: 1,
},
);
}
}
ActiveTab::Wireframe3D => {
let pitch_deg = state.display_pitch;
let yaw_deg = state.display_yaw;
let roll_deg = state.display_roll;
let mut _rendered_3d = false;
#[cfg(feature = "ratty")]
if let Some(main_cube) = &mut state.ratty_engines {
let rot = [pitch_deg as f32, yaw_deg as f32, roll_deg as f32];
main_cube.settings_mut().scale = 0.25;
main_cube.settings_mut().rotation = rot;
f.render_widget(&*main_cube, main_row[0]);
let gnomon_area = {
let area = main_row[0];
let g_width = 18u16;
let g_height = 9u16;
ratatui::layout::Rect {
x: area.x + area.width.saturating_sub(g_width + 2),
y: area.y + area.height.saturating_sub(g_height + 1),
width: g_width.min(area.width),
height: g_height.min(area.height),
}
};
f.render_widget(ratatui::widgets::Clear, gnomon_area);
let gnomon_canvas = ratatui::widgets::canvas::Canvas::default()
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.marker(ratatui::symbols::Marker::Braille)
.x_bounds([-5.0, 5.0])
.y_bounds([-5.0, 5.0])
.paint(|ctx| {
let p_origin = (0.0, 0.0);
let p_x = (4.0, 0.0);
let p_y = (0.0, 3.5);
let p_z = (-2.8, -2.8);
ctx.draw(&ratatui::widgets::canvas::Line {
x1: p_origin.0,
y1: p_origin.1,
x2: p_x.0,
y2: p_x.1,
color: Color::Red,
});
ctx.draw(&ratatui::widgets::canvas::Line {
x1: p_origin.0,
y1: p_origin.1,
x2: p_y.0,
y2: p_y.1,
color: Color::Yellow,
});
ctx.draw(&ratatui::widgets::canvas::Line {
x1: p_origin.0,
y1: p_origin.1,
x2: p_z.0,
y2: p_z.1,
color: Color::LightBlue,
});
ctx.print(
p_x.0 + 0.2,
p_x.1,
ratatui::text::Span::styled(
"X",
Style::default().fg(Color::Red),
),
);
ctx.print(
p_y.0,
p_y.1 + 0.2,
ratatui::text::Span::styled(
"Y",
Style::default().fg(Color::Yellow),
),
);
ctx.print(
p_z.0 - 0.5,
p_z.1 - 0.5,
ratatui::text::Span::styled(
"Z",
Style::default().fg(Color::LightBlue),
),
);
});
f.render_widget(gnomon_canvas, gnomon_area);
_rendered_3d = true;
}
if !_rendered_3d {
let pitch = pitch_deg.to_radians();
let yaw = yaw_deg.to_radians();
let roll = roll_deg.to_radians();
let (selected_model, displayed_title) = match &config.braille {
crate::config::BrailleModel::Cube => (
ratatui_wireframe::model::Model::cube(),
"Rolling 3D Cube".to_string(),
),
crate::config::BrailleModel::Tetrahedron => (
ratatui_wireframe::model::Model::tetrahedron(),
"Rolling 3D Tetrahedron".to_string(),
),
crate::config::BrailleModel::Octahedron => (
ratatui_wireframe::model::Model::octahedron(),
"Rolling 3D Octahedron".to_string(),
),
crate::config::BrailleModel::Custom(path) => {
if let Some(ref m) = state.custom_model {
(m, format!("Rolling Custom 3D [{}]", path))
} else {
(
ratatui_wireframe::model::Model::cube(),
format!("Error loading [{}], using Cube", path),
)
}
}
};
let wireframe = WireframeWidget::new(pitch, yaw, roll)
.title(displayed_title)
.color(Color::Green)
.model(selected_model);
f.render_widget(wireframe, main_row[0]);
}
}
}
let sidebar_block = Block::default()
.title(Span::styled(
" Sensors ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = sidebar_block.inner(main_row[1]);
f.render_widget(sidebar_block, main_row[1]);
if sidebar_rows.is_empty() {
let waiting = Paragraph::new("Waiting for\ndata…")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
f.render_widget(waiting, inner);
} else {
let mut lines: Vec<Line> = Vec::new();
for (name, color, cur, min, max) in &sidebar_rows {
lines.push(Line::from(vec![Span::styled(
format!("● {}", name),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" now: {:.3}", cur),
Style::default().fg(Color::White),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" min: {:.3}", min),
Style::default().fg(Color::Blue),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" max: {:.3}", max),
Style::default().fg(Color::Red),
)]));
lines.push(Line::from(vec![Span::styled(
" ─────────────",
Style::default().fg(Color::DarkGray),
)]));
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(para, inner);
}
let status_bg = if state.paused {
Color::DarkGray
} else {
Color::Reset
};
let error_span = if let Some(ref err) = last_error {
Span::styled(format!(" ⚠ {} ", err), Style::default().fg(Color::Red))
} else {
Span::raw("")
};
let status_line = Line::from(vec![
Span::styled(format!(" ⏱ {}", uptime), Style::default().fg(Color::Green)),
Span::raw(" "),
Span::styled(
format!(" {} sps", sample_rate),
Style::default().fg(Color::Cyan),
),
Span::raw(" "),
Span::styled(
format!(" {} total", total_samples),
Style::default().fg(Color::Yellow),
),
Span::raw(" "),
error_span,
Span::styled(
format!(" 🖥 {} ", state.terminal_type),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" [Space] pause [c] clear [1/2/Tab] views [q/Esc] quit [Ctrl + s] Export",
Style::default().fg(Color::DarkGray),
),
]);
let status_bar = Paragraph::new(status_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.style(Style::default().bg(status_bg));
f.render_widget(status_bar, outer[1]);
})?;
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
if !state.sensors.is_empty() {
println!("\n{color_green} Plotting Summary:{color_reset}");
for name in &state.sensor_order {
if let Some(s) = state.sensors.get(name) {
println!(
" {}: {} pts min={:.3} max={:.3} last={:.3}",
s.name,
s.data.len(),
s.min_value,
s.max_value,
s.current_value,
);
}
}
println!(
" Total samples: {} Uptime: {}",
state.total_samples,
state.uptime_str()
);
}
Ok(crate::AppExitState::Quit)
}