Files
xserial/crates/pipeview-gui/src/buffers.rs
FallenSigh 4e4c9cee65 feat(gui): add ANSI escape sequence renderer with full panel integration
- new ansi_render module: SGR, cursor movement, erase, scroll
- integrate ANSI rendering into console panel output pipeline
- wire app_state, buffers, hex_view, and shortcuts for ANSI-aware display
- add test_ansi.py for manual ANSI sequence testing
2026-06-14 04:12:35 +08:00

809 lines
23 KiB
Rust

use egui_plot::PlotBounds;
use pipeview_client::RingBuffer;
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
use std::collections::VecDeque;
use std::time::{Duration, Instant};
#[derive(Clone)]
pub enum LineDirection {
In,
Out,
}
#[derive(Clone)]
pub struct ConsoleLine {
pub elapsed: Duration,
pub pipeline: String,
pub text: String,
pub direction: LineDirection,
}
pub struct TextBuffer {
started_at: Instant,
lines: RingBuffer<ConsoleLine>,
}
impl TextBuffer {
pub fn new(cap: usize) -> Self {
Self {
started_at: Instant::now(),
lines: RingBuffer::new(cap),
}
}
pub fn push(&mut self, entry: &DecodedEntry) {
if let DecodedData::Text(s) = &entry.data {
self.lines.push(ConsoleLine {
elapsed: self.started_at.elapsed(),
pipeline: entry.pipeline_name.clone(),
text: s.clone(),
direction: LineDirection::In,
});
}
}
pub fn push_outbound(&mut self, text: String) {
self.lines.push(ConsoleLine {
elapsed: self.started_at.elapsed(),
pipeline: String::from("OUT"),
text,
direction: LineDirection::Out,
});
}
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) {
self.lines.clear();
}
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
// pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
// }
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
let query_lower = if case_sensitive {
String::new()
} else {
query.to_lowercase()
};
(0..self.lines.len())
.filter(|&i| {
self.lines.get(i).is_some_and(|line| {
if case_sensitive {
line.text.contains(query)
} else {
line.text.to_lowercase().contains(&query_lower)
}
})
})
.collect()
}
}
#[derive(Clone)]
pub struct HexLine {
pub elapsed: Duration,
pub pipeline: String,
pub hex: String,
pub ascii: String,
pub direction: LineDirection,
}
pub struct HexBuffer {
started_at: Instant,
lines: RingBuffer<HexLine>,
}
impl HexBuffer {
pub fn new(cap: usize) -> Self {
Self {
started_at: Instant::now(),
lines: RingBuffer::new(cap),
}
}
pub fn push(&mut self, entry: &DecodedEntry) {
if let DecodedData::Hex(s) = &entry.data {
let ascii = hex::decode(s.replace(' ', ""))
.map(|bytes| {
bytes
.into_iter()
.map(|b| {
if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'.'
}
})
.collect()
})
.unwrap_or_else(|_| String::from("[invalid hex]"));
self.lines.push(HexLine {
elapsed: self.started_at.elapsed(),
pipeline: entry.pipeline_name.clone(),
hex: s.clone(),
ascii,
direction: LineDirection::In,
});
}
}
pub fn push_outbound(&mut self, hex: String) {
let ascii = hex::decode(hex.replace(' ', ""))
.map(|bytes| {
bytes
.into_iter()
.map(|b| {
if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'.'
}
})
.collect()
})
.unwrap_or_else(|_| String::from("[invalid hex]"));
self.lines.push(HexLine {
elapsed: self.started_at.elapsed(),
pipeline: String::from("OUT"),
hex,
ascii,
direction: LineDirection::Out,
});
}
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) {
self.lines.clear();
}
pub fn set_limit(&mut self, limit: usize) {
self.lines.set_limit(limit);
}
// pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
// }
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
let query_lower = if case_sensitive {
String::new()
} else {
query.to_lowercase()
};
(0..self.lines.len())
.filter(|&i| {
self.lines.get(i).is_some_and(|line| {
if case_sensitive {
line.hex.contains(query) || line.ascii.contains(query)
} else {
line.hex.to_lowercase().contains(&query_lower)
|| line.ascii.to_lowercase().contains(&query_lower)
}
})
})
.collect()
}
}
pub struct PlotSeries {
pub name: String,
points: VecDeque<[f64; 2]>,
next_x: f64,
}
impl PlotSeries {
fn new(name: String) -> Self {
Self {
name,
points: VecDeque::new(),
next_x: 0.0,
}
}
fn push_samples(&mut self, samples: &[f64], limit: usize) {
for sample in samples {
if !sample.is_finite() {
continue;
}
self.push_point([self.next_x, *sample], limit);
self.next_x += 1.0;
}
}
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
}
}
pub fn points(&self) -> impl Iterator<Item = [f64; 2]> + '_ {
self.points.iter().copied()
}
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 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 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;
let bucket_end = if bucket_index + 1 == bucket_count {
x_max
} else {
bucket_start + bucket_width
};
let mut min_point: Option<[f64; 2]> = None;
let mut max_point: Option<[f64; 2]> = None;
while let Some(point) = points.peek().copied() {
if point[0] > bucket_end {
break;
}
if point[0] >= bucket_start {
match min_point {
Some(current) if current[1] <= point[1] => {}
_ => min_point = Some(point),
}
match max_point {
Some(current) if current[1] >= point[1] => {}
_ => max_point = Some(point),
}
}
points.next();
}
match (min_point, max_point) {
(Some(a), Some(b)) if a[0] <= b[0] => {
rendered.push(a);
if a != b {
rendered.push(b);
}
}
(Some(a), Some(b)) => {
rendered.push(b);
if a != b {
rendered.push(a);
}
}
(Some(a), None) | (None, Some(a)) => rendered.push(a),
(None, None) => {}
}
}
if rendered.len() > max_points {
let stride = rendered.len().div_ceil(max_points);
rendered.into_iter().step_by(stride).collect()
} else {
rendered
}
}
pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> {
if self.points.is_empty() || max_points == 0 {
return Vec::new();
}
if self.points.len() <= max_points {
return self.points.iter().copied().collect();
}
let stride = self.points.len().div_ceil(max_points);
self.points.iter().copied().step_by(stride).collect()
}
}
pub enum PlotSeriesKind {
TimeSeries,
XY,
}
#[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 {
pub fn new(limit: usize) -> Self {
Self {
limit,
kind: PlotSeriesKind::TimeSeries,
series: Vec::new(),
time_series_y_bounds: None,
time_series_y_dirty: false,
xy_bounds: None,
xy_bounds_dirty: false,
}
}
pub fn push(&mut self, entry: &DecodedEntry) {
if let DecodedData::Plot(frame) = &entry.data {
self.push_frame(&entry.pipeline_name, frame);
}
}
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
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);
return;
}
for (index, channel) in frame.channels.iter().enumerate() {
let series_name = if frame.channels.len() == 1 {
pipeline_name.to_owned()
} else {
format!("{pipeline_name}:ch{}", index + 1)
};
if let Some(series_index) = self
.series
.iter()
.position(|series| series.name == series_name)
{
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);
}
}
}
fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
if frame.channels.len() < 2 {
return;
}
let x = &frame.channels[0];
let y = &frame.channels[1];
let len = x.len().min(y.len());
let series_name = format!("{pipeline_name}:xy");
let series_index = if let Some(index) = self
.series
.iter()
.position(|series| series.name == series_name)
{
index
} else {
self.series.push(PlotSeries::new(series_name));
self.series.len() - 1
};
for index in 0..len {
if !x[index].is_finite() || !y[index].is_finite() {
continue;
}
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) {
self.limit = limit;
for series in &mut self.series {
while series.points.len() > self.limit {
series.points.pop_front();
}
}
self.invalidate_bounds();
}
pub fn is_empty(&self) -> bool {
self.series.is_empty()
}
pub fn kind(&self) -> &PlotSeriesKind {
&self.kind
}
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
self.series.iter()
}
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)]
mod tests {
use super::*;
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
use pipeview_core::protocol::plot::{PlotFrame, SampleType};
fn plot_entry() -> DecodedEntry {
DecodedEntry {
pipeline_name: String::from("plot"),
data: DecodedData::Plot(PlotFrame {
channels: vec![vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 30.0]],
raw: vec![],
sample_type: SampleType::F32,
format: PlotFormat::Interleaved,
}),
}
}
fn xy_plot_entry() -> DecodedEntry {
DecodedEntry {
pipeline_name: String::from("xy"),
data: DecodedData::Plot(PlotFrame {
channels: vec![vec![0.0, 1.0, 2.0], vec![10.0, 20.0, 30.0]],
raw: vec![],
sample_type: SampleType::F32,
format: PlotFormat::XY,
}),
}
}
#[test]
fn plot_buffer_creates_series_per_channel() {
let mut buffer = PlotBuffer::new(8);
buffer.push(&plot_entry());
let series: Vec<_> = buffer.iter().collect();
assert_eq!(series.len(), 2);
assert_eq!(series[0].name, "plot:ch1");
assert_eq!(series[1].name, "plot:ch2");
assert_eq!(series[0].points().count(), 3);
assert_eq!(series[1].points().count(), 3);
}
#[test]
fn plot_buffer_limit_and_clear_work() {
let mut buffer = PlotBuffer::new(2);
buffer.push(&plot_entry());
assert_eq!(buffer.iter().next().unwrap().points().count(), 2);
buffer.clear();
assert!(buffer.is_empty());
}
#[test]
fn plot_buffer_xy_uses_xy_points() {
let mut buffer = PlotBuffer::new(8);
buffer.push(&xy_plot_entry());
assert!(matches!(buffer.kind(), PlotSeriesKind::XY));
let series: Vec<_> = buffer.iter().collect();
assert_eq!(series.len(), 1);
let points: Vec<_> = series[0].points().collect();
assert_eq!(points, vec![[0.0, 10.0], [1.0, 20.0], [2.0, 30.0]]);
}
#[test]
fn plot_series_time_series_downsamples_to_budget() {
let mut series = PlotSeries::new(String::from("plot"));
series.push_samples(&(0..100).map(|n| n as f64).collect::<Vec<_>>(), 200);
let rendered = series.render_points_time_series(0.0, 99.0, 16);
assert!(!rendered.is_empty());
assert!(rendered.len() <= 16);
assert!(
rendered
.iter()
.all(|point| point[0] >= 0.0 && point[0] <= 99.0)
);
}
#[test]
fn plot_series_xy_downsamples_to_budget() {
let mut series = PlotSeries::new(String::from("xy"));
for n in 0..100 {
series.points.push_back([n as f64, (n * 2) as f64]);
}
let rendered = series.render_points_xy(10);
assert!(rendered.len() <= 10);
assert_eq!(rendered.first().copied(), Some([0.0, 0.0]));
}
}