Compare commits

..

3 Commits

Author SHA1 Message Date
9cdbd5a892 Add keyboard shortcut system to xserial-gui
Introduce a centralized shortcut system with ShortcutAction enum and bindings table in shortcuts.rs. Global shortcuts are processed at the top of XserialApp::ui() before any widget rendering. Text input focus is detected via wants_keyboard_input() to suppress shortcuts when typing. Also fix pre-existing import ordering in panels/config.rs from cargo fmt.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 22:41:21 +08:00
8207b5e883 Fix deadlock in handle_send_to_dead_session test
The test called handle.join() without first calling handle.close(), causing the spawned session task to park forever waiting for cmd_rx.recv() to return None. Since join() borrows &self, cmd_tx stays alive, and the task never exits. Add close() call before join() to send a Close command and unblock the task.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 22:41:12 +08:00
202b8261c2 init tui 2026-06-11 19:11:36 +08:00
11 changed files with 1954 additions and 5 deletions

View File

@@ -576,6 +576,7 @@ mod tests {
let handle = Session::spawn(2, tcp_config());
tokio::time::sleep(Duration::from_millis(200)).await;
let _ = handle.send(b"data".to_vec()).await;
let _ = handle.close().await;
handle.join().await;
}

View File

@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::panels::{config, console, hex_view, plot_view, sidebar};
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
use crate::shortcuts::{self, default_bindings};
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
use xserial_client::SessionManager;
@@ -92,6 +93,7 @@ pub struct XserialApp {
event_rx: mpsc::Receiver<SessionEvent>,
pending: Vec<SessionEvent>,
profiler: GuiProfiler,
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
}
impl XserialApp {
@@ -162,11 +164,105 @@ impl XserialApp {
event_rx,
pending: Vec::new(),
profiler,
shortcut_bindings: default_bindings(),
};
app.restore_saved_sessions(saved_state);
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) {
self.config_target = None;
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 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();
self.drain_events();
if self.wants_live_plot_repaint() {

View File

@@ -3,6 +3,7 @@ mod app_state;
mod buffers;
mod panels;
mod perf;
mod shortcuts;
mod ui_fonts;
use xserial_client::SessionManager;

View File

@@ -1,12 +1,12 @@
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use xserial_core::protocol::Endian;
use xserial_core::protocol::plot::{PlotFormat, SampleType};
use xserial_core::protocol::text::TextEncoding;
use xserial_core::protocol::Endian;
use xserial_core::transport::TransportConfig;
use xserial_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
};
use xserial_core::transport::TransportConfig;
#[derive(Clone)]
pub struct ConfigForm {

View 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");
}
}
}

View File

@@ -19,3 +19,6 @@ tracing-appender = { workspace = true }
clap = { workspace = true }
hex = { workspace = true }
image = { workspace = true }
mlua = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

File diff suppressed because it is too large Load Diff

View 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
}

View 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]"))
}

View File

@@ -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
}

View 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]
}