diff --git a/crates/xserial-client/src/history.rs b/crates/xserial-client/src/history.rs index 32d80cf..ac81d82 100644 --- a/crates/xserial-client/src/history.rs +++ b/crates/xserial-client/src/history.rs @@ -28,6 +28,9 @@ impl RingBuffer { pub fn iter(&self) -> impl Iterator { self.buf.iter() } + pub fn get(&self, index: usize) -> Option<&T> { + self.buf.get(index) + } pub fn clear(&mut self) { self.buf.clear(); } diff --git a/crates/xserial-core/src/protocol/mixed.rs b/crates/xserial-core/src/protocol/mixed.rs index bc35530..bae3ddd 100644 --- a/crates/xserial-core/src/protocol/mixed.rs +++ b/crates/xserial-core/src/protocol/mixed.rs @@ -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 diff --git a/crates/xserial-core/tests/pipeline.rs b/crates/xserial-core/tests/pipeline.rs index f6c9217..3616c50 100644 --- a/crates/xserial-core/tests/pipeline.rs +++ b/crates/xserial-core/tests/pipeline.rs @@ -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 = frames - .iter() - .filter_map(|f| decoder.decode(f)) - .collect(); + let results: Vec = 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")); diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 2c60b35..01ccf44 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -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, pending: Vec, + profiler: GuiProfiler, } impl XserialApp { @@ -94,16 +98,32 @@ impl XserialApp { mut rx: tokio::sync::broadcast::Receiver, 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) { diff --git a/crates/xserial-gui/src/buffers.rs b/crates/xserial-gui/src/buffers.rs index a59d32b..bf2d23f 100644 --- a/crates/xserial-gui/src/buffers.rs +++ b/crates/xserial-gui/src/buffers.rs @@ -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 { - 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 { - 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 { + 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, + time_series_y_bounds: Option<(f64, f64)>, + time_series_y_dirty: bool, + xy_bounds: Option, + 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 { 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 { + 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 { + 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 { + 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 = 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] diff --git a/crates/xserial-gui/src/main.rs b/crates/xserial-gui/src/main.rs index 47eba21..4fefd8d 100644 --- a/crates/xserial-gui/src/main.rs +++ b/crates/xserial-gui/src/main.rs @@ -2,6 +2,7 @@ mod app; mod app_state; mod buffers; mod panels; +mod perf; mod ui_fonts; use xserial_client::SessionManager; diff --git a/crates/xserial-gui/src/panels/console.rs b/crates/xserial-gui/src/panels/console.rs index 06c1a26..9e09d60 100644 --- a/crates/xserial-gui/src/panels/console.rs +++ b/crates/xserial-gui/src/panels/console.rs @@ -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) } diff --git a/crates/xserial-gui/src/panels/hex_view.rs b/crates/xserial-gui/src/panels/hex_view.rs index 2273180..286e207 100644 --- a/crates/xserial-gui/src/panels/hex_view.rs +++ b/crates/xserial-gui/src/panels/hex_view.rs @@ -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) } diff --git a/crates/xserial-gui/src/panels/plot_view.rs b/crates/xserial-gui/src/panels/plot_view.rs index 9ea070d..ba432b2 100644 --- a/crates/xserial-gui/src/panels/plot_view.rs +++ b/crates/xserial-gui/src/panels/plot_view.rs @@ -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, + 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 { - 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, + } } diff --git a/crates/xserial-gui/src/panels/sidebar.rs b/crates/xserial-gui/src/panels/sidebar.rs index 16e7c76..94d709c 100644 --- a/crates/xserial-gui/src/panels/sidebar.rs +++ b/crates/xserial-gui/src/panels/sidebar.rs @@ -12,6 +12,7 @@ pub fn render( sessions: &[SessionListItem], active: &mut usize, on_new: &mut bool, + on_edit: &mut Option, on_delete: &mut Option, ) { 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(); diff --git a/crates/xserial-gui/src/perf.rs b/crates/xserial-gui/src/perf.rs new file mode 100644 index 0000000..b1f1609 --- /dev/null +++ b/crates/xserial-gui/src/perf.rs @@ -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, +} + +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::().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) +} diff --git a/tools/test_plot.py b/tools/test_plot.py index 4fc4b1a..c4c470f 100755 --- a/tools/test_plot.py +++ b/tools/test_plot.py @@ -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"))