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:
21
crates/pipeview-gui/Cargo.toml
Normal file
21
crates/pipeview-gui/Cargo.toml
Normal 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"
|
||||
1484
crates/pipeview-gui/src/app.rs
Normal file
1484
crates/pipeview-gui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
175
crates/pipeview-gui/src/app_state.rs
Normal file
175
crates/pipeview-gui/src/app_state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
808
crates/pipeview-gui/src/buffers.rs
Normal file
808
crates/pipeview-gui/src/buffers.rs
Normal 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]));
|
||||
}
|
||||
}
|
||||
109
crates/pipeview-gui/src/logging.rs
Normal file
109
crates/pipeview-gui/src/logging.rs
Normal 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}")
|
||||
}
|
||||
28
crates/pipeview-gui/src/main.rs
Normal file
28
crates/pipeview-gui/src/main.rs
Normal 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())))),
|
||||
);
|
||||
}
|
||||
1046
crates/pipeview-gui/src/panels/config.rs
Normal file
1046
crates/pipeview-gui/src/panels/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
129
crates/pipeview-gui/src/panels/console.rs
Normal file
129
crates/pipeview-gui/src/panels/console.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
134
crates/pipeview-gui/src/panels/hex_view.rs
Normal file
134
crates/pipeview-gui/src/panels/hex_view.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
|
||||
5
crates/pipeview-gui/src/panels/mod.rs
Normal file
5
crates/pipeview-gui/src/panels/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod console;
|
||||
pub mod hex_view;
|
||||
pub mod plot_view;
|
||||
pub mod sidebar;
|
||||
219
crates/pipeview-gui/src/panels/plot_view.rs
Normal file
219
crates/pipeview-gui/src/panels/plot_view.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
68
crates/pipeview-gui/src/panels/sidebar.rs
Normal file
68
crates/pipeview-gui/src/panels/sidebar.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
257
crates/pipeview-gui/src/perf.rs
Normal file
257
crates/pipeview-gui/src/perf.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RepaintCounter {
|
||||
inner: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl RepaintCounter {
|
||||
pub fn increment(&self) {
|
||||
self.inner.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn take(&self) -> u64 {
|
||||
self.inner.swap(0, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DurationStats {
|
||||
count: u64,
|
||||
total: Duration,
|
||||
max: Duration,
|
||||
}
|
||||
|
||||
impl DurationStats {
|
||||
fn record(&mut self, elapsed: Duration) {
|
||||
self.count += 1;
|
||||
self.total += elapsed;
|
||||
self.max = self.max.max(elapsed);
|
||||
}
|
||||
|
||||
fn avg_ms(&self) -> f64 {
|
||||
if self.count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.total.as_secs_f64() * 1000.0) / self.count as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn max_ms(&self) -> f64 {
|
||||
self.max.as_secs_f64() * 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct IntervalStats {
|
||||
frames: DurationStats,
|
||||
drains: DurationStats,
|
||||
text_renders: DurationStats,
|
||||
hex_renders: DurationStats,
|
||||
plot_renders: DurationStats,
|
||||
drained_events: u64,
|
||||
drained_data_events: u64,
|
||||
drained_text_events: u64,
|
||||
drained_hex_events: u64,
|
||||
drained_plot_events: u64,
|
||||
max_pending_events: usize,
|
||||
max_text_lines: usize,
|
||||
max_hex_lines: usize,
|
||||
max_plot_series: usize,
|
||||
max_plot_stored_points: usize,
|
||||
max_plot_rendered_points: usize,
|
||||
tabs: usize,
|
||||
active_view: &'static str,
|
||||
active_text_lines: usize,
|
||||
active_hex_lines: usize,
|
||||
active_plot_series: usize,
|
||||
active_plot_points: usize,
|
||||
}
|
||||
|
||||
pub struct DrainStats {
|
||||
pub drained_events: u64,
|
||||
pub drained_data_events: u64,
|
||||
pub drained_text_events: u64,
|
||||
pub drained_hex_events: u64,
|
||||
pub drained_plot_events: u64,
|
||||
pub pending_events: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct GuiSnapshot {
|
||||
pub tabs: usize,
|
||||
pub active_view: &'static str,
|
||||
pub active_text_lines: usize,
|
||||
pub active_hex_lines: usize,
|
||||
pub active_plot_series: usize,
|
||||
pub active_plot_points: usize,
|
||||
}
|
||||
|
||||
pub struct PlotRenderStats {
|
||||
pub series_count: usize,
|
||||
pub stored_points: usize,
|
||||
pub rendered_points: usize,
|
||||
}
|
||||
|
||||
pub struct GuiProfiler {
|
||||
enabled: bool,
|
||||
interval: Duration,
|
||||
last_report_at: Instant,
|
||||
repaint_counter: RepaintCounter,
|
||||
interval_stats: IntervalStats,
|
||||
}
|
||||
|
||||
impl GuiProfiler {
|
||||
pub fn from_env() -> Self {
|
||||
let enabled = env_flag("XSERIAL_GUI_PROFILE");
|
||||
let interval_ms = env::var("XSERIAL_GUI_PROFILE_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1_000);
|
||||
Self {
|
||||
enabled,
|
||||
interval: Duration::from_millis(interval_ms),
|
||||
last_report_at: Instant::now(),
|
||||
repaint_counter: RepaintCounter::default(),
|
||||
interval_stats: IntervalStats {
|
||||
active_view: "none",
|
||||
..IntervalStats::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repaint_counter(&self) -> RepaintCounter {
|
||||
self.repaint_counter.clone()
|
||||
}
|
||||
|
||||
pub fn record_drain(&mut self, elapsed: Duration, stats: DrainStats) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.drains.record(elapsed);
|
||||
self.interval_stats.drained_events += stats.drained_events;
|
||||
self.interval_stats.drained_data_events += stats.drained_data_events;
|
||||
self.interval_stats.drained_text_events += stats.drained_text_events;
|
||||
self.interval_stats.drained_hex_events += stats.drained_hex_events;
|
||||
self.interval_stats.drained_plot_events += stats.drained_plot_events;
|
||||
self.interval_stats.max_pending_events = self
|
||||
.interval_stats
|
||||
.max_pending_events
|
||||
.max(stats.pending_events);
|
||||
}
|
||||
|
||||
pub fn record_text_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.text_renders.record(elapsed);
|
||||
self.interval_stats.max_text_lines = self.interval_stats.max_text_lines.max(line_count);
|
||||
}
|
||||
|
||||
pub fn record_hex_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.hex_renders.record(elapsed);
|
||||
self.interval_stats.max_hex_lines = self.interval_stats.max_hex_lines.max(line_count);
|
||||
}
|
||||
|
||||
pub fn record_plot_render(&mut self, elapsed: Duration, stats: PlotRenderStats) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.plot_renders.record(elapsed);
|
||||
self.interval_stats.max_plot_series =
|
||||
self.interval_stats.max_plot_series.max(stats.series_count);
|
||||
self.interval_stats.max_plot_stored_points = self
|
||||
.interval_stats
|
||||
.max_plot_stored_points
|
||||
.max(stats.stored_points);
|
||||
self.interval_stats.max_plot_rendered_points = self
|
||||
.interval_stats
|
||||
.max_plot_rendered_points
|
||||
.max(stats.rendered_points);
|
||||
}
|
||||
|
||||
pub fn record_frame(&mut self, elapsed: Duration, snapshot: GuiSnapshot) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.interval_stats.frames.record(elapsed);
|
||||
self.interval_stats.tabs = snapshot.tabs;
|
||||
self.interval_stats.active_view = snapshot.active_view;
|
||||
self.interval_stats.active_text_lines = snapshot.active_text_lines;
|
||||
self.interval_stats.active_hex_lines = snapshot.active_hex_lines;
|
||||
self.interval_stats.active_plot_series = snapshot.active_plot_series;
|
||||
self.interval_stats.active_plot_points = snapshot.active_plot_points;
|
||||
|
||||
if self.last_report_at.elapsed() < self.interval {
|
||||
return;
|
||||
}
|
||||
|
||||
let repaints = self.repaint_counter.take();
|
||||
info!(
|
||||
target: "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)
|
||||
}
|
||||
142
crates/pipeview-gui/src/shortcuts.rs
Normal file
142
crates/pipeview-gui/src/shortcuts.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
673
crates/pipeview-gui/src/ui_fonts.rs
Normal file
673
crates/pipeview-gui/src/ui_fonts.rs
Normal 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user