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",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"egui_plot",
|
"egui_plot",
|
||||||
|
"hex",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ use crate::config::SessionConfig;
|
|||||||
|
|
||||||
pub enum SessionCmd {
|
pub enum SessionCmd {
|
||||||
Send(Vec<u8>),
|
Send(Vec<u8>),
|
||||||
|
Connect,
|
||||||
|
Disconnect,
|
||||||
|
Reconnect,
|
||||||
|
SetAutoReconnect(bool),
|
||||||
Close,
|
Close,
|
||||||
Reconfigure(SessionConfig),
|
Reconfigure(SessionConfig),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::sync::{
|
|||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::{future, io};
|
||||||
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::select;
|
use tokio::select;
|
||||||
@@ -105,6 +106,38 @@ impl SessionHandle {
|
|||||||
.map_err(|_| String::from("session closed"))
|
.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> {
|
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
|
||||||
let mut rx = self.inner.event_tx.subscribe();
|
let mut rx = self.inner.event_tx.subscribe();
|
||||||
let deadline = Duration::from_millis(timeout_ms);
|
let deadline = Duration::from_millis(timeout_ms);
|
||||||
@@ -161,6 +194,7 @@ pub struct Session {
|
|||||||
cmd_rx: mpsc::Receiver<SessionCmd>,
|
cmd_rx: mpsc::Receiver<SessionCmd>,
|
||||||
event_tx: broadcast::Sender<SessionEvent>,
|
event_tx: broadcast::Sender<SessionEvent>,
|
||||||
close_requested: bool,
|
close_requested: bool,
|
||||||
|
desired_connected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@@ -176,6 +210,7 @@ impl Session {
|
|||||||
cmd_rx,
|
cmd_rx,
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
close_requested: false,
|
close_requested: false,
|
||||||
|
desired_connected: true,
|
||||||
};
|
};
|
||||||
let task = tokio::spawn(async move { session.run().await });
|
let task = tokio::spawn(async move { session.run().await });
|
||||||
let inner = Arc::new(SessionHandleInner::new(id, cmd_tx, event_tx, task));
|
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 {
|
if let Err(err) = self.connect().await {
|
||||||
warn!(session_id = self.id, error = %err, "initial connect failed");
|
warn!(session_id = self.id, error = %err, "initial connect failed");
|
||||||
self.emit_error(format!("connect failed: {err}"));
|
self.emit_error(format!("connect failed: {err}"));
|
||||||
self.emit_closed();
|
|
||||||
info!(session_id = self.id, "session ended");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let reconnect_timer = self.reconnect_timer();
|
||||||
select! {
|
select! {
|
||||||
cmd = self.cmd_rx.recv() => {
|
cmd = self.cmd_rx.recv() => {
|
||||||
if !self.handle_command(cmd).await {
|
if !self.handle_command(cmd).await {
|
||||||
@@ -217,6 +250,11 @@ impl Session {
|
|||||||
break;
|
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 {
|
match cmd {
|
||||||
Some(SessionCmd::Close) => {
|
Some(SessionCmd::Close) => {
|
||||||
self.close_requested = true;
|
self.close_requested = true;
|
||||||
|
self.desired_connected = false;
|
||||||
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)) => {
|
Some(SessionCmd::Send(data)) => {
|
||||||
self.handle_send(data).await;
|
self.handle_send(data).await;
|
||||||
true
|
true
|
||||||
@@ -283,22 +348,29 @@ impl Session {
|
|||||||
error!(session_id = self.id, error = %err, "read error");
|
error!(session_id = self.id, error = %err, "read error");
|
||||||
self.emit_error(err.to_string());
|
self.emit_error(err.to_string());
|
||||||
self.disconnect(true).await;
|
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
|
true
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
let transport = self.config.transport.clone();
|
||||||
debug!(session_id = self.id, ?transport, "connecting session");
|
debug!(session_id = self.id, ?transport, "connecting session");
|
||||||
let mut conn = Connection::new(transport);
|
let mut conn = Connection::new(transport);
|
||||||
@@ -332,19 +404,18 @@ impl Session {
|
|||||||
async fn try_read(
|
async fn try_read(
|
||||||
conn: &mut Option<Connection>,
|
conn: &mut Option<Connection>,
|
||||||
pipeline: &mut MultiPipeline,
|
pipeline: &mut MultiPipeline,
|
||||||
) -> Result<Vec<DecodedEntry>, std::io::Error> {
|
) -> Result<Vec<DecodedEntry>, io::Error> {
|
||||||
let conn = match conn.as_mut() {
|
if conn.is_none() {
|
||||||
Some(conn) => conn,
|
future::pending::<()>().await;
|
||||||
None => {
|
unreachable!("pending future should never resolve");
|
||||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
let conn = conn.as_mut().expect("connection presence checked");
|
||||||
|
|
||||||
let mut buf = [0u8; 4096];
|
let mut buf = [0u8; 4096];
|
||||||
match conn.read(&mut buf).await {
|
match conn.read(&mut buf).await {
|
||||||
Ok(0) => Err(std::io::Error::new(
|
Ok(0) => Err(io::Error::new(
|
||||||
std::io::ErrorKind::UnexpectedEof,
|
io::ErrorKind::UnexpectedEof,
|
||||||
"connection closed",
|
"connection closed",
|
||||||
)),
|
)),
|
||||||
Ok(n) => Ok(pipeline
|
Ok(n) => Ok(pipeline
|
||||||
@@ -360,7 +431,7 @@ async fn try_read(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
|
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)]
|
#[cfg(test)]
|
||||||
@@ -499,6 +570,20 @@ mod tests {
|
|||||||
handle.join().await;
|
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]
|
#[tokio::test]
|
||||||
async fn handle_drop_closes_session() {
|
async fn handle_drop_closes_session() {
|
||||||
let handle = Session::spawn(8, tcp_config());
|
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;
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut mgr = SessionManager::new();
|
let mgr = SessionManager::new();
|
||||||
assert_eq!(mgr.count(), 0);
|
assert_eq!(mgr.count(), 0);
|
||||||
assert!(mgr.list_ids().is_empty());
|
assert!(mgr.list_ids().is_empty());
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ async fn manager_event_subscription() {
|
|||||||
stream.write_all(b"data\n").await.unwrap();
|
stream.write_all(b"data\n").await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut mgr = SessionManager::new();
|
let mgr = SessionManager::new();
|
||||||
let mut rx = mgr.subscribe();
|
let mut rx = mgr.subscribe();
|
||||||
|
|
||||||
let handle = mgr.create(tcp_config(addr));
|
let handle = mgr.create(tcp_config(addr));
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ eframe = { workspace = true }
|
|||||||
egui_plot = { workspace = true }
|
egui_plot = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
|
hex = "0.4"
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use crate::panels::{config, sidebar};
|
use std::sync::mpsc;
|
||||||
use tracing::debug;
|
|
||||||
|
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::SessionManager;
|
||||||
|
use xserial_client::config::SessionConfig;
|
||||||
use xserial_client::session::SessionEvent;
|
use xserial_client::session::SessionEvent;
|
||||||
|
use xserial_core::protocol::DecodedData;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ConnectionStatus {
|
pub enum ConnectionStatus {
|
||||||
@@ -11,32 +16,336 @@ pub enum ConnectionStatus {
|
|||||||
Error(String),
|
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 struct SessionTab {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
pub session_config: SessionConfig,
|
||||||
pub status: ConnectionStatus,
|
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 {
|
pub struct XserialApp {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
tabs: Vec<SessionTab>,
|
tabs: Vec<SessionTab>,
|
||||||
active: usize,
|
active: usize,
|
||||||
|
display: DisplayOptions,
|
||||||
config_open: bool,
|
config_open: bool,
|
||||||
|
config_target: Option<u64>,
|
||||||
config_form: config::ConfigForm,
|
config_form: config::ConfigForm,
|
||||||
event_rx: Option<tokio::sync::broadcast::Receiver<SessionEvent>>,
|
event_rx: mpsc::Receiver<SessionEvent>,
|
||||||
pending: Vec<SessionEvent>,
|
pending: Vec<SessionEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XserialApp {
|
impl XserialApp {
|
||||||
pub fn new(m: SessionManager, rx: tokio::sync::broadcast::Receiver<SessionEvent>) -> Self {
|
pub fn new(
|
||||||
Self {
|
manager: SessionManager,
|
||||||
manager: m,
|
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
|
||||||
tabs: vec![],
|
ctx: egui::Context,
|
||||||
active: 0,
|
) -> Self {
|
||||||
config_open: false,
|
let (event_tx, event_rx) = mpsc::channel();
|
||||||
config_form: config::ConfigForm::default(),
|
tokio::spawn(async move {
|
||||||
event_rx: Some(rx),
|
loop {
|
||||||
pending: vec![],
|
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,
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,94 +353,181 @@ 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) {
|
||||||
// 拉取 SessionManager 事件
|
self.drain_events();
|
||||||
if let Some(ref mut rx) = self.event_rx {
|
self.render_config_window(ui.ctx());
|
||||||
while let Ok(e) = rx.try_recv() {
|
self.render_sidebar(ui);
|
||||||
debug!(event = ?e, "GUI received event");
|
self.render_main_panel(ui);
|
||||||
self.pending.push(e);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for e in self.pending.drain(..) {
|
fn render_session_controls(
|
||||||
match e {
|
ui: &mut egui::Ui,
|
||||||
SessionEvent::Connected(id) => {
|
manager: &SessionManager,
|
||||||
debug!(session_id = id, "GUI: Connected");
|
tab: &mut SessionTab,
|
||||||
if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) {
|
) -> bool {
|
||||||
t.status = ConnectionStatus::Connected;
|
let mut configure_clicked = false;
|
||||||
}
|
ui.horizontal_wrapped(|ui| {
|
||||||
}
|
if ui.button("Connect").clicked() {
|
||||||
SessionEvent::Disconnected(id) | SessionEvent::Closed(id) => {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
debug!(session_id = id, "GUI: Disconnected/Closed");
|
tab.status = ConnectionStatus::Connecting;
|
||||||
if let Some(t) = self.tabs.iter_mut().find(|t| t.id == id) {
|
tokio::spawn(async move {
|
||||||
t.status = ConnectionStatus::Disconnected;
|
let _ = handle.connect().await;
|
||||||
}
|
});
|
||||||
}
|
} else {
|
||||||
SessionEvent::Error(id, msg) => {
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
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);
|
|
||||||
}
|
if ui.button("Disconnect").clicked() {
|
||||||
}
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
SessionEvent::Data(id, entry) => {
|
tab.status = ConnectionStatus::Disconnected;
|
||||||
debug!(session_id = id, pipeline = %entry.pipeline_name, "GUI: Data");
|
tokio::spawn(async move {
|
||||||
}
|
let _ = handle.disconnect().await;
|
||||||
}
|
});
|
||||||
}
|
} else {
|
||||||
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
// 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) {
|
if ui.button("Reconnect").clicked() {
|
||||||
let h = self.manager.create(cfg);
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
self.tabs.push(SessionTab {
|
tab.status = ConnectionStatus::Connecting;
|
||||||
id: h.id,
|
tokio::spawn(async move {
|
||||||
status: ConnectionStatus::Connecting,
|
let _ = handle.reconnect().await;
|
||||||
});
|
});
|
||||||
self.active = self.tabs.len() - 1;
|
} else {
|
||||||
self.config_open = false;
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
if ui.button("Configure").clicked() {
|
||||||
// Sidebar
|
configure_clicked = true;
|
||||||
let mut on_new = false;
|
}
|
||||||
let mut on_delete: Option<usize> = None;
|
|
||||||
egui::Panel::left("sidebar")
|
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
||||||
.resizable(false)
|
if response.changed() {
|
||||||
.show_inside(ui, |ui| {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
let ss: Vec<(u64, ConnectionStatus)> =
|
let enabled = tab.auto_reconnect;
|
||||||
self.tabs.iter().map(|t| (t.id, t.status.clone())).collect();
|
tokio::spawn(async move {
|
||||||
sidebar::render(ui, &ss, &mut self.active, &mut on_new, &mut on_delete);
|
let _ = handle.set_auto_reconnect(enabled).await;
|
||||||
});
|
});
|
||||||
if on_new {
|
} else {
|
||||||
self.config_open = true;
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
}
|
}
|
||||||
if let Some(i) = on_delete {
|
}
|
||||||
if let Some(tab) = self.tabs.get(i) {
|
});
|
||||||
self.manager.remove(tab.id);
|
configure_clicked
|
||||||
}
|
}
|
||||||
self.tabs.remove(i);
|
|
||||||
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||||
self.active = self.tabs.len() - 1;
|
ui.set_width(ui.available_width());
|
||||||
}
|
ui.heading("Send");
|
||||||
}
|
ui.horizontal(|ui| {
|
||||||
|
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
|
||||||
// Central
|
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
|
||||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
if tab.send_mode == SendMode::Text {
|
||||||
if self.tabs.is_empty() {
|
ui.checkbox(&mut tab.append_newline, "Append newline");
|
||||||
ui.heading("No sessions.");
|
}
|
||||||
return;
|
});
|
||||||
}
|
ui.add_space(6.0);
|
||||||
if self.active >= self.tabs.len() {
|
|
||||||
self.active = self.tabs.len() - 1;
|
let hint = match tab.send_mode {
|
||||||
}
|
SendMode::Text => "Enter text to send",
|
||||||
let tab = &self.tabs[self.active];
|
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||||
let txt = match &tab.status {
|
};
|
||||||
ConnectionStatus::Connected => "🟢 Connected",
|
let response = ui.add(
|
||||||
ConnectionStatus::Disconnected => "⚫ Disconnected",
|
TextEdit::multiline(&mut tab.send_input)
|
||||||
ConnectionStatus::Connecting => "🟡 Connecting...",
|
.desired_rows(6)
|
||||||
ConnectionStatus::Error(e) => &format!("🔴 {}", e),
|
.desired_width(f32::INFINITY)
|
||||||
};
|
.hint_text(hint),
|
||||||
ui.heading(format!("Session {} — {}", tab.id, txt));
|
);
|
||||||
});
|
|
||||||
|
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 app;
|
||||||
|
mod buffers;
|
||||||
mod panels;
|
mod panels;
|
||||||
|
|
||||||
use xserial_client::SessionManager;
|
use xserial_client::SessionManager;
|
||||||
@@ -17,6 +18,6 @@ fn main() {
|
|||||||
let _ = eframe::run_native(
|
let _ = eframe::run_native(
|
||||||
"xserial",
|
"xserial",
|
||||||
eframe::NativeOptions::default(),
|
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 udp_remote: String,
|
||||||
pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>,
|
pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>,
|
||||||
pub history: usize,
|
pub history: usize,
|
||||||
|
pub auto_reconnect: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
@@ -47,11 +48,63 @@ impl Default for ConfigForm {
|
|||||||
udp_remote: String::new(),
|
udp_remote: String::new(),
|
||||||
pipelines: vec![("text".into(), FramerChoice::Line, DecoderChoice::Text)],
|
pipelines: vec![("text".into(), FramerChoice::Line, DecoderChoice::Text)],
|
||||||
history: 10000,
|
history: 10000,
|
||||||
|
auto_reconnect: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigForm {
|
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
|
// 把表单数据转换成 xserial-client 的 SessionConfig
|
||||||
pub fn to_session_config(&self) -> SessionConfig {
|
pub fn to_session_config(&self) -> SessionConfig {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
@@ -104,14 +157,14 @@ impl ConfigForm {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
history_limit: self.history,
|
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;
|
let mut result = None;
|
||||||
|
|
||||||
ScrollArea::vertical().show(ui, |ui| {
|
ScrollArea::vertical().show(ui, |ui| {
|
||||||
@@ -234,9 +287,10 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm) -> Option<SessionConfig> {
|
|||||||
ui.label("History:");
|
ui.label("History:");
|
||||||
ui.add(DragValue::new(&mut form.history).range(100..=1_000_000));
|
ui.add(DragValue::new(&mut form.history).range(100..=1_000_000));
|
||||||
});
|
});
|
||||||
|
ui.checkbox(&mut form.auto_reconnect, "Auto reconnect");
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui.button("✓ Create Session").clicked() {
|
if ui.button(submit_label).clicked() {
|
||||||
result = Some(form.to_session_config());
|
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 config;
|
||||||
|
pub mod console;
|
||||||
|
pub mod hex_view;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
|
|||||||
Reference in New Issue
Block a user