Improve session controls and GUI views
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -5788,6 +5788,7 @@ dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_plot",
|
||||
"hex",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -2,6 +2,10 @@ use crate::config::SessionConfig;
|
||||
|
||||
pub enum SessionCmd {
|
||||
Send(Vec<u8>),
|
||||
Connect,
|
||||
Disconnect,
|
||||
Reconnect,
|
||||
SetAutoReconnect(bool),
|
||||
Close,
|
||||
Reconfigure(SessionConfig),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use std::{future, io};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::select;
|
||||
@@ -105,6 +106,38 @@ impl SessionHandle {
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn connect(&self) -> Result<(), String> {
|
||||
self.inner
|
||||
.cmd_tx
|
||||
.send(SessionCmd::Connect)
|
||||
.await
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) -> Result<(), String> {
|
||||
self.inner
|
||||
.cmd_tx
|
||||
.send(SessionCmd::Disconnect)
|
||||
.await
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn reconnect(&self) -> Result<(), String> {
|
||||
self.inner
|
||||
.cmd_tx
|
||||
.send(SessionCmd::Reconnect)
|
||||
.await
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn set_auto_reconnect(&self, enabled: bool) -> Result<(), String> {
|
||||
self.inner
|
||||
.cmd_tx
|
||||
.send(SessionCmd::SetAutoReconnect(enabled))
|
||||
.await
|
||||
.map_err(|_| String::from("session closed"))
|
||||
}
|
||||
|
||||
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
|
||||
let mut rx = self.inner.event_tx.subscribe();
|
||||
let deadline = Duration::from_millis(timeout_ms);
|
||||
@@ -161,6 +194,7 @@ pub struct Session {
|
||||
cmd_rx: mpsc::Receiver<SessionCmd>,
|
||||
event_tx: broadcast::Sender<SessionEvent>,
|
||||
close_requested: bool,
|
||||
desired_connected: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -176,6 +210,7 @@ impl Session {
|
||||
cmd_rx,
|
||||
event_tx: event_tx.clone(),
|
||||
close_requested: false,
|
||||
desired_connected: true,
|
||||
};
|
||||
let task = tokio::spawn(async move { session.run().await });
|
||||
let inner = Arc::new(SessionHandleInner::new(id, cmd_tx, event_tx, task));
|
||||
@@ -200,12 +235,10 @@ impl Session {
|
||||
if let Err(err) = self.connect().await {
|
||||
warn!(session_id = self.id, error = %err, "initial connect failed");
|
||||
self.emit_error(format!("connect failed: {err}"));
|
||||
self.emit_closed();
|
||||
info!(session_id = self.id, "session ended");
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
let reconnect_timer = self.reconnect_timer();
|
||||
select! {
|
||||
cmd = self.cmd_rx.recv() => {
|
||||
if !self.handle_command(cmd).await {
|
||||
@@ -217,6 +250,11 @@ impl Session {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = reconnect_timer => {
|
||||
if let Err(err) = self.connect().await {
|
||||
self.emit_error(format!("connect failed: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +267,35 @@ impl Session {
|
||||
match cmd {
|
||||
Some(SessionCmd::Close) => {
|
||||
self.close_requested = true;
|
||||
self.desired_connected = false;
|
||||
false
|
||||
}
|
||||
Some(SessionCmd::Connect) => {
|
||||
self.desired_connected = true;
|
||||
if self.conn.is_none() {
|
||||
if let Err(err) = self.connect().await {
|
||||
self.emit_error(format!("connect failed: {err}"));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Some(SessionCmd::Disconnect) => {
|
||||
self.desired_connected = false;
|
||||
self.disconnect(true).await;
|
||||
true
|
||||
}
|
||||
Some(SessionCmd::Reconnect) => {
|
||||
self.desired_connected = true;
|
||||
self.disconnect(true).await;
|
||||
if let Err(err) = self.connect().await {
|
||||
self.emit_error(format!("reconnect: {err}"));
|
||||
}
|
||||
true
|
||||
}
|
||||
Some(SessionCmd::SetAutoReconnect(enabled)) => {
|
||||
self.config.auto_reconnect = enabled;
|
||||
true
|
||||
}
|
||||
Some(SessionCmd::Send(data)) => {
|
||||
self.handle_send(data).await;
|
||||
true
|
||||
@@ -283,22 +348,29 @@ impl Session {
|
||||
error!(session_id = self.id, error = %err, "read error");
|
||||
self.emit_error(err.to_string());
|
||||
self.disconnect(true).await;
|
||||
|
||||
if self.config.auto_reconnect && !self.close_requested {
|
||||
warn!(session_id = self.id, "auto-reconnecting");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
if let Err(err) = self.connect().await {
|
||||
self.emit_error(format!("reconnect: {err}"));
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(&mut self) -> Result<(), std::io::Error> {
|
||||
fn reconnect_timer(&self) -> impl std::future::Future<Output = ()> + Send + 'static {
|
||||
let should_retry = self.conn.is_none()
|
||||
&& self.desired_connected
|
||||
&& self.config.auto_reconnect
|
||||
&& !self.close_requested;
|
||||
async move {
|
||||
if should_retry {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
} else {
|
||||
future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(&mut self) -> Result<(), io::Error> {
|
||||
if self.conn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let transport = self.config.transport.clone();
|
||||
debug!(session_id = self.id, ?transport, "connecting session");
|
||||
let mut conn = Connection::new(transport);
|
||||
@@ -332,19 +404,18 @@ impl Session {
|
||||
async fn try_read(
|
||||
conn: &mut Option<Connection>,
|
||||
pipeline: &mut MultiPipeline,
|
||||
) -> Result<Vec<DecodedEntry>, std::io::Error> {
|
||||
let conn = match conn.as_mut() {
|
||||
Some(conn) => conn,
|
||||
None => {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
) -> Result<Vec<DecodedEntry>, io::Error> {
|
||||
if conn.is_none() {
|
||||
future::pending::<()>().await;
|
||||
unreachable!("pending future should never resolve");
|
||||
}
|
||||
|
||||
let conn = conn.as_mut().expect("connection presence checked");
|
||||
|
||||
let mut buf = [0u8; 4096];
|
||||
match conn.read(&mut buf).await {
|
||||
Ok(0) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
Ok(0) => Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"connection closed",
|
||||
)),
|
||||
Ok(n) => Ok(pipeline
|
||||
@@ -360,7 +431,7 @@ async fn try_read(
|
||||
}
|
||||
|
||||
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, err.to_string())
|
||||
io::Error::new(io::ErrorKind::Other, err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -499,6 +570,20 @@ mod tests {
|
||||
handle.join().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_read_without_connection_stays_pending() {
|
||||
let mut conn = None;
|
||||
let mut pipeline = MultiPipeline::new();
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_millis(50),
|
||||
try_read(&mut conn, &mut pipeline),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_drop_closes_session() {
|
||||
let handle = Session::spawn(8, tcp_config());
|
||||
|
||||
@@ -203,7 +203,7 @@ async fn manager_create_and_list() {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
|
||||
let mut mgr = SessionManager::new();
|
||||
let mgr = SessionManager::new();
|
||||
assert_eq!(mgr.count(), 0);
|
||||
assert!(mgr.list_ids().is_empty());
|
||||
|
||||
@@ -236,7 +236,7 @@ async fn manager_event_subscription() {
|
||||
stream.write_all(b"data\n").await.unwrap();
|
||||
});
|
||||
|
||||
let mut mgr = SessionManager::new();
|
||||
let mgr = SessionManager::new();
|
||||
let mut rx = mgr.subscribe();
|
||||
|
||||
let handle = mgr.create(tcp_config(addr));
|
||||
|
||||
@@ -16,3 +16,4 @@ eframe = { workspace = true }
|
||||
egui_plot = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
hex = "0.4"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use crate::panels::{config, sidebar};
|
||||
use tracing::debug;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use crate::buffers::{HexBuffer, TextBuffer};
|
||||
use crate::panels::{config, console, hex_view, sidebar};
|
||||
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
||||
use xserial_client::SessionManager;
|
||||
use xserial_client::config::SessionConfig;
|
||||
use xserial_client::session::SessionEvent;
|
||||
use xserial_core::protocol::DecodedData;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ConnectionStatus {
|
||||
@@ -11,127 +16,518 @@ pub enum ConnectionStatus {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl ConnectionStatus {
|
||||
fn badge(&self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Self::Connected => ("[connected]", "Connected"),
|
||||
Self::Disconnected => ("[disconnected]", "Disconnected"),
|
||||
Self::Connecting => ("[connecting]", "Connecting"),
|
||||
Self::Error(_) => ("[error]", "Error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum View {
|
||||
Text,
|
||||
Hex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SendMode {
|
||||
Text,
|
||||
Hex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DisplayOptions {
|
||||
pub show_timestamp: bool,
|
||||
pub show_direction: bool,
|
||||
pub show_pipeline: bool,
|
||||
}
|
||||
|
||||
pub struct SessionTab {
|
||||
pub id: u64,
|
||||
pub session_config: SessionConfig,
|
||||
pub status: ConnectionStatus,
|
||||
pub console: TextBuffer,
|
||||
pub hex: HexBuffer,
|
||||
pub view: View,
|
||||
pub auto_reconnect: bool,
|
||||
pub send_input: String,
|
||||
pub send_mode: SendMode,
|
||||
pub append_newline: bool,
|
||||
pub send_status: Option<String>,
|
||||
}
|
||||
|
||||
pub struct XserialApp {
|
||||
manager: SessionManager,
|
||||
tabs: Vec<SessionTab>,
|
||||
active: usize,
|
||||
display: DisplayOptions,
|
||||
config_open: bool,
|
||||
config_target: Option<u64>,
|
||||
config_form: config::ConfigForm,
|
||||
event_rx: Option<tokio::sync::broadcast::Receiver<SessionEvent>>,
|
||||
event_rx: mpsc::Receiver<SessionEvent>,
|
||||
pending: Vec<SessionEvent>,
|
||||
}
|
||||
|
||||
impl XserialApp {
|
||||
pub fn new(m: SessionManager, rx: tokio::sync::broadcast::Receiver<SessionEvent>) -> Self {
|
||||
pub fn new(
|
||||
manager: SessionManager,
|
||||
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
|
||||
ctx: egui::Context,
|
||||
) -> Self {
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
if event_tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
manager: m,
|
||||
tabs: vec![],
|
||||
manager,
|
||||
tabs: Vec::new(),
|
||||
active: 0,
|
||||
display: DisplayOptions {
|
||||
show_timestamp: true,
|
||||
show_direction: true,
|
||||
show_pipeline: true,
|
||||
},
|
||||
config_open: false,
|
||||
config_target: None,
|
||||
config_form: config::ConfigForm::default(),
|
||||
event_rx: Some(rx),
|
||||
pending: vec![],
|
||||
event_rx,
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_create_config(&mut self) {
|
||||
self.config_target = None;
|
||||
self.config_form = config::ConfigForm::default();
|
||||
self.config_open = true;
|
||||
}
|
||||
|
||||
fn open_edit_config(&mut self, id: u64) {
|
||||
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) {
|
||||
self.config_target = Some(id);
|
||||
self.config_form = config::ConfigForm::from_session_config(&tab.session_config);
|
||||
self.config_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_session_config(&mut self, id: u64, session_config: SessionConfig) {
|
||||
let handle = self.manager.get(id);
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||
let history_limit = session_config.history_limit;
|
||||
tab.session_config = session_config.clone();
|
||||
tab.auto_reconnect = session_config.auto_reconnect;
|
||||
tab.console = TextBuffer::new(history_limit);
|
||||
tab.hex = HexBuffer::new(history_limit);
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tab.send_status = Some(String::from("Session reconfigured"));
|
||||
}
|
||||
|
||||
if let Some(handle) = handle {
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.reconfigure(session_config).await;
|
||||
});
|
||||
} else if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_events(&mut self) {
|
||||
while let Ok(event) = self.event_rx.try_recv() {
|
||||
self.pending.push(event);
|
||||
}
|
||||
|
||||
for event in self.pending.drain(..) {
|
||||
match event {
|
||||
SessionEvent::Connected(id) => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||
tab.status = ConnectionStatus::Connected;
|
||||
}
|
||||
}
|
||||
SessionEvent::Disconnected(id) | SessionEvent::Closed(id) => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||
tab.status = ConnectionStatus::Disconnected;
|
||||
}
|
||||
}
|
||||
SessionEvent::Error(id, message) => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||
tab.status = ConnectionStatus::Error(message);
|
||||
}
|
||||
}
|
||||
SessionEvent::Data(id, entry) => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||
match &entry.data {
|
||||
DecodedData::Text(_) => tab.console.push(&entry),
|
||||
DecodedData::Hex(_) => tab.hex.push(&entry),
|
||||
DecodedData::Binary(_) | DecodedData::Plot(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_config_window(&mut self, ctx: &egui::Context) {
|
||||
if !self.config_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut open = self.config_open;
|
||||
let title = if self.config_target.is_some() {
|
||||
"Edit Session Config"
|
||||
} else {
|
||||
"Session Config"
|
||||
};
|
||||
let submit_label = if self.config_target.is_some() {
|
||||
"Save Session Config"
|
||||
} else {
|
||||
"Create Session"
|
||||
};
|
||||
let mut submitted = None;
|
||||
egui::Window::new(title).open(&mut open).show(ctx, |ui| {
|
||||
submitted = config::render(ui, &mut self.config_form, submit_label);
|
||||
});
|
||||
|
||||
if let Some(session_config) = submitted {
|
||||
if let Some(id) = self.config_target {
|
||||
self.apply_session_config(id, session_config);
|
||||
} else {
|
||||
let history_limit = session_config.history_limit;
|
||||
let auto_reconnect = session_config.auto_reconnect;
|
||||
let handle = self.manager.create(session_config.clone());
|
||||
self.tabs.push(SessionTab {
|
||||
id: handle.id(),
|
||||
session_config,
|
||||
status: ConnectionStatus::Connecting,
|
||||
console: TextBuffer::new(history_limit),
|
||||
hex: HexBuffer::new(history_limit),
|
||||
view: View::Text,
|
||||
auto_reconnect,
|
||||
send_input: String::new(),
|
||||
send_mode: SendMode::Text,
|
||||
append_newline: true,
|
||||
send_status: None,
|
||||
});
|
||||
self.active = self.tabs.len() - 1;
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
if !open {
|
||||
self.config_target = None;
|
||||
}
|
||||
self.config_open = open;
|
||||
}
|
||||
|
||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||
let mut on_new = false;
|
||||
let mut on_delete = None;
|
||||
|
||||
Panel::left("sidebar")
|
||||
.resizable(false)
|
||||
.show_inside(ui, |ui| {
|
||||
let sessions: Vec<_> = self
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| (tab.id, tab.status.clone()))
|
||||
.collect();
|
||||
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
|
||||
});
|
||||
|
||||
if on_new {
|
||||
self.open_create_config();
|
||||
}
|
||||
|
||||
if let Some(index) = on_delete {
|
||||
if let Some(tab) = self.tabs.get(index) {
|
||||
self.manager.remove(tab.id);
|
||||
}
|
||||
self.tabs.remove(index);
|
||||
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||
self.active = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
if self.tabs.is_empty() {
|
||||
ui.heading("No sessions.");
|
||||
return;
|
||||
}
|
||||
|
||||
if self.active >= self.tabs.len() {
|
||||
self.active = self.tabs.len() - 1;
|
||||
}
|
||||
|
||||
let manager = self.manager.clone();
|
||||
let display = &mut self.display;
|
||||
let mut edit_session = None;
|
||||
|
||||
{
|
||||
let tab = &mut self.tabs[self.active];
|
||||
let (badge, status_text) = tab.status.badge();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(format!("Session {}", tab.id));
|
||||
ui.label(badge);
|
||||
ui.separator();
|
||||
ui.label(status_text);
|
||||
ui.separator();
|
||||
if ui
|
||||
.selectable_label(tab.view == View::Text, "Text")
|
||||
.clicked()
|
||||
{
|
||||
tab.view = View::Text;
|
||||
}
|
||||
if ui.selectable_label(tab.view == View::Hex, "Hex").clicked() {
|
||||
tab.view = View::Hex;
|
||||
}
|
||||
});
|
||||
|
||||
if render_session_controls(ui, &manager, tab) {
|
||||
edit_session = Some(tab.id);
|
||||
}
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show:");
|
||||
ui.checkbox(&mut display.show_timestamp, "Timestamp");
|
||||
ui.checkbox(&mut display.show_direction, "Direction");
|
||||
ui.checkbox(&mut display.show_pipeline, "Pipe");
|
||||
});
|
||||
|
||||
if let ConnectionStatus::Error(message) = &tab.status {
|
||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||
}
|
||||
|
||||
let full = ui.available_rect_before_wrap();
|
||||
let send_height = 240.0;
|
||||
let separator = 8.0;
|
||||
let receive_height = (full.height() - send_height - separator).max(0.0);
|
||||
let receive_rect = Rect::from_min_max(
|
||||
full.min,
|
||||
Pos2::new(full.max.x, full.min.y + receive_height),
|
||||
);
|
||||
let send_rect =
|
||||
Rect::from_min_max(Pos2::new(full.min.x, full.max.y - send_height), full.max);
|
||||
|
||||
ui.scope_builder(
|
||||
UiBuilder::new()
|
||||
.max_rect(receive_rect)
|
||||
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
||||
|ui| match tab.view {
|
||||
View::Text => console::render(ui, &tab.console, *display),
|
||||
View::Hex => hex_view::render(ui, &tab.hex, *display),
|
||||
},
|
||||
);
|
||||
|
||||
ui.scope_builder(
|
||||
UiBuilder::new()
|
||||
.max_rect(send_rect)
|
||||
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
||||
|ui| render_send_panel(ui, &manager, tab),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(id) = edit_session {
|
||||
self.open_edit_config(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// 拉取 SessionManager 事件
|
||||
if let Some(ref mut rx) = self.event_rx {
|
||||
while let Ok(e) = rx.try_recv() {
|
||||
debug!(event = ?e, "GUI received event");
|
||||
self.pending.push(e);
|
||||
}
|
||||
}
|
||||
for e in self.pending.drain(..) {
|
||||
match e {
|
||||
SessionEvent::Connected(id) => {
|
||||
debug!(session_id = id, "GUI: Connected");
|
||||
if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) {
|
||||
t.status = ConnectionStatus::Connected;
|
||||
}
|
||||
}
|
||||
SessionEvent::Disconnected(id) | SessionEvent::Closed(id) => {
|
||||
debug!(session_id = id, "GUI: Disconnected/Closed");
|
||||
if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) {
|
||||
t.status = ConnectionStatus::Disconnected;
|
||||
}
|
||||
}
|
||||
SessionEvent::Error(id, msg) => {
|
||||
debug!(session_id = id, error = %msg, "GUI: Error");
|
||||
if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) {
|
||||
t.status = ConnectionStatus::Error(msg);
|
||||
}
|
||||
}
|
||||
SessionEvent::Data(id, entry) => {
|
||||
debug!(session_id = id, pipeline = %entry.pipeline_name, "GUI: Data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Config 弹窗
|
||||
if self.config_open {
|
||||
egui::Window::new("Session Config").show(ui.ctx(), |ui| {
|
||||
if let Some(cfg) = config::render(ui, &mut self.config_form) {
|
||||
let h = self.manager.create(cfg);
|
||||
self.tabs.push(SessionTab {
|
||||
id: h.id,
|
||||
status: ConnectionStatus::Connecting,
|
||||
});
|
||||
self.active = self.tabs.len() - 1;
|
||||
self.config_open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar
|
||||
let mut on_new = false;
|
||||
let mut on_delete: Option<usize> = None;
|
||||
egui::Panel::left("sidebar")
|
||||
.resizable(false)
|
||||
.show_inside(ui, |ui| {
|
||||
let ss: Vec<(u64, ConnectionStatus)> =
|
||||
self.tabs.iter().map(|t| (t.id, t.status.clone())).collect();
|
||||
sidebar::render(ui, &ss, &mut self.active, &mut on_new, &mut on_delete);
|
||||
});
|
||||
if on_new {
|
||||
self.config_open = true;
|
||||
}
|
||||
if let Some(i) = on_delete {
|
||||
if let Some(tab) = self.tabs.get(i) {
|
||||
self.manager.remove(tab.id);
|
||||
}
|
||||
self.tabs.remove(i);
|
||||
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||
self.active = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Central
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
if self.tabs.is_empty() {
|
||||
ui.heading("No sessions.");
|
||||
return;
|
||||
}
|
||||
if self.active >= self.tabs.len() {
|
||||
self.active = self.tabs.len() - 1;
|
||||
}
|
||||
let tab = &self.tabs[self.active];
|
||||
let txt = match &tab.status {
|
||||
ConnectionStatus::Connected => "🟢 Connected",
|
||||
ConnectionStatus::Disconnected => "⚫ Disconnected",
|
||||
ConnectionStatus::Connecting => "🟡 Connecting...",
|
||||
ConnectionStatus::Error(e) => &format!("🔴 {}", e),
|
||||
};
|
||||
ui.heading(format!("Session {} — {}", tab.id, txt));
|
||||
});
|
||||
self.drain_events();
|
||||
self.render_config_window(ui.ctx());
|
||||
self.render_sidebar(ui);
|
||||
self.render_main_panel(ui);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_session_controls(
|
||||
ui: &mut egui::Ui,
|
||||
manager: &SessionManager,
|
||||
tab: &mut SessionTab,
|
||||
) -> bool {
|
||||
let mut configure_clicked = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Connect").clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.connect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Disconnect").clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
tab.status = ConnectionStatus::Disconnected;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.disconnect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Reconnect").clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.reconnect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Configure").clicked() {
|
||||
configure_clicked = true;
|
||||
}
|
||||
|
||||
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
||||
if response.changed() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
let enabled = tab.auto_reconnect;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.set_auto_reconnect(enabled).await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
});
|
||||
configure_clicked
|
||||
}
|
||||
|
||||
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.heading("Send");
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
|
||||
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
|
||||
if tab.send_mode == SendMode::Text {
|
||||
ui.checkbox(&mut tab.append_newline, "Append newline");
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
let hint = match tab.send_mode {
|
||||
SendMode::Text => "Enter text to send",
|
||||
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||
};
|
||||
let response = ui.add(
|
||||
TextEdit::multiline(&mut tab.send_input)
|
||||
.desired_rows(6)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(hint),
|
||||
);
|
||||
|
||||
let wants_submit = response.has_focus()
|
||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only());
|
||||
|
||||
let mut send_clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
send_clicked = ui.button("Send").clicked();
|
||||
if let Some(status) = &tab.send_status {
|
||||
ui.label(
|
||||
egui::RichText::new(status).color(if status.starts_with("Send failed") {
|
||||
Color32::RED
|
||||
} else {
|
||||
Color32::GRAY
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if !(send_clicked || wants_submit) {
|
||||
return;
|
||||
}
|
||||
|
||||
match build_payload(tab) {
|
||||
Ok(Some(payload)) => {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
match tab.send_mode {
|
||||
SendMode::Text => {
|
||||
let text = if tab.append_newline {
|
||||
tab.send_input.trim_end_matches('\n').to_string()
|
||||
} else {
|
||||
tab.send_input.clone()
|
||||
};
|
||||
if !text.is_empty() {
|
||||
tab.console.push_outbound(text);
|
||||
}
|
||||
}
|
||||
SendMode::Hex => {
|
||||
let hex = tab
|
||||
.send_input
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !hex.is_empty() {
|
||||
tab.hex.push_outbound(hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.send(payload).await;
|
||||
});
|
||||
tab.send_input.clear();
|
||||
tab.send_status = Some(String::from("Sent"));
|
||||
} else {
|
||||
tab.send_status = Some(String::from("Send failed: session not found"));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tab.send_status = Some(String::from("Nothing to send"));
|
||||
}
|
||||
Err(message) => {
|
||||
tab.send_status = Some(format!("Send failed: {message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
||||
let trimmed = tab.send_input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match tab.send_mode {
|
||||
SendMode::Text => {
|
||||
let mut text = tab.send_input.clone();
|
||||
if tab.append_newline && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
Ok(Some(text.into_bytes()))
|
||||
}
|
||||
SendMode::Hex => {
|
||||
let compact: String = trimmed
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_ascii_whitespace())
|
||||
.collect();
|
||||
hex::decode(compact)
|
||||
.map(Some)
|
||||
.map_err(|err| format!("invalid hex input ({err})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
crates/xserial-gui/src/buffers.rs
Normal file
131
crates/xserial-gui/src/buffers.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use std::time::{Duration, Instant};
|
||||
use xserial_client::RingBuffer;
|
||||
use xserial_client::event::DecodedEntry;
|
||||
use xserial_core::protocol::DecodedData;
|
||||
|
||||
#[derive(Clone)]
|
||||
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(cap: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(cap),
|
||||
}
|
||||
}
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Text(s) = &entry.data {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
text: s.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 iter(&self) -> impl Iterator<Item = &ConsoleLine> {
|
||||
self.lines.iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[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(cap: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(cap),
|
||||
}
|
||||
}
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Hex(s) = &entry.data {
|
||||
let ascii = hex::decode(s.replace(' ', ""))
|
||||
.map(|bytes| {
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
if b.is_ascii_graphic() || b == b' ' {
|
||||
b as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|_| String::from("[invalid hex]"));
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
hex: s.clone(),
|
||||
ascii,
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, hex: String) {
|
||||
let ascii = hex::decode(hex.replace(' ', ""))
|
||||
.map(|bytes| {
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
if b.is_ascii_graphic() || b == b' ' {
|
||||
b as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|_| String::from("[invalid hex]"));
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
hex,
|
||||
ascii,
|
||||
direction: LineDirection::Out,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
|
||||
self.lines.iter()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod buffers;
|
||||
mod panels;
|
||||
|
||||
use xserial_client::SessionManager;
|
||||
@@ -17,6 +18,6 @@ fn main() {
|
||||
let _ = eframe::run_native(
|
||||
"xserial",
|
||||
eframe::NativeOptions::default(),
|
||||
Box::new(|_cc| Ok(Box::new(app::XserialApp::new(mgr, rx)))),
|
||||
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct ConfigForm {
|
||||
pub udp_remote: String,
|
||||
pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>,
|
||||
pub history: usize,
|
||||
pub auto_reconnect: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -47,11 +48,63 @@ impl Default for ConfigForm {
|
||||
udp_remote: String::new(),
|
||||
pipelines: vec![("text".into(), FramerChoice::Line, DecoderChoice::Text)],
|
||||
history: 10000,
|
||||
auto_reconnect: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigForm {
|
||||
pub fn from_session_config(config: &SessionConfig) -> Self {
|
||||
Self {
|
||||
transport: match &config.transport {
|
||||
TransportConfig::Serial { .. } => TransportChoice::Serial,
|
||||
TransportConfig::Tcp { .. } => TransportChoice::Tcp,
|
||||
TransportConfig::Udp { .. } => TransportChoice::Udp,
|
||||
},
|
||||
port: match &config.transport {
|
||||
TransportConfig::Serial { port, .. } => port.clone(),
|
||||
_ => String::from("/dev/ttyUSB0"),
|
||||
},
|
||||
baud: match &config.transport {
|
||||
TransportConfig::Serial { baud_rate, .. } => *baud_rate,
|
||||
_ => 115200,
|
||||
},
|
||||
addr: match &config.transport {
|
||||
TransportConfig::Tcp { addr } => addr.clone(),
|
||||
_ => String::from("127.0.0.1:8080"),
|
||||
},
|
||||
udp_bind: match &config.transport {
|
||||
TransportConfig::Udp { bind_addr, .. } => bind_addr.clone(),
|
||||
_ => String::from("0.0.0.0:9000"),
|
||||
},
|
||||
udp_remote: match &config.transport {
|
||||
TransportConfig::Udp { remote_addr, .. } => remote_addr.clone().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
},
|
||||
pipelines: config
|
||||
.pipelines
|
||||
.iter()
|
||||
.map(|pipeline| {
|
||||
let framer = match &pipeline.framer {
|
||||
FramerConfig::Line { .. } => FramerChoice::Line,
|
||||
FramerConfig::Fixed { frame_len } => FramerChoice::Fixed(*frame_len),
|
||||
FramerConfig::Length { .. } | FramerConfig::Cobs { .. } => {
|
||||
FramerChoice::Line
|
||||
}
|
||||
};
|
||||
let decoder = match &pipeline.decoder {
|
||||
DecoderConfig::Text { .. } => DecoderChoice::Text,
|
||||
DecoderConfig::Hex { .. } => DecoderChoice::Hex,
|
||||
DecoderConfig::Plot { .. } => DecoderChoice::Plot,
|
||||
};
|
||||
(pipeline.name.clone(), framer, decoder)
|
||||
})
|
||||
.collect(),
|
||||
history: config.history_limit,
|
||||
auto_reconnect: config.auto_reconnect,
|
||||
}
|
||||
}
|
||||
|
||||
// 把表单数据转换成 xserial-client 的 SessionConfig
|
||||
pub fn to_session_config(&self) -> SessionConfig {
|
||||
SessionConfig {
|
||||
@@ -104,14 +157,14 @@ impl ConfigForm {
|
||||
})
|
||||
.collect(),
|
||||
history_limit: self.history,
|
||||
auto_reconnect: false,
|
||||
auto_reconnect: self.auto_reconnect,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染函数 ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn render(ui: &mut Ui, form: &mut ConfigForm) -> Option<SessionConfig> {
|
||||
pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<SessionConfig> {
|
||||
let mut result = None;
|
||||
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
@@ -234,9 +287,10 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm) -> Option<SessionConfig> {
|
||||
ui.label("History:");
|
||||
ui.add(DragValue::new(&mut form.history).range(100..=1_000_000));
|
||||
});
|
||||
ui.checkbox(&mut form.auto_reconnect, "Auto reconnect");
|
||||
|
||||
ui.separator();
|
||||
if ui.button("✓ Create Session").clicked() {
|
||||
if ui.button(submit_label).clicked() {
|
||||
result = Some(form.to_session_config());
|
||||
}
|
||||
});
|
||||
|
||||
36
crates/xserial-gui/src/panels/console.rs
Normal file
36
crates/xserial-gui/src/panels/console.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::buffers::{LineDirection, TextBuffer};
|
||||
use egui::{ScrollArea, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) {
|
||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
||||
for line in buf.iter() {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
let ts = line.elapsed.as_secs_f64();
|
||||
let time = if ts < 60.0 {
|
||||
format!("{:05.2}", ts)
|
||||
} else {
|
||||
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
|
||||
};
|
||||
prefix.push(format!("[{time}]"));
|
||||
}
|
||||
if display.show_direction {
|
||||
let dir = match line.direction {
|
||||
LineDirection::In => "IN",
|
||||
LineDirection::Out => "OUT",
|
||||
};
|
||||
prefix.push(format!("[{dir}]"));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
prefix.push(format!("[{}]", line.pipeline));
|
||||
}
|
||||
let prefix = if prefix.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{} ", prefix.join(" "))
|
||||
};
|
||||
ui.monospace(format!("{prefix}{}", line.text));
|
||||
}
|
||||
});
|
||||
}
|
||||
41
crates/xserial-gui/src/panels/hex_view.rs
Normal file
41
crates/xserial-gui/src/panels/hex_view.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::buffers::{HexBuffer, LineDirection};
|
||||
use egui::{ScrollArea, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) {
|
||||
let mut lines = buf.iter().peekable();
|
||||
if lines.peek().is_none() {
|
||||
ui.label("no hex data");
|
||||
return;
|
||||
}
|
||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
||||
for line in lines {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
let ts = line.elapsed.as_secs_f64();
|
||||
let time = if ts < 60.0 {
|
||||
format!("{:05.2}", ts)
|
||||
} else {
|
||||
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
|
||||
};
|
||||
prefix.push(format!("[{time}]"));
|
||||
}
|
||||
if display.show_direction {
|
||||
let dir = match line.direction {
|
||||
LineDirection::In => "IN",
|
||||
LineDirection::Out => "OUT",
|
||||
};
|
||||
prefix.push(format!("[{dir}]"));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
prefix.push(format!("[{}]", line.pipeline));
|
||||
}
|
||||
let prefix = if prefix.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{} ", prefix.join(" "))
|
||||
};
|
||||
ui.monospace(format!("{prefix}{} |{}|", line.hex, line.ascii));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod console;
|
||||
pub mod hex_view;
|
||||
pub mod sidebar;
|
||||
|
||||
Reference in New Issue
Block a user