Improve GUI plot workflow and performance

This commit is contained in:
2026-06-10 11:54:57 +08:00
parent 9b826d79da
commit 18bdd2652a
12 changed files with 989 additions and 217 deletions

View File

@@ -28,6 +28,9 @@ impl<T> RingBuffer<T> {
pub fn iter(&self) -> impl Iterator<Item = &T> {
self.buf.iter()
}
pub fn get(&self, index: usize) -> Option<&T> {
self.buf.get(index)
}
pub fn clear(&mut self) {
self.buf.clear();
}

View File

@@ -52,7 +52,8 @@ impl MixedTextPlotDecoder {
Endian::Big => 1,
});
packet.push(channels.min(u8::MAX as usize) as u8);
packet.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes());
packet
.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes());
packet.extend_from_slice(&(payload.len().min(u32::MAX as usize) as u32).to_le_bytes());
packet.extend_from_slice(payload);
packet

View File

@@ -6,8 +6,8 @@ use tokio::time::timeout;
use xserial_core::frame::{
Endian, Framer,
cobs::cobs_encode as raw_cobs_encode,
cobs::CobsFramer,
cobs::cobs_encode as raw_cobs_encode,
fixed::FixedLengthFramer,
length::{LengthConfig, LengthPrefixedFramer},
line::{LineConfig, LineFramer},
@@ -298,10 +298,7 @@ async fn tcp_mixed_text_and_plot_single_stream() {
conn.disconnect().await.unwrap();
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
let results: Vec<DecodedData> = frames
.iter()
.filter_map(|f| decoder.decode(f))
.collect();
let results: Vec<DecodedData> = frames.iter().filter_map(|f| decoder.decode(f)).collect();
assert_eq!(results.len(), 3);
assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok"));

View File

@@ -1,9 +1,10 @@
use crate::app_state::{self, PersistedGuiState};
use std::sync::mpsc;
use std::time::Duration;
use std::time::{Duration, Instant};
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::panels::{config, console, hex_view, plot_view, sidebar};
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
use xserial_client::SessionManager;
@@ -12,6 +13,8 @@ use xserial_client::session::SessionEvent;
use xserial_core::protocol::DecodedData;
use xserial_core::transport::TransportConfig;
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
#[derive(Clone)]
pub enum ConnectionStatus {
Connected,
@@ -86,6 +89,7 @@ pub struct XserialApp {
config_form: config::ConfigForm,
event_rx: mpsc::Receiver<SessionEvent>,
pending: Vec<SessionEvent>,
profiler: GuiProfiler,
}
impl XserialApp {
@@ -94,16 +98,32 @@ impl XserialApp {
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
ctx: egui::Context,
) -> Self {
let profiler = GuiProfiler::from_env();
let repaint_counter = profiler.repaint_counter();
let (event_tx, event_rx) = mpsc::channel();
let repaint_ctx = ctx.clone();
tokio::spawn(async move {
let mut last_repaint = Instant::now()
.checked_sub(DATA_REPAINT_INTERVAL)
.unwrap_or_else(Instant::now);
let mut delayed_repaint_pending = false;
loop {
match rx.recv().await {
Ok(event) => {
if event_tx.send(event).is_err() {
break;
}
repaint_ctx.request_repaint();
let elapsed = last_repaint.elapsed();
if elapsed >= DATA_REPAINT_INTERVAL {
repaint_counter.increment();
repaint_ctx.request_repaint();
last_repaint = Instant::now();
delayed_repaint_pending = false;
} else if !delayed_repaint_pending {
repaint_counter.increment();
repaint_ctx.request_repaint_after(DATA_REPAINT_INTERVAL - elapsed);
delayed_repaint_pending = true;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
@@ -139,6 +159,7 @@ impl XserialApp {
config_form: config::ConfigForm::default(),
event_rx,
pending: Vec::new(),
profiler,
};
app.restore_saved_sessions(saved_state);
app
@@ -230,11 +251,22 @@ impl XserialApp {
}
fn drain_events(&mut self) {
let started = Instant::now();
let mut stats = DrainStats {
drained_events: 0,
drained_data_events: 0,
drained_text_events: 0,
drained_hex_events: 0,
drained_plot_events: 0,
pending_events: 0,
};
while let Ok(event) = self.event_rx.try_recv() {
self.pending.push(event);
}
stats.pending_events = self.pending.len();
for event in self.pending.drain(..) {
stats.drained_events += 1;
match event {
SessionEvent::Connected(id) => {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
@@ -252,26 +284,62 @@ impl XserialApp {
}
}
SessionEvent::Data(id, entry) => {
stats.drained_data_events += 1;
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::Plot(_) => tab.plot.push(&entry),
DecodedData::Text(_) => {
stats.drained_text_events += 1;
tab.console.push(&entry);
}
DecodedData::Hex(_) => {
stats.drained_hex_events += 1;
tab.hex.push(&entry);
}
DecodedData::Plot(_) => {
stats.drained_plot_events += 1;
tab.plot.push(&entry);
}
DecodedData::Binary(_) => {}
}
}
}
}
}
self.profiler.record_drain(started.elapsed(), stats);
}
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)
self.tabs.iter().enumerate().any(|(index, tab)| {
matches!(tab.status, ConnectionStatus::Connected)
&& (tab.plot_view.detached
|| (index == self.active && matches!(tab.view, View::Plot)))
})
}
fn profiler_snapshot(&self) -> GuiSnapshot {
if let Some(tab) = self.tabs.get(self.active) {
GuiSnapshot {
tabs: self.tabs.len(),
active_view: match tab.view {
View::Text => "text",
View::Hex => "hex",
View::Plot => "plot",
},
active_text_lines: tab.console.len(),
active_hex_lines: tab.hex.len(),
active_plot_series: tab.plot.series_len(),
active_plot_points: tab.plot.total_points(),
}
} else {
GuiSnapshot {
tabs: self.tabs.len(),
active_view: "none",
active_text_lines: 0,
active_hex_lines: 0,
active_plot_series: 0,
active_plot_points: 0,
}
}
}
fn render_config_window(&mut self, ctx: &egui::Context) {
@@ -313,6 +381,7 @@ impl XserialApp {
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
let mut on_new = false;
let mut on_edit = None;
let mut on_delete = None;
let previous_active = self.active;
@@ -328,13 +397,26 @@ impl XserialApp {
transport_summary: transport_summary(&tab.session_config.transport),
})
.collect();
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
sidebar::render(
ui,
&sessions,
&mut self.active,
&mut on_new,
&mut on_edit,
&mut on_delete,
);
});
if on_new {
self.open_create_config();
}
if let Some(index) = on_edit
&& let Some(tab) = self.tabs.get(index)
{
self.open_edit_config(tab.id);
}
if let Some(index) = on_delete {
if let Some(tab) = self.tabs.get(index) {
self.manager.remove(tab.id);
@@ -362,8 +444,10 @@ impl XserialApp {
let manager = self.manager.clone();
let display = &mut self.display;
let mut edit_session = None;
let mut persist_state = false;
let mut text_render = None;
let mut hex_render = None;
let mut plot_render = None;
{
let tab = &mut self.tabs[self.active];
@@ -392,11 +476,7 @@ impl XserialApp {
}
});
let (configure_clicked, auto_reconnect_changed) =
render_session_controls(ui, &manager, tab);
if configure_clicked {
edit_session = Some(tab.id);
}
let auto_reconnect_changed = render_session_controls(ui, &manager, tab);
persist_state |= auto_reconnect_changed;
let mut display_changed = false;
@@ -431,10 +511,41 @@ impl XserialApp {
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),
View::Plot => plot_view::render(ui, &tab.plot, tab.id, &mut tab.plot_view),
|ui| {
let started = Instant::now();
match tab.view {
View::Text => {
let line_count = console::render(ui, &tab.console, *display);
text_render = Some((started.elapsed(), line_count));
}
View::Hex => {
let line_count = hex_view::render(ui, &tab.hex, *display);
hex_render = Some((started.elapsed(), line_count));
}
View::Plot => {
if tab.plot_view.detached {
ui.heading(format!(
"Plot window detached for Session {}",
tab.id
));
ui.label("The plot is currently shown in a floating window.");
if ui.button("Dock Plot Back").clicked() {
tab.plot_view.detached = false;
}
} else {
let output = plot_view::render(
ui,
&mut tab.plot,
tab.id,
&mut tab.plot_view,
);
if output.toggle_detached {
tab.plot_view.detached = true;
}
plot_render = Some((started.elapsed(), output.stats));
}
}
}
},
);
@@ -446,8 +557,14 @@ impl XserialApp {
);
}
if let Some(id) = edit_session {
self.open_edit_config(id);
if let Some((elapsed, line_count)) = text_render {
self.profiler.record_text_render(elapsed, line_count);
}
if let Some((elapsed, line_count)) = hex_render {
self.profiler.record_hex_render(elapsed, line_count);
}
if let Some((elapsed, stats)) = plot_render {
self.profiler.record_plot_render(elapsed, stats);
}
if persist_state {
self.persist_gui_state();
@@ -455,6 +572,46 @@ impl XserialApp {
});
}
fn render_detached_plot_windows(&mut self, ctx: &egui::Context) {
let mut plot_renders = Vec::new();
for tab in &mut self.tabs {
if !tab.plot_view.detached {
continue;
}
let mut open = true;
let mut output = None;
egui::Window::new(format!("Plot - Session {}", tab.id))
.open(&mut open)
.default_width(720.0)
.default_height(480.0)
.resizable(true)
.vscroll(false)
.show(ctx, |ui| {
let started = Instant::now();
let render_output =
plot_view::render(ui, &mut tab.plot, tab.id, &mut tab.plot_view);
output = Some((started.elapsed(), render_output));
});
if let Some((elapsed, render_output)) = output {
if render_output.toggle_detached {
tab.plot_view.detached = false;
}
plot_renders.push((elapsed, render_output.stats));
}
if !open {
tab.plot_view.detached = false;
}
}
for (elapsed, stats) in plot_renders {
self.profiler.record_plot_render(elapsed, stats);
}
}
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
Panel::top("top_bar").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
@@ -686,6 +843,7 @@ 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) {
let frame_started = Instant::now();
self.drain_events();
if self.wants_live_plot_repaint() {
ui.ctx().request_repaint_after(Duration::from_millis(16));
@@ -695,6 +853,9 @@ impl eframe::App for XserialApp {
self.render_config_window(ui.ctx());
self.render_sidebar(ui);
self.render_main_panel(ui);
self.render_detached_plot_windows(ui.ctx());
self.profiler
.record_frame(frame_started.elapsed(), self.profiler_snapshot());
}
}
@@ -702,46 +863,42 @@ fn render_session_controls(
ui: &mut egui::Ui,
manager: &SessionManager,
tab: &mut SessionTab,
) -> (bool, bool) {
let mut configure_clicked = false;
) -> bool {
let mut auto_reconnect_changed = false;
ui.horizontal_wrapped(|ui| {
if ui.button("Connect").clicked() {
let connected = matches!(
tab.status,
ConnectionStatus::Connected | ConnectionStatus::Connecting
);
let connect_label = if connected { "Disconnect" } else { "Connect" };
if ui.button(connect_label).clicked() {
if let Some(handle) = manager.get(tab.id) {
tab.status = ConnectionStatus::Connecting;
tokio::spawn(async move {
let _ = handle.connect().await;
});
if connected {
tab.status = ConnectionStatus::Disconnected;
tokio::spawn(async move {
let _ = handle.disconnect().await;
});
} else {
tab.status = ConnectionStatus::Connecting;
tokio::spawn(async move {
let _ = handle.connect().await;
});
}
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if ui.button("Disconnect").clicked() {
if let Some(handle) = manager.get(tab.id) {
tab.status = ConnectionStatus::Disconnected;
tokio::spawn(async move {
let _ = handle.disconnect().await;
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if ui.button("Reconnect").clicked() {
if let Some(handle) = manager.get(tab.id) {
tab.status = ConnectionStatus::Connecting;
tokio::spawn(async move {
let _ = handle.reconnect().await;
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if ui.button("Configure").clicked() {
configure_clicked = true;
}
// if ui.button("Reconnect").clicked() {
// if let Some(handle) = manager.get(tab.id) {
// tab.status = ConnectionStatus::Connecting;
// tokio::spawn(async move {
// let _ = handle.reconnect().await;
// });
// } else {
// tab.status = ConnectionStatus::Error(String::from("session not found"));
// }
// }
if ui.button("Clear").clicked() {
tab.console.clear();
@@ -764,7 +921,7 @@ fn render_session_controls(
}
}
});
(configure_clicked, auto_reconnect_changed)
auto_reconnect_changed
}
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {

View File

@@ -1,3 +1,4 @@
use egui_plot::PlotBounds;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use xserial_client::RingBuffer;
@@ -51,8 +52,12 @@ impl TextBuffer {
});
}
pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
self.lines.iter()
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
self.lines.get(index)
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn clear(&mut self) {
@@ -135,8 +140,12 @@ impl HexBuffer {
});
}
pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
self.lines.iter()
pub fn get(&self, index: usize) -> Option<&HexLine> {
self.lines.get(index)
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn clear(&mut self) {
@@ -168,11 +177,17 @@ impl PlotSeries {
if !sample.is_finite() {
continue;
}
self.points.push_back([self.next_x, *sample]);
self.push_point([self.next_x, *sample], limit);
self.next_x += 1.0;
}
while self.points.len() > limit {
self.points.pop_front();
}
fn push_point(&mut self, point: [f64; 2], limit: usize) -> Option<[f64; 2]> {
self.points.push_back(point);
if self.points.len() > limit {
self.points.pop_front()
} else {
None
}
}
@@ -180,26 +195,56 @@ impl PlotSeries {
self.points.iter().copied()
}
pub fn render_points_time_series(&self, x_min: f64, x_max: f64, max_points: usize) -> Vec<[f64; 2]> {
pub fn len(&self) -> usize {
self.points.len()
}
fn first_point(&self) -> Option<[f64; 2]> {
self.points.front().copied()
}
fn last_point(&self) -> Option<[f64; 2]> {
self.points.back().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 mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
for point in self.points.iter().copied() {
if point[0] < x_min {
continue;
}
if point[0] > x_max {
break;
}
exact_visible.push(point);
if exact_visible.len() > max_points {
exact_visible.clear();
break;
}
}
if !exact_visible.is_empty() {
return exact_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;
let mut points = self
.points
.iter()
.copied()
.skip_while(|point| point[0] < x_min)
.peekable();
for bucket_index in 0..bucket_count {
let bucket_start = x_min + bucket_width * bucket_index as f64;
@@ -212,8 +257,7 @@ impl PlotSeries {
let mut min_point: Option<[f64; 2]> = None;
let mut max_point: Option<[f64; 2]> = None;
while start < visible.len() {
let point = visible[start];
while let Some(point) = points.peek().copied() {
if point[0] > bucket_end {
break;
}
@@ -227,7 +271,7 @@ impl PlotSeries {
_ => max_point = Some(point),
}
}
start += 1;
points.next();
}
match (min_point, max_point) {
@@ -274,10 +318,69 @@ pub enum PlotSeriesKind {
XY,
}
#[derive(Clone, Copy)]
struct BoundsRect {
min_x: f64,
max_x: f64,
min_y: f64,
max_y: f64,
}
impl BoundsRect {
fn from_point([x, y]: [f64; 2]) -> Option<Self> {
if !(x.is_finite() && y.is_finite()) {
return None;
}
Some(Self {
min_x: x,
max_x: x,
min_y: y,
max_y: y,
})
}
fn extend_with_point(&mut self, [x, y]: [f64; 2]) {
if !(x.is_finite() && y.is_finite()) {
return;
}
self.min_x = self.min_x.min(x);
self.max_x = self.max_x.max(x);
self.min_y = self.min_y.min(y);
self.max_y = self.max_y.max(y);
}
fn touches(&self, [x, y]: [f64; 2]) -> bool {
x == self.min_x || x == self.max_x || y == self.min_y || y == self.max_y
}
fn into_plot_bounds(self) -> PlotBounds {
let mut bounds =
PlotBounds::from_min_max([self.min_x, self.min_y], [self.max_x, self.max_y]);
if bounds.width() <= 0.0 {
bounds.set_x_center_width(bounds.center().x, 1.0);
} else {
bounds.expand_x(bounds.width() * 0.05);
}
if bounds.height() <= 0.0 {
bounds.set_y_center_height(bounds.center().y, 1.0);
} else {
bounds.expand_y(bounds.height() * 0.1);
}
bounds
}
}
pub struct PlotBuffer {
limit: usize,
kind: PlotSeriesKind,
series: Vec<PlotSeries>,
time_series_y_bounds: Option<(f64, f64)>,
time_series_y_dirty: bool,
xy_bounds: Option<BoundsRect>,
xy_bounds_dirty: bool,
}
impl PlotBuffer {
@@ -286,6 +389,10 @@ impl PlotBuffer {
limit,
kind: PlotSeriesKind::TimeSeries,
series: Vec::new(),
time_series_y_bounds: None,
time_series_y_dirty: false,
xy_bounds: None,
xy_bounds_dirty: false,
}
}
@@ -296,10 +403,18 @@ impl PlotBuffer {
}
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
self.kind = match frame.format {
let next_kind = match frame.format {
PlotFormat::XY => PlotSeriesKind::XY,
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
};
if !matches!(
(&self.kind, &next_kind),
(PlotSeriesKind::TimeSeries, PlotSeriesKind::TimeSeries)
| (PlotSeriesKind::XY, PlotSeriesKind::XY)
) {
self.invalidate_bounds();
}
self.kind = next_kind;
if matches!(frame.format, PlotFormat::XY) {
self.push_xy_frame(pipeline_name, frame);
@@ -313,15 +428,33 @@ impl PlotBuffer {
format!("{pipeline_name}:ch{}", index + 1)
};
if let Some(series) = self
if let Some(series_index) = self
.series
.iter_mut()
.find(|series| series.name == series_name)
.iter()
.position(|series| series.name == series_name)
{
series.push_samples(channel, self.limit);
for sample in channel {
if !sample.is_finite() {
continue;
}
let (point, removed) = {
let series = &mut self.series[series_index];
let point = [series.next_x, *sample];
let removed = series.push_point(point, self.limit);
series.next_x += 1.0;
(point, removed)
};
if let Some(removed) = removed {
self.note_time_series_removed(removed);
}
self.note_time_series_point(point);
}
} else {
let mut series = PlotSeries::new(series_name);
series.push_samples(channel, self.limit);
for point in series.points() {
self.note_time_series_point(point);
}
self.series.push(series);
}
}
@@ -337,30 +470,36 @@ impl PlotBuffer {
let len = x.len().min(y.len());
let series_name = format!("{pipeline_name}:xy");
let series = if let Some(series) = self
let series_index = if let Some(index) = self
.series
.iter_mut()
.find(|series| series.name == series_name)
.iter()
.position(|series| series.name == series_name)
{
series
index
} else {
self.series.push(PlotSeries::new(series_name));
self.series.last_mut().expect("just pushed")
self.series.len() - 1
};
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();
let point = [x[index], y[index]];
let removed = {
let series = &mut self.series[series_index];
series.push_point(point, self.limit)
};
if let Some(removed) = removed {
self.note_xy_removed(removed);
}
self.note_xy_point(point);
}
}
pub fn clear(&mut self) {
self.series.clear();
self.invalidate_bounds();
}
pub fn set_limit(&mut self, limit: usize) {
@@ -370,6 +509,7 @@ impl PlotBuffer {
series.points.pop_front();
}
}
self.invalidate_bounds();
}
pub fn is_empty(&self) -> bool {
@@ -383,6 +523,141 @@ impl PlotBuffer {
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
self.series.iter()
}
pub fn series_len(&self) -> usize {
self.series.len()
}
pub fn total_points(&self) -> usize {
self.series.iter().map(PlotSeries::len).sum()
}
pub fn plot_bounds(&mut self) -> Option<PlotBounds> {
match self.kind {
PlotSeriesKind::TimeSeries => self.time_series_plot_bounds(),
PlotSeriesKind::XY => self.xy_plot_bounds(),
}
}
fn invalidate_bounds(&mut self) {
self.time_series_y_bounds = None;
self.time_series_y_dirty = false;
self.xy_bounds = None;
self.xy_bounds_dirty = false;
}
fn note_time_series_point(&mut self, point: [f64; 2]) {
if !point[1].is_finite() {
return;
}
match &mut self.time_series_y_bounds {
Some((min_y, max_y)) => {
*min_y = min_y.min(point[1]);
*max_y = max_y.max(point[1]);
}
None => self.time_series_y_bounds = Some((point[1], point[1])),
}
}
fn note_time_series_removed(&mut self, removed: [f64; 2]) {
if let Some((min_y, max_y)) = self.time_series_y_bounds
&& (removed[1] == min_y || removed[1] == max_y)
{
self.time_series_y_dirty = true;
}
}
fn note_xy_point(&mut self, point: [f64; 2]) {
match &mut self.xy_bounds {
Some(bounds) => bounds.extend_with_point(point),
None => self.xy_bounds = BoundsRect::from_point(point),
}
}
fn note_xy_removed(&mut self, removed: [f64; 2]) {
if let Some(bounds) = self.xy_bounds
&& bounds.touches(removed)
{
self.xy_bounds_dirty = true;
}
}
fn time_series_plot_bounds(&mut self) -> Option<PlotBounds> {
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
for series in &self.series {
if let Some([x, _]) = series.first_point() {
min_x = min_x.min(x);
}
if let Some([x, _]) = series.last_point() {
max_x = max_x.max(x);
}
}
if !(min_x.is_finite() && max_x.is_finite()) {
return None;
}
if self.time_series_y_dirty {
self.recompute_time_series_y_bounds();
}
let (min_y, max_y) = self.time_series_y_bounds?;
Some(
BoundsRect {
min_x,
max_x,
min_y,
max_y,
}
.into_plot_bounds(),
)
}
fn xy_plot_bounds(&mut self) -> Option<PlotBounds> {
if self.xy_bounds_dirty {
self.recompute_xy_bounds();
}
self.xy_bounds.map(BoundsRect::into_plot_bounds)
}
fn recompute_time_series_y_bounds(&mut self) {
let mut min_y = f64::INFINITY;
let mut max_y = f64::NEG_INFINITY;
for series in &self.series {
for [_, y] in series.points() {
if y.is_finite() {
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
}
self.time_series_y_bounds = if min_y.is_finite() && max_y.is_finite() {
Some((min_y, max_y))
} else {
None
};
self.time_series_y_dirty = false;
}
fn recompute_xy_bounds(&mut self) {
let mut bounds: Option<BoundsRect> = None;
for series in &self.series {
for point in series.points() {
match &mut bounds {
Some(existing) => existing.extend_with_point(point),
None => bounds = BoundsRect::from_point(point),
}
}
}
self.xy_bounds = bounds;
self.xy_bounds_dirty = false;
}
}
#[cfg(test)]
@@ -459,7 +734,11 @@ mod tests {
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));
assert!(
rendered
.iter()
.all(|point| point[0] >= 0.0 && point[0] <= 99.0)
);
}
#[test]

View File

@@ -2,6 +2,7 @@ mod app;
mod app_state;
mod buffers;
mod panels;
mod perf;
mod ui_fonts;
use xserial_client::SessionManager;

View File

@@ -1,36 +1,53 @@
use crate::app::DisplayOptions;
use crate::buffers::{LineDirection, TextBuffer};
use egui::{ScrollArea, Ui};
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, 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}]"));
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
let line_count = buf.len();
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::vertical().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(RichText::new(format_console_line(line, display)).monospace())
.wrap_mode(TextWrapMode::Extend),
);
}
}
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));
}
});
},
);
line_count
}
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
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(" "))
};
format!("{prefix}{}", line.text)
}

View File

@@ -1,41 +1,57 @@
use crate::app::DisplayOptions;
use crate::buffers::{HexBuffer, LineDirection};
use egui::{ScrollArea, Ui};
use crate::buffers::{HexBuffer, HexLine, LineDirection};
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, Ui};
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) {
let mut lines = buf.iter().peekable();
if lines.peek().is_none() {
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
let line_count = buf.len();
if line_count == 0 {
ui.label("no hex data");
return;
return 0;
}
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}]"));
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::vertical().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(RichText::new(format_hex_line(line, display)).monospace())
.wrap_mode(TextWrapMode::Extend),
);
}
}
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));
}
});
},
);
line_count
}
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
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(" "))
};
format!("{prefix}{} |{}|", line.hex, line.ascii)
}

View File

@@ -1,13 +1,21 @@
use crate::buffers::{PlotBuffer, PlotSeriesKind};
use crate::perf::PlotRenderStats;
use egui::{Button, Ui, vec2};
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PlotDisplayMode {
Embedded,
Detached,
}
pub struct PlotViewState {
pub lock_x: bool,
pub lock_y: bool,
pub box_zoom: bool,
pub follow_latest: bool,
pub last_bounds: Option<PlotBounds>,
pub detached: bool,
}
impl Default for PlotViewState {
@@ -18,14 +26,48 @@ impl Default for PlotViewState {
box_zoom: false,
follow_latest: false,
last_bounds: None,
detached: false,
}
}
}
pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotViewState) {
pub struct PlotRenderOutput {
pub stats: PlotRenderStats,
pub toggle_detached: bool,
}
pub fn render(
ui: &mut Ui,
buf: &mut PlotBuffer,
session_id: u64,
state: &mut PlotViewState,
) -> PlotRenderOutput {
let mode = if state.detached {
PlotDisplayMode::Detached
} else {
PlotDisplayMode::Embedded
};
let mut toggle_detached = false;
if buf.is_empty() {
ui.label("no plot data");
return;
ui.horizontal_wrapped(|ui| {
let toggle_label = match mode {
PlotDisplayMode::Embedded => "Pop Out",
PlotDisplayMode::Detached => "Dock Back",
};
if ui.button(toggle_label).clicked() {
toggle_detached = true;
}
ui.label("no plot data");
});
return PlotRenderOutput {
stats: PlotRenderStats {
series_count: 0,
stored_points: 0,
rendered_points: 0,
},
toggle_detached,
};
}
let mut auto_fit = false;
@@ -40,6 +82,13 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
if ui.button("Reset View").clicked() {
reset_view = true;
}
let toggle_label = match mode {
PlotDisplayMode::Embedded => "Pop Out",
PlotDisplayMode::Detached => "Dock Back",
};
if ui.button(toggle_label).clicked() {
toggle_detached = true;
}
ui.separator();
ui.checkbox(&mut state.lock_x, "Lock X");
ui.checkbox(&mut state.lock_y, "Lock Y");
@@ -92,7 +141,7 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
});
}
let data_bounds = plot_bounds(buf);
let data_bounds = buf.plot_bounds();
let mut plot = Plot::new(format!("plot_view_{session_id}"))
.legend(Legend::default())
.allow_boxed_zoom(state.box_zoom)
@@ -107,6 +156,7 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
plot = plot.reset();
}
let mut rendered_points = 0usize;
plot.show(ui, |plot_ui| {
let current_bounds = plot_ui.plot_bounds();
state.last_bounds = current_bounds.is_finite().then_some(current_bounds);
@@ -123,7 +173,10 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
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 {
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]
@@ -132,7 +185,10 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
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]]));
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 {
@@ -151,49 +207,25 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
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::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),
};
rendered_points += sampled_points.len();
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)
PlotRenderOutput {
stats: PlotRenderStats {
series_count: buf.series_len(),
stored_points: buf.total_points(),
rendered_points,
},
toggle_detached,
}
}

View File

@@ -12,6 +12,7 @@ pub fn render(
sessions: &[SessionListItem],
active: &mut usize,
on_new: &mut bool,
on_edit: &mut Option<usize>,
on_delete: &mut Option<usize>,
) {
ui.heading("Sessions");
@@ -37,12 +38,23 @@ pub fn render(
"{} Session {}\n{}",
status_tag, session.id, session.transport_summary
);
if ui
.selectable_label(*active == i, RichText::new(label).color(color))
.clicked()
{
*active = i;
}
ui.horizontal(|ui| {
if ui
.selectable_label(*active == i, RichText::new(label).color(color))
.clicked()
{
*active = i;
}
if ui
.small_button("")
.on_hover_text("Edit session config")
.clicked()
{
*active = i;
*on_edit = Some(i);
}
});
}
ui.separator();

View File

@@ -0,0 +1,257 @@
use std::env;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tracing::info;
#[derive(Clone, Default)]
pub struct RepaintCounter {
inner: Arc<AtomicU64>,
}
impl RepaintCounter {
pub fn increment(&self) {
self.inner.fetch_add(1, Ordering::Relaxed);
}
fn take(&self) -> u64 {
self.inner.swap(0, Ordering::Relaxed)
}
}
#[derive(Default)]
struct DurationStats {
count: u64,
total: Duration,
max: Duration,
}
impl DurationStats {
fn record(&mut self, elapsed: Duration) {
self.count += 1;
self.total += elapsed;
self.max = self.max.max(elapsed);
}
fn avg_ms(&self) -> f64 {
if self.count == 0 {
0.0
} else {
(self.total.as_secs_f64() * 1000.0) / self.count as f64
}
}
fn max_ms(&self) -> f64 {
self.max.as_secs_f64() * 1000.0
}
}
#[derive(Default)]
struct IntervalStats {
frames: DurationStats,
drains: DurationStats,
text_renders: DurationStats,
hex_renders: DurationStats,
plot_renders: DurationStats,
drained_events: u64,
drained_data_events: u64,
drained_text_events: u64,
drained_hex_events: u64,
drained_plot_events: u64,
max_pending_events: usize,
max_text_lines: usize,
max_hex_lines: usize,
max_plot_series: usize,
max_plot_stored_points: usize,
max_plot_rendered_points: usize,
tabs: usize,
active_view: &'static str,
active_text_lines: usize,
active_hex_lines: usize,
active_plot_series: usize,
active_plot_points: usize,
}
pub struct DrainStats {
pub drained_events: u64,
pub drained_data_events: u64,
pub drained_text_events: u64,
pub drained_hex_events: u64,
pub drained_plot_events: u64,
pub pending_events: usize,
}
#[derive(Clone, Copy)]
pub struct GuiSnapshot {
pub tabs: usize,
pub active_view: &'static str,
pub active_text_lines: usize,
pub active_hex_lines: usize,
pub active_plot_series: usize,
pub active_plot_points: usize,
}
pub struct PlotRenderStats {
pub series_count: usize,
pub stored_points: usize,
pub rendered_points: usize,
}
pub struct GuiProfiler {
enabled: bool,
interval: Duration,
last_report_at: Instant,
repaint_counter: RepaintCounter,
interval_stats: IntervalStats,
}
impl GuiProfiler {
pub fn from_env() -> Self {
let enabled = env_flag("XSERIAL_GUI_PROFILE");
let interval_ms = env::var("XSERIAL_GUI_PROFILE_INTERVAL_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(1_000);
Self {
enabled,
interval: Duration::from_millis(interval_ms),
last_report_at: Instant::now(),
repaint_counter: RepaintCounter::default(),
interval_stats: IntervalStats {
active_view: "none",
..IntervalStats::default()
},
}
}
pub fn repaint_counter(&self) -> RepaintCounter {
self.repaint_counter.clone()
}
pub fn record_drain(&mut self, elapsed: Duration, stats: DrainStats) {
if !self.enabled {
return;
}
self.interval_stats.drains.record(elapsed);
self.interval_stats.drained_events += stats.drained_events;
self.interval_stats.drained_data_events += stats.drained_data_events;
self.interval_stats.drained_text_events += stats.drained_text_events;
self.interval_stats.drained_hex_events += stats.drained_hex_events;
self.interval_stats.drained_plot_events += stats.drained_plot_events;
self.interval_stats.max_pending_events = self
.interval_stats
.max_pending_events
.max(stats.pending_events);
}
pub fn record_text_render(&mut self, elapsed: Duration, line_count: usize) {
if !self.enabled {
return;
}
self.interval_stats.text_renders.record(elapsed);
self.interval_stats.max_text_lines = self.interval_stats.max_text_lines.max(line_count);
}
pub fn record_hex_render(&mut self, elapsed: Duration, line_count: usize) {
if !self.enabled {
return;
}
self.interval_stats.hex_renders.record(elapsed);
self.interval_stats.max_hex_lines = self.interval_stats.max_hex_lines.max(line_count);
}
pub fn record_plot_render(&mut self, elapsed: Duration, stats: PlotRenderStats) {
if !self.enabled {
return;
}
self.interval_stats.plot_renders.record(elapsed);
self.interval_stats.max_plot_series =
self.interval_stats.max_plot_series.max(stats.series_count);
self.interval_stats.max_plot_stored_points = self
.interval_stats
.max_plot_stored_points
.max(stats.stored_points);
self.interval_stats.max_plot_rendered_points = self
.interval_stats
.max_plot_rendered_points
.max(stats.rendered_points);
}
pub fn record_frame(&mut self, elapsed: Duration, snapshot: GuiSnapshot) {
if !self.enabled {
return;
}
self.interval_stats.frames.record(elapsed);
self.interval_stats.tabs = snapshot.tabs;
self.interval_stats.active_view = snapshot.active_view;
self.interval_stats.active_text_lines = snapshot.active_text_lines;
self.interval_stats.active_hex_lines = snapshot.active_hex_lines;
self.interval_stats.active_plot_series = snapshot.active_plot_series;
self.interval_stats.active_plot_points = snapshot.active_plot_points;
if self.last_report_at.elapsed() < self.interval {
return;
}
let repaints = self.repaint_counter.take();
info!(
target: "xserial_gui::perf",
frames = self.interval_stats.frames.count,
frame_avg_ms = self.interval_stats.frames.avg_ms(),
frame_max_ms = self.interval_stats.frames.max_ms(),
drains = self.interval_stats.drains.count,
drain_avg_ms = self.interval_stats.drains.avg_ms(),
drain_max_ms = self.interval_stats.drains.max_ms(),
drained_events = self.interval_stats.drained_events,
drained_data_events = self.interval_stats.drained_data_events,
drained_text_events = self.interval_stats.drained_text_events,
drained_hex_events = self.interval_stats.drained_hex_events,
drained_plot_events = self.interval_stats.drained_plot_events,
repaint_requests = repaints,
max_pending_events = self.interval_stats.max_pending_events,
text_renders = self.interval_stats.text_renders.count,
text_avg_ms = self.interval_stats.text_renders.avg_ms(),
text_max_ms = self.interval_stats.text_renders.max_ms(),
max_text_lines = self.interval_stats.max_text_lines,
hex_renders = self.interval_stats.hex_renders.count,
hex_avg_ms = self.interval_stats.hex_renders.avg_ms(),
hex_max_ms = self.interval_stats.hex_renders.max_ms(),
max_hex_lines = self.interval_stats.max_hex_lines,
plot_renders = self.interval_stats.plot_renders.count,
plot_avg_ms = self.interval_stats.plot_renders.avg_ms(),
plot_max_ms = self.interval_stats.plot_renders.max_ms(),
max_plot_series = self.interval_stats.max_plot_series,
max_plot_stored_points = self.interval_stats.max_plot_stored_points,
max_plot_rendered_points = self.interval_stats.max_plot_rendered_points,
tabs = self.interval_stats.tabs,
active_view = self.interval_stats.active_view,
active_text_lines = self.interval_stats.active_text_lines,
active_hex_lines = self.interval_stats.active_hex_lines,
active_plot_series = self.interval_stats.active_plot_series,
active_plot_points = self.interval_stats.active_plot_points,
"gui perf"
);
self.last_report_at = Instant::now();
self.interval_stats = IntervalStats {
active_view: "none",
..IntervalStats::default()
};
}
}
fn env_flag(name: &str) -> bool {
env::var(name)
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!value.is_empty() && value != "0" && value != "false" && value != "off"
})
.unwrap_or(false)
}

View File

@@ -258,7 +258,7 @@ def main() -> None:
for index, value in enumerate(values)
)
line = (
f"time={now - started_at:7.3f}s "
f"测试 time={now - started_at:7.3f}s "
f"sample={sample_index:7d} {joined}\n"
)
conn.sendall(line.encode("utf-8"))