use super::popups::draw_popup;
use crate::app::App;
use crate::config::Column;
use crate::interpretator::fmt_num;
use crate::register::RegisterCell;
use crate::state::{ReadPanel, ReadParams};
use crate::tui::hints::{self, Hint};
use crate::tui::theme::{spinner_frame, Theme};
use chrono::Local;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Axis, Block, Cell, Chart, Dataset, GraphType, Paragraph, Row, Table};
use ratatui::Frame;
fn ascii_title(ascii: &str, theme: &Theme) -> Line<'static> {
Line::from(vec![
Span::styled(" ASCII ", theme.dim_style()),
Span::styled(format!("'{ascii}' "), theme.base()),
])
.left_aligned()
}
fn hscroll(text: &str, prefix: u16, offset: u16) -> String {
if offset == 0 {
return text.to_string();
}
let prefix = prefix as usize;
let mut out: String = text.chars().take(prefix).collect();
out.extend(text.chars().skip(prefix + offset as usize));
out
}
fn with_hscroll_hint(
block: Block<'static>,
theme: &Theme,
offset: u16,
max_offset: u16,
) -> Block<'static> {
if max_offset == 0 {
return block;
}
let left = if offset > 0 { '\u{25c2}' } else { ' ' };
let right = if offset < max_offset { '\u{25b8}' } else { ' ' };
block.title_bottom(
Line::styled(format!(" {left} cols {right} "), theme.dim_style()).right_aligned(),
)
}
fn full_width_table(
rows: Vec<Row<'static>>,
header_cell: Cell<'static>,
theme: &Theme,
block: Block<'static>,
) -> Table<'static> {
Table::new(rows, [Constraint::Percentage(100)])
.header(Row::new([header_cell]).style(theme.header_style()))
.block(block)
}
struct TableCtx<'a> {
params: &'a ReadParams,
app: &'a App,
theme: &'a Theme,
inner_width: u16,
}
impl TableCtx<'_> {
fn horizontal_offset(&self, rows: &[(String, Style)], header: &str, prefix: u16) -> u16 {
let prefix = prefix as usize;
let content_rest = rows
.iter()
.map(|(t, _)| t.chars().count())
.chain(std::iter::once(header.chars().count()))
.max()
.unwrap_or(0)
.saturating_sub(prefix);
let visible_rest = (self.inner_width as usize).saturating_sub(prefix);
let max_offset = content_rest.saturating_sub(visible_rest) as u16;
self.app.h_max_offset.set(max_offset);
self.params.col_offset.min(max_offset)
}
fn scrollable_table(
&self,
rows: Vec<(String, Style)>,
header: &str,
prefix: u16,
block: Block<'static>,
) -> Table<'static> {
let h_off = self.horizontal_offset(&rows, header, prefix);
let table_rows: Vec<Row> = rows
.into_iter()
.map(|(text, style)| {
let cell = if h_off == 0 {
text
} else {
hscroll(&text, prefix, h_off)
};
Row::new([Cell::from(cell)]).style(style)
})
.collect();
let block = with_hscroll_hint(block, self.theme, h_off, self.app.h_max_offset.get());
full_width_table(
table_rows,
Cell::from(hscroll(header, prefix, h_off)),
self.theme,
block,
)
}
fn main_table(&self, visible: u16, header: &str, ascii: Option<&str>) -> Table<'static> {
let (params, app, theme) = (self.params, self.app, self.theme);
let now = Local::now();
let mut rows: Vec<(String, Style)> = Vec::with_capacity(visible as usize);
for i in 0..visible {
let Some(addr) = params.window_start.checked_add(i) else {
break;
};
let selected = addr == params.position;
let zebra = i % 2 == 1;
let (text, base_style) = match app.cell_row((params.register_type, addr), now) {
Some((text, changed)) => (text, theme.row_style(zebra, changed)),
None => {
let label = app.label_text(params.register_type, addr);
(
app.interpreter.placeholder(addr, label.as_deref()),
theme.dim_style(),
)
}
};
let style = if selected {
theme.selected_style()
} else {
base_style
};
rows.push((text, style));
}
let mut block = theme.panel("Main");
if let Some(error) = ¶ms.read_error {
block = block.title_bottom(
Line::styled(format!(" \u{26a0} {error} "), theme.err_style()).left_aligned(),
);
} else if let Some(ascii) = ascii {
block = block.title_bottom(ascii_title(ascii, theme));
}
self.scrollable_table(rows, header, app.interpreter.prefix_width(), block)
}
fn list_table(
&self,
cells: &[RegisterCell],
top: usize,
title: &str,
ascii: Option<&str>,
) -> Table<'static> {
let (params, app, theme) = (self.params, self.app, self.theme);
let now = Local::now();
let header = format!("{:<2}{}", "T", app.interpreter.header());
let mut rows: Vec<(String, Style)> = Vec::with_capacity(cells.len());
for (row_idx, &(kind, address)) in cells.iter().enumerate() {
let (text, changed) = match app.cell_row((kind, address), now) {
Some(row) => row,
None => {
let label = app.label_text(kind, address);
(
app.interpreter.placeholder(address, label.as_deref()),
false,
)
}
};
let text = format!("{:<2}{text}", kind.marker());
let style = if (top + row_idx) as u16 == params.pinned_index {
theme.selected_style()
} else {
theme.row_style(row_idx % 2 == 1, changed)
};
rows.push((text, style));
}
let mut block = theme.panel(title);
if let Some(ascii) = ascii {
block = block.title_bottom(ascii_title(ascii, theme));
}
self.scrollable_table(rows, &header, 2 + app.interpreter.prefix_width(), block)
}
fn matrix_table(&self, visible: u16) -> Table<'static> {
let (params, app, theme) = (self.params, self.app, self.theme);
let cols = app.config.matrix_cols.max(1);
let base = params.window_start - (params.window_start % cols);
let mut header = format!("{: >5} ", "");
for c in 0..cols {
header.push_str(&format!("{: >5} ", format!("+{c}")));
}
let mut table_rows: Vec<Row> = Vec::with_capacity(visible as usize);
for r in 0..visible {
let row_base = (base as u32) + (r as u32) * (cols as u32);
if row_base > u16::MAX as u32 {
break;
}
let row_base = row_base as u16;
let zebra = r % 2 == 1;
let mut spans = vec![Span::styled(format!("{row_base: >5}: "), theme.dim_style())];
for c in 0..cols {
let Some(addr) = row_base.checked_add(c) else {
break;
};
let cell = (params.register_type, addr);
let (text, mut style) = match app.cell_value(cell) {
Some(value) => {
let style = theme.row_style(zebra, app.cell_changed(cell));
(format!("{value: >5}"), style)
}
None => (format!("{: >5}", "--"), theme.dim_style()),
};
if addr == params.position {
style = theme.selected_style();
}
spans.push(Span::styled(text, style));
spans.push(Span::raw(" "));
}
table_rows.push(Row::new([Cell::from(Line::from(spans))]));
}
full_width_table(table_rows, Cell::from(header), theme, theme.panel("Matrix"))
}
}
pub fn draw(
params: &ReadParams,
app: &App,
frame: &mut Frame,
area: Rect,
theme: &Theme,
device: &str,
) {
let (info_type, info_addr) = app.cursor_cell();
let is_pinned = app
.pinned_registers
.iter()
.any(|&(kind, address)| kind == info_type && address == info_addr);
let show_ascii = app.interpreter.shows_ascii() && !params.graph;
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(area);
let access_style = if info_type.is_writable() {
theme.ok_style()
} else {
theme.warn_style()
};
let mut identity: Vec<Vec<Span>> = Vec::new();
if !app.config.name.is_empty() {
identity.push(vec![Span::styled(
app.config.name.clone(),
theme.accent_style(),
)]);
}
if app.config.read_only {
identity.push(vec![Span::styled("READ-ONLY", theme.err_style())]);
}
identity.push(vec![
Span::styled("Device: ", theme.dim_style()),
Span::styled(device.to_string(), theme.base()),
]);
identity.push(vec![
Span::styled("slave ", theme.dim_style()),
Span::styled(app.config.device.slave_id.to_string(), theme.base()),
]);
identity.push(vec![Span::styled(
info_type.access().to_string(),
access_style,
)]);
identity.push(vec![
Span::styled("order ", theme.dim_style()),
Span::styled(format!("{:?}", app.config.device.word_order), theme.base()),
]);
identity.push(vec![
Span::styled("batch ", theme.dim_style()),
Span::styled(app.config.registers_batch.to_string(), theme.base()),
]);
let mut cell_seg = vec![
Span::styled(format!("{info_type:?}"), theme.base()),
Span::styled(" @ ", theme.dim_style()),
Span::styled(info_addr.to_string(), theme.accent_style()),
];
if is_pinned {
cell_seg.push(Span::styled(" (pinned)", theme.changed_style()));
}
let mut right: Vec<Vec<Span>> = Vec::new();
if let Some(d) = params.read_duration {
right.push(vec![Span::styled(
format!("{:>8}", format!("{d:.2?}")),
theme.dim_style(),
)]);
}
right.push(cell_seg);
let mut left_line = vec![Span::raw(" ")];
left_line.extend(theme.join_dotted(identity));
let mut right_line = theme.join_dotted(right);
right_line.push(Span::raw(" "));
let info_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(rows[0]);
frame.render_widget(Paragraph::new(Line::from(left_line)), info_rows[0]);
frame.render_widget(
Paragraph::new(Line::from(right_line)).alignment(Alignment::Right),
info_rows[0],
);
if let Some(status) = params.active_status() {
frame.render_widget(Paragraph::new(theme.status_line(status)), info_rows[1]);
}
let header = app.interpreter.header();
let visible = rows[1].height.saturating_sub(3).max(1);
app.visible_rows.set(visible);
let inner_width = rows[1].width.saturating_sub(2);
app.h_max_offset.set(0);
if params.graph {
draw_graph(
frame,
rows[1],
theme,
app,
(info_type, info_addr),
app.active_graph_column(),
);
if let Some(popup) = ¶ms.popup {
draw_popup(frame, area, theme, app, popup);
}
return;
}
let ctx = TableCtx {
params,
app,
theme,
inner_width,
};
match params.panel {
ReadPanel::Main => {
let ascii = show_ascii.then(|| {
app.ascii_string_for(
(0..visible)
.filter_map(|i| params.window_start.checked_add(i))
.map(|addr| (params.register_type, addr)),
)
});
frame.render_widget(ctx.main_table(visible, header, ascii.as_deref()), rows[1]);
}
ReadPanel::Matrix => {
frame.render_widget(ctx.matrix_table(visible), rows[1]);
}
_ => {
let (title, empty_message) = match params.panel {
ReadPanel::Labeled => ("Labeled", "No labeled registers."),
ReadPanel::Custom => ("Custom", "No custom rules."),
_ => ("Pinned", "No pinned registers."),
};
let len = app.panel_len() as usize;
if len == 0 {
let table_rows = vec![Row::new([Cell::from(empty_message)]).style(theme.base())];
let t = Table::new(table_rows, [Constraint::Percentage(100)])
.header(Row::new([Cell::from(header)]).style(theme.header_style()))
.block(theme.panel(title));
frame.render_widget(t, rows[1]);
} else {
let top = (params.pinned_top as usize).min(len - 1);
let cells = app.panel_window(top, visible as usize);
let ascii = show_ascii.then(|| app.ascii_string_for(cells.iter().copied()));
frame.render_widget(
ctx.list_table(&cells, top, title, ascii.as_deref()),
rows[1],
);
}
}
}
if let Some(popup) = ¶ms.popup {
draw_popup(frame, area, theme, app, popup);
}
}
pub fn live_status(app: &App, params: &ReadParams, theme: &Theme) -> Vec<Span<'static>> {
let mut fields: Vec<Vec<Span<'static>>> = Vec::new();
if app.paused {
fields.push(vec![Span::styled("\u{23f8} paused", theme.warn_style())]);
} else if let Some(interval) = app.config.update_interval_ms.filter(|_| !app.sweep.active) {
let remaining =
(interval as u128).saturating_sub(params.refresh_timer.elapsed().as_millis());
fields.push(vec![Span::styled(
format!(" \u{27f3} {:>4.1}s", remaining as f64 / 1000.0),
theme.ok_style(),
)]);
}
if app.sweep.active {
let mode = if app.sweep.continuous { " loop" } else { "" };
let span = app.sweep.to.saturating_sub(app.sweep.from);
let done = app.sweep.current.saturating_sub(app.sweep.from);
let percent = if span == 0 {
100
} else {
(done as u32 * 100 / span as u32).min(100)
};
fields.push(vec![Span::styled(
format!(
" {}{} {}\u{2192}{} ({:>2}%)",
spinner_frame(app.frame),
mode,
app.sweep.from,
app.sweep.to,
percent.min(99),
),
theme.accent_style(),
)]);
}
let mut spans = theme.join_dotted(fields);
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans
}
fn draw_graph(
frame: &mut Frame,
area: Rect,
theme: &Theme,
app: &App,
cell: RegisterCell,
column: Option<Column>,
) {
let (kind, address) = cell;
let bit_plot = kind.is_bit();
let mode = if bit_plot {
"bit"
} else {
column.map_or("--", Column::name)
};
let label = app.label_text(kind, address);
let title = match &label {
Some(l) => format!(" Graph [{mode}] \u{201c}{l}\u{201d} "),
None => format!(" Graph [{mode}] "),
};
let block = if !bit_plot && app.graph_cycle_len() > 1 {
let hint = hints::footer(theme, [Hint::key(app.config.keybinds.dump, "cycle")]);
theme.panel(&title).title_bottom(hint.right_aligned())
} else {
theme.panel(&title)
};
let inner = block.inner(area);
frame.render_widget(block, area);
if !bit_plot && column.is_none() {
let hint = Paragraph::new(Line::from(Span::styled(
"Enable a numeric column (u16, i16, f32, \u{2026}) to graph.",
theme.dim_style(),
)));
frame.render_widget(hint, inner);
return;
}
let points: Vec<(f64, f64)> = if bit_plot {
app.value_history(cell)
.map(|h| {
h.iter()
.enumerate()
.map(|(i, &v)| (i as f64, v as f64))
.collect()
})
.unwrap_or_default()
} else {
app.column_history(cell, column.unwrap())
.into_iter()
.enumerate()
.map(|(i, v)| (i as f64, v))
.collect()
};
let is_float = match column {
_ if bit_plot => false,
Some(Column::Custom) => points.iter().any(|&(_, y)| y.fract() != 0.0),
Some(c) => c.graph_is_float(),
None => false,
};
if points.len() < 2 {
let kb = &app.config.keybinds;
let mut spans = vec![Span::styled(
"Collecting samples\u{2026} read this register a few times ",
theme.dim_style(),
)];
spans.extend(
hints::footer(
theme,
[
Hint::pair(kb.action, kb.refresh, "Read"),
Hint::key(kb.pause, "Pause"),
Hint::pair(kb.exit, kb.graph, "Close"),
],
)
.spans,
);
frame.render_widget(Paragraph::new(Line::from(spans)), inner);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(inner);
let count = points.len();
let last = points[count - 1].1;
let prev = points[count - 2].1;
let delta = last - prev;
let mut min = f64::MAX;
let mut max = f64::MIN;
let mut sum = 0.0;
for &(_, y) in &points {
min = min.min(y);
max = max.max(y);
sum += y;
}
let avg = sum / count as f64;
let span = max - min;
let (y_lo, y_hi) = if bit_plot {
(0.0, 1.0)
} else if span < f64::EPSILON {
(min - 1.0, max + 1.0)
} else {
let pad = span * 0.08;
(min - pad, max + pad)
};
let x_hi = (count - 1) as f64;
let y_labels: Vec<Span> = if bit_plot {
vec![
Span::styled("0", theme.dim_style()),
Span::styled("1", theme.dim_style()),
]
} else {
(0..=4)
.map(|i| {
let v = y_lo + (y_hi - y_lo) * (i as f64 / 4.0);
Span::styled(fmt_num(v, is_float), theme.dim_style())
})
.collect()
};
let x_labels = vec![
Span::styled(format!("-{}", count - 1), theme.dim_style()),
Span::styled(format!("-{}", (count - 1) / 2), theme.dim_style()),
Span::styled("now", theme.accent_style()),
];
let stepped: Vec<(f64, f64)>;
let plot_data: &[(f64, f64)] = if bit_plot {
let mut steps = Vec::with_capacity(points.len() * 2);
for pair in points.windows(2) {
let (x0, y0) = pair[0];
let (x1, _) = pair[1];
steps.push((x0, y0));
steps.push((x1, y0));
}
if let Some(&last) = points.last() {
steps.push(last);
}
stepped = steps;
&stepped
} else {
&points
};
let datasets = vec![Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(theme.accent_style())
.data(plot_data)];
let chart = Chart::new(datasets)
.x_axis(
Axis::default()
.title(Span::styled("samples", theme.dim_style()))
.style(theme.dim_style())
.bounds([0.0, x_hi])
.labels(x_labels),
)
.y_axis(
Axis::default()
.title(Span::styled("value", theme.dim_style()))
.style(theme.dim_style())
.bounds([y_lo, y_hi])
.labels(y_labels),
);
frame.render_widget(chart, chunks[0]);
let delta_style = if delta > 0.0 {
theme.ok_style()
} else if delta < 0.0 {
theme.warn_style()
} else {
theme.dim_style()
};
let delta_str = if is_float {
format!(
"{}{}",
if delta < 0.0 { "-" } else { "+" },
fmt_num(delta.abs(), true)
)
} else {
format!("{delta:+.0}")
};
let avg_str = if is_float {
fmt_num(avg, true)
} else {
format!("{avg:.1}")
};
let footer = Line::from(vec![
Span::styled("cur ", theme.dim_style()),
Span::styled(fmt_num(last, is_float), theme.accent_style()),
Span::styled(format!(" \u{0394}{delta_str}"), delta_style),
Span::styled(" min ", theme.dim_style()),
Span::styled(fmt_num(min, is_float), theme.base()),
Span::styled(" max ", theme.dim_style()),
Span::styled(fmt_num(max, is_float), theme.base()),
Span::styled(" avg ", theme.dim_style()),
Span::styled(avg_str, theme.base()),
Span::styled(" span ", theme.dim_style()),
Span::styled(fmt_num(span, is_float), theme.base()),
Span::styled(" n ", theme.dim_style()),
Span::styled(format!("{count}"), theme.base()),
]);
frame.render_widget(Paragraph::new(footer), chunks[1]);
}