Update core modules and add GUI panels

This commit is contained in:
2026-06-08 17:32:42 +08:00
parent 2d33b4b2ae
commit 2eb02350da
23 changed files with 822 additions and 161 deletions

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

View File

@@ -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)))),
);
}

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

View File

@@ -0,0 +1,2 @@
pub mod config;
pub mod sidebar;

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