Full ratatui terminal application mirroring pipeview-gui: - Single-session telemetry console with Text/Hex/Plot views - Serial/TCP/UDP transport with full parameter config - DTR/RTS control, auto-reconnect, data send (Text/Hex modes) - Search with case-sensitive toggle and match navigation - Interactive config form with inline editing - ANSI SGR parsing via ansitok: 4-bit, 256-color, true color, bold/italic/underline/strikethrough rendering in text view - Persistent session state via JSON - Mouse clickable controls and mousewheel scroll/zoom Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1783 lines
58 KiB
Rust
1783 lines
58 KiB
Rust
use std::io;
|
|
use std::time::Duration;
|
|
|
|
use crossterm::event::{
|
|
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
|
|
MouseEventKind,
|
|
};
|
|
use pipeview_client::{
|
|
DecoderConfig, FramerConfig, PipelineConfig, SessionConfig, SessionEvent, SessionHandle,
|
|
SessionId, SessionManager,
|
|
};
|
|
use pipeview_core::protocol::DecodedData;
|
|
use pipeview_core::protocol::Endian;
|
|
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
|
use pipeview_core::protocol::text::TextEncoding;
|
|
use pipeview_core::transport::TransportConfig;
|
|
use pipeview_core::transport::serial::{
|
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
|
};
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::layout::Rect;
|
|
use tokio::sync::broadcast;
|
|
|
|
use crate::ansi::ansi_visible_text;
|
|
use crate::app_state;
|
|
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
|
|
|
const TICK_RATE: Duration = Duration::from_millis(33);
|
|
const DEFAULT_HISTORY_LIMIT: usize = 10_000;
|
|
|
|
#[derive(Clone)]
|
|
pub enum ConnectionStatus {
|
|
Connected,
|
|
Disconnected,
|
|
Connecting,
|
|
Error(String),
|
|
}
|
|
|
|
impl ConnectionStatus {
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
Self::Connected => "CONNECTED",
|
|
Self::Disconnected => "OFFLINE",
|
|
Self::Connecting => "CONNECTING",
|
|
Self::Error(_) => "ERROR",
|
|
}
|
|
}
|
|
|
|
pub fn is_connectedish(&self) -> bool {
|
|
matches!(self, Self::Connected | Self::Connecting)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum View {
|
|
Text,
|
|
Hex,
|
|
Plot,
|
|
}
|
|
|
|
impl View {
|
|
pub const ALL: [Self; 3] = [Self::Text, Self::Hex, Self::Plot];
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Text => "Text",
|
|
Self::Hex => "Hex",
|
|
Self::Plot => "Plot",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum SendMode {
|
|
Text,
|
|
Hex,
|
|
}
|
|
|
|
impl SendMode {
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Text => "Text",
|
|
Self::Hex => "Hex",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
#[allow(clippy::upper_case_acronyms)]
|
|
pub enum LineEnding {
|
|
None,
|
|
LF,
|
|
CR,
|
|
CRLF,
|
|
}
|
|
|
|
impl LineEnding {
|
|
pub const ALL: [Self; 4] = [Self::None, Self::LF, Self::CR, Self::CRLF];
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::None => "None",
|
|
Self::LF => "LF",
|
|
Self::CR => "CR",
|
|
Self::CRLF => "CRLF",
|
|
}
|
|
}
|
|
|
|
fn bytes(self) -> &'static [u8] {
|
|
match self {
|
|
Self::None => b"",
|
|
Self::LF => b"\n",
|
|
Self::CR => b"\r",
|
|
Self::CRLF => b"\r\n",
|
|
}
|
|
}
|
|
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::None => Self::LF,
|
|
Self::LF => Self::CR,
|
|
Self::CR => Self::CRLF,
|
|
Self::CRLF => Self::None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum FocusPane {
|
|
Controls,
|
|
Main,
|
|
Composer,
|
|
}
|
|
|
|
impl FocusPane {
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::Controls => Self::Main,
|
|
Self::Main => Self::Composer,
|
|
Self::Composer => Self::Controls,
|
|
}
|
|
}
|
|
|
|
fn prev(self) -> Self {
|
|
match self {
|
|
Self::Controls => Self::Composer,
|
|
Self::Main => Self::Controls,
|
|
Self::Composer => Self::Main,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct DisplayOptions {
|
|
pub show_timestamp: bool,
|
|
pub show_direction: bool,
|
|
pub show_pipeline: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct SearchState {
|
|
pub query: String,
|
|
pub matches: Vec<usize>,
|
|
pub current_match: usize,
|
|
pub case_sensitive: bool,
|
|
pub active: bool,
|
|
}
|
|
|
|
impl SearchState {
|
|
pub fn current_line(&self) -> Option<usize> {
|
|
self.matches.get(self.current_match).copied()
|
|
}
|
|
|
|
pub fn count(&self) -> usize {
|
|
self.matches.len()
|
|
}
|
|
|
|
pub fn display_index(&self) -> usize {
|
|
if self.matches.is_empty() {
|
|
0
|
|
} else {
|
|
self.current_match + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum AppMode {
|
|
Normal,
|
|
EditingSend,
|
|
Search,
|
|
Config,
|
|
Help,
|
|
}
|
|
|
|
pub struct SingleSession {
|
|
pub id: SessionId,
|
|
pub config: SessionConfig,
|
|
pub status: ConnectionStatus,
|
|
pub console: TextBuffer,
|
|
pub hex: HexBuffer,
|
|
pub plot: PlotBuffer,
|
|
pub view: View,
|
|
pub send_input: String,
|
|
pub input_cursor: usize,
|
|
pub send_mode: SendMode,
|
|
pub line_ending: LineEnding,
|
|
pub send_status: String,
|
|
pub auto_reconnect: bool,
|
|
pub dtr: bool,
|
|
pub rts: bool,
|
|
pub received_messages: usize,
|
|
pub sent_messages: usize,
|
|
}
|
|
|
|
impl SingleSession {
|
|
fn new(id: SessionId, config: SessionConfig, view: View) -> Self {
|
|
let history_limit = config.history_limit;
|
|
let (dtr, rts) = match &config.transport {
|
|
TransportConfig::Serial { dtr, rts, .. } => (*dtr, *rts),
|
|
_ => (false, false),
|
|
};
|
|
|
|
Self {
|
|
id,
|
|
auto_reconnect: config.auto_reconnect,
|
|
config,
|
|
status: ConnectionStatus::Connecting,
|
|
console: TextBuffer::new(history_limit),
|
|
hex: HexBuffer::new(history_limit),
|
|
plot: PlotBuffer::new(history_limit),
|
|
view,
|
|
send_input: String::new(),
|
|
input_cursor: 0,
|
|
send_mode: SendMode::Text,
|
|
line_ending: LineEnding::LF,
|
|
send_status: String::from("idle"),
|
|
dtr,
|
|
rts,
|
|
received_messages: 0,
|
|
sent_messages: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum TransportChoice {
|
|
Serial,
|
|
Tcp,
|
|
Udp,
|
|
}
|
|
|
|
impl TransportChoice {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Serial => "Serial",
|
|
Self::Tcp => "TCP",
|
|
Self::Udp => "UDP",
|
|
}
|
|
}
|
|
|
|
fn next(self, forward: bool) -> Self {
|
|
match (self, forward) {
|
|
(Self::Serial, true) => Self::Tcp,
|
|
(Self::Tcp, true) => Self::Udp,
|
|
(Self::Udp, true) => Self::Serial,
|
|
(Self::Serial, false) => Self::Udp,
|
|
(Self::Tcp, false) => Self::Serial,
|
|
(Self::Udp, false) => Self::Tcp,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum PipelinePreset {
|
|
TextHexLines,
|
|
TextLines,
|
|
HexLines,
|
|
MixedTextPlot,
|
|
PlotF32Fixed,
|
|
}
|
|
|
|
impl PipelinePreset {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::TextHexLines => "Text + Hex lines",
|
|
Self::TextLines => "Text lines",
|
|
Self::HexLines => "Hex lines",
|
|
Self::MixedTextPlot => "Mixed text/plot",
|
|
Self::PlotF32Fixed => "Plot f32 fixed",
|
|
}
|
|
}
|
|
|
|
fn next(self, forward: bool) -> Self {
|
|
match (self, forward) {
|
|
(Self::TextHexLines, true) => Self::TextLines,
|
|
(Self::TextLines, true) => Self::HexLines,
|
|
(Self::HexLines, true) => Self::MixedTextPlot,
|
|
(Self::MixedTextPlot, true) => Self::PlotF32Fixed,
|
|
(Self::PlotF32Fixed, true) => Self::TextHexLines,
|
|
(Self::TextHexLines, false) => Self::PlotF32Fixed,
|
|
(Self::TextLines, false) => Self::TextHexLines,
|
|
(Self::HexLines, false) => Self::TextLines,
|
|
(Self::MixedTextPlot, false) => Self::HexLines,
|
|
(Self::PlotF32Fixed, false) => Self::MixedTextPlot,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum ConfigField {
|
|
Transport,
|
|
TcpAddr,
|
|
UdpBind,
|
|
UdpRemote,
|
|
SerialPort,
|
|
SerialBaud,
|
|
SerialDataBits,
|
|
SerialParity,
|
|
SerialStopBits,
|
|
SerialFlowControl,
|
|
PipelinePreset,
|
|
PipelineName,
|
|
HistoryLimit,
|
|
AutoReconnect,
|
|
PlotFrameLen,
|
|
PlotChannels,
|
|
Apply,
|
|
}
|
|
|
|
pub struct ConfigRow {
|
|
pub field: ConfigField,
|
|
pub label: &'static str,
|
|
pub value: String,
|
|
pub editable: bool,
|
|
}
|
|
|
|
pub struct ConfigForm {
|
|
pub transport: TransportChoice,
|
|
pub tcp_addr: String,
|
|
pub udp_bind_addr: String,
|
|
pub udp_remote_addr: String,
|
|
pub serial_port: String,
|
|
pub serial_baud_rate: String,
|
|
pub serial_data_bits: SerialDataBits,
|
|
pub serial_parity: SerialParity,
|
|
pub serial_stop_bits: SerialStopBits,
|
|
pub serial_flow_control: SerialFlowControl,
|
|
pub pipeline_preset: PipelinePreset,
|
|
pub pipeline_name: String,
|
|
pub history_limit: String,
|
|
pub auto_reconnect: bool,
|
|
pub plot_frame_len: String,
|
|
pub plot_channels: String,
|
|
pub focused: usize,
|
|
pub available_serial_ports: Vec<String>,
|
|
}
|
|
|
|
impl ConfigForm {
|
|
fn from_session(config: &SessionConfig) -> Self {
|
|
let available_serial_ports = available_serial_ports();
|
|
let mut form = Self {
|
|
transport: TransportChoice::Tcp,
|
|
tcp_addr: String::from("127.0.0.1:8080"),
|
|
udp_bind_addr: String::from("0.0.0.0:9000"),
|
|
udp_remote_addr: String::new(),
|
|
serial_port: available_serial_ports.first().cloned().unwrap_or_default(),
|
|
serial_baud_rate: String::from("115200"),
|
|
serial_data_bits: SerialDataBits::Eight,
|
|
serial_parity: SerialParity::None,
|
|
serial_stop_bits: SerialStopBits::One,
|
|
serial_flow_control: SerialFlowControl::None,
|
|
pipeline_preset: infer_pipeline_preset(config),
|
|
pipeline_name: config
|
|
.pipelines
|
|
.first()
|
|
.map(|pipeline| pipeline.name.clone())
|
|
.unwrap_or_else(|| String::from("text")),
|
|
history_limit: config.history_limit.to_string(),
|
|
auto_reconnect: config.auto_reconnect,
|
|
plot_frame_len: String::from("4"),
|
|
plot_channels: String::from("1"),
|
|
focused: 0,
|
|
available_serial_ports,
|
|
};
|
|
|
|
match &config.transport {
|
|
TransportConfig::Tcp { addr } => {
|
|
form.transport = TransportChoice::Tcp;
|
|
form.tcp_addr = addr.clone();
|
|
}
|
|
TransportConfig::Udp {
|
|
bind_addr,
|
|
remote_addr,
|
|
} => {
|
|
form.transport = TransportChoice::Udp;
|
|
form.udp_bind_addr = bind_addr.clone();
|
|
form.udp_remote_addr = remote_addr.clone().unwrap_or_default();
|
|
}
|
|
TransportConfig::Serial {
|
|
port,
|
|
baud_rate,
|
|
data_bits,
|
|
parity,
|
|
stop_bits,
|
|
flow_control,
|
|
..
|
|
} => {
|
|
form.transport = TransportChoice::Serial;
|
|
form.serial_port = port.clone();
|
|
form.serial_baud_rate = baud_rate.to_string();
|
|
form.serial_data_bits = *data_bits;
|
|
form.serial_parity = *parity;
|
|
form.serial_stop_bits = *stop_bits;
|
|
form.serial_flow_control = *flow_control;
|
|
}
|
|
}
|
|
|
|
if let Some(pipeline) = config.pipelines.first() {
|
|
if let FramerConfig::Fixed { frame_len } = &pipeline.framer {
|
|
form.plot_frame_len = frame_len.to_string();
|
|
}
|
|
if let DecoderConfig::Plot { channels, .. } = &pipeline.decoder {
|
|
form.plot_channels = channels.to_string();
|
|
}
|
|
}
|
|
|
|
form
|
|
}
|
|
|
|
pub fn rows(&self) -> Vec<ConfigRow> {
|
|
self.fields()
|
|
.into_iter()
|
|
.map(|field| ConfigRow {
|
|
field,
|
|
label: field.label(),
|
|
value: self.field_value(field),
|
|
editable: self.is_text_field(field),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn focused_field(&self) -> ConfigField {
|
|
let fields = self.fields();
|
|
fields[self.focused.min(fields.len().saturating_sub(1))]
|
|
}
|
|
|
|
fn fields(&self) -> Vec<ConfigField> {
|
|
let mut fields = vec![ConfigField::Transport];
|
|
match self.transport {
|
|
TransportChoice::Tcp => fields.push(ConfigField::TcpAddr),
|
|
TransportChoice::Udp => {
|
|
fields.push(ConfigField::UdpBind);
|
|
fields.push(ConfigField::UdpRemote);
|
|
}
|
|
TransportChoice::Serial => {
|
|
fields.push(ConfigField::SerialPort);
|
|
fields.push(ConfigField::SerialBaud);
|
|
fields.push(ConfigField::SerialDataBits);
|
|
fields.push(ConfigField::SerialParity);
|
|
fields.push(ConfigField::SerialStopBits);
|
|
fields.push(ConfigField::SerialFlowControl);
|
|
}
|
|
}
|
|
fields.push(ConfigField::PipelinePreset);
|
|
fields.push(ConfigField::PipelineName);
|
|
fields.push(ConfigField::HistoryLimit);
|
|
fields.push(ConfigField::AutoReconnect);
|
|
if self.pipeline_preset == PipelinePreset::PlotF32Fixed {
|
|
fields.push(ConfigField::PlotFrameLen);
|
|
fields.push(ConfigField::PlotChannels);
|
|
}
|
|
fields.push(ConfigField::Apply);
|
|
fields
|
|
}
|
|
|
|
fn field_value(&self, field: ConfigField) -> String {
|
|
match field {
|
|
ConfigField::Transport => self.transport.label().to_string(),
|
|
ConfigField::TcpAddr => self.tcp_addr.clone(),
|
|
ConfigField::UdpBind => self.udp_bind_addr.clone(),
|
|
ConfigField::UdpRemote => {
|
|
if self.udp_remote_addr.is_empty() {
|
|
String::from("<none>")
|
|
} else {
|
|
self.udp_remote_addr.clone()
|
|
}
|
|
}
|
|
ConfigField::SerialPort => {
|
|
if self.serial_port.is_empty() {
|
|
String::from("<none>")
|
|
} else {
|
|
self.serial_port.clone()
|
|
}
|
|
}
|
|
ConfigField::SerialBaud => self.serial_baud_rate.clone(),
|
|
ConfigField::SerialDataBits => serial_data_bits_name(self.serial_data_bits).to_string(),
|
|
ConfigField::SerialParity => serial_parity_name(self.serial_parity).to_string(),
|
|
ConfigField::SerialStopBits => serial_stop_bits_name(self.serial_stop_bits).to_string(),
|
|
ConfigField::SerialFlowControl => {
|
|
serial_flow_control_name(self.serial_flow_control).to_string()
|
|
}
|
|
ConfigField::PipelinePreset => self.pipeline_preset.label().to_string(),
|
|
ConfigField::PipelineName => self.pipeline_name.clone(),
|
|
ConfigField::HistoryLimit => self.history_limit.clone(),
|
|
ConfigField::AutoReconnect => bool_label(self.auto_reconnect).to_string(),
|
|
ConfigField::PlotFrameLen => self.plot_frame_len.clone(),
|
|
ConfigField::PlotChannels => self.plot_channels.clone(),
|
|
ConfigField::Apply => String::from("Apply and reconnect"),
|
|
}
|
|
}
|
|
|
|
fn is_text_field(&self, field: ConfigField) -> bool {
|
|
matches!(
|
|
field,
|
|
ConfigField::TcpAddr
|
|
| ConfigField::UdpBind
|
|
| ConfigField::UdpRemote
|
|
| ConfigField::SerialPort
|
|
| ConfigField::SerialBaud
|
|
| ConfigField::PipelineName
|
|
| ConfigField::HistoryLimit
|
|
| ConfigField::PlotFrameLen
|
|
| ConfigField::PlotChannels
|
|
)
|
|
}
|
|
|
|
fn focus_field(&mut self, field: ConfigField) {
|
|
if let Some(index) = self.fields().into_iter().position(|item| item == field) {
|
|
self.focused = index;
|
|
}
|
|
}
|
|
|
|
fn next_field(&mut self) {
|
|
let count = self.fields().len();
|
|
self.focused = (self.focused + 1) % count;
|
|
}
|
|
|
|
fn prev_field(&mut self) {
|
|
let count = self.fields().len();
|
|
self.focused = if self.focused == 0 {
|
|
count.saturating_sub(1)
|
|
} else {
|
|
self.focused - 1
|
|
};
|
|
}
|
|
|
|
fn cycle_current_option(&mut self, forward: bool) {
|
|
match self.focused_field() {
|
|
ConfigField::Transport => {
|
|
self.transport = self.transport.next(forward);
|
|
self.focused = self.focused.min(self.fields().len().saturating_sub(1));
|
|
}
|
|
ConfigField::SerialPort => self.cycle_serial_port(forward),
|
|
ConfigField::SerialDataBits => {
|
|
self.serial_data_bits = next_serial_data_bits(self.serial_data_bits, forward);
|
|
}
|
|
ConfigField::SerialParity => {
|
|
self.serial_parity = next_serial_parity(self.serial_parity, forward);
|
|
}
|
|
ConfigField::SerialStopBits => {
|
|
self.serial_stop_bits = next_serial_stop_bits(self.serial_stop_bits, forward);
|
|
}
|
|
ConfigField::SerialFlowControl => {
|
|
self.serial_flow_control =
|
|
next_serial_flow_control(self.serial_flow_control, forward);
|
|
}
|
|
ConfigField::PipelinePreset => {
|
|
self.pipeline_preset = self.pipeline_preset.next(forward);
|
|
self.focused = self.focused.min(self.fields().len().saturating_sub(1));
|
|
}
|
|
ConfigField::AutoReconnect => self.auto_reconnect = !self.auto_reconnect,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn cycle_serial_port(&mut self, forward: bool) {
|
|
if self.available_serial_ports.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let next_index = self
|
|
.available_serial_ports
|
|
.iter()
|
|
.position(|port| port == &self.serial_port)
|
|
.map(|index| {
|
|
if forward {
|
|
(index + 1) % self.available_serial_ports.len()
|
|
} else if index == 0 {
|
|
self.available_serial_ports.len() - 1
|
|
} else {
|
|
index - 1
|
|
}
|
|
})
|
|
.unwrap_or(0);
|
|
self.serial_port = self.available_serial_ports[next_index].clone();
|
|
}
|
|
|
|
fn focused_text_field_mut(&mut self) -> Option<&mut String> {
|
|
match self.focused_field() {
|
|
ConfigField::TcpAddr => Some(&mut self.tcp_addr),
|
|
ConfigField::UdpBind => Some(&mut self.udp_bind_addr),
|
|
ConfigField::UdpRemote => Some(&mut self.udp_remote_addr),
|
|
ConfigField::SerialPort => Some(&mut self.serial_port),
|
|
ConfigField::SerialBaud => Some(&mut self.serial_baud_rate),
|
|
ConfigField::PipelineName => Some(&mut self.pipeline_name),
|
|
ConfigField::HistoryLimit => Some(&mut self.history_limit),
|
|
ConfigField::PlotFrameLen => Some(&mut self.plot_frame_len),
|
|
ConfigField::PlotChannels => Some(&mut self.plot_channels),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn accept_char(&mut self, c: char) {
|
|
let numeric = matches!(
|
|
self.focused_field(),
|
|
ConfigField::SerialBaud
|
|
| ConfigField::HistoryLimit
|
|
| ConfigField::PlotFrameLen
|
|
| ConfigField::PlotChannels
|
|
);
|
|
if let Some(field) = self.focused_text_field_mut() {
|
|
if numeric && !c.is_ascii_digit() {
|
|
return;
|
|
}
|
|
field.push(c);
|
|
} else if c == ' ' {
|
|
self.cycle_current_option(true);
|
|
}
|
|
}
|
|
|
|
fn backspace(&mut self) {
|
|
if let Some(field) = self.focused_text_field_mut() {
|
|
field.pop();
|
|
}
|
|
}
|
|
|
|
fn to_session_config(&self, dtr: bool, rts: bool) -> Result<SessionConfig, String> {
|
|
let history_limit = parse_usize(&self.history_limit, "history limit")?.max(1);
|
|
let pipeline_name = self.pipeline_name.trim();
|
|
if pipeline_name.is_empty() {
|
|
return Err(String::from("pipeline name cannot be empty"));
|
|
}
|
|
|
|
let transport = match self.transport {
|
|
TransportChoice::Tcp => {
|
|
let addr = self.tcp_addr.trim();
|
|
if addr.is_empty() {
|
|
return Err(String::from("tcp addr cannot be empty"));
|
|
}
|
|
TransportConfig::Tcp {
|
|
addr: addr.to_string(),
|
|
}
|
|
}
|
|
TransportChoice::Udp => {
|
|
let bind_addr = self.udp_bind_addr.trim();
|
|
if bind_addr.is_empty() {
|
|
return Err(String::from("udp bind addr cannot be empty"));
|
|
}
|
|
let remote_addr = if self.udp_remote_addr.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(self.udp_remote_addr.trim().to_string())
|
|
};
|
|
TransportConfig::Udp {
|
|
bind_addr: bind_addr.to_string(),
|
|
remote_addr,
|
|
}
|
|
}
|
|
TransportChoice::Serial => {
|
|
let port = self.serial_port.trim();
|
|
if port.is_empty() {
|
|
return Err(String::from("serial port cannot be empty"));
|
|
}
|
|
TransportConfig::Serial {
|
|
port: port.to_string(),
|
|
baud_rate: parse_u32(&self.serial_baud_rate, "serial baud rate")?,
|
|
data_bits: self.serial_data_bits,
|
|
parity: self.serial_parity,
|
|
stop_bits: self.serial_stop_bits,
|
|
flow_control: self.serial_flow_control,
|
|
dtr,
|
|
rts,
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(SessionConfig {
|
|
transport,
|
|
pipelines: build_pipelines(
|
|
self.pipeline_preset,
|
|
pipeline_name,
|
|
parse_usize(&self.plot_frame_len, "plot frame length")?,
|
|
parse_usize(&self.plot_channels, "plot channels")?.max(1),
|
|
),
|
|
history_limit,
|
|
auto_reconnect: self.auto_reconnect,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ConfigField {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Transport => "Transport",
|
|
Self::TcpAddr => "TCP address",
|
|
Self::UdpBind => "UDP bind",
|
|
Self::UdpRemote => "UDP remote",
|
|
Self::SerialPort => "Serial port",
|
|
Self::SerialBaud => "Baud rate",
|
|
Self::SerialDataBits => "Data bits",
|
|
Self::SerialParity => "Parity",
|
|
Self::SerialStopBits => "Stop bits",
|
|
Self::SerialFlowControl => "Flow control",
|
|
Self::PipelinePreset => "Pipeline",
|
|
Self::PipelineName => "Pipeline name",
|
|
Self::HistoryLimit => "History",
|
|
Self::AutoReconnect => "Auto reconnect",
|
|
Self::PlotFrameLen => "Plot frame bytes",
|
|
Self::PlotChannels => "Plot channels",
|
|
Self::Apply => "Commit",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct PlotViewState {
|
|
pub follow_latest: bool,
|
|
pub x_window: f64,
|
|
}
|
|
|
|
impl Default for PlotViewState {
|
|
fn default() -> Self {
|
|
Self {
|
|
follow_latest: true,
|
|
x_window: 500.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum HotAction {
|
|
Focus(FocusPane),
|
|
View(View),
|
|
ToggleConnection,
|
|
Reconnect,
|
|
Clear,
|
|
OpenConfig,
|
|
OpenSearch,
|
|
OpenHelp,
|
|
Send,
|
|
ToggleAutoReconnect,
|
|
ToggleDtr,
|
|
ToggleRts,
|
|
ToggleTimestamp,
|
|
ToggleDirection,
|
|
TogglePipeline,
|
|
SendMode(SendMode),
|
|
LineEnding(LineEnding),
|
|
PlotFollow,
|
|
PlotZoomIn,
|
|
PlotZoomOut,
|
|
ConfigFocus(ConfigField),
|
|
ConfigSubmit,
|
|
ConfigCancel,
|
|
CloseModal,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct HotZone {
|
|
pub area: Rect,
|
|
pub action: HotAction,
|
|
}
|
|
|
|
pub struct App {
|
|
pub should_quit: bool,
|
|
pub mode: AppMode,
|
|
pub manager: SessionManager,
|
|
pub handle: SessionHandle,
|
|
pub session: SingleSession,
|
|
pub display: DisplayOptions,
|
|
pub search: SearchState,
|
|
pub config_form: ConfigForm,
|
|
pub focus: FocusPane,
|
|
pub scroll_from_bottom: usize,
|
|
pub plot: PlotViewState,
|
|
pub notice: String,
|
|
pub hot_zones: Vec<HotZone>,
|
|
session_rx: broadcast::Receiver<SessionEvent>,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new() -> Self {
|
|
let saved = app_state::load_tui_state();
|
|
let manager = SessionManager::new();
|
|
let session_rx = manager.subscribe();
|
|
let handle = manager.create(saved.session.clone());
|
|
let display = DisplayOptions {
|
|
show_timestamp: saved.show_timestamp,
|
|
show_direction: saved.show_direction,
|
|
show_pipeline: saved.show_pipeline,
|
|
};
|
|
let session =
|
|
SingleSession::new(handle.id(), saved.session.clone(), saved.active_view.into());
|
|
let config_form = ConfigForm::from_session(&saved.session);
|
|
|
|
Self {
|
|
should_quit: false,
|
|
mode: AppMode::Normal,
|
|
manager,
|
|
handle,
|
|
session,
|
|
display,
|
|
search: SearchState::default(),
|
|
config_form,
|
|
focus: FocusPane::Main,
|
|
scroll_from_bottom: 0,
|
|
plot: PlotViewState::default(),
|
|
notice: String::from("Ready"),
|
|
hot_zones: Vec::new(),
|
|
session_rx,
|
|
}
|
|
}
|
|
|
|
pub async fn run(
|
|
&mut self,
|
|
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
|
) -> io::Result<()> {
|
|
loop {
|
|
self.drain_events();
|
|
terminal.draw(|frame| crate::ui::render(frame, self))?;
|
|
|
|
if self.should_quit {
|
|
break;
|
|
}
|
|
|
|
if event::poll(TICK_RATE)? {
|
|
match event::read()? {
|
|
Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key(key),
|
|
Event::Mouse(mouse) => self.on_mouse(mouse),
|
|
Event::Resize(_, _) => {}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn shutdown(&mut self) {
|
|
let _ = self.handle.close().await;
|
|
self.manager.remove(self.session.id);
|
|
}
|
|
|
|
pub fn reset_hot_zones(&mut self) {
|
|
self.hot_zones.clear();
|
|
}
|
|
|
|
pub fn add_hot_zone(&mut self, area: Rect, action: HotAction) {
|
|
if area.width > 0 && area.height > 0 {
|
|
self.hot_zones.push(HotZone { area, action });
|
|
}
|
|
}
|
|
|
|
pub fn active_line_count(&self) -> usize {
|
|
match self.session.view {
|
|
View::Text => self.session.console.len(),
|
|
View::Hex => self.session.hex.len(),
|
|
View::Plot => 0,
|
|
}
|
|
}
|
|
|
|
pub fn visible_start(&self, total: usize, viewport_height: usize) -> usize {
|
|
if viewport_height == 0 || total <= viewport_height {
|
|
return 0;
|
|
}
|
|
total
|
|
.saturating_sub(viewport_height)
|
|
.saturating_sub(self.scroll_from_bottom)
|
|
}
|
|
|
|
pub fn persist_state(&self) {
|
|
app_state::save_tui_state(&self.session.config, self.session.view, self.display);
|
|
}
|
|
|
|
pub fn transport_summary(&self) -> String {
|
|
transport_summary(&self.session.config.transport)
|
|
}
|
|
|
|
pub fn pipeline_summary(&self) -> String {
|
|
let names = self
|
|
.session
|
|
.config
|
|
.pipelines
|
|
.iter()
|
|
.map(|pipeline| pipeline.name.as_str())
|
|
.collect::<Vec<_>>();
|
|
if names.is_empty() {
|
|
String::from("no pipelines")
|
|
} else {
|
|
names.join(", ")
|
|
}
|
|
}
|
|
|
|
pub fn set_notice(&mut self, message: impl Into<String>) {
|
|
self.notice = message.into();
|
|
}
|
|
|
|
pub fn plot_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
|
let (min, max) = self.session.plot.bounds()?;
|
|
if self.plot.follow_latest && max[0].is_finite() && self.plot.x_window > 0.0 {
|
|
let min_x = (max[0] - self.plot.x_window).max(min[0]);
|
|
Some(([min_x, min[1]], max))
|
|
} else {
|
|
Some((min, max))
|
|
}
|
|
}
|
|
|
|
fn drain_events(&mut self) {
|
|
while let Ok(event) = self.session_rx.try_recv() {
|
|
match event {
|
|
SessionEvent::Connected(id) if id == self.session.id => {
|
|
self.session.status = ConnectionStatus::Connected;
|
|
self.set_notice("Connected");
|
|
}
|
|
SessionEvent::Disconnected(id) | SessionEvent::Closed(id)
|
|
if id == self.session.id =>
|
|
{
|
|
self.session.status = ConnectionStatus::Disconnected;
|
|
self.set_notice("Disconnected");
|
|
}
|
|
SessionEvent::Error(id, message) if id == self.session.id => {
|
|
self.session.status = ConnectionStatus::Error(message.clone());
|
|
self.set_notice(message);
|
|
}
|
|
SessionEvent::Data(id, entry) if id == self.session.id => {
|
|
self.session.received_messages += 1;
|
|
match &entry.data {
|
|
DecodedData::Text(_) => self.session.console.push(&entry),
|
|
DecodedData::Hex(_) => self.session.hex.push(&entry),
|
|
DecodedData::Plot(_) => self.session.plot.push(&entry),
|
|
DecodedData::Binary(bytes) => {
|
|
self.session.send_status =
|
|
format!("received binary frame: {} bytes", bytes.len());
|
|
}
|
|
}
|
|
if self.search.active && !self.search.query.is_empty() {
|
|
self.rebuild_search();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_key(&mut self, key: KeyEvent) {
|
|
match self.mode {
|
|
AppMode::Normal => self.on_normal_key(key),
|
|
AppMode::EditingSend => self.on_send_key(key),
|
|
AppMode::Search => self.on_search_key(key),
|
|
AppMode::Config => self.on_config_key(key),
|
|
AppMode::Help => {
|
|
if matches!(key.code, KeyCode::Esc | KeyCode::Char('?') | KeyCode::Enter) {
|
|
self.mode = AppMode::Normal;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_normal_key(&mut self, key: KeyEvent) {
|
|
if key.modifiers == KeyModifiers::CONTROL {
|
|
match key.code {
|
|
KeyCode::Char('s') => self.send_active_input(),
|
|
KeyCode::Char('l') => self.clear_buffers(),
|
|
KeyCode::Char('f') => self.open_search(),
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Char('q') => self.should_quit = true,
|
|
KeyCode::Char('?') => self.mode = AppMode::Help,
|
|
KeyCode::Esc => {
|
|
self.search.active = false;
|
|
self.set_notice("Search cleared");
|
|
}
|
|
KeyCode::Tab => self.focus = self.focus.next(),
|
|
KeyCode::BackTab => self.focus = self.focus.prev(),
|
|
KeyCode::Char('1') => self.set_view(View::Text),
|
|
KeyCode::Char('2') => self.set_view(View::Hex),
|
|
KeyCode::Char('3') => self.set_view(View::Plot),
|
|
KeyCode::Char('c') => self.toggle_connection(),
|
|
KeyCode::Char('r') => self.reconnect(),
|
|
KeyCode::Char('e') => self.open_config(),
|
|
KeyCode::Char('/') => self.open_search(),
|
|
KeyCode::Char('x') => self.clear_buffers(),
|
|
KeyCode::Char('m') => self.toggle_send_mode(),
|
|
KeyCode::Char('l') => self.cycle_line_ending(),
|
|
KeyCode::Char('a') => self.toggle_auto_reconnect(),
|
|
KeyCode::Char('d') => self.toggle_dtr(),
|
|
KeyCode::Char('s') => self.toggle_rts(),
|
|
KeyCode::Char('t') => self.toggle_timestamp(),
|
|
KeyCode::Char('o') => self.toggle_direction(),
|
|
KeyCode::Char('p') => self.toggle_pipeline_display(),
|
|
KeyCode::Char('f') if self.session.view == View::Plot => self.toggle_plot_follow(),
|
|
KeyCode::Char('+') | KeyCode::Char('=') if self.session.view == View::Plot => {
|
|
self.zoom_plot(true);
|
|
}
|
|
KeyCode::Char('-') if self.session.view == View::Plot => self.zoom_plot(false),
|
|
KeyCode::Char('n') if self.search.active => self.next_match(),
|
|
KeyCode::Char('N') if self.search.active => self.prev_match(),
|
|
KeyCode::Up | KeyCode::Char('k') => self.scroll_up(1),
|
|
KeyCode::Down | KeyCode::Char('j') => self.scroll_down(1),
|
|
KeyCode::PageUp => self.scroll_up(10),
|
|
KeyCode::PageDown => self.scroll_down(10),
|
|
KeyCode::Home => self.scroll_to_top(),
|
|
KeyCode::End => self.scroll_to_bottom(),
|
|
KeyCode::Enter if self.focus == FocusPane::Composer => self.mode = AppMode::EditingSend,
|
|
KeyCode::Char(c) if self.focus == FocusPane::Composer && key.modifiers.is_empty() => {
|
|
self.mode = AppMode::EditingSend;
|
|
self.insert_send_char(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn on_send_key(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc => self.mode = AppMode::Normal,
|
|
KeyCode::Enter if key.modifiers == KeyModifiers::CONTROL => self.insert_send_char('\n'),
|
|
KeyCode::Enter => {
|
|
self.send_active_input();
|
|
self.mode = AppMode::Normal;
|
|
}
|
|
KeyCode::Backspace => self.backspace_send_input(),
|
|
KeyCode::Delete => self.delete_send_input(),
|
|
KeyCode::Left => self.move_send_cursor_left(),
|
|
KeyCode::Right => self.move_send_cursor_right(),
|
|
KeyCode::Home => self.session.input_cursor = 0,
|
|
KeyCode::End => self.session.input_cursor = self.send_input_char_len(),
|
|
KeyCode::Tab => self.insert_send_char('\t'),
|
|
KeyCode::Char(c)
|
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
|
{
|
|
self.insert_send_char(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn on_search_key(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc => self.mode = AppMode::Normal,
|
|
KeyCode::Enter => {
|
|
self.search.active = !self.search.query.is_empty();
|
|
self.mode = AppMode::Normal;
|
|
self.focus_current_match();
|
|
}
|
|
KeyCode::Backspace => {
|
|
self.search.query.pop();
|
|
self.rebuild_search();
|
|
}
|
|
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => {
|
|
self.search.case_sensitive = !self.search.case_sensitive;
|
|
self.rebuild_search();
|
|
}
|
|
KeyCode::Char(c)
|
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
|
{
|
|
self.search.query.push(c);
|
|
self.search.active = true;
|
|
self.rebuild_search();
|
|
}
|
|
KeyCode::Down => self.next_match(),
|
|
KeyCode::Up => self.prev_match(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn on_config_key(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Esc => self.mode = AppMode::Normal,
|
|
KeyCode::Tab | KeyCode::Down => self.config_form.next_field(),
|
|
KeyCode::BackTab | KeyCode::Up => self.config_form.prev_field(),
|
|
KeyCode::Left => self.config_form.cycle_current_option(false),
|
|
KeyCode::Right => self.config_form.cycle_current_option(true),
|
|
KeyCode::Backspace => self.config_form.backspace(),
|
|
KeyCode::Char(c)
|
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
|
{
|
|
self.config_form.accept_char(c);
|
|
}
|
|
KeyCode::Enter => self.apply_config(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn on_mouse(&mut self, mouse: MouseEvent) {
|
|
match mouse.kind {
|
|
MouseEventKind::ScrollUp => {
|
|
if self.session.view == View::Plot {
|
|
self.zoom_plot(true);
|
|
} else {
|
|
self.scroll_up(3);
|
|
}
|
|
}
|
|
MouseEventKind::ScrollDown => {
|
|
if self.session.view == View::Plot {
|
|
self.zoom_plot(false);
|
|
} else {
|
|
self.scroll_down(3);
|
|
}
|
|
}
|
|
MouseEventKind::Down(MouseButton::Left) => {
|
|
let action = self
|
|
.hot_zones
|
|
.iter()
|
|
.rev()
|
|
.find(|zone| point_in_rect(mouse.column, mouse.row, zone.area))
|
|
.map(|zone| zone.action);
|
|
if let Some(action) = action {
|
|
self.execute_hot_action(action);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn execute_hot_action(&mut self, action: HotAction) {
|
|
match action {
|
|
HotAction::Focus(focus) => self.focus = focus,
|
|
HotAction::View(view) => self.set_view(view),
|
|
HotAction::ToggleConnection => self.toggle_connection(),
|
|
HotAction::Reconnect => self.reconnect(),
|
|
HotAction::Clear => self.clear_buffers(),
|
|
HotAction::OpenConfig => self.open_config(),
|
|
HotAction::OpenSearch => self.open_search(),
|
|
HotAction::OpenHelp => self.mode = AppMode::Help,
|
|
HotAction::Send => self.send_active_input(),
|
|
HotAction::ToggleAutoReconnect => self.toggle_auto_reconnect(),
|
|
HotAction::ToggleDtr => self.toggle_dtr(),
|
|
HotAction::ToggleRts => self.toggle_rts(),
|
|
HotAction::ToggleTimestamp => self.toggle_timestamp(),
|
|
HotAction::ToggleDirection => self.toggle_direction(),
|
|
HotAction::TogglePipeline => self.toggle_pipeline_display(),
|
|
HotAction::SendMode(mode) => self.session.send_mode = mode,
|
|
HotAction::LineEnding(ending) => self.session.line_ending = ending,
|
|
HotAction::PlotFollow => self.toggle_plot_follow(),
|
|
HotAction::PlotZoomIn => self.zoom_plot(true),
|
|
HotAction::PlotZoomOut => self.zoom_plot(false),
|
|
HotAction::ConfigFocus(field) => {
|
|
self.mode = AppMode::Config;
|
|
self.config_form.focus_field(field);
|
|
}
|
|
HotAction::ConfigSubmit => self.apply_config(),
|
|
HotAction::ConfigCancel | HotAction::CloseModal => self.mode = AppMode::Normal,
|
|
}
|
|
}
|
|
|
|
fn set_view(&mut self, view: View) {
|
|
self.session.view = view;
|
|
self.scroll_from_bottom = 0;
|
|
self.rebuild_search();
|
|
self.persist_state();
|
|
}
|
|
|
|
fn toggle_connection(&mut self) {
|
|
let handle = self.handle.clone();
|
|
if self.session.status.is_connectedish() {
|
|
self.session.status = ConnectionStatus::Disconnected;
|
|
self.set_notice("Disconnect requested");
|
|
tokio::spawn(async move {
|
|
let _ = handle.disconnect().await;
|
|
});
|
|
} else {
|
|
self.session.status = ConnectionStatus::Connecting;
|
|
self.set_notice("Connect requested");
|
|
tokio::spawn(async move {
|
|
let _ = handle.connect().await;
|
|
});
|
|
}
|
|
}
|
|
|
|
fn reconnect(&mut self) {
|
|
self.session.status = ConnectionStatus::Connecting;
|
|
self.set_notice("Reconnect requested");
|
|
let handle = self.handle.clone();
|
|
tokio::spawn(async move {
|
|
let _ = handle.reconnect().await;
|
|
});
|
|
}
|
|
|
|
fn clear_buffers(&mut self) {
|
|
self.session.console.clear();
|
|
self.session.hex.clear();
|
|
self.session.plot.clear();
|
|
self.scroll_from_bottom = 0;
|
|
self.search.matches.clear();
|
|
self.session.send_status = String::from("cleared");
|
|
self.set_notice("Buffers cleared");
|
|
}
|
|
|
|
fn open_config(&mut self) {
|
|
self.config_form = ConfigForm::from_session(&self.session.config);
|
|
self.mode = AppMode::Config;
|
|
}
|
|
|
|
fn open_search(&mut self) {
|
|
self.mode = AppMode::Search;
|
|
self.search.active = true;
|
|
self.rebuild_search();
|
|
}
|
|
|
|
fn toggle_send_mode(&mut self) {
|
|
self.session.send_mode = match self.session.send_mode {
|
|
SendMode::Text => SendMode::Hex,
|
|
SendMode::Hex => SendMode::Text,
|
|
};
|
|
}
|
|
|
|
fn cycle_line_ending(&mut self) {
|
|
self.session.line_ending = self.session.line_ending.next();
|
|
}
|
|
|
|
fn toggle_auto_reconnect(&mut self) {
|
|
self.session.auto_reconnect = !self.session.auto_reconnect;
|
|
self.session.config.auto_reconnect = self.session.auto_reconnect;
|
|
let enabled = self.session.auto_reconnect;
|
|
self.persist_state();
|
|
|
|
let handle = self.handle.clone();
|
|
tokio::spawn(async move {
|
|
let _ = handle.set_auto_reconnect(enabled).await;
|
|
});
|
|
}
|
|
|
|
fn toggle_dtr(&mut self) {
|
|
if !matches!(
|
|
self.session.config.transport,
|
|
TransportConfig::Serial { .. }
|
|
) {
|
|
self.set_notice("DTR is only available for serial sessions");
|
|
return;
|
|
}
|
|
|
|
self.session.dtr = !self.session.dtr;
|
|
if let TransportConfig::Serial { dtr, .. } = &mut self.session.config.transport {
|
|
*dtr = self.session.dtr;
|
|
}
|
|
self.persist_state();
|
|
|
|
let handle = self.handle.clone();
|
|
let state = self.session.dtr;
|
|
tokio::spawn(async move {
|
|
let _ = handle.set_dtr(state).await;
|
|
});
|
|
}
|
|
|
|
fn toggle_rts(&mut self) {
|
|
if !matches!(
|
|
self.session.config.transport,
|
|
TransportConfig::Serial { .. }
|
|
) {
|
|
self.set_notice("RTS is only available for serial sessions");
|
|
return;
|
|
}
|
|
|
|
self.session.rts = !self.session.rts;
|
|
if let TransportConfig::Serial { rts, .. } = &mut self.session.config.transport {
|
|
*rts = self.session.rts;
|
|
}
|
|
self.persist_state();
|
|
|
|
let handle = self.handle.clone();
|
|
let state = self.session.rts;
|
|
tokio::spawn(async move {
|
|
let _ = handle.set_rts(state).await;
|
|
});
|
|
}
|
|
|
|
fn toggle_timestamp(&mut self) {
|
|
self.display.show_timestamp = !self.display.show_timestamp;
|
|
self.persist_state();
|
|
}
|
|
|
|
fn toggle_direction(&mut self) {
|
|
self.display.show_direction = !self.display.show_direction;
|
|
self.persist_state();
|
|
}
|
|
|
|
fn toggle_pipeline_display(&mut self) {
|
|
self.display.show_pipeline = !self.display.show_pipeline;
|
|
self.persist_state();
|
|
}
|
|
|
|
fn toggle_plot_follow(&mut self) {
|
|
self.plot.follow_latest = !self.plot.follow_latest;
|
|
}
|
|
|
|
fn zoom_plot(&mut self, zoom_in: bool) {
|
|
self.plot.x_window = if zoom_in {
|
|
(self.plot.x_window * 0.75).max(20.0)
|
|
} else {
|
|
(self.plot.x_window * 1.35).min(100_000.0)
|
|
};
|
|
}
|
|
|
|
fn apply_config(&mut self) {
|
|
let config = match self
|
|
.config_form
|
|
.to_session_config(self.session.dtr, self.session.rts)
|
|
{
|
|
Ok(config) => config,
|
|
Err(message) => {
|
|
self.set_notice(message);
|
|
return;
|
|
}
|
|
};
|
|
|
|
self.session.config = config.clone();
|
|
self.session.auto_reconnect = config.auto_reconnect;
|
|
self.session.console.set_limit(config.history_limit);
|
|
self.session.hex.set_limit(config.history_limit);
|
|
self.session.plot.set_limit(config.history_limit);
|
|
self.session.status = ConnectionStatus::Connecting;
|
|
self.session.send_status = String::from("session reconfigured");
|
|
self.mode = AppMode::Normal;
|
|
self.persist_state();
|
|
|
|
let handle = self.handle.clone();
|
|
tokio::spawn(async move {
|
|
let _ = handle.reconfigure(config).await;
|
|
});
|
|
}
|
|
|
|
fn send_active_input(&mut self) {
|
|
let payload = match self.build_payload() {
|
|
Ok(Some(payload)) => payload,
|
|
Ok(None) => {
|
|
self.session.send_status = String::from("nothing to send");
|
|
return;
|
|
}
|
|
Err(message) => {
|
|
self.session.send_status = format!("send failed: {message}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
match self.session.send_mode {
|
|
SendMode::Text => {
|
|
let text = self.session.send_input.clone();
|
|
if !text.is_empty() {
|
|
self.session.console.push_outbound(text);
|
|
}
|
|
}
|
|
SendMode::Hex => {
|
|
let compact = self
|
|
.session
|
|
.send_input
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
if !compact.is_empty() {
|
|
self.session.hex.push_outbound(compact);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.session.sent_messages += 1;
|
|
self.session.send_input.clear();
|
|
self.session.input_cursor = 0;
|
|
self.session.send_status = String::from("sent");
|
|
self.scroll_from_bottom = 0;
|
|
|
|
let handle = self.handle.clone();
|
|
tokio::spawn(async move {
|
|
let _ = handle.send(payload).await;
|
|
});
|
|
}
|
|
|
|
fn build_payload(&self) -> Result<Option<Vec<u8>>, String> {
|
|
if self.session.send_input.trim().is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
match self.session.send_mode {
|
|
SendMode::Text => {
|
|
let mut payload = self.session.send_input.clone().into_bytes();
|
|
payload.extend_from_slice(self.session.line_ending.bytes());
|
|
Ok(Some(payload))
|
|
}
|
|
SendMode::Hex => {
|
|
let compact: String = self
|
|
.session
|
|
.send_input
|
|
.chars()
|
|
.filter(|ch| !ch.is_ascii_whitespace())
|
|
.collect();
|
|
if !compact.len().is_multiple_of(2) {
|
|
return Err(String::from("hex input needs an even number of digits"));
|
|
}
|
|
hex::decode(compact)
|
|
.map(Some)
|
|
.map_err(|err| format!("invalid hex input ({err})"))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rebuild_search(&mut self) {
|
|
if self.search.query.is_empty() {
|
|
self.search.matches.clear();
|
|
self.search.current_match = 0;
|
|
return;
|
|
}
|
|
|
|
let query = if self.search.case_sensitive {
|
|
self.search.query.clone()
|
|
} else {
|
|
self.search.query.to_lowercase()
|
|
};
|
|
|
|
let total = self.active_line_count();
|
|
self.search.matches = (0..total)
|
|
.filter(|index| {
|
|
let line = match self.session.view {
|
|
View::Text => self
|
|
.session
|
|
.console
|
|
.get(*index)
|
|
.map(|line| ansi_visible_text(&line.text))
|
|
.unwrap_or_default(),
|
|
View::Hex => self
|
|
.session
|
|
.hex
|
|
.get(*index)
|
|
.map(|line| format!("{} {}", line.hex, line.ascii))
|
|
.unwrap_or_default(),
|
|
View::Plot => String::new(),
|
|
};
|
|
|
|
if self.search.case_sensitive {
|
|
line.contains(&query)
|
|
} else {
|
|
line.to_lowercase().contains(&query)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if self.search.current_match >= self.search.matches.len() {
|
|
self.search.current_match = self.search.matches.len().saturating_sub(1);
|
|
}
|
|
}
|
|
|
|
fn next_match(&mut self) {
|
|
if self.search.matches.is_empty() {
|
|
return;
|
|
}
|
|
self.search.current_match = (self.search.current_match + 1) % self.search.matches.len();
|
|
self.focus_current_match();
|
|
}
|
|
|
|
fn prev_match(&mut self) {
|
|
if self.search.matches.is_empty() {
|
|
return;
|
|
}
|
|
self.search.current_match = if self.search.current_match == 0 {
|
|
self.search.matches.len() - 1
|
|
} else {
|
|
self.search.current_match - 1
|
|
};
|
|
self.focus_current_match();
|
|
}
|
|
|
|
fn focus_current_match(&mut self) {
|
|
let Some(index) = self.search.current_line() else {
|
|
return;
|
|
};
|
|
let total = self.active_line_count();
|
|
self.scroll_from_bottom = total.saturating_sub(index + 1);
|
|
}
|
|
|
|
fn scroll_up(&mut self, lines: usize) {
|
|
let total = self.active_line_count();
|
|
self.scroll_from_bottom = (self.scroll_from_bottom + lines).min(total.saturating_sub(1));
|
|
}
|
|
|
|
fn scroll_down(&mut self, lines: usize) {
|
|
self.scroll_from_bottom = self.scroll_from_bottom.saturating_sub(lines);
|
|
}
|
|
|
|
fn scroll_to_top(&mut self) {
|
|
self.scroll_from_bottom = self.active_line_count().saturating_sub(1);
|
|
}
|
|
|
|
fn scroll_to_bottom(&mut self) {
|
|
self.scroll_from_bottom = 0;
|
|
}
|
|
|
|
fn insert_send_char(&mut self, c: char) {
|
|
let byte_index = byte_index_for_char(&self.session.send_input, self.session.input_cursor);
|
|
self.session.send_input.insert(byte_index, c);
|
|
self.session.input_cursor += 1;
|
|
}
|
|
|
|
fn backspace_send_input(&mut self) {
|
|
if self.session.input_cursor == 0 {
|
|
return;
|
|
}
|
|
let remove_at =
|
|
byte_index_for_char(&self.session.send_input, self.session.input_cursor - 1);
|
|
self.session.send_input.remove(remove_at);
|
|
self.session.input_cursor -= 1;
|
|
}
|
|
|
|
fn delete_send_input(&mut self) {
|
|
if self.session.input_cursor >= self.send_input_char_len() {
|
|
return;
|
|
}
|
|
let remove_at = byte_index_for_char(&self.session.send_input, self.session.input_cursor);
|
|
self.session.send_input.remove(remove_at);
|
|
}
|
|
|
|
fn move_send_cursor_left(&mut self) {
|
|
self.session.input_cursor = self.session.input_cursor.saturating_sub(1);
|
|
}
|
|
|
|
fn move_send_cursor_right(&mut self) {
|
|
self.session.input_cursor = (self.session.input_cursor + 1).min(self.send_input_char_len());
|
|
}
|
|
|
|
fn send_input_char_len(&self) -> usize {
|
|
self.session.send_input.chars().count()
|
|
}
|
|
}
|
|
|
|
pub fn default_session_config() -> SessionConfig {
|
|
SessionConfig {
|
|
transport: TransportConfig::Tcp {
|
|
addr: String::from("127.0.0.1:8080"),
|
|
},
|
|
pipelines: build_pipelines(PipelinePreset::TextHexLines, "text", 4, 1),
|
|
history_limit: DEFAULT_HISTORY_LIMIT,
|
|
auto_reconnect: false,
|
|
}
|
|
}
|
|
|
|
fn build_pipelines(
|
|
preset: PipelinePreset,
|
|
pipeline_name: &str,
|
|
plot_frame_len: usize,
|
|
plot_channels: usize,
|
|
) -> Vec<PipelineConfig> {
|
|
match preset {
|
|
PipelinePreset::TextHexLines => vec![
|
|
PipelineConfig {
|
|
name: String::from("text"),
|
|
framer: FramerConfig::Line {
|
|
strip_cr: true,
|
|
max_line_len: 1_048_576,
|
|
},
|
|
decoder: DecoderConfig::Text {
|
|
encoding: TextEncoding::Utf8,
|
|
},
|
|
},
|
|
PipelineConfig {
|
|
name: String::from("hex"),
|
|
framer: FramerConfig::Line {
|
|
strip_cr: true,
|
|
max_line_len: 1_048_576,
|
|
},
|
|
decoder: DecoderConfig::Hex {
|
|
uppercase: false,
|
|
separator: String::from(" "),
|
|
bytes_per_group: 1,
|
|
endian: Endian::Big,
|
|
},
|
|
},
|
|
],
|
|
PipelinePreset::TextLines => vec![PipelineConfig {
|
|
name: pipeline_name.to_string(),
|
|
framer: FramerConfig::Line {
|
|
strip_cr: true,
|
|
max_line_len: 1_048_576,
|
|
},
|
|
decoder: DecoderConfig::Text {
|
|
encoding: TextEncoding::Utf8,
|
|
},
|
|
}],
|
|
PipelinePreset::HexLines => vec![PipelineConfig {
|
|
name: pipeline_name.to_string(),
|
|
framer: FramerConfig::Line {
|
|
strip_cr: true,
|
|
max_line_len: 1_048_576,
|
|
},
|
|
decoder: DecoderConfig::Hex {
|
|
uppercase: false,
|
|
separator: String::from(" "),
|
|
bytes_per_group: 1,
|
|
endian: Endian::Big,
|
|
},
|
|
}],
|
|
PipelinePreset::MixedTextPlot => vec![PipelineConfig {
|
|
name: pipeline_name.to_string(),
|
|
framer: FramerConfig::MixedTextPlot {
|
|
strip_cr: true,
|
|
max_line_len: 1_048_576,
|
|
max_plot_frame: 1_048_576,
|
|
},
|
|
decoder: DecoderConfig::MixedTextPlot {
|
|
encoding: TextEncoding::Utf8,
|
|
},
|
|
}],
|
|
PipelinePreset::PlotF32Fixed => vec![PipelineConfig {
|
|
name: pipeline_name.to_string(),
|
|
framer: FramerConfig::Fixed {
|
|
frame_len: plot_frame_len.max(4),
|
|
},
|
|
decoder: DecoderConfig::Plot {
|
|
sample_type: SampleType::F32,
|
|
endian: Endian::Little,
|
|
channels: plot_channels.max(1),
|
|
format: PlotFormat::Interleaved,
|
|
},
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn infer_pipeline_preset(config: &SessionConfig) -> PipelinePreset {
|
|
let has_text = config
|
|
.pipelines
|
|
.iter()
|
|
.any(|pipeline| matches!(pipeline.decoder, DecoderConfig::Text { .. }));
|
|
let has_hex = config
|
|
.pipelines
|
|
.iter()
|
|
.any(|pipeline| matches!(pipeline.decoder, DecoderConfig::Hex { .. }));
|
|
if has_text && has_hex {
|
|
return PipelinePreset::TextHexLines;
|
|
}
|
|
|
|
config
|
|
.pipelines
|
|
.first()
|
|
.map(|pipeline| match pipeline.decoder {
|
|
DecoderConfig::Hex { .. } => PipelinePreset::HexLines,
|
|
DecoderConfig::Plot { .. } => PipelinePreset::PlotF32Fixed,
|
|
DecoderConfig::MixedTextPlot { .. } => PipelinePreset::MixedTextPlot,
|
|
DecoderConfig::Text { .. } | DecoderConfig::Lua { .. } => PipelinePreset::TextLines,
|
|
})
|
|
.unwrap_or(PipelinePreset::TextHexLines)
|
|
}
|
|
|
|
fn transport_summary(config: &TransportConfig) -> String {
|
|
match config {
|
|
TransportConfig::Serial {
|
|
port, baud_rate, ..
|
|
} => format!("Serial {port} @ {baud_rate}"),
|
|
TransportConfig::Tcp { addr } => format!("TCP {addr}"),
|
|
TransportConfig::Udp {
|
|
bind_addr,
|
|
remote_addr,
|
|
} => match remote_addr {
|
|
Some(remote) => format!("UDP {bind_addr} -> {remote}"),
|
|
None => format!("UDP {bind_addr}"),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn available_serial_ports() -> Vec<String> {
|
|
let mut ports: Vec<_> = SerialTransport::list_ports()
|
|
.into_iter()
|
|
.map(|port| port.port_name)
|
|
.collect();
|
|
ports.sort();
|
|
ports.dedup();
|
|
ports
|
|
}
|
|
|
|
fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool {
|
|
x >= rect.x
|
|
&& x < rect.x.saturating_add(rect.width)
|
|
&& y >= rect.y
|
|
&& y < rect.y.saturating_add(rect.height)
|
|
}
|
|
|
|
fn byte_index_for_char(text: &str, char_index: usize) -> usize {
|
|
text.char_indices()
|
|
.nth(char_index)
|
|
.map(|(index, _)| index)
|
|
.unwrap_or(text.len())
|
|
}
|
|
|
|
fn parse_usize(text: &str, label: &str) -> Result<usize, String> {
|
|
text.trim()
|
|
.parse::<usize>()
|
|
.map_err(|_| format!("{label} must be a positive integer"))
|
|
}
|
|
|
|
fn parse_u32(text: &str, label: &str) -> Result<u32, String> {
|
|
text.trim()
|
|
.parse::<u32>()
|
|
.map_err(|_| format!("{label} must be a positive integer"))
|
|
}
|
|
|
|
fn bool_label(value: bool) -> &'static str {
|
|
if value { "on" } else { "off" }
|
|
}
|
|
|
|
fn serial_data_bits_name(value: SerialDataBits) -> &'static str {
|
|
match value {
|
|
SerialDataBits::Five => "5",
|
|
SerialDataBits::Six => "6",
|
|
SerialDataBits::Seven => "7",
|
|
SerialDataBits::Eight => "8",
|
|
}
|
|
}
|
|
|
|
fn serial_parity_name(value: SerialParity) -> &'static str {
|
|
match value {
|
|
SerialParity::None => "None",
|
|
SerialParity::Odd => "Odd",
|
|
SerialParity::Even => "Even",
|
|
}
|
|
}
|
|
|
|
fn serial_stop_bits_name(value: SerialStopBits) -> &'static str {
|
|
match value {
|
|
SerialStopBits::One => "1",
|
|
SerialStopBits::Two => "2",
|
|
}
|
|
}
|
|
|
|
fn serial_flow_control_name(value: SerialFlowControl) -> &'static str {
|
|
match value {
|
|
SerialFlowControl::None => "None",
|
|
SerialFlowControl::Software => "Software",
|
|
SerialFlowControl::Hardware => "Hardware",
|
|
}
|
|
}
|
|
|
|
fn next_serial_data_bits(value: SerialDataBits, forward: bool) -> SerialDataBits {
|
|
match (value, forward) {
|
|
(SerialDataBits::Five, true) => SerialDataBits::Six,
|
|
(SerialDataBits::Six, true) => SerialDataBits::Seven,
|
|
(SerialDataBits::Seven, true) => SerialDataBits::Eight,
|
|
(SerialDataBits::Eight, true) => SerialDataBits::Five,
|
|
(SerialDataBits::Five, false) => SerialDataBits::Eight,
|
|
(SerialDataBits::Six, false) => SerialDataBits::Five,
|
|
(SerialDataBits::Seven, false) => SerialDataBits::Six,
|
|
(SerialDataBits::Eight, false) => SerialDataBits::Seven,
|
|
}
|
|
}
|
|
|
|
fn next_serial_parity(value: SerialParity, forward: bool) -> SerialParity {
|
|
match (value, forward) {
|
|
(SerialParity::None, true) => SerialParity::Odd,
|
|
(SerialParity::Odd, true) => SerialParity::Even,
|
|
(SerialParity::Even, true) => SerialParity::None,
|
|
(SerialParity::None, false) => SerialParity::Even,
|
|
(SerialParity::Odd, false) => SerialParity::None,
|
|
(SerialParity::Even, false) => SerialParity::Odd,
|
|
}
|
|
}
|
|
|
|
fn next_serial_stop_bits(value: SerialStopBits, _forward: bool) -> SerialStopBits {
|
|
match value {
|
|
SerialStopBits::One => SerialStopBits::Two,
|
|
SerialStopBits::Two => SerialStopBits::One,
|
|
}
|
|
}
|
|
|
|
fn next_serial_flow_control(value: SerialFlowControl, forward: bool) -> SerialFlowControl {
|
|
match (value, forward) {
|
|
(SerialFlowControl::None, true) => SerialFlowControl::Software,
|
|
(SerialFlowControl::Software, true) => SerialFlowControl::Hardware,
|
|
(SerialFlowControl::Hardware, true) => SerialFlowControl::None,
|
|
(SerialFlowControl::None, false) => SerialFlowControl::Hardware,
|
|
(SerialFlowControl::Software, false) => SerialFlowControl::None,
|
|
(SerialFlowControl::Hardware, false) => SerialFlowControl::Software,
|
|
}
|
|
}
|