Add mixed text/plot transport and improve plot UI

This commit is contained in:
2026-06-09 00:51:34 +08:00
parent 25433038d5
commit d5b6bb52c8
18 changed files with 2101 additions and 192 deletions

View File

@@ -1,7 +1,8 @@
use std::sync::mpsc;
use std::time::Duration;
use crate::buffers::{HexBuffer, TextBuffer};
use crate::panels::{config, console, hex_view, sidebar};
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::panels::{config, console, hex_view, plot_view, sidebar};
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
use xserial_client::SessionManager;
use xserial_client::config::SessionConfig;
@@ -31,6 +32,7 @@ impl ConnectionStatus {
pub enum View {
Text,
Hex,
Plot,
}
#[derive(Clone, Copy, PartialEq)]
@@ -52,6 +54,8 @@ pub struct SessionTab {
pub status: ConnectionStatus,
pub console: TextBuffer,
pub hex: HexBuffer,
pub plot: PlotBuffer,
pub plot_view: plot_view::PlotViewState,
pub view: View,
pub auto_reconnect: bool,
pub send_input: String,
@@ -131,8 +135,9 @@ impl XserialApp {
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.console.set_limit(history_limit);
tab.hex.set_limit(history_limit);
tab.plot.set_limit(history_limit);
tab.status = ConnectionStatus::Connecting;
tab.send_status = Some(String::from("Session reconfigured"));
}
@@ -173,7 +178,8 @@ impl XserialApp {
match &entry.data {
DecodedData::Text(_) => tab.console.push(&entry),
DecodedData::Hex(_) => tab.hex.push(&entry),
DecodedData::Binary(_) | DecodedData::Plot(_) => {}
DecodedData::Plot(_) => tab.plot.push(&entry),
DecodedData::Binary(_) => {}
}
}
}
@@ -181,6 +187,15 @@ impl XserialApp {
}
}
fn wants_live_plot_repaint(&self) -> bool {
self.tabs
.get(self.active)
.map(|tab| {
matches!(tab.view, View::Plot) && matches!(tab.status, ConnectionStatus::Connected)
})
.unwrap_or(false)
}
fn render_config_window(&mut self, ctx: &egui::Context) {
if !self.config_open {
return;
@@ -215,6 +230,8 @@ impl XserialApp {
status: ConnectionStatus::Connecting,
console: TextBuffer::new(history_limit),
hex: HexBuffer::new(history_limit),
plot: PlotBuffer::new(history_limit),
plot_view: plot_view::PlotViewState::default(),
view: View::Text,
auto_reconnect,
send_input: String::new(),
@@ -296,6 +313,12 @@ impl XserialApp {
if ui.selectable_label(tab.view == View::Hex, "Hex").clicked() {
tab.view = View::Hex;
}
if ui
.selectable_label(tab.view == View::Plot, "Plot")
.clicked()
{
tab.view = View::Plot;
}
});
if render_session_controls(ui, &manager, tab) {
@@ -331,6 +354,7 @@ impl XserialApp {
|ui| match tab.view {
View::Text => console::render(ui, &tab.console, *display),
View::Hex => hex_view::render(ui, &tab.hex, *display),
View::Plot => plot_view::render(ui, &tab.plot, tab.id, &mut tab.plot_view),
},
);
@@ -354,6 +378,9 @@ impl eframe::App for XserialApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.drain_events();
if self.wants_live_plot_repaint() {
ui.ctx().request_repaint_after(Duration::from_millis(16));
}
self.render_config_window(ui.ctx());
self.render_sidebar(ui);
self.render_main_panel(ui);
@@ -404,6 +431,13 @@ fn render_session_controls(
configure_clicked = true;
}
if ui.button("Clear").clicked() {
tab.console.clear();
tab.hex.clear();
tab.plot.clear();
tab.send_status = Some(String::from("Cleared"));
}
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
if response.changed() {
if let Some(handle) = manager.get(tab.id) {

View File

@@ -1,7 +1,9 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use xserial_client::RingBuffer;
use xserial_client::event::DecodedEntry;
use xserial_core::protocol::DecodedData;
use xserial_core::protocol::plot::{PlotFormat, PlotFrame};
#[derive(Clone)]
pub enum LineDirection {
@@ -52,6 +54,14 @@ impl TextBuffer {
pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
self.lines.iter()
}
pub fn clear(&mut self) {
self.lines.clear();
}
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
}
#[derive(Clone)]
@@ -128,4 +138,339 @@ impl HexBuffer {
pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
self.lines.iter()
}
pub fn clear(&mut self) {
self.lines.clear();
}
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
}
pub struct PlotSeries {
pub name: String,
points: VecDeque<[f64; 2]>,
next_x: f64,
}
impl PlotSeries {
fn new(name: String) -> Self {
Self {
name,
points: VecDeque::new(),
next_x: 0.0,
}
}
fn push_samples(&mut self, samples: &[f64], limit: usize) {
for sample in samples {
if !sample.is_finite() {
continue;
}
self.points.push_back([self.next_x, *sample]);
self.next_x += 1.0;
}
while self.points.len() > limit {
self.points.pop_front();
}
}
pub fn points(&self) -> impl Iterator<Item = [f64; 2]> + '_ {
self.points.iter().copied()
}
pub fn render_points_time_series(&self, x_min: f64, x_max: f64, max_points: usize) -> Vec<[f64; 2]> {
if self.points.is_empty() || max_points == 0 {
return Vec::new();
}
let visible: Vec<[f64; 2]> = self
.points
.iter()
.copied()
.filter(|point| point[0] >= x_min && point[0] <= x_max)
.collect();
if visible.len() <= max_points {
return visible;
}
let bucket_count = (max_points / 2).max(1);
let width = (x_max - x_min).max(1.0);
let bucket_width = width / bucket_count as f64;
let mut rendered = Vec::with_capacity(bucket_count * 2);
let mut start = 0usize;
for bucket_index in 0..bucket_count {
let bucket_start = x_min + bucket_width * bucket_index as f64;
let bucket_end = if bucket_index + 1 == bucket_count {
x_max
} else {
bucket_start + bucket_width
};
let mut min_point: Option<[f64; 2]> = None;
let mut max_point: Option<[f64; 2]> = None;
while start < visible.len() {
let point = visible[start];
if point[0] > bucket_end {
break;
}
if point[0] >= bucket_start {
match min_point {
Some(current) if current[1] <= point[1] => {}
_ => min_point = Some(point),
}
match max_point {
Some(current) if current[1] >= point[1] => {}
_ => max_point = Some(point),
}
}
start += 1;
}
match (min_point, max_point) {
(Some(a), Some(b)) if a[0] <= b[0] => {
rendered.push(a);
if a != b {
rendered.push(b);
}
}
(Some(a), Some(b)) => {
rendered.push(b);
if a != b {
rendered.push(a);
}
}
(Some(a), None) | (None, Some(a)) => rendered.push(a),
(None, None) => {}
}
}
if rendered.len() > max_points {
let stride = rendered.len().div_ceil(max_points);
rendered.into_iter().step_by(stride).collect()
} else {
rendered
}
}
pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> {
if self.points.is_empty() || max_points == 0 {
return Vec::new();
}
if self.points.len() <= max_points {
return self.points.iter().copied().collect();
}
let stride = self.points.len().div_ceil(max_points);
self.points.iter().copied().step_by(stride).collect()
}
}
pub enum PlotSeriesKind {
TimeSeries,
XY,
}
pub struct PlotBuffer {
limit: usize,
kind: PlotSeriesKind,
series: Vec<PlotSeries>,
}
impl PlotBuffer {
pub fn new(limit: usize) -> Self {
Self {
limit,
kind: PlotSeriesKind::TimeSeries,
series: Vec::new(),
}
}
pub fn push(&mut self, entry: &DecodedEntry) {
if let DecodedData::Plot(frame) = &entry.data {
self.push_frame(&entry.pipeline_name, frame);
}
}
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
self.kind = match frame.format {
PlotFormat::XY => PlotSeriesKind::XY,
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
};
if matches!(frame.format, PlotFormat::XY) {
self.push_xy_frame(pipeline_name, frame);
return;
}
for (index, channel) in frame.channels.iter().enumerate() {
let series_name = if frame.channels.len() == 1 {
pipeline_name.to_owned()
} else {
format!("{pipeline_name}:ch{}", index + 1)
};
if let Some(series) = self
.series
.iter_mut()
.find(|series| series.name == series_name)
{
series.push_samples(channel, self.limit);
} else {
let mut series = PlotSeries::new(series_name);
series.push_samples(channel, self.limit);
self.series.push(series);
}
}
}
fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
if frame.channels.len() < 2 {
return;
}
let x = &frame.channels[0];
let y = &frame.channels[1];
let len = x.len().min(y.len());
let series_name = format!("{pipeline_name}:xy");
let series = if let Some(series) = self
.series
.iter_mut()
.find(|series| series.name == series_name)
{
series
} else {
self.series.push(PlotSeries::new(series_name));
self.series.last_mut().expect("just pushed")
};
for index in 0..len {
if !x[index].is_finite() || !y[index].is_finite() {
continue;
}
series.points.push_back([x[index], y[index]]);
}
while series.points.len() > self.limit {
series.points.pop_front();
}
}
pub fn clear(&mut self) {
self.series.clear();
}
pub fn set_limit(&mut self, limit: usize) {
self.limit = limit;
for series in &mut self.series {
while series.points.len() > self.limit {
series.points.pop_front();
}
}
}
pub fn is_empty(&self) -> bool {
self.series.is_empty()
}
pub fn kind(&self) -> &PlotSeriesKind {
&self.kind
}
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
self.series.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
use xserial_client::event::DecodedEntry;
use xserial_core::protocol::DecodedData;
use xserial_core::protocol::plot::{PlotFrame, SampleType};
fn plot_entry() -> DecodedEntry {
DecodedEntry {
pipeline_name: String::from("plot"),
data: DecodedData::Plot(PlotFrame {
channels: vec![vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 30.0]],
raw: vec![],
sample_type: SampleType::F32,
format: PlotFormat::Interleaved,
}),
}
}
fn xy_plot_entry() -> DecodedEntry {
DecodedEntry {
pipeline_name: String::from("xy"),
data: DecodedData::Plot(PlotFrame {
channels: vec![vec![0.0, 1.0, 2.0], vec![10.0, 20.0, 30.0]],
raw: vec![],
sample_type: SampleType::F32,
format: PlotFormat::XY,
}),
}
}
#[test]
fn plot_buffer_creates_series_per_channel() {
let mut buffer = PlotBuffer::new(8);
buffer.push(&plot_entry());
let series: Vec<_> = buffer.iter().collect();
assert_eq!(series.len(), 2);
assert_eq!(series[0].name, "plot:ch1");
assert_eq!(series[1].name, "plot:ch2");
assert_eq!(series[0].points().count(), 3);
assert_eq!(series[1].points().count(), 3);
}
#[test]
fn plot_buffer_limit_and_clear_work() {
let mut buffer = PlotBuffer::new(2);
buffer.push(&plot_entry());
assert_eq!(buffer.iter().next().unwrap().points().count(), 2);
buffer.clear();
assert!(buffer.is_empty());
}
#[test]
fn plot_buffer_xy_uses_xy_points() {
let mut buffer = PlotBuffer::new(8);
buffer.push(&xy_plot_entry());
assert!(matches!(buffer.kind(), PlotSeriesKind::XY));
let series: Vec<_> = buffer.iter().collect();
assert_eq!(series.len(), 1);
let points: Vec<_> = series[0].points().collect();
assert_eq!(points, vec![[0.0, 10.0], [1.0, 20.0], [2.0, 30.0]]);
}
#[test]
fn plot_series_time_series_downsamples_to_budget() {
let mut series = PlotSeries::new(String::from("plot"));
series.push_samples(&(0..100).map(|n| n as f64).collect::<Vec<_>>(), 200);
let rendered = series.render_points_time_series(0.0, 99.0, 16);
assert!(!rendered.is_empty());
assert!(rendered.len() <= 16);
assert!(rendered.iter().all(|point| point[0] >= 0.0 && point[0] <= 99.0));
}
#[test]
fn plot_series_xy_downsamples_to_budget() {
let mut series = PlotSeries::new(String::from("xy"));
for n in 0..100 {
series.points.push_back([n as f64, (n * 2) as f64]);
}
let rendered = series.render_points_xy(10);
assert!(rendered.len() <= 10);
assert_eq!(rendered.first().copied(), Some([0.0, 0.0]));
}
}

View File

@@ -1,6 +1,10 @@
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use xserial_core::protocol::plot::PlotFormat;
use xserial_core::transport::TransportConfig;
use xserial_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
// ── 表单数据结构(简化版,方便 UI 渲染)────────────────────────────
@@ -9,14 +13,27 @@ pub struct ConfigForm {
pub transport: TransportChoice,
pub port: String,
pub baud: u32,
pub serial_data_bits: SerialDataBits,
pub serial_parity: SerialParity,
pub serial_stop_bits: SerialStopBits,
pub serial_flow_control: SerialFlowControl,
pub addr: String,
pub udp_bind: String,
pub udp_remote: String,
pub pipelines: Vec<(String, FramerChoice, DecoderChoice)>,
pub pipelines: Vec<PipelineForm>,
pub history: usize,
pub auto_reconnect: bool,
}
#[derive(Clone)]
pub struct PipelineForm {
pub name: String,
pub framer: FramerChoice,
pub decoder: DecoderChoice,
pub plot_channels: usize,
pub plot_format: PlotFormat,
}
#[derive(Clone, PartialEq)]
pub enum TransportChoice {
Serial,
@@ -28,6 +45,7 @@ pub enum TransportChoice {
pub enum FramerChoice {
Line,
Fixed(usize),
MixedTextPlot,
}
#[derive(Clone, PartialEq)]
@@ -35,6 +53,7 @@ pub enum DecoderChoice {
Text,
Hex,
Plot,
MixedTextPlot,
}
impl Default for ConfigForm {
@@ -43,10 +62,20 @@ impl Default for ConfigForm {
transport: TransportChoice::Tcp,
port: "/dev/ttyUSB0".into(),
baud: 115200,
serial_data_bits: SerialDataBits::Eight,
serial_parity: SerialParity::None,
serial_stop_bits: SerialStopBits::One,
serial_flow_control: SerialFlowControl::None,
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)],
pipelines: vec![PipelineForm {
name: "text".into(),
framer: FramerChoice::Line,
decoder: DecoderChoice::Text,
plot_channels: 1,
plot_format: PlotFormat::Interleaved,
}],
history: 10000,
auto_reconnect: false,
}
@@ -69,6 +98,22 @@ impl ConfigForm {
TransportConfig::Serial { baud_rate, .. } => *baud_rate,
_ => 115200,
},
serial_data_bits: match &config.transport {
TransportConfig::Serial { data_bits, .. } => *data_bits,
_ => SerialDataBits::Eight,
},
serial_parity: match &config.transport {
TransportConfig::Serial { parity, .. } => *parity,
_ => SerialParity::None,
},
serial_stop_bits: match &config.transport {
TransportConfig::Serial { stop_bits, .. } => *stop_bits,
_ => SerialStopBits::One,
},
serial_flow_control: match &config.transport {
TransportConfig::Serial { flow_control, .. } => *flow_control,
_ => SerialFlowControl::None,
},
addr: match &config.transport {
TransportConfig::Tcp { addr } => addr.clone(),
_ => String::from("127.0.0.1:8080"),
@@ -88,6 +133,7 @@ impl ConfigForm {
let framer = match &pipeline.framer {
FramerConfig::Line { .. } => FramerChoice::Line,
FramerConfig::Fixed { frame_len } => FramerChoice::Fixed(*frame_len),
FramerConfig::MixedTextPlot { .. } => FramerChoice::MixedTextPlot,
FramerConfig::Length { .. } | FramerConfig::Cobs { .. } => {
FramerChoice::Line
}
@@ -96,8 +142,23 @@ impl ConfigForm {
DecoderConfig::Text { .. } => DecoderChoice::Text,
DecoderConfig::Hex { .. } => DecoderChoice::Hex,
DecoderConfig::Plot { .. } => DecoderChoice::Plot,
DecoderConfig::MixedTextPlot { .. } => DecoderChoice::MixedTextPlot,
};
(pipeline.name.clone(), framer, decoder)
let plot_channels = match &pipeline.decoder {
DecoderConfig::Plot { channels, .. } => *channels,
_ => 1,
};
let plot_format = match &pipeline.decoder {
DecoderConfig::Plot { format, .. } => *format,
_ => PlotFormat::Interleaved,
};
PipelineForm {
name: pipeline.name.clone(),
framer,
decoder,
plot_channels,
plot_format,
}
})
.collect(),
history: config.history_limit,
@@ -112,6 +173,10 @@ impl ConfigForm {
TransportChoice::Serial => TransportConfig::Serial {
port: self.port.clone(),
baud_rate: self.baud,
data_bits: self.serial_data_bits,
parity: self.serial_parity,
stop_bits: self.serial_stop_bits,
flow_control: self.serial_flow_control,
},
TransportChoice::Tcp => TransportConfig::Tcp {
addr: self.addr.clone(),
@@ -128,16 +193,21 @@ impl ConfigForm {
pipelines: self
.pipelines
.iter()
.map(|(name, fc, dc)| PipelineConfig {
name: name.clone(),
framer: match fc {
.map(|pipeline| PipelineConfig {
name: pipeline.name.clone(),
framer: match &pipeline.framer {
FramerChoice::Line => FramerConfig::Line {
strip_cr: true,
max_line_len: 1_048_576,
},
FramerChoice::Fixed(n) => FramerConfig::Fixed { frame_len: *n },
FramerChoice::MixedTextPlot => FramerConfig::MixedTextPlot {
strip_cr: true,
max_line_len: 1_048_576,
max_plot_frame: 1_048_576,
},
},
decoder: match dc {
decoder: match &pipeline.decoder {
DecoderChoice::Text => DecoderConfig::Text {
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
},
@@ -150,8 +220,11 @@ impl ConfigForm {
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,
channels: pipeline.plot_channels.max(1),
format: pipeline.plot_format,
},
DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot {
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
},
},
})
@@ -166,6 +239,7 @@ impl ConfigForm {
pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<SessionConfig> {
let mut result = None;
let mut validation_errors = Vec::new();
ScrollArea::vertical().show(ui, |ui| {
ui.heading("Transport");
@@ -194,6 +268,106 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
ui.label("Baud:");
ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000));
});
ui.horizontal(|ui| {
ui.label("Data bits:");
ComboBox::from_id_salt("serial_data_bits")
.selected_text(match form.serial_data_bits {
SerialDataBits::Five => "5",
SerialDataBits::Six => "6",
SerialDataBits::Seven => "7",
SerialDataBits::Eight => "8",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Five,
"5",
);
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Six,
"6",
);
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Seven,
"7",
);
ui.selectable_value(
&mut form.serial_data_bits,
SerialDataBits::Eight,
"8",
);
});
});
ui.horizontal(|ui| {
ui.label("Parity:");
ComboBox::from_id_salt("serial_parity")
.selected_text(match form.serial_parity {
SerialParity::None => "None",
SerialParity::Odd => "Odd",
SerialParity::Even => "Even",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_parity,
SerialParity::None,
"None",
);
ui.selectable_value(&mut form.serial_parity, SerialParity::Odd, "Odd");
ui.selectable_value(
&mut form.serial_parity,
SerialParity::Even,
"Even",
);
});
});
ui.horizontal(|ui| {
ui.label("Stop bits:");
ComboBox::from_id_salt("serial_stop_bits")
.selected_text(match form.serial_stop_bits {
SerialStopBits::One => "1",
SerialStopBits::Two => "2",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_stop_bits,
SerialStopBits::One,
"1",
);
ui.selectable_value(
&mut form.serial_stop_bits,
SerialStopBits::Two,
"2",
);
});
});
ui.horizontal(|ui| {
ui.label("Flow:");
ComboBox::from_id_salt("serial_flow_control")
.selected_text(match form.serial_flow_control {
SerialFlowControl::None => "None",
SerialFlowControl::Software => "Software",
SerialFlowControl::Hardware => "Hardware",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut form.serial_flow_control,
SerialFlowControl::None,
"None",
);
ui.selectable_value(
&mut form.serial_flow_control,
SerialFlowControl::Software,
"Software",
);
ui.selectable_value(
&mut form.serial_flow_control,
SerialFlowControl::Hardware,
"Hardware",
);
});
});
}
TransportChoice::Tcp => {
ui.horizontal(|ui| {
@@ -219,47 +393,60 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
let mut remove = None;
// 管道列表
for (i, (name, fc, dc)) in form.pipelines.iter_mut().enumerate() {
for (i, pipeline) in form.pipelines.iter_mut().enumerate() {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.add(TextEdit::singleline(name).desired_width(60.0));
ui.add(TextEdit::singleline(&mut pipeline.name).desired_width(60.0));
// Framer 选择
let fsel = match fc {
let fsel = match &pipeline.framer {
FramerChoice::Line => "Line".to_string(),
FramerChoice::Fixed(n) => format!("Fixed({})", n),
FramerChoice::MixedTextPlot => "MixedTextPlot".to_string(),
};
ComboBox::from_id_salt(format!("f{}", i))
.selected_text(fsel)
.show_ui(ui, |ui| {
if ui.selectable_label(false, "Line").clicked() {
*fc = FramerChoice::Line;
pipeline.framer = FramerChoice::Line;
}
if ui.selectable_label(false, "Fixed").clicked() {
*fc = FramerChoice::Fixed(8);
pipeline.framer = FramerChoice::Fixed(8);
}
if ui.selectable_label(false, "MixedTextPlot").clicked() {
pipeline.framer = FramerChoice::MixedTextPlot;
pipeline.decoder = DecoderChoice::MixedTextPlot;
}
});
if let FramerChoice::Fixed(n) = fc {
if let FramerChoice::Fixed(n) = &mut pipeline.framer {
ui.add(DragValue::new(n).range(1..=65536));
}
// Decoder 选择
ComboBox::from_id_salt(format!("d{}", i))
.selected_text(match dc {
.selected_text(match pipeline.decoder {
DecoderChoice::Text => "Text",
DecoderChoice::Hex => "Hex",
DecoderChoice::Plot => "Plot",
DecoderChoice::MixedTextPlot => "MixedTextPlot",
})
.show_ui(ui, |ui| {
if ui.selectable_label(false, "Text").clicked() {
*dc = DecoderChoice::Text;
pipeline.decoder = DecoderChoice::Text;
}
if ui.selectable_label(false, "Hex").clicked() {
*dc = DecoderChoice::Hex;
pipeline.decoder = DecoderChoice::Hex;
}
if ui.selectable_label(false, "Plot").clicked() {
*dc = DecoderChoice::Plot;
if matches!(pipeline.framer, FramerChoice::Line) {
pipeline.framer = FramerChoice::Fixed(256);
}
pipeline.decoder = DecoderChoice::Plot;
}
if ui.selectable_label(false, "MixedTextPlot").clicked() {
pipeline.framer = FramerChoice::MixedTextPlot;
pipeline.decoder = DecoderChoice::MixedTextPlot;
}
});
@@ -267,6 +454,88 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
remove = Some(i);
}
});
if matches!(pipeline.decoder, DecoderChoice::Plot) {
ui.horizontal(|ui| {
ui.label("Plot channels:");
ui.add(DragValue::new(&mut pipeline.plot_channels).range(1..=32));
});
ui.horizontal(|ui| {
ui.label("Plot format:");
ComboBox::from_id_salt(format!("plot_format{}", i))
.selected_text(match pipeline.plot_format {
PlotFormat::Interleaved => "Interleaved",
PlotFormat::Block => "Block",
PlotFormat::XY => "XY",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut pipeline.plot_format,
PlotFormat::Interleaved,
"Interleaved",
);
ui.selectable_value(
&mut pipeline.plot_format,
PlotFormat::Block,
"Block",
);
ui.selectable_value(
&mut pipeline.plot_format,
PlotFormat::XY,
"XY",
);
});
});
if matches!(pipeline.plot_format, PlotFormat::XY) {
pipeline.plot_channels = 2;
}
match &pipeline.framer {
FramerChoice::Line => {
let message = "Plot decoder requires Fixed framer for raw binary samples";
ui.colored_label(egui::Color32::RED, message);
validation_errors
.push(format!("pipeline '{}': {message}", pipeline.name));
}
FramerChoice::Fixed(n)
if *n % (pipeline.plot_channels.max(1) * 4) != 0 =>
{
let message = format!(
"Plot decoder currently expects interleaved f32 data, so frame length must be a multiple of {} bytes",
pipeline.plot_channels.max(1) * 4
);
ui.colored_label(egui::Color32::RED, &message);
validation_errors
.push(format!("pipeline '{}': {message}", pipeline.name));
}
FramerChoice::MixedTextPlot => {
let message = "Raw Plot decoder cannot be used with MixedTextPlot framer";
ui.colored_label(egui::Color32::RED, message);
validation_errors
.push(format!("pipeline '{}': {message}", pipeline.name));
}
FramerChoice::Fixed(_) => {}
}
if matches!(pipeline.plot_format, PlotFormat::XY) && pipeline.plot_channels != 2
{
let message = "XY plot requires exactly 2 channels";
ui.colored_label(egui::Color32::RED, message);
validation_errors.push(format!(
"pipeline '{}': {message}",
pipeline.name
));
}
}
if matches!(pipeline.decoder, DecoderChoice::MixedTextPlot)
&& !matches!(pipeline.framer, FramerChoice::MixedTextPlot)
{
let message = "MixedTextPlot decoder requires MixedTextPlot framer";
ui.colored_label(egui::Color32::RED, message);
validation_errors.push(format!("pipeline '{}': {message}", pipeline.name));
}
});
}
@@ -275,11 +544,13 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
}
if ui.button("+ Add Pipeline").clicked() {
form.pipelines.push((
format!("p{}", form.pipelines.len()),
FramerChoice::Line,
DecoderChoice::Text,
));
form.pipelines.push(PipelineForm {
name: format!("p{}", form.pipelines.len()),
framer: FramerChoice::Line,
decoder: DecoderChoice::Text,
plot_channels: 1,
plot_format: PlotFormat::Interleaved,
});
}
ui.separator();
@@ -290,8 +561,13 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
ui.checkbox(&mut form.auto_reconnect, "Auto reconnect");
ui.separator();
for error in &validation_errors {
ui.colored_label(egui::Color32::RED, error);
}
if ui.button(submit_label).clicked() {
result = Some(form.to_session_config());
if validation_errors.is_empty() {
result = Some(form.to_session_config());
}
}
});

View File

@@ -1,4 +1,5 @@
pub mod config;
pub mod console;
pub mod hex_view;
pub mod plot_view;
pub mod sidebar;

View File

@@ -0,0 +1,199 @@
use crate::buffers::{PlotBuffer, PlotSeriesKind};
use egui::{Button, Ui, vec2};
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
pub struct PlotViewState {
pub lock_x: bool,
pub lock_y: bool,
pub box_zoom: bool,
pub follow_latest: bool,
pub last_bounds: Option<PlotBounds>,
}
impl Default for PlotViewState {
fn default() -> Self {
Self {
lock_x: false,
lock_y: false,
box_zoom: false,
follow_latest: false,
last_bounds: None,
}
}
}
pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotViewState) {
if buf.is_empty() {
ui.label("no plot data");
return;
}
let mut auto_fit = false;
let mut reset_view = false;
let mut zoom_x = 1.0_f32;
let mut zoom_y = 1.0_f32;
ui.horizontal_wrapped(|ui| {
if ui.button("Auto Fit").clicked() {
auto_fit = true;
}
if ui.button("Reset View").clicked() {
reset_view = true;
}
ui.separator();
ui.checkbox(&mut state.lock_x, "Lock X");
ui.checkbox(&mut state.lock_y, "Lock Y");
ui.checkbox(&mut state.box_zoom, "Box Zoom");
if matches!(buf.kind(), PlotSeriesKind::TimeSeries) {
ui.checkbox(&mut state.follow_latest, "Follow Latest");
} else {
state.follow_latest = false;
}
ui.separator();
if ui
.add_enabled(!state.lock_x, Button::new("X-"))
.on_hover_text("Zoom out X axis")
.clicked()
{
zoom_x = 0.8;
}
if ui
.add_enabled(!state.lock_x, Button::new("X+"))
.on_hover_text("Zoom in X axis")
.clicked()
{
zoom_x = 1.25;
}
if ui
.add_enabled(!state.lock_y, Button::new("Y-"))
.on_hover_text("Zoom out Y axis")
.clicked()
{
zoom_y = 0.8;
}
if ui
.add_enabled(!state.lock_y, Button::new("Y+"))
.on_hover_text("Zoom in Y axis")
.clicked()
{
zoom_y = 1.25;
}
});
if let Some(bounds) = state.last_bounds {
ui.horizontal_wrapped(|ui| {
ui.monospace(format!(
"X [{:.3}, {:.3}] Y [{:.3}, {:.3}]",
bounds.min()[0],
bounds.max()[0],
bounds.min()[1],
bounds.max()[1],
));
});
}
let data_bounds = plot_bounds(buf);
let mut plot = Plot::new(format!("plot_view_{session_id}"))
.legend(Legend::default())
.allow_boxed_zoom(state.box_zoom)
.allow_zoom([!state.lock_x, !state.lock_y])
.allow_drag([!state.lock_x, !state.lock_y])
.allow_axis_zoom_drag([!state.lock_x, !state.lock_y])
.allow_scroll([
!state.lock_x && matches!(buf.kind(), PlotSeriesKind::TimeSeries),
!state.lock_y && matches!(buf.kind(), PlotSeriesKind::TimeSeries),
]);
if reset_view {
plot = plot.reset();
}
plot.show(ui, |plot_ui| {
let current_bounds = plot_ui.plot_bounds();
state.last_bounds = current_bounds.is_finite().then_some(current_bounds);
let pixel_width = plot_ui.response().rect.width().max(64.0);
let max_render_points = (pixel_width as usize).saturating_mul(2);
if auto_fit {
if let Some(bounds) = data_bounds {
plot_ui.set_auto_bounds(false);
plot_ui.set_plot_bounds(bounds);
state.last_bounds = Some(bounds);
}
} else if state.follow_latest && matches!(buf.kind(), PlotSeriesKind::TimeSeries) {
if let Some(bounds) = data_bounds {
let current_width = current_bounds.width();
let data_width = bounds.width();
let x_range = if current_width.is_finite() && current_width > 0.0 && data_width > current_width {
(bounds.max()[0] - current_width)..=bounds.max()[0]
} else {
bounds.min()[0]..=bounds.max()[0]
};
plot_ui.set_auto_bounds([false, plot_ui.auto_bounds().y]);
plot_ui.set_plot_bounds_x(x_range.clone());
let mut followed = current_bounds;
followed.set_x(&PlotBounds::from_min_max([*x_range.start(), current_bounds.min()[1]], [*x_range.end(), current_bounds.max()[1]]));
state.last_bounds = Some(followed);
}
} else if zoom_x != 1.0 || zoom_y != 1.0 {
let center = plot_ui.plot_bounds().center();
plot_ui.zoom_bounds(vec2(zoom_x, zoom_y), center);
}
if let Some(bounds) = data_bounds {
if !bounds.is_valid_x() {
plot_ui.set_auto_bounds([true, plot_ui.auto_bounds().y]);
}
if !bounds.is_valid_y() {
plot_ui.set_auto_bounds([plot_ui.auto_bounds().x, true]);
}
}
for series in buf.iter() {
let sampled_points = match buf.kind() {
PlotSeriesKind::TimeSeries => {
series.render_points_time_series(
current_bounds.min()[0],
current_bounds.max()[0],
max_render_points,
)
}
PlotSeriesKind::XY => series.render_points_xy(max_render_points),
};
let points = PlotPoints::from_iter(sampled_points);
plot_ui.line(Line::new(series.name.clone(), points));
}
});
}
fn plot_bounds(buf: &PlotBuffer) -> Option<PlotBounds> {
let mut bounds = PlotBounds::NOTHING;
for series in buf.iter() {
for [x, y] in series.points() {
if x.is_finite() && y.is_finite() {
bounds.extend_with(&[x, y].into());
}
}
}
if !bounds.is_finite() {
return None;
}
if bounds.width() <= 0.0 {
let center_x = bounds.center().x;
bounds.set_x_center_width(center_x, 1.0);
} else {
bounds.expand_x(bounds.width() * 0.05);
}
if bounds.height() <= 0.0 {
let center_y = bounds.center().y;
bounds.set_y_center_height(center_y, 1.0);
} else {
bounds.expand_y(bounds.height() * 0.1);
}
Some(bounds)
}