chore: remove pipeview-tui from workspace

This commit is contained in:
2026-08-17 20:23:41 +08:00
parent f9be74257a
commit 81c0ce13c4
11 changed files with 50 additions and 5058 deletions

View File

@@ -1,25 +0,0 @@
[package]
name = "pipeview-tui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "pipeview-tui"
path = "src/main.rs"
[dependencies]
pipeview-core = { path = "../pipeview-core" }
pipeview-client = { path = "../pipeview-client" }
tokio = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-appender = { workspace = true }
clap = { workspace = true }
hex = { workspace = true }
image = { workspace = true }
mlua = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
ansitok = "0.3"

View File

@@ -1,295 +0,0 @@
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
/// Parse ANSI-encoded text and return a Vec of styled Spans.
///
/// ANSI SGR sequences (colors, bold, italic, underline, strikethrough)
/// are converted to ratatui `Style` applied on top of `base_style`.
/// Non-SGR sequences (cursor movement, etc.) are silently ignored.
pub fn ansi_to_spans(text: &str, base_style: Style) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut style = AnsiStyleStack::default();
for element in parse_ansi(text) {
match element.kind() {
ElementKind::Text => {
let slice = &text[element.start()..element.end()];
if !slice.is_empty() {
spans.push(Span::styled(
slice.to_string(),
style.merge(base_style),
));
}
}
ElementKind::Sgr => {
let sgr = &text[element.start()..element.end()];
apply_sgr_sequence(&mut style, sgr);
}
_ => {}
}
}
spans
}
/// Extract visible text from ANSI-encoded string (strips escape sequences).
///
/// This is the text that a user would actually see on a terminal —
/// useful for search, copy, and line counting.
pub fn ansi_visible_text(text: &str) -> String {
let mut visible = String::new();
for element in parse_ansi(text) {
if element.kind() == ElementKind::Text {
visible.push_str(&text[element.start()..element.end()]);
}
}
visible
}
// ── internal style stack ──
#[derive(Default, Clone)]
struct AnsiStyleStack {
fg: Option<Color>,
bg: Option<Color>,
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
}
impl AnsiStyleStack {
fn apply_sgr_attr(&mut self, attr: VisualAttribute) {
match attr {
VisualAttribute::Reset(0) => *self = Self::default(),
VisualAttribute::Reset(22) => self.bold = false,
VisualAttribute::Reset(23) => self.italic = false,
VisualAttribute::Reset(24) => self.underline = false,
VisualAttribute::Reset(29) => self.strikethrough = false,
VisualAttribute::Reset(39) => self.fg = None,
VisualAttribute::Reset(49) => self.bg = None,
VisualAttribute::Reset(_) => {}
VisualAttribute::Bold => self.bold = true,
VisualAttribute::Faint => self.bold = false,
VisualAttribute::Italic => self.italic = true,
VisualAttribute::Underline => self.underline = true,
VisualAttribute::Crossedout => self.strikethrough = true,
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_ratatui(c)),
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_ratatui(c)),
_ => {}
}
}
fn apply_sgr(&mut self, sgr: &str) {
// Strip ESC[ ... m wrapper and delegate to ansitok's SGR parser
let params = sgr
.strip_prefix("\x1b[")
.and_then(|s| s.strip_suffix('m'))
.unwrap_or(sgr);
if params.is_empty() {
*self = Self::default();
return;
}
for output in parse_ansi_sgr(sgr) {
if let Some(attr) = output.as_escape() {
self.apply_sgr_attr(attr);
}
}
}
fn merge(&self, base: Style) -> Style {
let mut style = base;
if let Some(fg) = self.fg {
if self.bold {
style = style.fg(brighten(fg));
} else {
style = style.fg(fg);
}
}
if let Some(bg) = self.bg {
style = style.bg(bg);
}
if self.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if self.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if self.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
if self.bold {
// Bold applied only when there's no explicit fg (brighten handles it otherwise)
style = style.add_modifier(Modifier::BOLD);
}
style
}
}
fn apply_sgr_sequence(style: &mut AnsiStyleStack, sgr: &str) {
style.apply_sgr(sgr);
}
// ── color conversion ──
fn ansi_color_to_ratatui(color: AnsiColor) -> Color {
match color {
AnsiColor::Bit4(c) => ansi_4bit(c),
AnsiColor::Bit8(c) => ansi_256(c),
AnsiColor::Bit24 { r, g, b } => Color::Rgb(r, g, b),
}
}
fn ansi_4bit(code: u8) -> Color {
match code {
30 => Color::Rgb(0, 0, 0),
31 => Color::Rgb(194, 54, 33),
32 => Color::Rgb(37, 188, 36),
33 => Color::Rgb(173, 173, 39),
34 => Color::Rgb(73, 46, 225),
35 => Color::Rgb(211, 56, 211),
36 => Color::Rgb(51, 187, 200),
37 => Color::Rgb(203, 204, 205),
90 => Color::Rgb(129, 131, 131),
91 => Color::Rgb(252, 57, 31),
92 => Color::Rgb(49, 231, 34),
93 => Color::Rgb(234, 236, 35),
94 => Color::Rgb(88, 51, 255),
95 => Color::Rgb(249, 53, 248),
96 => Color::Rgb(20, 240, 240),
97 => Color::Rgb(233, 235, 235),
// Background colors (same values as foreground but offset by 10)
40 => Color::Rgb(0, 0, 0),
41 => Color::Rgb(194, 54, 33),
42 => Color::Rgb(37, 188, 36),
43 => Color::Rgb(173, 173, 39),
44 => Color::Rgb(73, 46, 225),
45 => Color::Rgb(211, 56, 211),
46 => Color::Rgb(51, 187, 200),
47 => Color::Rgb(203, 204, 205),
100 => Color::Rgb(129, 131, 131),
101 => Color::Rgb(252, 57, 31),
102 => Color::Rgb(49, 231, 34),
103 => Color::Rgb(234, 236, 35),
104 => Color::Rgb(88, 51, 255),
105 => Color::Rgb(249, 53, 248),
106 => Color::Rgb(20, 240, 240),
107 => Color::Rgb(233, 235, 235),
_ => Color::Rgb(255, 255, 255),
}
}
fn ansi_256(code: u8) -> Color {
match code {
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
16..=231 => {
let idx = code - 16;
let cube = [0, 95, 135, 175, 215, 255];
let r = cube[(idx / 36) as usize];
let g = cube[((idx / 6) % 6) as usize];
let b = cube[(idx % 6) as usize];
Color::Rgb(r, g, b)
}
232..=255 => {
let v = (code - 232) * 10 + 8;
Color::Rgb(v, v, v)
}
}
}
fn brighten(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(
((r as u32) * 13 / 10).min(255) as u8,
((g as u32) * 13 / 10).min(255) as u8,
((b as u32) * 13 / 10).min(255) as u8,
),
_ => color,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
fn visible<'a>(spans: &[Span<'a>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect::<String>()
}
#[test]
fn plain_text_no_ansi() {
let spans = ansi_to_spans("hello", Style::default());
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "hello");
}
#[test]
fn fg_red_reset() {
let spans = ansi_to_spans("\x1b[31mred\x1b[0m plain", Style::default());
assert!(spans.len() >= 2);
assert_eq!(visible(&spans), "red plain");
}
#[test]
fn empty_sgr_resets_all() {
let base = Style::default().fg(Color::Rgb(1, 2, 3));
let spans = ansi_to_spans("\x1b[31mred\x1b[m plain", base);
assert_eq!(visible(&spans), "red plain");
assert!(spans.len() >= 2);
// First span should have red foreground
assert_ne!(spans[0].style.fg, Some(Color::Rgb(1, 2, 3)));
// Last span should have base foreground
assert_eq!(spans.last().unwrap().style.fg, Some(Color::Rgb(1, 2, 3)));
}
#[test]
fn bold_brightens_color() {
let spans = ansi_to_spans("\x1b[1;31mbold red\x1b[0m", Style::default());
assert_eq!(spans.len(), 1);
assert_eq!(visible(&spans), "bold red");
}
#[test]
fn strip_ansi_codes_from_output() {
let spans = ansi_to_spans("\x1b[32mgreen\x1b[0m", Style::default());
assert_eq!(visible(&spans), "green");
}
#[test]
fn visible_text_strips_ansi() {
assert_eq!(ansi_visible_text("plain"), "plain");
assert_eq!(ansi_visible_text("\x1b[31mred\x1b[0m"), "red");
assert_eq!(
ansi_visible_text("\x1b[1;32mbold green\x1b[0m plain"),
"bold green plain"
);
}
#[test]
fn xterm_256_color_cube() {
assert_eq!(ansi_256(16), Color::Rgb(0, 0, 0));
assert_eq!(ansi_256(21), Color::Rgb(0, 0, 255));
assert_eq!(ansi_256(52), Color::Rgb(95, 0, 0));
assert_eq!(ansi_256(67), Color::Rgb(95, 135, 175));
}
#[test]
fn true_color_24bit() {
let spans = ansi_to_spans(
"\x1b[38;2;100;200;50mtruecolor\x1b[0m",
Style::default(),
);
assert_eq!(visible(&spans), "truecolor");
assert_eq!(spans[0].style.fg, Some(Color::Rgb(100, 200, 50)));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,169 +0,0 @@
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use pipeview_client::SessionConfig;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::app::{DisplayOptions, View, default_session_config};
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedTuiState {
#[serde(default = "default_session")]
pub session: SessionConfig,
#[serde(default)]
pub active_view: PersistedView,
#[serde(default = "default_true")]
pub show_timestamp: bool,
#[serde(default = "default_true")]
pub show_direction: bool,
#[serde(default = "default_true")]
pub show_pipeline: bool,
}
impl Default for PersistedTuiState {
fn default() -> Self {
Self {
session: default_session(),
active_view: PersistedView::Text,
show_timestamp: true,
show_direction: true,
show_pipeline: true,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub enum PersistedView {
#[default]
Text,
Hex,
Plot,
}
impl From<View> for PersistedView {
fn from(value: View) -> Self {
match value {
View::Text => Self::Text,
View::Hex => Self::Hex,
View::Plot => Self::Plot,
}
}
}
impl From<PersistedView> for View {
fn from(value: PersistedView) -> Self {
match value {
PersistedView::Text => View::Text,
PersistedView::Hex => View::Hex,
PersistedView::Plot => View::Plot,
}
}
}
pub fn load_tui_state() -> PersistedTuiState {
match load_tui_state_from_path(&tui_state_path()) {
Ok(mut state) => {
if state.session.pipelines.is_empty() {
state.session = default_session();
}
state
}
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
Err(err) => {
warn!(error = %err, "Failed to load persisted TUI state");
PersistedTuiState::default()
}
}
}
pub fn save_tui_state(session: &SessionConfig, view: View, display: DisplayOptions) {
let state = PersistedTuiState {
session: session.clone(),
active_view: view.into(),
show_timestamp: display.show_timestamp,
show_direction: display.show_direction,
show_pipeline: display.show_pipeline,
};
if let Err(err) = save_tui_state_to_path(&state, &tui_state_path()) {
warn!(error = %err, "Failed to persist TUI state");
}
}
fn default_session() -> SessionConfig {
default_session_config()
}
fn tui_state_path() -> PathBuf {
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
}
pub fn config_dir() -> PathBuf {
tui_state_path()
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
match os {
"windows" => {
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
}
"macos" => {
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join(".config")
.join("pipeview")
.join(TUI_STATE_FILE_NAME);
}
}
}
PathBuf::from(TUI_STATE_FILE_NAME)
}
fn load_tui_state_from_path(path: &Path) -> io::Result<PersistedTuiState> {
let text = fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(io::Error::other)
}
fn save_tui_state_to_path(state: &PersistedTuiState, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
fs::write(path, json)
}
const fn default_true() -> bool {
true
}

View File

@@ -1,637 +0,0 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use pipeview_client::{DecodedEntry, RingBuffer};
use pipeview_core::protocol::DecodedData;
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum LineDirection {
In,
Out,
}
#[derive(Clone)]
pub struct ConsoleLine {
pub elapsed: Duration,
pub pipeline: String,
pub text: String,
pub direction: LineDirection,
}
pub struct TextBuffer {
started_at: Instant,
lines: RingBuffer<ConsoleLine>,
}
impl TextBuffer {
pub fn new(limit: usize) -> Self {
Self {
started_at: Instant::now(),
lines: RingBuffer::new(limit),
}
}
pub fn push(&mut self, entry: &DecodedEntry) {
if let DecodedData::Text(text) = &entry.data {
self.lines.push(ConsoleLine {
elapsed: self.started_at.elapsed(),
pipeline: entry.pipeline_name.clone(),
text: text.clone(),
direction: LineDirection::In,
});
}
}
pub fn push_outbound(&mut self, text: String) {
self.lines.push(ConsoleLine {
elapsed: self.started_at.elapsed(),
pipeline: String::from("OUT"),
text,
direction: LineDirection::Out,
});
}
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
self.lines.get(index)
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn clear(&mut self) {
self.lines.clear();
}
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
}
#[derive(Clone)]
pub struct HexLine {
pub elapsed: Duration,
pub pipeline: String,
pub hex: String,
pub ascii: String,
pub direction: LineDirection,
}
pub struct HexBuffer {
started_at: Instant,
lines: RingBuffer<HexLine>,
}
impl HexBuffer {
pub fn new(limit: usize) -> Self {
Self {
started_at: Instant::now(),
lines: RingBuffer::new(limit),
}
}
pub fn push(&mut self, entry: &DecodedEntry) {
if let DecodedData::Hex(hex) = &entry.data {
self.lines.push(HexLine {
elapsed: self.started_at.elapsed(),
pipeline: entry.pipeline_name.clone(),
ascii: decode_ascii(hex),
hex: hex.clone(),
direction: LineDirection::In,
});
}
}
pub fn push_outbound(&mut self, hex: String) {
self.lines.push(HexLine {
elapsed: self.started_at.elapsed(),
pipeline: String::from("OUT"),
ascii: decode_ascii(&hex),
hex,
direction: LineDirection::Out,
});
}
pub fn get(&self, index: usize) -> Option<&HexLine> {
self.lines.get(index)
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn clear(&mut self) {
self.lines.clear();
}
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
}
pub struct PlotSeries {
pub name: String,
points: VecDeque<[f64; 2]>,
next_x: f64,
}
impl PlotSeries {
fn new(name: String) -> Self {
Self {
name,
points: VecDeque::new(),
next_x: 0.0,
}
}
fn push_samples(&mut self, samples: &[f64], limit: usize) {
for sample in samples {
if !sample.is_finite() {
continue;
}
self.push_point([self.next_x, *sample], limit);
self.next_x += 1.0;
}
}
fn push_point(&mut self, point: [f64; 2], limit: usize) -> Option<[f64; 2]> {
self.points.push_back(point);
if self.points.len() > limit {
self.points.pop_front()
} else {
None
}
}
pub fn points(&self) -> impl Iterator<Item = [f64; 2]> + '_ {
self.points.iter().copied()
}
pub fn len(&self) -> usize {
self.points.len()
}
fn first_point(&self) -> Option<[f64; 2]> {
self.points.front().copied()
}
fn last_point(&self) -> Option<[f64; 2]> {
self.points.back().copied()
}
pub fn render_points_time_series(
&self,
x_min: f64,
x_max: f64,
max_points: usize,
) -> Vec<[f64; 2]> {
if self.points.is_empty() || max_points == 0 {
return Vec::new();
}
let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
for point in self.points.iter().copied() {
if point[0] < x_min {
continue;
}
if point[0] > x_max {
break;
}
exact_visible.push(point);
if exact_visible.len() > max_points {
exact_visible.clear();
break;
}
}
if !exact_visible.is_empty() {
return exact_visible;
}
let bucket_count = (max_points / 2).max(1);
let width = (x_max - x_min).max(1.0);
let bucket_width = width / bucket_count as f64;
let mut rendered = Vec::with_capacity(bucket_count * 2);
let mut points = self
.points
.iter()
.copied()
.skip_while(|point| point[0] < x_min)
.peekable();
for bucket_index in 0..bucket_count {
let bucket_start = x_min + bucket_width * bucket_index as f64;
let bucket_end = if bucket_index + 1 == bucket_count {
x_max
} else {
bucket_start + bucket_width
};
let mut min_point: Option<[f64; 2]> = None;
let mut max_point: Option<[f64; 2]> = None;
while let Some(point) = points.peek().copied() {
if point[0] > bucket_end {
break;
}
if point[0] >= bucket_start {
match min_point {
Some(current) if current[1] <= point[1] => {}
_ => min_point = Some(point),
}
match max_point {
Some(current) if current[1] >= point[1] => {}
_ => max_point = Some(point),
}
}
points.next();
}
match (min_point, max_point) {
(Some(a), Some(b)) if a[0] <= b[0] => {
rendered.push(a);
if a != b {
rendered.push(b);
}
}
(Some(a), Some(b)) => {
rendered.push(b);
if a != b {
rendered.push(a);
}
}
(Some(a), None) | (None, Some(a)) => rendered.push(a),
(None, None) => {}
}
}
if rendered.len() > max_points {
let stride = rendered.len().div_ceil(max_points);
rendered.into_iter().step_by(stride).collect()
} else {
rendered
}
}
pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> {
if self.points.is_empty() || max_points == 0 {
return Vec::new();
}
if self.points.len() <= max_points {
return self.points.iter().copied().collect();
}
let stride = self.points.len().div_ceil(max_points);
self.points.iter().copied().step_by(stride).collect()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PlotSeriesKind {
TimeSeries,
XY,
}
#[derive(Clone, Copy)]
struct BoundsRect {
min_x: f64,
max_x: f64,
min_y: f64,
max_y: f64,
}
impl BoundsRect {
fn from_point([x, y]: [f64; 2]) -> Option<Self> {
if !(x.is_finite() && y.is_finite()) {
return None;
}
Some(Self {
min_x: x,
max_x: x,
min_y: y,
max_y: y,
})
}
fn extend_with_point(&mut self, [x, y]: [f64; 2]) {
if !(x.is_finite() && y.is_finite()) {
return;
}
self.min_x = self.min_x.min(x);
self.max_x = self.max_x.max(x);
self.min_y = self.min_y.min(y);
self.max_y = self.max_y.max(y);
}
fn touches(&self, [x, y]: [f64; 2]) -> bool {
x == self.min_x || x == self.max_x || y == self.min_y || y == self.max_y
}
}
pub struct PlotBuffer {
limit: usize,
kind: PlotSeriesKind,
series: Vec<PlotSeries>,
time_series_y_bounds: Option<(f64, f64)>,
time_series_y_dirty: bool,
xy_bounds: Option<BoundsRect>,
xy_bounds_dirty: bool,
}
impl PlotBuffer {
pub fn new(limit: usize) -> Self {
Self {
limit,
kind: PlotSeriesKind::TimeSeries,
series: Vec::new(),
time_series_y_bounds: None,
time_series_y_dirty: false,
xy_bounds: None,
xy_bounds_dirty: false,
}
}
pub fn push(&mut self, entry: &DecodedEntry) {
if let DecodedData::Plot(frame) = &entry.data {
self.push_frame(&entry.pipeline_name, frame);
}
}
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
let next_kind = match frame.format {
PlotFormat::XY => PlotSeriesKind::XY,
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
};
if self.kind != next_kind {
self.invalidate_bounds();
}
self.kind = next_kind;
if matches!(frame.format, PlotFormat::XY) {
self.push_xy_frame(pipeline_name, frame);
return;
}
for (index, channel) in frame.channels.iter().enumerate() {
let series_name = if frame.channels.len() == 1 {
pipeline_name.to_owned()
} else {
format!("{pipeline_name}:ch{}", index + 1)
};
if let Some(series_index) = self
.series
.iter()
.position(|series| series.name == series_name)
{
for sample in channel {
if !sample.is_finite() {
continue;
}
let (point, removed) = {
let series = &mut self.series[series_index];
let point = [series.next_x, *sample];
let removed = series.push_point(point, self.limit);
series.next_x += 1.0;
(point, removed)
};
if let Some(removed) = removed {
self.note_time_series_removed(removed);
}
self.note_time_series_point(point);
}
} else {
let mut series = PlotSeries::new(series_name);
series.push_samples(channel, self.limit);
for point in series.points() {
self.note_time_series_point(point);
}
self.series.push(series);
}
}
}
fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
if frame.channels.len() < 2 {
return;
}
let x = &frame.channels[0];
let y = &frame.channels[1];
let len = x.len().min(y.len());
let series_name = format!("{pipeline_name}:xy");
let series_index = if let Some(index) = self
.series
.iter()
.position(|series| series.name == series_name)
{
index
} else {
self.series.push(PlotSeries::new(series_name));
self.series.len() - 1
};
for index in 0..len {
if !x[index].is_finite() || !y[index].is_finite() {
continue;
}
let point = [x[index], y[index]];
let removed = {
let series = &mut self.series[series_index];
series.push_point(point, self.limit)
};
if let Some(removed) = removed {
self.note_xy_removed(removed);
}
self.note_xy_point(point);
}
}
pub fn clear(&mut self) {
self.series.clear();
self.invalidate_bounds();
}
pub fn set_limit(&mut self, limit: usize) {
self.limit = limit;
for series in &mut self.series {
while series.points.len() > self.limit {
series.points.pop_front();
}
}
self.invalidate_bounds();
}
pub fn is_empty(&self) -> bool {
self.series.is_empty()
}
pub fn kind(&self) -> PlotSeriesKind {
self.kind
}
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
self.series.iter()
}
pub fn series_len(&self) -> usize {
self.series.len()
}
pub fn total_points(&self) -> usize {
self.series.iter().map(PlotSeries::len).sum()
}
pub fn bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
match self.kind {
PlotSeriesKind::TimeSeries => self.time_series_bounds(),
PlotSeriesKind::XY => self.xy_bounds(),
}
}
fn invalidate_bounds(&mut self) {
self.time_series_y_bounds = None;
self.time_series_y_dirty = false;
self.xy_bounds = None;
self.xy_bounds_dirty = false;
}
fn note_time_series_point(&mut self, point: [f64; 2]) {
if !point[1].is_finite() {
return;
}
match &mut self.time_series_y_bounds {
Some((min_y, max_y)) => {
*min_y = min_y.min(point[1]);
*max_y = max_y.max(point[1]);
}
None => self.time_series_y_bounds = Some((point[1], point[1])),
}
}
fn note_time_series_removed(&mut self, removed: [f64; 2]) {
if let Some((min_y, max_y)) = self.time_series_y_bounds
&& (removed[1] == min_y || removed[1] == max_y)
{
self.time_series_y_dirty = true;
}
}
fn note_xy_point(&mut self, point: [f64; 2]) {
match &mut self.xy_bounds {
Some(bounds) => bounds.extend_with_point(point),
None => self.xy_bounds = BoundsRect::from_point(point),
}
}
fn note_xy_removed(&mut self, removed: [f64; 2]) {
if let Some(bounds) = self.xy_bounds
&& bounds.touches(removed)
{
self.xy_bounds_dirty = true;
}
}
fn time_series_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
for series in &self.series {
if let Some([x, _]) = series.first_point() {
min_x = min_x.min(x);
}
if let Some([x, _]) = series.last_point() {
max_x = max_x.max(x);
}
}
if !(min_x.is_finite() && max_x.is_finite()) {
return None;
}
if self.time_series_y_dirty {
self.recompute_time_series_y_bounds();
}
let (mut min_y, mut max_y) = self.time_series_y_bounds?;
if min_y == max_y {
min_y -= 1.0;
max_y += 1.0;
}
Some(([min_x, min_y], [max_x, max_y]))
}
fn xy_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
if self.xy_bounds_dirty {
self.recompute_xy_bounds();
}
let bounds = self.xy_bounds?;
let mut min_x = bounds.min_x;
let mut max_x = bounds.max_x;
let mut min_y = bounds.min_y;
let mut max_y = bounds.max_y;
if min_x == max_x {
min_x -= 1.0;
max_x += 1.0;
}
if min_y == max_y {
min_y -= 1.0;
max_y += 1.0;
}
Some(([min_x, min_y], [max_x, max_y]))
}
fn recompute_time_series_y_bounds(&mut self) {
let mut min_y = f64::INFINITY;
let mut max_y = f64::NEG_INFINITY;
for series in &self.series {
for [_, y] in series.points() {
if y.is_finite() {
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
}
self.time_series_y_bounds = if min_y.is_finite() && max_y.is_finite() {
Some((min_y, max_y))
} else {
None
};
self.time_series_y_dirty = false;
}
fn recompute_xy_bounds(&mut self) {
let mut bounds: Option<BoundsRect> = None;
for series in &self.series {
for point in series.points() {
match &mut bounds {
Some(existing) => existing.extend_with_point(point),
None => bounds = BoundsRect::from_point(point),
}
}
}
self.xy_bounds = bounds;
self.xy_bounds_dirty = false;
}
}
fn decode_ascii(hex: &str) -> String {
hex::decode(hex.replace(' ', ""))
.map(|bytes| {
bytes
.into_iter()
.map(|byte| {
if byte.is_ascii_graphic() || byte == b' ' {
byte as char
} else {
'.'
}
})
.collect()
})
.unwrap_or_else(|_| String::from("[invalid hex]"))
}

View File

@@ -1,65 +0,0 @@
mod ansi;
mod app;
mod app_state;
mod buffers;
mod ui;
use std::io;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{EnvFilter, fmt};
use crate::app::App;
#[tokio::main]
async fn main() -> io::Result<()> {
let _log_guard = init_tracing();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = async {
let mut app = App::new();
app.run(&mut terminal).await?;
app.shutdown().await;
io::Result::Ok(())
}
.await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
fn init_tracing() -> Option<WorkerGuard> {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("pipeview_tui=info,pipeview_client=info"));
let log_dir = app_state::config_dir();
let _ = std::fs::create_dir_all(&log_dir);
let file_appender = tracing_appender::rolling::never(log_dir, "tui.log");
let (writer, guard) = tracing_appender::non_blocking(file_appender);
fmt()
.with_env_filter(filter)
.with_target(false)
.with_ansi(false)
.with_writer(writer)
.try_init()
.ok()
.map(|_| guard)
}

View File

@@ -1,989 +0,0 @@
use std::time::Duration;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols::Marker,
text::{Line, Span},
widgets::{
Axis, Block, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, Paragraph, Wrap,
},
};
use crate::ansi::ansi_to_spans;
use crate::app::{
App, AppMode, ConfigField, ConnectionStatus, DisplayOptions, FocusPane, HotAction, LineEnding,
SendMode, View,
};
use crate::buffers::{ConsoleLine, HexLine, LineDirection, PlotSeriesKind};
const BG: Color = Color::Rgb(8, 12, 18);
const PANEL: Color = Color::Rgb(12, 20, 28);
const PANEL_ALT: Color = Color::Rgb(17, 27, 38);
const CYAN: Color = Color::Rgb(55, 214, 230);
const GREEN: Color = Color::Rgb(72, 211, 137);
const AMBER: Color = Color::Rgb(244, 184, 74);
const MAGENTA: Color = Color::Rgb(219, 111, 220);
const RED: Color = Color::Rgb(238, 92, 107);
const BLUE: Color = Color::Rgb(96, 165, 250);
const MUTED: Color = Color::Rgb(120, 134, 153);
const TEXT: Color = Color::Rgb(224, 234, 244);
pub fn render(frame: &mut Frame, app: &mut App) {
app.reset_hot_zones();
let area = frame.area();
frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(10),
Constraint::Length(6),
Constraint::Length(2),
])
.split(area);
render_header(frame, app, root[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(34), Constraint::Min(30)])
.split(root[1]);
render_controls(frame, app, body[0]);
render_main(frame, app, body[1]);
render_composer(frame, app, root[2]);
render_footer(frame, app, root[3]);
match app.mode {
AppMode::Search => render_search_modal(frame, app),
AppMode::Config => render_config_modal(frame, app),
AppMode::Help => render_help_modal(frame, app),
AppMode::Normal | AppMode::EditingSend => {}
}
}
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
let status = &app.session.status;
let status_color = status_color(status);
let error = match status {
ConnectionStatus::Error(message) => format!(" {message}"),
_ => String::new(),
};
let header = vec![
Line::from(vec![
Span::styled(
" PIPEVIEW ",
Style::default()
.fg(Color::Black)
.bg(CYAN)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
"single session telemetry console",
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
status.label(),
Style::default()
.fg(Color::Black)
.bg(status_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(error, Style::default().fg(RED)),
]),
Line::from(vec![
Span::styled(app.transport_summary(), Style::default().fg(AMBER)),
Span::raw(" | pipelines: "),
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
Span::raw(" | view: "),
Span::styled(
app.session.view.label(),
Style::default().fg(view_color(app.session.view)),
),
Span::raw(" | rx/tx: "),
Span::styled(
format!(
"{}/{}",
app.session.received_messages, app.session.sent_messages
),
Style::default().fg(GREEN),
),
]),
];
frame.render_widget(
Paragraph::new(header)
.block(panel_block("Status", false, CYAN))
.wrap(Wrap { trim: false }),
area,
);
}
fn render_controls(frame: &mut Frame, app: &mut App, area: Rect) {
app.add_hot_zone(area, HotAction::Focus(FocusPane::Controls));
let focused = app.focus == FocusPane::Controls;
let block = panel_block("Control Deck", focused, AMBER);
let inner = block.inner(area);
frame.render_widget(block, area);
let connect_label = if app.session.status.is_connectedish() {
"[ Disconnect ]"
} else {
"[ Connect ]"
};
let ending = next_line_ending(app.session.line_ending);
let rows: Vec<(Line<'static>, Option<HotAction>)> = vec![
section_line("SESSION"),
button_line(
connect_label,
status_color(&app.session.status),
Some(HotAction::ToggleConnection),
),
button_line("[ Reconnect ]", BLUE, Some(HotAction::Reconnect)),
button_line("[ Clear Buffers ]", RED, Some(HotAction::Clear)),
button_line("[ Configure ]", MAGENTA, Some(HotAction::OpenConfig)),
spacer_line(),
section_line("LINES"),
toggle_line(
"Auto reconnect",
app.session.auto_reconnect,
Some(HotAction::ToggleAutoReconnect),
),
toggle_line("DTR", app.session.dtr, Some(HotAction::ToggleDtr)),
toggle_line("RTS", app.session.rts, Some(HotAction::ToggleRts)),
spacer_line(),
section_line("DISPLAY"),
toggle_line(
"Timestamp",
app.display.show_timestamp,
Some(HotAction::ToggleTimestamp),
),
toggle_line(
"Direction",
app.display.show_direction,
Some(HotAction::ToggleDirection),
),
toggle_line(
"Pipeline",
app.display.show_pipeline,
Some(HotAction::TogglePipeline),
),
spacer_line(),
section_line("SEND"),
value_line(
"Mode",
app.session.send_mode.label(),
Some(HotAction::SendMode(match app.session.send_mode {
SendMode::Text => SendMode::Hex,
SendMode::Hex => SendMode::Text,
})),
),
value_line(
"Ending",
app.session.line_ending.label(),
Some(HotAction::LineEnding(ending)),
),
button_line("[ Search ]", CYAN, Some(HotAction::OpenSearch)),
button_line("[ Help ]", MUTED, Some(HotAction::OpenHelp)),
];
for (index, (_, action)) in rows.iter().enumerate() {
if let Some(action) = action {
let y = inner.y.saturating_add(index as u16);
if y < inner.y.saturating_add(inner.height) {
app.add_hot_zone(
Rect {
x: inner.x,
y,
width: inner.width,
height: 1,
},
*action,
);
}
}
}
frame.render_widget(
Paragraph::new(rows.into_iter().map(|(line, _)| line).collect::<Vec<_>>())
.style(Style::default().fg(TEXT).bg(PANEL)),
inner,
);
}
fn render_main(frame: &mut Frame, app: &mut App, area: Rect) {
app.add_hot_zone(area, HotAction::Focus(FocusPane::Main));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
render_tabs(frame, app, layout[0]);
match app.session.view {
View::Text => render_text_view(frame, app, layout[1]),
View::Hex => render_hex_view(frame, app, layout[1]),
View::Plot => render_plot_view(frame, app, layout[1]),
}
}
fn render_tabs(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(area);
for (idx, view) in View::ALL.into_iter().enumerate() {
let selected = app.session.view == view;
app.add_hot_zone(chunks[idx], HotAction::View(view));
let color = view_color(view);
let block = panel_block(view.label(), selected, color);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
view.label(),
Style::default()
.fg(if selected { Color::Black } else { color })
.bg(if selected { color } else { PANEL })
.add_modifier(Modifier::BOLD),
),
Span::styled(
match view {
View::Text => format!(" {} lines", app.session.console.len()),
View::Hex => format!(" {} lines", app.session.hex.len()),
View::Plot => format!(
" {} series / {} pts",
app.session.plot.series_len(),
app.session.plot.total_points()
),
},
Style::default().fg(MUTED),
),
]))
.block(block)
.alignment(Alignment::Center),
chunks[idx],
);
}
}
fn render_text_view(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focus == FocusPane::Main;
let block = panel_block("Text Stream", focused, CYAN);
let inner = block.inner(area);
let height = inner.height as usize;
let total = app.session.console.len();
let start = app.visible_start(total, height);
let end = start.saturating_add(height).min(total);
let lines = if total == 0 {
vec![Line::styled("no text data", Style::default().fg(MUTED))]
} else {
(start..end)
.filter_map(|index| {
app.session
.console
.get(index)
.map(|line| format_console_line(index, line, app.display, app))
})
.collect()
};
frame.render_widget(
Paragraph::new(lines)
.block(block)
.style(Style::default().bg(PANEL_ALT))
.wrap(Wrap { trim: false }),
area,
);
}
fn render_hex_view(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focus == FocusPane::Main;
let block = panel_block("Hex Stream", focused, AMBER);
let inner = block.inner(area);
let height = inner.height as usize;
let total = app.session.hex.len();
let start = app.visible_start(total, height);
let end = start.saturating_add(height).min(total);
let lines = if total == 0 {
vec![Line::styled("no hex data", Style::default().fg(MUTED))]
} else {
(start..end)
.filter_map(|index| {
app.session
.hex
.get(index)
.map(|line| format_hex_line(index, line, app.display, app))
})
.collect()
};
frame.render_widget(
Paragraph::new(lines)
.block(block)
.style(Style::default().bg(PANEL_ALT))
.wrap(Wrap { trim: false }),
area,
);
}
fn render_plot_view(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focus == FocusPane::Main;
let block = panel_block("Plot View", focused, GREEN);
let inner = block.inner(area);
app.add_hot_zone(inner, HotAction::Focus(FocusPane::Main));
if app.session.plot.is_empty() {
frame.render_widget(
Paragraph::new(vec![
Line::styled("no plot data", Style::default().fg(MUTED)),
Line::from(vec![
Span::raw("pipeline: "),
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
]),
])
.block(block)
.style(Style::default().bg(PANEL_ALT))
.alignment(Alignment::Center),
area,
);
return;
}
let Some((min, max)) = app.plot_bounds() else {
frame.render_widget(
Paragraph::new("plot bounds unavailable")
.block(block)
.style(Style::default().fg(MUTED).bg(PANEL_ALT)),
area,
);
return;
};
let max_points = (inner.width as usize).saturating_mul(2).max(32);
let kind = app.session.plot.kind();
let series_points: Vec<(String, Vec<(f64, f64)>)> = app
.session
.plot
.iter()
.map(|series| {
let points = match kind {
PlotSeriesKind::TimeSeries => {
series.render_points_time_series(min[0], max[0], max_points)
}
PlotSeriesKind::XY => series.render_points_xy(max_points),
};
(
series.name.clone(),
points.into_iter().map(|[x, y]| (x, y)).collect(),
)
})
.collect();
let palette = [GREEN, CYAN, AMBER, MAGENTA, BLUE, RED];
let datasets = series_points
.iter()
.enumerate()
.map(|(index, (name, points))| {
Dataset::default()
.name(name.as_str())
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(palette[index % palette.len()]))
.data(points)
})
.collect::<Vec<_>>();
let chart = Chart::new(datasets)
.block(block)
.style(Style::default().bg(PANEL_ALT))
.x_axis(axis("X", min[0], max[0], CYAN))
.y_axis(axis("Y", min[1], max[1], GREEN));
frame.render_widget(chart, area);
let overlay = Rect {
x: inner.x.saturating_add(1),
y: inner.y,
width: inner.width.saturating_sub(2).min(72),
height: 1,
};
let controls = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(16),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Min(1),
])
.split(overlay);
app.add_hot_zone(controls[0], HotAction::PlotFollow);
app.add_hot_zone(controls[1], HotAction::PlotZoomIn);
app.add_hot_zone(controls[2], HotAction::PlotZoomOut);
let mode = match kind {
PlotSeriesKind::TimeSeries => "time",
PlotSeriesKind::XY => "xy",
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!(
"[follow {}] ",
if app.plot.follow_latest { "on" } else { "off" }
),
Style::default()
.fg(Color::Black)
.bg(if app.plot.follow_latest { GREEN } else { MUTED })
.add_modifier(Modifier::BOLD),
),
Span::styled("[zoom +] ", Style::default().fg(Color::Black).bg(CYAN)),
Span::styled("[zoom -] ", Style::default().fg(Color::Black).bg(AMBER)),
Span::styled(format!("{mode} "), Style::default().fg(MUTED)),
Span::styled(
format!(
"{} series / {} pts",
app.session.plot.series_len(),
app.session.plot.total_points()
),
Style::default().fg(TEXT),
),
]))
.style(Style::default().bg(PANEL_ALT)),
overlay,
);
}
fn render_composer(frame: &mut Frame, app: &mut App, area: Rect) {
app.add_hot_zone(area, HotAction::Focus(FocusPane::Composer));
let focused = app.focus == FocusPane::Composer || matches!(app.mode, AppMode::EditingSend);
let block = panel_block("Composer", focused, MAGENTA);
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(20), Constraint::Length(28)])
.split(inner);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { MAGENTA } else { MUTED }))
.style(Style::default().bg(PANEL_ALT));
frame.render_widget(
Paragraph::new(input_line(app))
.block(input_block)
.wrap(Wrap { trim: false }),
chunks[0],
);
let side_rows = vec![
value_line(
"Mode",
app.session.send_mode.label(),
Some(HotAction::SendMode(match app.session.send_mode {
SendMode::Text => SendMode::Hex,
SendMode::Hex => SendMode::Text,
})),
),
value_line(
"Ending",
app.session.line_ending.label(),
Some(HotAction::LineEnding(next_line_ending(
app.session.line_ending,
))),
),
button_line("[ Send ]", GREEN, Some(HotAction::Send)),
(
Line::from(vec![
Span::styled("Status ", Style::default().fg(MUTED)),
Span::styled(app.session.send_status.clone(), Style::default().fg(TEXT)),
]),
None,
),
];
for (index, (_, action)) in side_rows.iter().enumerate() {
if let Some(action) = action {
app.add_hot_zone(
Rect {
x: chunks[1].x,
y: chunks[1].y.saturating_add(index as u16),
width: chunks[1].width,
height: 1,
},
*action,
);
}
}
frame.render_widget(
Paragraph::new(
side_rows
.into_iter()
.map(|(line, _)| line)
.collect::<Vec<_>>(),
)
.style(Style::default().bg(PANEL)),
chunks[1],
);
}
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
let search = if app.search.active && !app.search.query.is_empty() {
format!(
" search {}/{} '{}'",
app.search.display_index(),
app.search.count(),
app.search.query
)
} else {
String::new()
};
let shortcuts = "1/2/3 view c connect r reconnect e config / search Ctrl-S send q quit";
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!(" {} ", app.notice),
Style::default().fg(TEXT).bg(PANEL_ALT),
),
Span::styled(search, Style::default().fg(AMBER).bg(PANEL_ALT)),
Span::styled(" ", Style::default().bg(PANEL_ALT)),
Span::styled(shortcuts, Style::default().fg(MUTED).bg(PANEL_ALT)),
])),
area,
);
}
fn render_search_modal(frame: &mut Frame, app: &mut App) {
let area = centered_rect(70, 7, frame.area());
app.add_hot_zone(area, HotAction::CloseModal);
frame.render_widget(Clear, area);
let block = panel_block("Search", true, CYAN);
let inner = block.inner(area);
frame.render_widget(block, area);
let body = vec![
Line::from(vec![
Span::styled("Query: ", Style::default().fg(MUTED)),
Span::styled(app.search.query.clone(), Style::default().fg(TEXT)),
Span::styled(
"_",
Style::default().fg(CYAN).add_modifier(Modifier::SLOW_BLINK),
),
]),
Line::from(vec![
Span::styled("Matches: ", Style::default().fg(MUTED)),
Span::styled(
format!("{}/{}", app.search.display_index(), app.search.count()),
Style::default().fg(AMBER),
),
Span::raw(" "),
Span::styled(
if app.search.case_sensitive {
"case sensitive"
} else {
"case insensitive"
},
Style::default().fg(MAGENTA),
),
]),
Line::styled(
"Enter close Up/Down navigate Ctrl-C case",
Style::default().fg(MUTED),
),
];
frame.render_widget(
Paragraph::new(body)
.style(Style::default().bg(PANEL))
.wrap(Wrap { trim: false }),
inner,
);
}
fn render_config_modal(frame: &mut Frame, app: &mut App) {
let rows = app.config_form.rows();
let height = (rows.len() as u16 + 6).min(frame.area().height.saturating_sub(2));
let area = centered_rect(88, height, frame.area());
frame.render_widget(Clear, area);
let block = panel_block("Session Config", true, MAGENTA);
let inner = block.inner(area);
frame.render_widget(block, area);
let footer_height = 3;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(footer_height)])
.split(inner);
let items = rows
.iter()
.enumerate()
.map(|(index, row)| {
let focused = index == app.config_form.focused;
let value_style = if row.editable {
Style::default().fg(CYAN)
} else {
Style::default().fg(AMBER)
};
ListItem::new(Line::from(vec![
Span::styled(
format!("{:<18}", row.label),
Style::default().fg(if focused { Color::Black } else { MUTED }),
),
Span::styled(
row.value.clone(),
if focused {
value_style.bg(MAGENTA).fg(Color::Black)
} else {
value_style
},
),
]))
.style(if focused {
Style::default().bg(MAGENTA)
} else {
Style::default().bg(PANEL)
})
})
.collect::<Vec<_>>();
for (index, row) in rows.iter().enumerate() {
let y = layout[0].y.saturating_add(index as u16);
if y >= layout[0].y.saturating_add(layout[0].height) {
break;
}
app.add_hot_zone(
Rect {
x: layout[0].x,
y,
width: layout[0].width,
height: 1,
},
if row.field == ConfigField::Apply {
HotAction::ConfigSubmit
} else {
HotAction::ConfigFocus(row.field)
},
);
}
frame.render_widget(
List::new(items).style(Style::default().bg(PANEL)),
layout[0],
);
app.add_hot_zone(layout[1], HotAction::ConfigCancel);
frame.render_widget(
Paragraph::new(vec![
Line::styled(
"Tab move Left/Right change options Enter apply Esc cancel",
Style::default().fg(MUTED),
),
Line::styled(serial_ports_hint(app), Style::default().fg(BLUE)),
])
.style(Style::default().bg(PANEL)),
layout[1],
);
}
fn render_help_modal(frame: &mut Frame, app: &mut App) {
let area = centered_rect(74, 12, frame.area());
app.add_hot_zone(area, HotAction::CloseModal);
frame.render_widget(Clear, area);
let block = panel_block("Help", true, BLUE);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(vec![
Line::styled(
"Keyboard",
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
),
Line::raw("1/2/3 switch views, Tab shift focus, q quit"),
Line::raw("c connect, r reconnect, e config, / search, x clear"),
Line::raw("Ctrl-S send, Enter edit/send composer, Esc close modal"),
Line::styled(
"Mouse",
Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
),
Line::raw("Click tabs, buttons, toggles, config rows, and composer."),
Line::raw("Wheel scrolls Text/Hex and zooms Plot."),
])
.style(Style::default().fg(TEXT).bg(PANEL))
.wrap(Wrap { trim: false }),
inner,
);
}
fn format_console_line(
index: usize,
line: &ConsoleLine,
display: DisplayOptions,
app: &App,
) -> Line<'static> {
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
let base_style = Style::default().fg(TEXT);
spans.extend(ansi_to_spans(&line.text, base_style));
apply_search_style(index, Line::from(spans), app)
}
fn format_hex_line(
index: usize,
line: &HexLine,
display: DisplayOptions,
app: &App,
) -> Line<'static> {
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
spans.push(Span::styled(line.hex.clone(), Style::default().fg(AMBER)));
spans.push(Span::styled(" |", Style::default().fg(MUTED)));
spans.push(Span::styled(line.ascii.clone(), Style::default().fg(TEXT)));
spans.push(Span::styled("|", Style::default().fg(MUTED)));
apply_search_style(index, Line::from(spans), app)
}
fn prefix_spans(
elapsed: Duration,
direction: LineDirection,
pipeline: &str,
display: DisplayOptions,
) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if display.show_timestamp {
spans.push(Span::styled(
format!("[{}] ", format_elapsed(elapsed)),
Style::default().fg(MUTED),
));
}
if display.show_direction {
let (label, color) = match direction {
LineDirection::In => ("IN", CYAN),
LineDirection::Out => ("OUT", AMBER),
};
spans.push(Span::styled(
format!("[{label}] "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
if display.show_pipeline {
spans.push(Span::styled(
format!("[{pipeline}] "),
Style::default().fg(MAGENTA),
));
}
spans
}
fn apply_search_style(index: usize, line: Line<'static>, app: &App) -> Line<'static> {
if !app.search.active || app.search.query.is_empty() {
return line;
}
if app.search.current_line() == Some(index) {
line.style(Style::default().bg(AMBER))
} else if app.search.matches.contains(&index) {
line.style(Style::default().bg(Color::Rgb(47, 61, 91)))
} else {
line
}
}
fn input_line(app: &App) -> Line<'static> {
if app.session.send_input.is_empty() {
return Line::styled(
match app.session.send_mode {
SendMode::Text => "type text payload",
SendMode::Hex => "hex bytes, e.g. 48 65 6c 6c 6f",
},
Style::default().fg(MUTED),
);
}
if !matches!(app.mode, AppMode::EditingSend) {
return Line::styled(app.session.send_input.clone(), Style::default().fg(TEXT));
}
let mut spans = Vec::new();
let cursor = app.session.input_cursor;
for (index, ch) in app.session.send_input.chars().enumerate() {
if index == cursor {
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(Color::Black).bg(MAGENTA),
));
} else {
spans.push(Span::styled(ch.to_string(), Style::default().fg(TEXT)));
}
}
if cursor >= app.session.send_input.chars().count() {
spans.push(Span::styled(" ", Style::default().bg(MAGENTA)));
}
Line::from(spans)
}
fn section_line(label: &'static str) -> (Line<'static>, Option<HotAction>) {
(
Line::styled(
format!("-- {label} "),
Style::default().fg(MUTED).add_modifier(Modifier::BOLD),
),
None,
)
}
fn spacer_line() -> (Line<'static>, Option<HotAction>) {
(Line::raw(""), None)
}
fn button_line(
label: &'static str,
color: Color,
action: Option<HotAction>,
) -> (Line<'static>, Option<HotAction>) {
(
Line::from(vec![Span::styled(
label,
Style::default()
.fg(Color::Black)
.bg(color)
.add_modifier(Modifier::BOLD),
)]),
action,
)
}
fn toggle_line(
label: &'static str,
enabled: bool,
action: Option<HotAction>,
) -> (Line<'static>, Option<HotAction>) {
(
Line::from(vec![
Span::styled(
if enabled { "[x] " } else { "[ ] " },
Style::default().fg(if enabled { GREEN } else { MUTED }),
),
Span::styled(label, Style::default().fg(TEXT)),
]),
action,
)
}
fn value_line(
label: &'static str,
value: &'static str,
action: Option<HotAction>,
) -> (Line<'static>, Option<HotAction>) {
(
Line::from(vec![
Span::styled(format!("{label:<8}"), Style::default().fg(MUTED)),
Span::styled(value.to_string(), Style::default().fg(CYAN)),
]),
action,
)
}
fn panel_block(title: &str, focused: bool, color: Color) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
format!(" {title} "),
Style::default()
.fg(if focused { Color::Black } else { color })
.bg(if focused { color } else { PANEL })
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(if focused {
color
} else {
Color::Rgb(45, 58, 74)
}))
.style(Style::default().bg(PANEL))
}
fn axis(title: &'static str, min: f64, max: f64, color: Color) -> Axis<'static> {
Axis::default()
.title(title)
.style(Style::default().fg(color))
.bounds([min, max])
.labels(vec![
Span::styled(format!("{min:.2}"), Style::default().fg(MUTED)),
Span::styled(format!("{max:.2}"), Style::default().fg(MUTED)),
])
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(height.min(area.height)),
Constraint::Fill(1),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(width.min(area.width)),
Constraint::Fill(1),
])
.split(vertical[1])[1]
}
fn status_color(status: &ConnectionStatus) -> Color {
match status {
ConnectionStatus::Connected => GREEN,
ConnectionStatus::Disconnected => MUTED,
ConnectionStatus::Connecting => AMBER,
ConnectionStatus::Error(_) => RED,
}
}
fn view_color(view: View) -> Color {
match view {
View::Text => CYAN,
View::Hex => AMBER,
View::Plot => GREEN,
}
}
fn format_elapsed(elapsed: Duration) -> String {
let secs = elapsed.as_secs_f64();
if secs < 60.0 {
format!("{secs:05.2}")
} else {
format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64)
}
}
fn next_line_ending(current: LineEnding) -> LineEnding {
let endings = LineEnding::ALL;
let index = endings
.iter()
.position(|ending| *ending == current)
.unwrap_or(0);
endings[(index + 1) % endings.len()]
}
fn serial_ports_hint(app: &App) -> String {
if app.config_form.available_serial_ports.is_empty() {
String::from("Serial ports: none detected")
} else {
format!(
"Serial ports: {}",
app.config_form.available_serial_ports.join(", ")
)
}
}