Rewrite README in Chinese and English, add drone examples

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-12 21:56:20 +08:00
parent a2c7c0fa71
commit e939b16d2d
67 changed files with 1101 additions and 675 deletions

View File

@@ -0,0 +1,21 @@
[package]
name = "pipeview-gui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "pipeview-gui"
path = "src/main.rs"
[dependencies]
pipeview-core = { path = "../pipeview-core" }
pipeview-client = { path = "../pipeview-client" }
tokio = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true }
egui_plot = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
hex = "0.4"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::warn;
use pipeview_client::config::SessionConfig;
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionLogConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub file_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PersistedGuiState {
#[serde(default)]
pub sessions: Vec<SessionConfig>,
#[serde(default)]
pub sessions_log: Vec<SessionLogConfig>,
#[serde(default)]
pub active: usize,
#[serde(default = "default_true")]
pub show_timestamp: bool,
#[serde(default = "default_true")]
pub show_direction: bool,
#[serde(default = "default_true")]
pub show_pipeline: bool,
}
pub fn load_gui_state() -> PersistedGuiState {
match load_gui_state_from_path(&gui_state_path()) {
Ok(state) => state,
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedGuiState::default(),
Err(err) => {
warn!(error = %err, "Failed to load persisted GUI state");
PersistedGuiState::default()
}
}
}
pub fn save_gui_state(state: &PersistedGuiState) {
if let Err(err) = save_gui_state_to_path(state, &gui_state_path()) {
warn!(error = %err, "Failed to persist GUI state");
}
}
fn gui_state_path() -> PathBuf {
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
}
/// Returns the pipeview configuration directory (the parent of gui-state.json).
pub fn config_dir() -> PathBuf {
gui_state_path()
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
match os {
"windows" => {
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
}
"macos" => {
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join(".config")
.join("pipeview")
.join(GUI_STATE_FILE_NAME);
}
}
}
PathBuf::from(GUI_STATE_FILE_NAME)
}
fn load_gui_state_from_path(path: &Path) -> io::Result<PersistedGuiState> {
let text = fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(io::Error::other)
}
fn save_gui_state_to_path(state: &PersistedGuiState, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
fs::write(path, json)
}
const fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
use pipeview_core::transport::TransportConfig;
#[test]
fn windows_gui_state_path_uses_appdata() {
let path = gui_state_path_for_os_and_env("windows", |key| match key {
"APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")),
_ => None,
});
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("pipeview")
.join(GUI_STATE_FILE_NAME)
);
}
#[test]
fn gui_state_roundtrip() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = env::temp_dir().join(format!("pipeview-gui-state-{unique}"));
let path = dir.join(GUI_STATE_FILE_NAME);
let state = PersistedGuiState {
sessions: vec![SessionConfig {
transport: TransportConfig::Tcp {
addr: String::from("127.0.0.1:9000"),
},
..SessionConfig::default()
}],
sessions_log: vec![SessionLogConfig::default()],
active: 0,
show_timestamp: false,
show_direction: true,
show_pipeline: false,
};
save_gui_state_to_path(&state, &path).unwrap();
let loaded = load_gui_state_from_path(&path).unwrap();
assert_eq!(loaded.sessions.len(), 1);
assert!(!loaded.show_timestamp);
assert!(loaded.show_direction);
assert!(!loaded.show_pipeline);
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
}

View File

@@ -0,0 +1,808 @@
use egui_plot::PlotBounds;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use pipeview_client::RingBuffer;
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
#[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]));
}
}

View File

@@ -0,0 +1,109 @@
use std::fs::{self, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::mpsc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
/// Manages background file logging via a dedicated writer thread.
///
/// Lines are sent through an mpsc channel to a background thread that
/// appends to the log file using buffered I/O, keeping the GUI thread
/// non-blocking.
pub struct LogWriter {
sender: mpsc::Sender<String>,
}
impl LogWriter {
/// Spawns a background thread that opens `path` in append mode and
/// writes every line it receives through the returned `LogWriter`.
pub fn open(path: impl Into<PathBuf>) -> std::io::Result<Self> {
let path: PathBuf = path.into();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let mut writer = BufWriter::with_capacity(1024, file);
let (sender, receiver) = mpsc::channel::<String>();
let thread_name = format!("pipeview-log-{}", path.display());
thread::Builder::new().name(thread_name).spawn(move || {
while let Ok(line) = receiver.recv() {
if writeln!(writer, "{line}").is_err() {
break;
}
}
let _ = writer.flush();
})?;
Ok(Self { sender })
}
/// Send a line to the background writer. Non-blocking — drops the line
/// silently if the writer thread has stopped.
pub fn write_line(&self, line: &str) {
let _ = self.sender.send(line.to_owned());
}
}
impl Drop for LogWriter {
fn drop(&mut self) {
// Sender is dropped here, which causes the background thread's
// receiver.recv() to return Err, triggering flush + exit.
}
}
/// Format a [`DecodedEntry`] into a log line.
///
/// Format: `[HH:MM:SS.mmm] [IN] [pipeline] payload`
///
/// Returns `None` for Binary and Plot entries (not meaningful as text logs).
pub fn format_data_log(
entry: &DecodedEntry,
direction: &str,
show_timestamp: bool,
show_direction: bool,
show_pipeline: bool,
) -> Option<String> {
let data = match &entry.data {
DecodedData::Text(text) => text.clone(),
DecodedData::Hex(hex) => hex.clone(),
DecodedData::Binary(_) | DecodedData::Plot(_) => return None,
};
let mut parts = Vec::with_capacity(4);
if show_timestamp {
parts.push(utc_timestamp());
}
if show_direction {
parts.push(format!("[{direction}]"));
}
if show_pipeline {
parts.push(format!("[{}]", entry.pipeline_name));
}
parts.push(data);
Some(parts.join(" "))
}
/// Format a sent text string into a log line (no `DecodedEntry` available
/// for outbound data).
pub fn format_sent_log(text: &str) -> String {
format!("{} [OUT] {text}", utc_timestamp())
}
fn utc_timestamp() -> String {
let since_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = since_epoch.as_secs();
let ms = since_epoch.subsec_millis();
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
}

View File

@@ -0,0 +1,28 @@
mod app;
mod app_state;
mod buffers;
mod logging;
mod panels;
mod perf;
mod shortcuts;
mod ui_fonts;
use pipeview_client::SessionManager;
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let _guard = rt.enter();
let mgr = SessionManager::new();
let rx = mgr.subscribe();
let _ = eframe::run_native(
"pipeview",
eframe::NativeOptions::default(),
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
use crate::app::{DisplayOptions, SearchState};
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
use egui::{
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
text::{LayoutJob, TextFormat},
};
pub fn render(
ui: &mut Ui,
buf: &TextBuffer,
display: DisplayOptions,
search: Option<&SearchState>,
) -> usize {
let line_count = buf.len();
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::both().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(format_console_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
}
}
},
);
line_count
}
fn highlight_matches(
text: &str,
query: &str,
case_sensitive: bool,
default_format: TextFormat,
highlight_format: TextFormat,
) -> LayoutJob {
let search_text = if case_sensitive {
text.to_string()
} else {
text.to_lowercase()
};
let query = if case_sensitive {
query.to_string()
} else {
query.to_lowercase()
};
let mut job = LayoutJob::default();
let mut last_end = 0;
let mut idx = 0;
while let Some(pos) = search_text[idx..].find(&query) {
let start = idx + pos;
let end = start + query.len();
if last_end < start {
job.append(&text[last_end..start], 0.0, default_format.clone());
}
job.append(&text[start..end], 0.0, highlight_format.clone());
last_end = end;
idx = end;
}
if last_end < text.len() {
job.append(&text[last_end..], 0.0, default_format);
}
job
}
fn monospace_format(style: &egui::Style) -> TextFormat {
TextFormat {
font_id: style
.text_styles
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
..Default::default()
}
}
pub fn format_console_line(
line: &ConsoleLine,
display: DisplayOptions,
search: Option<&SearchState>,
style: &egui::Style,
) -> LayoutJob {
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(" "))
};
let full_text = format!("{prefix}{}", line.text);
let default = monospace_format(style);
let highlight = TextFormat {
font_id: default.font_id.clone(),
color: Color32::BLACK,
background: Color32::from_rgb(255, 255, 0),
..Default::default()
};
match search {
Some(state) if state.active && !state.query.is_empty() => {
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
}
_ => LayoutJob::single_section(full_text, default),
}
}

View File

@@ -0,0 +1,134 @@
use crate::app::{DisplayOptions, SearchState};
use crate::buffers::{HexBuffer, HexLine, LineDirection};
use egui::{
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
text::{LayoutJob, TextFormat},
};
pub fn render(
ui: &mut Ui,
buf: &HexBuffer,
display: DisplayOptions,
search: Option<&SearchState>,
) -> usize {
let line_count = buf.len();
if line_count == 0 {
ui.label("no hex data");
return 0;
}
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::both().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(format_hex_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
}
}
},
);
line_count
}
fn highlight_matches(
text: &str,
query: &str,
case_sensitive: bool,
default_format: TextFormat,
highlight_format: TextFormat,
) -> LayoutJob {
let search_text = if case_sensitive {
text.to_string()
} else {
text.to_lowercase()
};
let query = if case_sensitive {
query.to_string()
} else {
query.to_lowercase()
};
let mut job = LayoutJob::default();
let mut last_end = 0;
let mut idx = 0;
while let Some(pos) = search_text[idx..].find(&query) {
let start = idx + pos;
let end = start + query.len();
if last_end < start {
job.append(&text[last_end..start], 0.0, default_format.clone());
}
job.append(&text[start..end], 0.0, highlight_format.clone());
last_end = end;
idx = end;
}
if last_end < text.len() {
job.append(&text[last_end..], 0.0, default_format);
}
job
}
fn monospace_format(style: &egui::Style) -> TextFormat {
TextFormat {
font_id: style
.text_styles
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
..Default::default()
}
}
fn format_hex_line(
line: &HexLine,
display: DisplayOptions,
search: Option<&SearchState>,
style: &egui::Style,
) -> LayoutJob {
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(" "))
};
let full_text = format!("{prefix}{} |{}|", line.hex, line.ascii);
let default = monospace_format(style);
let highlight = TextFormat {
font_id: default.font_id.clone(),
color: Color32::BLACK,
background: Color32::from_rgb(255, 255, 0),
..Default::default()
};
match search {
Some(state) if state.active && !state.query.is_empty() => {
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
}
_ => LayoutJob::single_section(full_text, default),
}
}

View File

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

View File

@@ -0,0 +1,219 @@
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,
}
#[derive(Default)]
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,
}
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.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;
let mut reset_view = false;
let mut zoom_x = 1.0_f32;
let mut zoom_y = 1.0_f32;
ui.horizontal_wrapped(|ui| {
if ui.button("Auto Fit").clicked() {
auto_fit = true;
}
if ui.button("Reset View").clicked() {
reset_view = true;
}
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");
ui.checkbox(&mut state.box_zoom, "Box Zoom");
if matches!(buf.kind(), PlotSeriesKind::TimeSeries) {
ui.checkbox(&mut state.follow_latest, "Follow Latest");
} else {
state.follow_latest = false;
}
ui.separator();
if ui
.add_enabled(!state.lock_x, Button::new("X-"))
.on_hover_text("Zoom out X axis")
.clicked()
{
zoom_x = 0.8;
}
if ui
.add_enabled(!state.lock_x, Button::new("X+"))
.on_hover_text("Zoom in X axis")
.clicked()
{
zoom_x = 1.25;
}
if ui
.add_enabled(!state.lock_y, Button::new("Y-"))
.on_hover_text("Zoom out Y axis")
.clicked()
{
zoom_y = 0.8;
}
if ui
.add_enabled(!state.lock_y, Button::new("Y+"))
.on_hover_text("Zoom in Y axis")
.clicked()
{
zoom_y = 1.25;
}
});
if let Some(bounds) = state.last_bounds {
ui.horizontal_wrapped(|ui| {
ui.monospace(format!(
"X [{:.3}, {:.3}] Y [{:.3}, {:.3}]",
bounds.min()[0],
bounds.max()[0],
bounds.min()[1],
bounds.max()[1],
));
});
}
let data_bounds = buf.plot_bounds();
let mut plot = Plot::new(format!("plot_view_{session_id}"))
.legend(Legend::default())
.allow_boxed_zoom(state.box_zoom)
.allow_zoom([!state.lock_x, !state.lock_y])
.allow_drag([!state.lock_x, !state.lock_y])
.allow_axis_zoom_drag([!state.lock_x, !state.lock_y])
.allow_scroll([
!state.lock_x && matches!(buf.kind(), PlotSeriesKind::TimeSeries),
!state.lock_y && matches!(buf.kind(), PlotSeriesKind::TimeSeries),
]);
if reset_view {
plot = plot.reset();
}
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);
let pixel_width = plot_ui.response().rect.width().max(64.0);
let max_render_points = (pixel_width as usize).saturating_mul(2);
if auto_fit {
if let Some(bounds) = data_bounds {
plot_ui.set_auto_bounds(false);
plot_ui.set_plot_bounds(bounds);
state.last_bounds = Some(bounds);
}
} else if state.follow_latest && matches!(buf.kind(), PlotSeriesKind::TimeSeries) {
if let Some(bounds) = data_bounds {
let current_width = current_bounds.width();
let data_width = bounds.width();
let x_range = if current_width.is_finite()
&& current_width > 0.0
&& data_width > current_width
{
(bounds.max()[0] - current_width)..=bounds.max()[0]
} else {
bounds.min()[0]..=bounds.max()[0]
};
plot_ui.set_auto_bounds([false, plot_ui.auto_bounds().y]);
plot_ui.set_plot_bounds_x(x_range.clone());
let mut followed = current_bounds;
followed.set_x(&PlotBounds::from_min_max(
[*x_range.start(), current_bounds.min()[1]],
[*x_range.end(), current_bounds.max()[1]],
));
state.last_bounds = Some(followed);
}
} else if zoom_x != 1.0 || zoom_y != 1.0 {
let center = plot_ui.plot_bounds().center();
plot_ui.zoom_bounds(vec2(zoom_x, zoom_y), center);
}
if let Some(bounds) = data_bounds {
if !bounds.is_valid_x() {
plot_ui.set_auto_bounds([true, plot_ui.auto_bounds().y]);
}
if !bounds.is_valid_y() {
plot_ui.set_auto_bounds([plot_ui.auto_bounds().x, true]);
}
}
for series in buf.iter() {
let sampled_points = match buf.kind() {
PlotSeriesKind::TimeSeries => series.render_points_time_series(
current_bounds.min()[0],
current_bounds.max()[0],
max_render_points,
),
PlotSeriesKind::XY => series.render_points_xy(max_render_points),
};
rendered_points += sampled_points.len();
let points = PlotPoints::from_iter(sampled_points);
plot_ui.line(Line::new(series.name.clone(), points));
}
});
PlotRenderOutput {
stats: PlotRenderStats {
series_count: buf.series_len(),
stored_points: buf.total_points(),
rendered_points,
},
toggle_detached,
}
}

View File

@@ -0,0 +1,68 @@
use crate::app::ConnectionStatus;
use egui::{Color32, RichText, Ui};
pub struct SessionListItem {
pub id: u64,
pub status: ConnectionStatus,
pub transport_summary: String,
}
pub fn render(
ui: &mut Ui,
sessions: &[SessionListItem],
active: &mut usize,
on_new: &mut bool,
on_edit: &mut Option<usize>,
on_delete: &mut Option<usize>,
) {
ui.heading("Sessions");
if ui
.button(RichText::new("+ New Session").color(Color32::GREEN))
.clicked()
{
*on_new = true;
}
ui.separator();
for (i, session) in sessions.iter().enumerate() {
let (color, status_tag) = match &session.status {
ConnectionStatus::Connected => (Color32::GREEN, "[connected]"),
ConnectionStatus::Disconnected => (Color32::GRAY, "[disconnected]"),
ConnectionStatus::Connecting => (Color32::YELLOW, "[connecting]"),
ConnectionStatus::Error(_) => (Color32::RED, "[error]"),
};
let label = format!(
"{} Session {}\n{}",
status_tag, session.id, session.transport_summary
);
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();
if ui
.button(RichText::new("Delete").color(Color32::RED))
.clicked()
{
*on_delete = Some(*active);
}
}

View File

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

View File

@@ -0,0 +1,142 @@
use egui::{Context, Key, KeyboardShortcut, Modifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
NextTab,
PrevTab,
ViewText,
ViewHex,
ViewPlot,
NewSession,
EditSession,
DeleteSession,
ToggleConnect,
Clear,
UiSettings,
CloseOverlay,
Search,
SearchNext,
SearchPrev,
}
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
use Action::*;
vec![
(NextTab, KeyboardShortcut::new(Modifiers::CTRL, Key::Tab)),
(
PrevTab,
KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab),
),
(ViewText, KeyboardShortcut::new(Modifiers::CTRL, Key::T)),
(ViewHex, KeyboardShortcut::new(Modifiers::CTRL, Key::H)),
(ViewPlot, KeyboardShortcut::new(Modifiers::CTRL, Key::P)),
(NewSession, KeyboardShortcut::new(Modifiers::CTRL, Key::N)),
(EditSession, KeyboardShortcut::new(Modifiers::CTRL, Key::E)),
(
DeleteSession,
KeyboardShortcut::new(Modifiers::CTRL, Key::W),
),
(
ToggleConnect,
KeyboardShortcut::new(Modifiers::CTRL, Key::F5),
),
(Clear, KeyboardShortcut::new(Modifiers::CTRL, Key::L)),
(
UiSettings,
KeyboardShortcut::new(Modifiers::CTRL, Key::Comma),
),
(
CloseOverlay,
KeyboardShortcut::new(Modifiers::NONE, Key::Escape),
),
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
(
SearchPrev,
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
),
]
}
pub fn process(
ctx: &Context,
suppress_char_shortcuts: bool,
bindings: &[(Action, KeyboardShortcut)],
) -> Vec<Action> {
let mut actions = Vec::new();
ctx.input_mut(|input| {
for (action, shortcut) in bindings {
if !input.consume_shortcut(shortcut) {
continue;
}
if suppress_char_shortcuts && is_char_based(*action) {
continue;
}
actions.push(*action);
}
});
actions
}
fn is_char_based(action: Action) -> bool {
use Action::*;
!matches!(action, CloseOverlay)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bindings_contain_no_duplicate_shortcuts() {
let bindings = default_bindings();
for (i, (_, a)) in bindings.iter().enumerate() {
for (j, (_, b)) in bindings.iter().enumerate() {
if i != j {
assert_ne!(a, b, "Duplicate shortcut at indices {i} and {j}");
}
}
}
}
#[test]
fn bindings_non_empty() {
assert!(!default_bindings().is_empty());
}
#[test]
fn close_overlay_not_char_based() {
assert!(!is_char_based(Action::CloseOverlay));
}
#[test]
fn all_other_actions_are_char_based() {
for action in [
Action::NextTab,
Action::PrevTab,
Action::ViewText,
Action::ViewHex,
Action::ViewPlot,
Action::NewSession,
Action::EditSession,
Action::DeleteSession,
Action::ToggleConnect,
Action::Clear,
Action::UiSettings,
Action::Search,
Action::SearchNext,
Action::SearchPrev,
] {
assert!(is_char_based(action), "{action:?} should be char-based");
}
}
}

View File

@@ -0,0 +1,673 @@
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use eframe::egui::{self, FontData, FontDefinitions, FontFamily, FontId, Style, TextStyle};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
const FONT_SETTINGS_FILE_NAME: &str = "gui-fonts.json";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FontChoice {
Auto,
#[default]
Default,
System(String),
}
#[derive(Clone, Debug)]
pub struct FontCandidate {
pub id: String,
pub label: String,
pub display_label: String,
pub path: String,
pub likely_cjk: bool,
pub search_key: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiFontSettings {
#[serde(default = "default_primary_choice")]
pub primary_choice: FontChoice,
#[serde(default = "default_fallback_choice")]
pub fallback_choice: FontChoice,
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
#[serde(default = "default_monospace_font_size")]
pub monospace_font_size: f32,
#[serde(default = "default_heading_font_size")]
pub heading_font_size: f32,
}
impl Default for UiFontSettings {
fn default() -> Self {
Self {
primary_choice: FontChoice::Auto,
fallback_choice: FontChoice::Default,
ui_font_size: 14.0,
monospace_font_size: 14.0,
heading_font_size: 20.0,
}
}
}
pub fn discover_font_candidates() -> Vec<FontCandidate> {
let mut candidates = discover_via_fc_list().unwrap_or_default();
let mut seen_paths = candidates
.iter()
.map(|candidate| candidate.path.clone())
.collect::<std::collections::BTreeSet<_>>();
for candidate in discover_via_font_directories() {
if seen_paths.insert(candidate.path.clone()) {
candidates.push(candidate);
}
}
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path)));
candidates.dedup_by(|a, b| a.path == b.path);
candidates
}
pub fn load_font_settings() -> UiFontSettings {
match load_font_settings_from_path(&font_settings_path()) {
Ok(settings) => settings,
Err(err) if err.kind() == io::ErrorKind::NotFound => UiFontSettings::default(),
Err(err) => {
warn!(error = %err, "Failed to load persisted font settings");
UiFontSettings::default()
}
}
}
pub fn save_font_settings(settings: &UiFontSettings) {
if let Err(err) = save_font_settings_to_path(settings, &font_settings_path()) {
warn!(error = %err, "Failed to persist font settings");
}
}
pub fn apply_font_settings(
ctx: &egui::Context,
settings: &UiFontSettings,
candidates: &[FontCandidate],
) {
let mut fonts = FontDefinitions::default();
let mut inserted = Vec::new();
for choice in [&settings.primary_choice, &settings.fallback_choice] {
if let Some((font_name, font_bytes)) = load_selected_font(choice, candidates, &inserted) {
fonts
.font_data
.insert(font_name.clone(), FontData::from_owned(font_bytes).into());
inserted.push(font_name);
}
}
if !inserted.is_empty() {
if let Some(family) = fonts.families.get_mut(&FontFamily::Proportional) {
for font_name in inserted.iter().rev() {
family.insert(0, font_name.clone());
}
}
if let Some(family) = fonts.families.get_mut(&FontFamily::Monospace) {
for font_name in inserted.iter().rev() {
family.insert(0, font_name.clone());
}
}
info!(fonts = ?inserted, "Applied egui font chain");
} else {
if settings.primary_choice != FontChoice::Default
|| settings.fallback_choice != FontChoice::Default
{
warn!("No configured fonts could be loaded; falling back to egui default fonts");
}
}
ctx.set_fonts(fonts);
let mut style = (*ctx.global_style()).clone();
apply_font_size(&mut style, settings);
ctx.set_global_style(style);
}
pub fn font_choice_label(choice: &FontChoice, candidates: &[FontCandidate]) -> String {
match choice {
FontChoice::Auto => String::from("Auto"),
FontChoice::Default => String::from("Default"),
FontChoice::System(id) => candidates
.iter()
.find(|candidate| candidate.id == *id)
.map(|candidate| candidate.label.clone())
.unwrap_or_else(|| id.clone()),
}
}
fn load_selected_font(
choice: &FontChoice,
candidates: &[FontCandidate],
already_loaded: &[String],
) -> Option<(String, Vec<u8>)> {
match choice {
FontChoice::Default => None,
FontChoice::Auto => load_auto_font(candidates, already_loaded),
FontChoice::System(id) => {
let candidate = candidates.iter().find(|candidate| &candidate.id == id)?;
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
return None;
}
match fs::read(&candidate.path) {
Ok(bytes) => Some((candidate.id.clone(), bytes)),
Err(err) => {
warn!(path = %candidate.path, error = %err, "Failed to load selected font");
None
}
}
}
}
}
fn load_auto_font(
candidates: &[FontCandidate],
already_loaded: &[String],
) -> Option<(String, Vec<u8>)> {
for candidate in candidates.iter().filter(|candidate| candidate.likely_cjk) {
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
continue;
}
match fs::read(&candidate.path) {
Ok(bytes) => return Some((candidate.id.clone(), bytes)),
Err(err) => {
warn!(path = %candidate.path, error = %err, "Failed to load preferred CJK font");
}
}
}
for candidate in candidates {
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
continue;
}
match fs::read(&candidate.path) {
Ok(bytes) => return Some((candidate.id.clone(), bytes)),
Err(err) => {
warn!(path = %candidate.path, error = %err, "Failed to load candidate font");
}
}
}
None
}
fn apply_font_size(style: &mut Style, settings: &UiFontSettings) {
style
.text_styles
.insert(TextStyle::Body, FontId::proportional(settings.ui_font_size));
style.text_styles.insert(
TextStyle::Button,
FontId::proportional(settings.ui_font_size),
);
style.text_styles.insert(
TextStyle::Monospace,
FontId::monospace(settings.monospace_font_size),
);
style.text_styles.insert(
TextStyle::Small,
FontId::proportional((settings.ui_font_size - 2.0).max(8.0)),
);
style.text_styles.insert(
TextStyle::Heading,
FontId::proportional(settings.heading_font_size),
);
}
fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
let output = Command::new("fc-list")
.args([":", "file", "family", "style"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let mut candidates = Vec::new();
for line in stdout.lines() {
let (path, rest) = line.split_once(": ")?;
let (family_part, style_part) = rest.split_once(":style=").unwrap_or((rest, ""));
let family = family_part
.split(',')
.find(|name| !name.trim().is_empty())
.unwrap_or(family_part)
.trim()
.to_owned();
let style = style_part
.split(',')
.find(|name| !name.trim().is_empty())
.unwrap_or(style_part)
.trim()
.to_owned();
let path = path.trim();
if !Path::new(path).is_file() {
continue;
}
let stem = Path::new(path)
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("font");
let id = sanitize_id(&format!("{family}_{style}_{stem}"));
let label = if style.is_empty() || style == "Regular" {
family.clone()
} else {
format!("{family} ({style})")
};
candidates.push(FontCandidate {
id,
label,
display_label: font_display_label(&family, &style, path),
path: path.to_owned(),
likely_cjk: is_likely_cjk(&family, &style, path),
search_key: build_search_key(&family, &style, path),
});
}
(!candidates.is_empty()).then_some(candidates)
}
fn discover_via_font_directories() -> Vec<FontCandidate> {
let mut candidates = Vec::new();
let mut stack = platform_font_directories()
.into_iter()
.filter(|path| path.is_dir())
.map(|path| (path, 0usize))
.collect::<Vec<_>>();
while let Some((dir, depth)) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) => {
warn!(path = %dir.display(), error = %err, "Failed to read font directory");
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_dir() {
if depth < 4 {
stack.push((path, depth + 1));
}
continue;
}
if let Some(candidate) = font_candidate_from_path(&path) {
candidates.push(candidate);
}
}
}
candidates
}
fn font_display_label(family: &str, style: &str, path: &str) -> String {
let likely_cjk = is_likely_cjk(family, style, path);
let base = if style.is_empty() || style == "Regular" {
family.to_owned()
} else {
format!("{family} ({style})")
};
if likely_cjk {
format!("{base} [CJK]")
} else {
base
}
}
fn build_search_key(family: &str, style: &str, path: &str) -> String {
format!("{family}\n{style}\n{path}").to_lowercase()
}
fn font_settings_path() -> PathBuf {
font_settings_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
}
fn load_font_settings_from_path(path: &Path) -> io::Result<UiFontSettings> {
let text = fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(io::Error::other)
}
fn save_font_settings_to_path(settings: &UiFontSettings, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(settings).map_err(io::Error::other)?;
fs::write(path, json)
}
fn font_settings_path_for_os_and_env(
os: &str,
get_env: impl Fn(&str) -> Option<String>,
) -> PathBuf {
match os {
"windows" => {
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
}
"macos" => {
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join(".config")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME);
}
}
}
PathBuf::from(FONT_SETTINGS_FILE_NAME)
}
fn platform_font_directories() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
{
if let Some(windir) = env::var("WINDIR")
.ok()
.filter(|path| !path.trim().is_empty())
{
dirs.push(PathBuf::from(windir).join("Fonts"));
} else {
dirs.push(PathBuf::from(r"C:\Windows\Fonts"));
}
if let Some(local_app_data) = env::var("LOCALAPPDATA")
.ok()
.filter(|path| !path.trim().is_empty())
{
dirs.push(
PathBuf::from(local_app_data)
.join("Microsoft")
.join("Windows")
.join("Fonts"),
);
}
}
#[cfg(target_os = "macos")]
{
dirs.push(PathBuf::from("/System/Library/Fonts"));
dirs.push(PathBuf::from("/Library/Fonts"));
if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) {
dirs.push(PathBuf::from(home).join("Library").join("Fonts"));
}
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
dirs.push(PathBuf::from("/usr/share/fonts"));
dirs.push(PathBuf::from("/usr/local/share/fonts"));
if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) {
dirs.push(
PathBuf::from(&home)
.join(".local")
.join("share")
.join("fonts"),
);
dirs.push(PathBuf::from(home).join(".fonts"));
}
}
dirs
}
fn font_candidate_from_path(path: &Path) -> Option<FontCandidate> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
if !matches!(extension.as_str(), "ttf" | "ttc" | "otf" | "otc") {
return None;
}
let stem = path.file_stem()?.to_str()?.trim();
if stem.is_empty() {
return None;
}
let (family, style) = split_font_name(stem);
let path_string = path.to_string_lossy().into_owned();
let id = sanitize_id(&format!("{family}_{style}_{stem}"));
let label = if style.is_empty() || style == "Regular" {
family.clone()
} else {
format!("{family} ({style})")
};
Some(FontCandidate {
id,
label,
display_label: font_display_label(&family, &style, &path_string),
path: path_string.clone(),
likely_cjk: is_likely_cjk(&family, &style, &path_string),
search_key: build_search_key(&family, &style, &path_string),
})
}
fn split_font_name(stem: &str) -> (String, String) {
let normalized = stem.replace(['_', '-'], " ");
let compact = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
let lower = compact.to_lowercase();
for style in [
"ExtraBold Italic",
"Extra Light Italic",
"SemiBold Italic",
"Bold Italic",
"ExtraBold",
"SemiBold",
"Medium",
"Regular",
"Italic",
"Bold",
"Light",
"Thin",
] {
let suffix = style.to_lowercase();
if let Some(prefix) = lower.strip_suffix(&suffix) {
let family = compact[..prefix.trim_end().len()].trim().to_owned();
if !family.is_empty() {
return (family, style.to_owned());
}
}
}
(compact, String::from("Regular"))
}
const fn default_ui_font_size() -> f32 {
14.0
}
const fn default_monospace_font_size() -> f32 {
14.0
}
const fn default_heading_font_size() -> f32 {
20.0
}
fn default_primary_choice() -> FontChoice {
FontChoice::Auto
}
fn default_fallback_choice() -> FontChoice {
FontChoice::Default
}
fn sanitize_id(input: &str) -> String {
input
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool {
let haystack = format!("{family} {style} {path}").to_lowercase();
[
"cjk",
"noto sans sc",
"noto sans tc",
"noto sans jp",
"noto sans kr",
"source han",
"sarasa",
"wenquanyi",
"fallback",
"han",
"hei",
"kai",
"song",
"fang",
"yahei",
"deng",
"ming",
"simsun",
"simhei",
"simkai",
"simfang",
"msyh",
"msjh",
"meiryo",
"msgothic",
"ms gothic",
"malgun",
"gulim",
"gothic",
"mincho",
]
.iter()
.any(|needle| haystack.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn font_settings_roundtrip() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = env::temp_dir().join(format!("pipeview-gui-font-settings-{unique}"));
let path = dir.join("gui-fonts.json");
let settings = UiFontSettings {
primary_choice: FontChoice::System(String::from("primary")),
fallback_choice: FontChoice::System(String::from("fallback")),
ui_font_size: 15.5,
monospace_font_size: 13.0,
heading_font_size: 22.0,
};
save_font_settings_to_path(&settings, &path).unwrap();
let loaded = load_font_settings_from_path(&path).unwrap();
assert_eq!(loaded.primary_choice, settings.primary_choice);
assert_eq!(loaded.fallback_choice, settings.fallback_choice);
assert_eq!(loaded.ui_font_size, settings.ui_font_size);
assert_eq!(loaded.monospace_font_size, settings.monospace_font_size);
assert_eq!(loaded.heading_font_size, settings.heading_font_size);
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn linux_config_path_uses_xdg() {
let path = font_settings_path_for_os_and_env("linux", |key| match key {
"XDG_CONFIG_HOME" => Some(String::from("/tmp/xdg-config")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/tmp/xdg-config")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn windows_config_path_uses_appdata() {
let path = font_settings_path_for_os_and_env("windows", |key| match key {
"APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")),
_ => None,
});
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn macos_config_path_uses_application_support() {
let path = font_settings_path_for_os_and_env("macos", |key| match key {
"HOME" => Some(String::from("/Users/tester")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/Users/tester")
.join("Library")
.join("Application Support")
.join("pipeview")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn detects_windows_cjk_fonts() {
assert!(is_likely_cjk(
"Microsoft YaHei UI",
"Regular",
r"C:\Windows\Fonts\msyh.ttc"
));
assert!(is_likely_cjk(
"SimSun",
"Regular",
r"C:\Windows\Fonts\simsun.ttc"
));
}
}