Improve GUI plot workflow and performance
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,6 +2,7 @@ mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod panels;
|
||||
mod perf;
|
||||
mod ui_fonts;
|
||||
|
||||
use xserial_client::SessionManager;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
257
crates/xserial-gui/src/perf.rs
Normal file
257
crates/xserial-gui/src/perf.rs
Normal 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)
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user