Compare commits
3 Commits
dc8f2eb8a4
...
9cdbd5a892
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cdbd5a892 | |||
| 8207b5e883 | |||
| 202b8261c2 |
@@ -576,6 +576,7 @@ mod tests {
|
|||||||
let handle = Session::spawn(2, tcp_config());
|
let handle = Session::spawn(2, tcp_config());
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
let _ = handle.send(b"data".to_vec()).await;
|
let _ = handle.send(b"data".to_vec()).await;
|
||||||
|
let _ = handle.close().await;
|
||||||
handle.join().await;
|
handle.join().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
|
|||||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||||
use crate::panels::{config, console, hex_view, plot_view, sidebar};
|
use crate::panels::{config, console, hex_view, plot_view, sidebar};
|
||||||
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
||||||
|
use crate::shortcuts::{self, default_bindings};
|
||||||
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
||||||
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
||||||
use xserial_client::SessionManager;
|
use xserial_client::SessionManager;
|
||||||
@@ -92,6 +93,7 @@ pub struct XserialApp {
|
|||||||
event_rx: mpsc::Receiver<SessionEvent>,
|
event_rx: mpsc::Receiver<SessionEvent>,
|
||||||
pending: Vec<SessionEvent>,
|
pending: Vec<SessionEvent>,
|
||||||
profiler: GuiProfiler,
|
profiler: GuiProfiler,
|
||||||
|
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XserialApp {
|
impl XserialApp {
|
||||||
@@ -162,11 +164,105 @@ impl XserialApp {
|
|||||||
event_rx,
|
event_rx,
|
||||||
pending: Vec::new(),
|
pending: Vec::new(),
|
||||||
profiler,
|
profiler,
|
||||||
|
shortcut_bindings: default_bindings(),
|
||||||
};
|
};
|
||||||
app.restore_saved_sessions(saved_state);
|
app.restore_saved_sessions(saved_state);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn execute(&mut self, action: shortcuts::Action) {
|
||||||
|
use shortcuts::Action::*;
|
||||||
|
|
||||||
|
match action {
|
||||||
|
NextTab => {
|
||||||
|
if self.tabs.len() > 1 {
|
||||||
|
self.active = (self.active + 1) % self.tabs.len();
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrevTab => {
|
||||||
|
if self.tabs.len() > 1 {
|
||||||
|
self.active = if self.active == 0 {
|
||||||
|
self.tabs.len() - 1
|
||||||
|
} else {
|
||||||
|
self.active - 1
|
||||||
|
};
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewText => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.view = View::Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewHex => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.view = View::Hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewPlot => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.view = View::Plot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NewSession => self.open_create_config(),
|
||||||
|
EditSession => {
|
||||||
|
if let Some(tab) = self.tabs.get(self.active) {
|
||||||
|
self.open_edit_config(tab.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeleteSession => {
|
||||||
|
if !self.tabs.is_empty() {
|
||||||
|
let id = self.tabs[self.active].id;
|
||||||
|
self.manager.remove(id);
|
||||||
|
self.tabs.remove(self.active);
|
||||||
|
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||||
|
self.active = self.tabs.len() - 1;
|
||||||
|
}
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToggleConnect => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
let connected = matches!(
|
||||||
|
tab.status,
|
||||||
|
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||||
|
);
|
||||||
|
if let Some(handle) = self.manager.get(tab.id) {
|
||||||
|
if connected {
|
||||||
|
tab.status = ConnectionStatus::Disconnected;
|
||||||
|
let handle = handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = handle.disconnect().await;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tab.status = ConnectionStatus::Connecting;
|
||||||
|
let handle = handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = handle.connect().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Clear => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.console.clear();
|
||||||
|
tab.hex.clear();
|
||||||
|
tab.plot.clear();
|
||||||
|
tab.send_status = Some(String::from("Cleared"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiSettings => self.font_settings_open = true,
|
||||||
|
CloseOverlay => {
|
||||||
|
self.config_open = false;
|
||||||
|
self.font_settings_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn open_create_config(&mut self) {
|
fn open_create_config(&mut self) {
|
||||||
self.config_target = None;
|
self.config_target = None;
|
||||||
self.config_form = config::ConfigForm::default();
|
self.config_form = config::ConfigForm::default();
|
||||||
@@ -851,6 +947,15 @@ impl eframe::App for XserialApp {
|
|||||||
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||||
|
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
{
|
||||||
|
let supress = ui.ctx().egui_wants_keyboard_input();
|
||||||
|
let actions = shortcuts::process(ui.ctx(), supress, &self.shortcut_bindings);
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
self.execute(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let frame_started = Instant::now();
|
let frame_started = Instant::now();
|
||||||
self.drain_events();
|
self.drain_events();
|
||||||
if self.wants_live_plot_repaint() {
|
if self.wants_live_plot_repaint() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod app_state;
|
|||||||
mod buffers;
|
mod buffers;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod perf;
|
mod perf;
|
||||||
|
mod shortcuts;
|
||||||
mod ui_fonts;
|
mod ui_fonts;
|
||||||
|
|
||||||
use xserial_client::SessionManager;
|
use xserial_client::SessionManager;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
||||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||||
|
use xserial_core::protocol::Endian;
|
||||||
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
||||||
use xserial_core::protocol::text::TextEncoding;
|
use xserial_core::protocol::text::TextEncoding;
|
||||||
use xserial_core::protocol::Endian;
|
use xserial_core::transport::TransportConfig;
|
||||||
use xserial_core::transport::serial::{
|
use xserial_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
||||||
};
|
};
|
||||||
use xserial_core::transport::TransportConfig;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ConfigForm {
|
pub struct ConfigForm {
|
||||||
|
|||||||
129
crates/xserial-gui/src/shortcuts.rs
Normal file
129
crates/xserial-gui/src/shortcuts.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use egui::{Context, Key, KeyboardShortcut, Modifiers};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
NextTab,
|
||||||
|
PrevTab,
|
||||||
|
|
||||||
|
ViewText,
|
||||||
|
ViewHex,
|
||||||
|
ViewPlot,
|
||||||
|
|
||||||
|
NewSession,
|
||||||
|
EditSession,
|
||||||
|
DeleteSession,
|
||||||
|
ToggleConnect,
|
||||||
|
Clear,
|
||||||
|
|
||||||
|
UiSettings,
|
||||||
|
CloseOverlay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||||
|
use Action::*;
|
||||||
|
|
||||||
|
vec![
|
||||||
|
(NextTab, KeyboardShortcut::new(Modifiers::CTRL, Key::Tab)),
|
||||||
|
(
|
||||||
|
PrevTab,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab),
|
||||||
|
),
|
||||||
|
(ViewText, KeyboardShortcut::new(Modifiers::CTRL, Key::T)),
|
||||||
|
(ViewHex, KeyboardShortcut::new(Modifiers::CTRL, Key::H)),
|
||||||
|
(ViewPlot, KeyboardShortcut::new(Modifiers::CTRL, Key::P)),
|
||||||
|
(NewSession, KeyboardShortcut::new(Modifiers::CTRL, Key::N)),
|
||||||
|
(EditSession, KeyboardShortcut::new(Modifiers::CTRL, Key::E)),
|
||||||
|
(
|
||||||
|
DeleteSession,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::W),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ToggleConnect,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::F5),
|
||||||
|
),
|
||||||
|
(Clear, KeyboardShortcut::new(Modifiers::CTRL, Key::L)),
|
||||||
|
(
|
||||||
|
UiSettings,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::Comma),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CloseOverlay,
|
||||||
|
KeyboardShortcut::new(Modifiers::NONE, Key::Escape),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(
|
||||||
|
ctx: &Context,
|
||||||
|
suppress_char_shortcuts: bool,
|
||||||
|
bindings: &[(Action, KeyboardShortcut)],
|
||||||
|
) -> Vec<Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
|
ctx.input_mut(|input| {
|
||||||
|
for (action, shortcut) in bindings {
|
||||||
|
if !input.consume_shortcut(shortcut) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if suppress_char_shortcuts && is_char_based(*action) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(*action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_char_based(action: Action) -> bool {
|
||||||
|
use Action::*;
|
||||||
|
!matches!(action, CloseOverlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bindings_contain_no_duplicate_shortcuts() {
|
||||||
|
let bindings = default_bindings();
|
||||||
|
for (i, (_, a)) in bindings.iter().enumerate() {
|
||||||
|
for (j, (_, b)) in bindings.iter().enumerate() {
|
||||||
|
if i != j {
|
||||||
|
assert_ne!(a, b, "Duplicate shortcut at indices {i} and {j}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bindings_non_empty() {
|
||||||
|
assert!(!default_bindings().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_overlay_not_char_based() {
|
||||||
|
assert!(!is_char_based(Action::CloseOverlay));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_other_actions_are_char_based() {
|
||||||
|
for action in [
|
||||||
|
Action::NextTab,
|
||||||
|
Action::PrevTab,
|
||||||
|
Action::ViewText,
|
||||||
|
Action::ViewHex,
|
||||||
|
Action::ViewPlot,
|
||||||
|
Action::NewSession,
|
||||||
|
Action::EditSession,
|
||||||
|
Action::DeleteSession,
|
||||||
|
Action::ToggleConnect,
|
||||||
|
Action::Clear,
|
||||||
|
Action::UiSettings,
|
||||||
|
] {
|
||||||
|
assert!(is_char_based(action), "{action:?} should be char-based");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,3 +19,6 @@ tracing-appender = { workspace = true }
|
|||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|||||||
1089
crates/xserial-tui/src/app.rs
Normal file
1089
crates/xserial-tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
103
crates/xserial-tui/src/app_state.rs
Normal file
103
crates/xserial-tui/src/app_state.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
use xserial_client::SessionConfig;
|
||||||
|
|
||||||
|
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistedTuiState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: usize,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_timestamp: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_direction: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_pipeline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_tui_state() -> PersistedTuiState {
|
||||||
|
match load_tui_state_from_path(&tui_state_path()) {
|
||||||
|
Ok(state) => 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(state: &PersistedTuiState) {
|
||||||
|
if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) {
|
||||||
|
warn!(error = %err, "Failed to persist TUI state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tui_state_path() -> PathBuf {
|
||||||
|
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
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("xserial")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("xserial")
|
||||||
|
.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("xserial")
|
||||||
|
.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("xserial")
|
||||||
|
.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("xserial")
|
||||||
|
.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
|
||||||
|
}
|
||||||
146
crates/xserial-tui/src/buffers.rs
Normal file
146
crates/xserial-tui/src/buffers.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use xserial_client::{DecodedEntry, RingBuffer};
|
||||||
|
use xserial_core::protocol::DecodedData;
|
||||||
|
|
||||||
|
#[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 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 fn recent(&self, count: usize) -> Vec<ConsoleLine> {
|
||||||
|
self.lines.drain_recent(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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 fn recent(&self, count: usize) -> Vec<HexLine> {
|
||||||
|
self.lines.drain_recent(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]"))
|
||||||
|
}
|
||||||
@@ -1 +1,38 @@
|
|||||||
fn main() {}
|
mod app;
|
||||||
|
mod app_state;
|
||||||
|
mod buffers;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
execute,
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
|
};
|
||||||
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
|
use xserial_client::SessionManager;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> io::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let rx = manager.subscribe();
|
||||||
|
|
||||||
|
let result = app::run(&mut terminal, app::App::new(manager, rx));
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|||||||
335
crates/xserial-tui/src/ui.rs
Normal file
335
crates/xserial-tui/src/ui.rs
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{App, AppMode, ConnectionStatus, DisplayOptions, SendMode, SessionForm, View};
|
||||||
|
use crate::buffers::{ConsoleLine, HexLine, LineDirection};
|
||||||
|
|
||||||
|
const MAX_RENDER_LINES: usize = 400;
|
||||||
|
|
||||||
|
pub fn render(frame: &mut Frame, app: &App) {
|
||||||
|
let root = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(34), Constraint::Min(20)])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
render_sidebar(frame, app, root[0]);
|
||||||
|
render_main(frame, app, root[1]);
|
||||||
|
|
||||||
|
if let AppMode::SessionForm(form) = app.mode() {
|
||||||
|
render_session_form(frame, form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.tabs()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, tab)| {
|
||||||
|
let prefix = if index == app.active_index() {
|
||||||
|
"> "
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let transport = App::transport_summary(&tab.session_config);
|
||||||
|
ListItem::new(format!(
|
||||||
|
"{prefix}[{}] {}\n {}",
|
||||||
|
tab.id,
|
||||||
|
tab.status.badge(),
|
||||||
|
transport
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sidebar = if items.is_empty() {
|
||||||
|
Paragraph::new("No sessions.\nPress n to create one.")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
} else {
|
||||||
|
Paragraph::new("").block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(sidebar, area);
|
||||||
|
if !items.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
List::new(items).block(Block::default().borders(Borders::ALL).title("Sessions")),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_main(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let sections = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
if let Some(tab) = app.active_tab() {
|
||||||
|
render_header(frame, app, sections[0]);
|
||||||
|
render_receive(frame, app, sections[1]);
|
||||||
|
render_send(frame, app, sections[2]);
|
||||||
|
let footer = format!(
|
||||||
|
"{}{}",
|
||||||
|
app.help_text(),
|
||||||
|
app.notice()
|
||||||
|
.map(|notice| format!(" | {notice}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(footer)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
sections[3],
|
||||||
|
);
|
||||||
|
|
||||||
|
if matches!(tab.status, ConnectionStatus::Error(_)) {
|
||||||
|
if let ConnectionStatus::Error(message) = &tab.status {
|
||||||
|
let _ = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"No active session.\n{}\n{}",
|
||||||
|
app.help_text(),
|
||||||
|
app.notice().unwrap_or("")
|
||||||
|
))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("xserial-tui"))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let tab = app.active_tab().expect("active tab");
|
||||||
|
let display = app.display();
|
||||||
|
let status_line = match &tab.status {
|
||||||
|
ConnectionStatus::Error(message) => {
|
||||||
|
format!("Session {} [{}] {}", tab.id, tab.status.badge(), message)
|
||||||
|
}
|
||||||
|
_ => format!("Session {} [{}]", tab.id, tab.status.badge()),
|
||||||
|
};
|
||||||
|
let line2 = format!(
|
||||||
|
"{} | View: {} | Send: {} | AutoReconnect: {} | AppendNewline: {}",
|
||||||
|
App::transport_summary(&tab.session_config),
|
||||||
|
match tab.view {
|
||||||
|
View::Text => "Text",
|
||||||
|
View::Hex => "Hex",
|
||||||
|
},
|
||||||
|
match tab.send_mode {
|
||||||
|
SendMode::Text => "Text",
|
||||||
|
SendMode::Hex => "Hex",
|
||||||
|
},
|
||||||
|
tab.auto_reconnect,
|
||||||
|
tab.append_newline
|
||||||
|
);
|
||||||
|
let line3 = format!(
|
||||||
|
"Display: ts={} dir={} pipe={} | Text lines={} Hex lines={}",
|
||||||
|
display.show_timestamp,
|
||||||
|
display.show_direction,
|
||||||
|
display.show_pipeline,
|
||||||
|
tab.console.len(),
|
||||||
|
tab.hex.len()
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!("{status_line}\n{line2}\n{line3}"))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Session"))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_receive(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let tab = app.active_tab().expect("active tab");
|
||||||
|
let display = app.display();
|
||||||
|
let title = match tab.view {
|
||||||
|
View::Text => "Receive Text",
|
||||||
|
View::Hex => "Receive Hex",
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines: Vec<String> = match tab.view {
|
||||||
|
View::Text => tab
|
||||||
|
.console
|
||||||
|
.recent(MAX_RENDER_LINES)
|
||||||
|
.into_iter()
|
||||||
|
.map(|line| format_console_line(&line, display))
|
||||||
|
.collect(),
|
||||||
|
View::Hex => tab
|
||||||
|
.hex
|
||||||
|
.recent(MAX_RENDER_LINES)
|
||||||
|
.into_iter()
|
||||||
|
.map(|line| format_hex_line(&line, display))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = if lines.is_empty() {
|
||||||
|
String::from("no data")
|
||||||
|
} else {
|
||||||
|
lines.join("\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(body)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_send(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let tab = app.active_tab().expect("active tab");
|
||||||
|
let editing = matches!(app.mode(), AppMode::SendInput);
|
||||||
|
let title = if editing { "Send [editing]" } else { "Send" };
|
||||||
|
let status = tab.send_status.as_deref().unwrap_or("idle");
|
||||||
|
let hint = match tab.send_mode {
|
||||||
|
SendMode::Text => "Press i to edit input, Enter to send while editing",
|
||||||
|
SendMode::Hex => "Hex bytes, e.g. 48 65 6C 6C 6F",
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
"Mode: {} | Append newline: {}\nStatus: {}\nHint: {}\n\n{}",
|
||||||
|
match tab.send_mode {
|
||||||
|
SendMode::Text => "Text",
|
||||||
|
SendMode::Hex => "Hex",
|
||||||
|
},
|
||||||
|
tab.append_newline,
|
||||||
|
status,
|
||||||
|
hint,
|
||||||
|
if tab.send_input.is_empty() {
|
||||||
|
String::from("<empty>")
|
||||||
|
} else {
|
||||||
|
tab.send_input.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(body)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_session_form(frame: &mut Frame, form: &SessionForm) {
|
||||||
|
let rows = form.rows();
|
||||||
|
let footer_lines = form.footer_lines();
|
||||||
|
let height = (rows.len() + footer_lines.len() + 4) as u16;
|
||||||
|
let area = centered_rect(82, height, frame.area());
|
||||||
|
let inner = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(footer_lines.len() as u16),
|
||||||
|
])
|
||||||
|
.margin(1)
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = rows
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, row)| ListItem::new(row).style(focus_style(idx == form.focused_field)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default().borders(Borders::ALL).title(form.title()),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
frame.render_widget(List::new(items), inner[0]);
|
||||||
|
frame.render_widget(Paragraph::new(footer_lines.join("\n")), inner[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
if display.show_timestamp {
|
||||||
|
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||||
|
}
|
||||||
|
if display.show_direction {
|
||||||
|
prefix.push(format!(
|
||||||
|
"[{}]",
|
||||||
|
match line.direction {
|
||||||
|
LineDirection::In => "IN",
|
||||||
|
LineDirection::Out => "OUT",
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if display.show_pipeline {
|
||||||
|
prefix.push(format!("[{}]", line.pipeline));
|
||||||
|
}
|
||||||
|
if prefix.is_empty() {
|
||||||
|
line.text.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", prefix.join(" "), line.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
if display.show_timestamp {
|
||||||
|
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||||
|
}
|
||||||
|
if display.show_direction {
|
||||||
|
prefix.push(format!(
|
||||||
|
"[{}]",
|
||||||
|
match line.direction {
|
||||||
|
LineDirection::In => "IN",
|
||||||
|
LineDirection::Out => "OUT",
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if display.show_pipeline {
|
||||||
|
prefix.push(format!("[{}]", line.pipeline));
|
||||||
|
}
|
||||||
|
let base = format!("{} |{}|", line.hex, line.ascii);
|
||||||
|
if prefix.is_empty() {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
format!("{} {}", prefix.join(" "), base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_elapsed(elapsed: std::time::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 focus_style(focused: bool) -> Style {
|
||||||
|
if focused {
|
||||||
|
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user