Update core modules and add GUI panels
This commit is contained in:
137
crates/xserial-gui/src/app.rs
Normal file
137
crates/xserial-gui/src/app.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use crate::panels::{config, sidebar};
|
||||
use tracing::debug;
|
||||
use xserial_client::SessionManager;
|
||||
use xserial_client::session::SessionEvent;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct SessionTab {
|
||||
pub id: u64,
|
||||
pub status: ConnectionStatus,
|
||||
}
|
||||
|
||||
pub struct XserialApp {
|
||||
manager: SessionManager,
|
||||
tabs: Vec<SessionTab>,
|
||||
active: usize,
|
||||
config_open: bool,
|
||||
config_form: config::ConfigForm,
|
||||
event_rx: Option<tokio::sync::broadcast::Receiver<SessionEvent>>,
|
||||
pending: Vec<SessionEvent>,
|
||||
}
|
||||
|
||||
impl XserialApp {
|
||||
pub fn new(m: SessionManager, rx: tokio::sync::broadcast::Receiver<SessionEvent>) -> Self {
|
||||
Self {
|
||||
manager: m,
|
||||
tabs: vec![],
|
||||
active: 0,
|
||||
config_open: false,
|
||||
config_form: config::ConfigForm::default(),
|
||||
event_rx: Some(rx),
|
||||
pending: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,22 @@
|
||||
mod app;
|
||||
mod panels;
|
||||
|
||||
use xserial_client::SessionManager;
|
||||
|
||||
fn main() {
|
||||
println!("xserial-gui");
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let _guard = rt.enter();
|
||||
|
||||
let mgr = SessionManager::new();
|
||||
let rx = mgr.subscribe();
|
||||
|
||||
let _ = eframe::run_native(
|
||||
"xserial",
|
||||
eframe::NativeOptions::default(),
|
||||
Box::new(|_cc| Ok(Box::new(app::XserialApp::new(mgr, rx)))),
|
||||
);
|
||||
}
|
||||
|
||||
245
crates/xserial-gui/src/panels/config.rs
Normal file
245
crates/xserial-gui/src/panels/config.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||
use xserial_core::transport::TransportConfig;
|
||||
|
||||
// ── 表单数据结构(简化版,方便 UI 渲染)────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigForm {
|
||||
pub transport: TransportChoice,
|
||||
pub port: String,
|
||||
pub baud: u32,
|
||||
pub addr: String,
|
||||
pub udp_bind: String,
|
||||
pub udp_remote: String,
|
||||
pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>,
|
||||
pub history: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum TransportChoice {
|
||||
Serial,
|
||||
Tcp,
|
||||
Udp,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum FramerChoice {
|
||||
Line,
|
||||
Fixed(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum DecoderChoice {
|
||||
Text,
|
||||
Hex,
|
||||
Plot,
|
||||
}
|
||||
|
||||
impl Default for ConfigForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
transport: TransportChoice::Tcp,
|
||||
port: "/dev/ttyUSB0".into(),
|
||||
baud: 115200,
|
||||
addr: "127.0.0.1:8080".into(),
|
||||
udp_bind: "0.0.0.0:9000".into(),
|
||||
udp_remote: String::new(),
|
||||
pipelines: vec![("text".into(), FramerChoice::Line, DecoderChoice::Text)],
|
||||
history: 10000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigForm {
|
||||
// 把表单数据转换成 xserial-client 的 SessionConfig
|
||||
pub fn to_session_config(&self) -> SessionConfig {
|
||||
SessionConfig {
|
||||
transport: match self.transport {
|
||||
TransportChoice::Serial => TransportConfig::Serial {
|
||||
port: self.port.clone(),
|
||||
baud_rate: self.baud,
|
||||
},
|
||||
TransportChoice::Tcp => TransportConfig::Tcp {
|
||||
addr: self.addr.clone(),
|
||||
},
|
||||
TransportChoice::Udp => TransportConfig::Udp {
|
||||
bind_addr: self.udp_bind.clone(),
|
||||
remote_addr: if self.udp_remote.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.udp_remote.clone())
|
||||
},
|
||||
},
|
||||
},
|
||||
pipelines: self
|
||||
.pipelines
|
||||
.iter()
|
||||
.map(|(name, fc, dc)| PipelineConfig {
|
||||
name: name.clone(),
|
||||
framer: match fc {
|
||||
FramerChoice::Line => FramerConfig::Line {
|
||||
strip_cr: true,
|
||||
max_line_len: 1_048_576,
|
||||
},
|
||||
FramerChoice::Fixed(n) => FramerConfig::Fixed { frame_len: *n },
|
||||
},
|
||||
decoder: match dc {
|
||||
DecoderChoice::Text => DecoderConfig::Text {
|
||||
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
||||
},
|
||||
DecoderChoice::Hex => DecoderConfig::Hex {
|
||||
uppercase: false,
|
||||
separator: " ".into(),
|
||||
bytes_per_group: 1,
|
||||
endian: xserial_core::protocol::Endian::Big,
|
||||
},
|
||||
DecoderChoice::Plot => DecoderConfig::Plot {
|
||||
sample_type: xserial_core::protocol::plot::SampleType::F32,
|
||||
endian: xserial_core::protocol::Endian::Little,
|
||||
channels: 1,
|
||||
format: xserial_core::protocol::plot::PlotFormat::Interleaved,
|
||||
},
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
history_limit: self.history,
|
||||
auto_reconnect: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染函数 ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn render(ui: &mut Ui, form: &mut ConfigForm) -> Option<SessionConfig> {
|
||||
let mut result = None;
|
||||
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.heading("Transport");
|
||||
|
||||
// 传输类型下拉
|
||||
ComboBox::from_label("Type")
|
||||
.selected_text(match form.transport {
|
||||
TransportChoice::Serial => "Serial",
|
||||
TransportChoice::Tcp => "TCP",
|
||||
TransportChoice::Udp => "UDP",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut form.transport, TransportChoice::Serial, "Serial");
|
||||
ui.selectable_value(&mut form.transport, TransportChoice::Tcp, "TCP");
|
||||
ui.selectable_value(&mut form.transport, TransportChoice::Udp, "UDP");
|
||||
});
|
||||
|
||||
// 根据类型显示不同字段
|
||||
match form.transport {
|
||||
TransportChoice::Serial => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Port:");
|
||||
ui.text_edit_singleline(&mut form.port);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Baud:");
|
||||
ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000));
|
||||
});
|
||||
}
|
||||
TransportChoice::Tcp => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Addr:");
|
||||
ui.text_edit_singleline(&mut form.addr);
|
||||
});
|
||||
}
|
||||
TransportChoice::Udp => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Bind:");
|
||||
ui.text_edit_singleline(&mut form.udp_bind);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Remote:");
|
||||
ui.text_edit_singleline(&mut form.udp_remote);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.heading("Pipelines");
|
||||
|
||||
let mut remove = None;
|
||||
|
||||
// 管道列表
|
||||
for (i, (name, fc, dc)) in form.pipelines.iter_mut().enumerate() {
|
||||
ui.group(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.add(TextEdit::singleline(name).desired_width(60.0));
|
||||
|
||||
// Framer 选择
|
||||
let fsel = match fc {
|
||||
FramerChoice::Line => "Line".to_string(),
|
||||
FramerChoice::Fixed(n) => format!("Fixed({})", n),
|
||||
};
|
||||
ComboBox::from_id_salt(format!("f{}", i))
|
||||
.selected_text(fsel)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui.selectable_label(false, "Line").clicked() {
|
||||
*fc = FramerChoice::Line;
|
||||
}
|
||||
if ui.selectable_label(false, "Fixed").clicked() {
|
||||
*fc = FramerChoice::Fixed(8);
|
||||
}
|
||||
});
|
||||
if let FramerChoice::Fixed(n) = fc {
|
||||
ui.add(DragValue::new(n).range(1..=65536));
|
||||
}
|
||||
|
||||
// Decoder 选择
|
||||
ComboBox::from_id_salt(format!("d{}", i))
|
||||
.selected_text(match dc {
|
||||
DecoderChoice::Text => "Text",
|
||||
DecoderChoice::Hex => "Hex",
|
||||
DecoderChoice::Plot => "Plot",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
if ui.selectable_label(false, "Text").clicked() {
|
||||
*dc = DecoderChoice::Text;
|
||||
}
|
||||
if ui.selectable_label(false, "Hex").clicked() {
|
||||
*dc = DecoderChoice::Hex;
|
||||
}
|
||||
if ui.selectable_label(false, "Plot").clicked() {
|
||||
*dc = DecoderChoice::Plot;
|
||||
}
|
||||
});
|
||||
|
||||
if ui.button("✕").clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(i) = remove {
|
||||
form.pipelines.remove(i);
|
||||
}
|
||||
|
||||
if ui.button("+ Add Pipeline").clicked() {
|
||||
form.pipelines.push((
|
||||
format!("p{}", form.pipelines.len()),
|
||||
FramerChoice::Line,
|
||||
DecoderChoice::Text,
|
||||
));
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("History:");
|
||||
ui.add(DragValue::new(&mut form.history).range(100..=1_000_000));
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
if ui.button("✓ Create Session").clicked() {
|
||||
result = Some(form.to_session_config());
|
||||
}
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
2
crates/xserial-gui/src/panels/mod.rs
Normal file
2
crates/xserial-gui/src/panels/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod config;
|
||||
pub mod sidebar;
|
||||
51
crates/xserial-gui/src/panels/sidebar.rs
Normal file
51
crates/xserial-gui/src/panels/sidebar.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::app::ConnectionStatus;
|
||||
use egui::{Color32, RichText, Ui};
|
||||
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
sessions: &[(u64, ConnectionStatus)], // (id, 连接状态)
|
||||
active: &mut usize, // 当前选中的会话索引
|
||||
on_new: &mut bool, // 点击了"新建"
|
||||
on_delete: &mut Option<usize>, // 点击了"删除"
|
||||
) {
|
||||
ui.heading("Sessions");
|
||||
|
||||
// 新建按钮 — 绿色文字
|
||||
if ui
|
||||
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
||||
.clicked()
|
||||
{
|
||||
*on_new = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
// 会话列表
|
||||
for (i, (id, status)) in sessions.iter().enumerate() {
|
||||
let (color, icon) = match status {
|
||||
ConnectionStatus::Connected => (Color32::GREEN, "●"),
|
||||
ConnectionStatus::Disconnected => (Color32::GRAY, "○"),
|
||||
ConnectionStatus::Connecting => (Color32::YELLOW, "◌"),
|
||||
ConnectionStatus::Error(_) => (Color32::RED, "⨉"),
|
||||
};
|
||||
|
||||
// selectable_label: 点击高亮,clicked() 判断是否被点击
|
||||
let label = format!("{} Session {}", icon, id);
|
||||
if ui
|
||||
.selectable_label(*active == i, RichText::new(label).color(color))
|
||||
.clicked()
|
||||
{
|
||||
*active = i;
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
// 删除按钮 — 红色文字
|
||||
if ui
|
||||
.button(RichText::new("Delete").color(Color32::RED))
|
||||
.clicked()
|
||||
{
|
||||
*on_delete = Some(*active);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user