Compare commits
3 Commits
f9be74257a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a23168728 | |||
| 5f5477d658 | |||
| 81c0ce13c4 |
41
.github/workflows/ci.yml
vendored
Normal file
41
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libudev-dev libxkbcommon-dev libxcb1-dev libxcb-icccm4-dev libxcb-shape0-dev libxcb-xfixes0-dev libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Smoke-check Python tools
|
||||
run: |
|
||||
python3 -m py_compile tools/*.py
|
||||
for f in tools/test_plot.py tools/test_drone.py tools/test_ansi.py tools/stress_test.py; do
|
||||
python3 "$f" --help >/dev/null
|
||||
done
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace
|
||||
1160
Cargo.lock
generated
1160
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -3,7 +3,6 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/pipeview-core",
|
||||
"crates/pipeview-client",
|
||||
"crates/pipeview-tui",
|
||||
"crates/pipeview-gui",
|
||||
]
|
||||
|
||||
@@ -30,21 +29,10 @@ thiserror = "2"
|
||||
# ── 日志 ──
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
|
||||
# ── 工具 ──
|
||||
hex = "0.4"
|
||||
|
||||
# ── 图像 ──
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# ── CLI ──
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# ── TUI ──
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.28"
|
||||
|
||||
# ── GUI ──
|
||||
egui = "0.34"
|
||||
eframe = "0.34"
|
||||
|
||||
10
README.md
10
README.md
@@ -229,7 +229,7 @@ return {
|
||||
}
|
||||
```
|
||||
|
||||
参考实现:`tests/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
|
||||
参考实现:`crates/pipeview-client/tests/fixtures/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
|
||||
|
||||
### 解码器 API
|
||||
|
||||
@@ -277,7 +277,7 @@ return {
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||
│ pipeview-gui (egui) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ pipeview-client │
|
||||
│ SessionManager · Session · Config · History │
|
||||
@@ -293,9 +293,8 @@ return {
|
||||
| `pipeview-core` | library | 传输层(Serial/TCP/UDP)、分帧器(Line/Fixed/Length/COBS/Mixed/Lua)、协议解码器(Text/Hex/Plot)、MultiPipeline |
|
||||
| `pipeview-client` | library | Session 生命周期管理、SessionManager、事件广播(tokio broadcast)、RingBuffer 历史、Lua 运行时及会话 API |
|
||||
| `pipeview-gui` | binary | egui 桌面应用,包含 sidebar/config/console/text/hex/plot 面板、键盘快捷键、字体管理、性能分析 |
|
||||
| `pipeview-tui` | binary | ratatui 终端应用(功能未对齐 GUI,仍在开发中) |
|
||||
|
||||
**依赖方向:** `core ← client ← {gui, tui}`
|
||||
**依赖方向:** `core ← client ← gui`
|
||||
|
||||
**数据流:**
|
||||
|
||||
@@ -364,12 +363,11 @@ RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
|
||||
crates/
|
||||
pipeview-core/ # 传输、分帧、协议
|
||||
pipeview-client/ # 会话管理、Lua 运行时
|
||||
tests/fixtures/ # Lua 测试夹具
|
||||
pipeview-gui/ # egui 桌面应用
|
||||
pipeview-tui/ # ratatui 终端应用
|
||||
examples/ # Lua 脚本示例
|
||||
drone_plot.lua # 飞控遥测波形解码器
|
||||
drone_text.lua # 飞控遥测文本解码器
|
||||
tests/ # Lua 测试 fixture
|
||||
tools/ # 开发辅助工具
|
||||
test_plot.py # 波形测试数据生成器
|
||||
test_drone.py # 飞控测试数据生成器
|
||||
|
||||
10
README_en.md
10
README_en.md
@@ -217,7 +217,7 @@ return {
|
||||
}
|
||||
```
|
||||
|
||||
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
|
||||
Reference: `crates/pipeview-client/tests/fixtures/lua_line_framer.lua` (line-based framer splitting on `\n`).
|
||||
|
||||
### Decoder API
|
||||
|
||||
@@ -265,7 +265,7 @@ More examples in `examples/`.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||
│ pipeview-gui (egui) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ pipeview-client │
|
||||
│ SessionManager · Session · Config · History │
|
||||
@@ -281,9 +281,8 @@ More examples in `examples/`.
|
||||
| `pipeview-core` | library | Transport (Serial/TCP/UDP), framers (Line/Fixed/Length/COBS/Mixed/Lua), decoders (Text/Hex/Plot), MultiPipeline |
|
||||
| `pipeview-client` | library | Session lifecycle, SessionManager, event broadcast (tokio broadcast), RingBuffer history, Lua runtime & session API |
|
||||
| `pipeview-gui` | binary | egui desktop app: sidebar, config, console, text/hex/plot panels, keyboard shortcuts, font management, profiling |
|
||||
| `pipeview-tui` | binary | ratatui terminal app (feature-incomplete, under development) |
|
||||
|
||||
**Dependency flow:** `core ← client ← {gui, tui}`
|
||||
**Dependency flow:** `core ← client ← gui`
|
||||
|
||||
**Data flow:**
|
||||
|
||||
@@ -348,12 +347,11 @@ RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui
|
||||
crates/
|
||||
pipeview-core/ # Transport, framing, protocol
|
||||
pipeview-client/ # Session management, Lua runtime
|
||||
tests/fixtures/ # Lua test fixtures
|
||||
pipeview-gui/ # egui desktop application
|
||||
pipeview-tui/ # ratatui terminal application
|
||||
examples/ # Lua script examples
|
||||
drone_plot.lua # Drone telemetry plot decoder
|
||||
drone_text.lua # Drone telemetry text decoder
|
||||
tests/ # Lua test fixtures
|
||||
tools/ # Development utilities
|
||||
test_plot.py # Waveform test data generator
|
||||
test_drone.py # Drone test data generator
|
||||
|
||||
@@ -15,3 +15,4 @@ thiserror = { workspace = true }
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
||||
@@ -57,3 +57,116 @@ impl RingBuffer<DecodedEntry> {
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::event::DecodedEntry;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
|
||||
#[test]
|
||||
fn push_evicts_oldest_when_limit_reached() {
|
||||
let mut rb = RingBuffer::new(3);
|
||||
rb.push(1);
|
||||
rb.push(2);
|
||||
rb.push(3);
|
||||
rb.push(4);
|
||||
rb.push(5);
|
||||
|
||||
assert_eq!(rb.len(), 3);
|
||||
assert_eq!(rb.get(0), Some(&3));
|
||||
assert_eq!(rb.get(1), Some(&4));
|
||||
assert_eq!(rb.get(2), Some(&5));
|
||||
assert_eq!(rb.get(3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_yields_oldest_to_newest() {
|
||||
let mut rb = RingBuffer::new(3);
|
||||
rb.push("a");
|
||||
rb.push("b");
|
||||
rb.push("c");
|
||||
|
||||
let items: Vec<_> = rb.iter().collect();
|
||||
assert_eq!(items, vec![&"a", &"b", &"c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_buffer() {
|
||||
let mut rb = RingBuffer::new(3);
|
||||
rb.push(1);
|
||||
rb.push(2);
|
||||
rb.clear();
|
||||
|
||||
assert!(rb.is_empty());
|
||||
assert_eq!(rb.len(), 0);
|
||||
assert_eq!(rb.get(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_limit_trims_excess() {
|
||||
let mut rb = RingBuffer::new(5);
|
||||
for i in 0..5 {
|
||||
rb.push(i);
|
||||
}
|
||||
rb.set_limit(2);
|
||||
|
||||
assert_eq!(rb.len(), 2);
|
||||
assert_eq!(rb.get(0), Some(&3));
|
||||
assert_eq!(rb.get(1), Some(&4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_limit_can_grow_without_data_loss() {
|
||||
let mut rb = RingBuffer::new(2);
|
||||
rb.push(1);
|
||||
rb.push(2);
|
||||
rb.set_limit(5);
|
||||
|
||||
assert_eq!(rb.len(), 2);
|
||||
assert_eq!(rb.get(0), Some(&1));
|
||||
assert_eq!(rb.get(1), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_recent_respects_count_and_order() {
|
||||
let mut rb = RingBuffer::new(5);
|
||||
for i in 0..5 {
|
||||
rb.push(i);
|
||||
}
|
||||
|
||||
let empty: Vec<i32> = rb.drain_recent(0);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let last_two = rb.drain_recent(2);
|
||||
assert_eq!(last_two, vec![3, 4]);
|
||||
|
||||
let all = rb.drain_recent(10);
|
||||
assert_eq!(all, vec![0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_for_pipeline_filters_by_name() {
|
||||
let mut rb = RingBuffer::new(10);
|
||||
rb.push(DecodedEntry {
|
||||
pipeline_name: "a".into(),
|
||||
data: DecodedData::Text("one".into()),
|
||||
});
|
||||
rb.push(DecodedEntry {
|
||||
pipeline_name: "b".into(),
|
||||
data: DecodedData::Text("two".into()),
|
||||
});
|
||||
rb.push(DecodedEntry {
|
||||
pipeline_name: "a".into(),
|
||||
data: DecodedData::Text("three".into()),
|
||||
});
|
||||
|
||||
let a = rb.entries_for_pipeline("a");
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(a[0].pipeline_name, "a");
|
||||
assert_eq!(a[1].pipeline_name, "a");
|
||||
|
||||
let none = rb.entries_for_pipeline("missing");
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
66
crates/pipeview-client/tests/lua_fixtures.rs
Normal file
66
crates/pipeview-client/tests/lua_fixtures.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mlua::{Function, Lua, Table};
|
||||
|
||||
fn fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
fn load_fixture(name: &str) -> (Lua, Table) {
|
||||
let lua = Lua::new();
|
||||
let src = fs::read_to_string(fixture_path(name)).unwrap();
|
||||
let table: Table = lua.load(&src).set_name(name).eval().unwrap();
|
||||
(lua, table)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_line_framer_fixture_splits_crlf_and_lf() {
|
||||
let (lua, table) = load_fixture("lua_line_framer.lua");
|
||||
let feed: Function = table.get("feed").unwrap();
|
||||
|
||||
let input = lua.create_string(b"hello\r\nworld\n").unwrap();
|
||||
let frames: Vec<String> = feed.call(input).unwrap();
|
||||
assert_eq!(frames, vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_line_framer_fixture_handles_split_chunks_and_flush() {
|
||||
let (lua, table) = load_fixture("lua_line_framer.lua");
|
||||
let feed: Function = table.get("feed").unwrap();
|
||||
let flush: Function = table.get("flush").unwrap();
|
||||
let pending_len: Function = table.get("pending_len").unwrap();
|
||||
let reset: Function = table.get("reset").unwrap();
|
||||
|
||||
let frames: Vec<String> = feed.call(lua.create_string(b"par").unwrap()).unwrap();
|
||||
assert!(frames.is_empty());
|
||||
assert_eq!(pending_len.call::<usize>(()).unwrap(), 3);
|
||||
|
||||
let frames: Vec<String> = feed.call(lua.create_string(b"tial\n").unwrap()).unwrap();
|
||||
assert_eq!(frames, vec!["partial"]);
|
||||
|
||||
let flushed: Option<String> = flush.call(()).unwrap();
|
||||
assert_eq!(flushed.as_deref(), None);
|
||||
|
||||
reset.call::<()>(()).unwrap();
|
||||
assert_eq!(pending_len.call::<usize>(()).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_text_decoder_fixture_decodes_text_and_rejects_empty() {
|
||||
let (lua, table) = load_fixture("lua_text_decoder.lua");
|
||||
let decode: Function = table.get("decode").unwrap();
|
||||
|
||||
let decoded: Option<Table> = decode.call(lua.create_string(b"abc").unwrap()).unwrap();
|
||||
let decoded = decoded.expect("non-empty frame should decode");
|
||||
let kind: String = decoded.get("kind").unwrap();
|
||||
let data: String = decoded.get("data").unwrap();
|
||||
assert_eq!(kind, "text");
|
||||
assert_eq!(data, "abc");
|
||||
|
||||
let empty: Option<Table> = decode.call(lua.create_string(b"").unwrap()).unwrap();
|
||||
assert!(empty.is_none(), "empty frame should be rejected");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Once;
|
||||
use std::time::Duration;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use mlua::Lua;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
@@ -17,14 +18,30 @@ fn init_tracing() {
|
||||
});
|
||||
}
|
||||
|
||||
fn write_temp_lua(name: &str, script: &str) -> PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||
fs::write(&path, script).unwrap();
|
||||
path
|
||||
struct TempScript {
|
||||
path: PathBuf,
|
||||
_guard: tempfile::TempPath,
|
||||
}
|
||||
|
||||
impl TempScript {
|
||||
fn new(name: &str, script: &str) -> Self {
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix(&format!("pipeview_{name}_"))
|
||||
.suffix(".lua")
|
||||
.tempfile()
|
||||
.unwrap();
|
||||
file.write_all(script.as_bytes()).unwrap();
|
||||
let path = file.path().to_path_buf();
|
||||
let guard = file.into_temp_path();
|
||||
Self {
|
||||
path,
|
||||
_guard: guard,
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
fn text_pipeline_lua() -> &'static str {
|
||||
@@ -208,7 +225,7 @@ async fn lua_session_custom_lua_pipeline() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
|
||||
let framer_path = write_temp_lua(
|
||||
let framer_script = TempScript::new(
|
||||
"custom_framer",
|
||||
r#"
|
||||
local buffer = ""
|
||||
@@ -239,7 +256,7 @@ async fn lua_session_custom_lua_pipeline() {
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let decoder_path = write_temp_lua(
|
||||
let decoder_script = TempScript::new(
|
||||
"custom_decoder",
|
||||
r#"
|
||||
return {
|
||||
@@ -287,14 +304,12 @@ async fn lua_session_custom_lua_pipeline() {
|
||||
sess:close()
|
||||
"#,
|
||||
addr,
|
||||
framer_path.display(),
|
||||
decoder_path.display()
|
||||
framer_script.path().display(),
|
||||
decoder_script.path().display()
|
||||
);
|
||||
|
||||
lua.load(&script).exec_async().await.unwrap();
|
||||
server.await.unwrap();
|
||||
fs::remove_file(framer_path).unwrap();
|
||||
fs::remove_file(decoder_path).unwrap();
|
||||
}
|
||||
|
||||
// ── session:on_data callback ─────────────────────────────────────
|
||||
|
||||
@@ -15,3 +15,6 @@ serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
||||
96
crates/pipeview-core/tests/properties.rs
Normal file
96
crates/pipeview-core/tests/properties.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use proptest::prelude::*;
|
||||
|
||||
use pipeview_core::frame::Framer;
|
||||
use pipeview_core::frame::cobs::{CobsFramer, cobs_decode, cobs_encode};
|
||||
use pipeview_core::frame::length::{LengthConfig, LengthPrefixedFramer};
|
||||
use pipeview_core::protocol::Endian;
|
||||
use pipeview_core::protocol::ProtocolDecoder;
|
||||
use pipeview_core::protocol::hex::{HexConfig, HexDecoder};
|
||||
use pipeview_core::protocol::plot::{PlotConfig, PlotDecoder, SampleType};
|
||||
use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
// ── COBS ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cobs_roundtrip_any_payload(payload in prop::collection::vec(any::<u8>(), 0..512)) {
|
||||
let encoded = cobs_encode(&payload);
|
||||
let decoded = cobs_decode(&encoded).unwrap();
|
||||
prop_assert_eq!(&decoded, &payload);
|
||||
|
||||
let mut framer = CobsFramer::new(1024 * 1024);
|
||||
let mut wire = encoded;
|
||||
wire.push(0x00);
|
||||
let frames = framer.feed(&wire);
|
||||
prop_assert_eq!(frames.len(), 1);
|
||||
prop_assert_eq!(&frames[0], &payload);
|
||||
}
|
||||
|
||||
// ── Length-prefixed ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn length_roundtrip_any_payload(
|
||||
len_bytes in prop_oneof![Just(1usize), Just(2usize), Just(4usize)],
|
||||
little_endian in any::<bool>(),
|
||||
includes_self in any::<bool>(),
|
||||
payload in prop::collection::vec(any::<u8>(), 0..200)
|
||||
) {
|
||||
let endian = if little_endian { Endian::Little } else { Endian::Big };
|
||||
let raw_len = if includes_self {
|
||||
payload.len() + len_bytes
|
||||
} else {
|
||||
payload.len()
|
||||
};
|
||||
|
||||
let mut wire = Vec::with_capacity(len_bytes + payload.len());
|
||||
match (len_bytes, endian) {
|
||||
(1, _) => wire.push(raw_len as u8),
|
||||
(2, Endian::Big) => wire.extend_from_slice(&(raw_len as u16).to_be_bytes()),
|
||||
(2, Endian::Little) => wire.extend_from_slice(&(raw_len as u16).to_le_bytes()),
|
||||
(4, Endian::Big) => wire.extend_from_slice(&(raw_len as u32).to_be_bytes()),
|
||||
(4, Endian::Little) => wire.extend_from_slice(&(raw_len as u32).to_le_bytes()),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
wire.extend_from_slice(&payload);
|
||||
|
||||
let config = LengthConfig {
|
||||
len_bytes,
|
||||
endian,
|
||||
length_includes_self: includes_self,
|
||||
max_payload: 1024 * 1024,
|
||||
};
|
||||
let mut framer = LengthPrefixedFramer::new(config);
|
||||
let frames = framer.feed(&wire);
|
||||
prop_assert_eq!(frames.len(), 1);
|
||||
prop_assert_eq!(&frames[0], &payload);
|
||||
}
|
||||
|
||||
// ── Protocol decoders never panic on arbitrary bytes ─────────────
|
||||
|
||||
#[test]
|
||||
fn plot_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
|
||||
let decoder = PlotDecoder::new(PlotConfig {
|
||||
sample_type: SampleType::F32,
|
||||
endian: Endian::Little,
|
||||
channels: 2,
|
||||
format: pipeview_core::protocol::plot::PlotFormat::Interleaved,
|
||||
});
|
||||
let _ = decoder.decode(&bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
|
||||
let decoder = HexDecoder::new(HexConfig::default());
|
||||
let decoded = decoder.decode(&bytes);
|
||||
prop_assert!(decoded.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin1_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
|
||||
let decoder = TextDecoder::new(TextEncoding::Latin1);
|
||||
let decoded = decoder.decode(&bytes);
|
||||
prop_assert!(decoded.is_some());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||
use crate::logging::{self, LogWriter};
|
||||
use crate::panels::{config, console, font_settings, hex_view, plot_view, send_panel, session_controls, sidebar};
|
||||
use crate::panels::{
|
||||
config, console, font_settings, hex_view, plot_view, send_panel, session_controls, sidebar,
|
||||
};
|
||||
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
||||
use crate::shortcuts::{self, default_bindings};
|
||||
use crate::ui_fonts::{self, FontCandidate, UiFontSettings};
|
||||
@@ -557,7 +559,10 @@ impl XserialApp {
|
||||
let (badge, status_text) = tab.status.badge();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false));
|
||||
ui.add(
|
||||
Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading())
|
||||
.selectable(false),
|
||||
);
|
||||
ui.add(Label::new(badge).selectable(false));
|
||||
ui.separator();
|
||||
ui.add(Label::new(status_text).selectable(false));
|
||||
@@ -579,7 +584,8 @@ impl XserialApp {
|
||||
}
|
||||
});
|
||||
|
||||
let auto_reconnect_changed = session_controls::render_session_controls(ui, &manager, tab);
|
||||
let auto_reconnect_changed =
|
||||
session_controls::render_session_controls(ui, &manager, tab);
|
||||
persist_state |= auto_reconnect_changed;
|
||||
|
||||
let mut display_changed = false;
|
||||
@@ -598,7 +604,10 @@ impl XserialApp {
|
||||
session_controls::render_search_bar(ui, tab);
|
||||
|
||||
if let ConnectionStatus::Error(message) = &tab.status {
|
||||
ui.add(Label::new(egui::RichText::new(message).color(Color32::RED)).selectable(false));
|
||||
ui.add(
|
||||
Label::new(egui::RichText::new(message).color(Color32::RED))
|
||||
.selectable(false),
|
||||
);
|
||||
}
|
||||
|
||||
let full = ui.available_rect_before_wrap();
|
||||
@@ -620,11 +629,21 @@ impl XserialApp {
|
||||
let started = Instant::now();
|
||||
match tab.view {
|
||||
View::Text => {
|
||||
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
|
||||
let line_count = console::render(
|
||||
ui,
|
||||
&tab.console,
|
||||
*display,
|
||||
tab.search.active.then_some(&tab.search),
|
||||
);
|
||||
text_render = Some((started.elapsed(), line_count));
|
||||
}
|
||||
View::Hex => {
|
||||
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
|
||||
let line_count = hex_view::render(
|
||||
ui,
|
||||
&tab.hex,
|
||||
*display,
|
||||
tab.search.active.then_some(&tab.search),
|
||||
);
|
||||
hex_render = Some((started.elapsed(), line_count));
|
||||
}
|
||||
View::Plot => {
|
||||
|
||||
@@ -37,8 +37,7 @@ pub fn render(
|
||||
input.pointer.button_down(egui::PointerButton::Primary)
|
||||
&& input.pointer.hover_pos().is_some_and(|pos| {
|
||||
let area = ui.max_rect();
|
||||
pos.y > area.bottom() - EDGE_SCROLL_ZONE
|
||||
&& pos.y < area.bottom() + 20.0
|
||||
pos.y > area.bottom() - EDGE_SCROLL_ZONE && pos.y < area.bottom() + 20.0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -56,9 +55,8 @@ pub fn render(
|
||||
// offset for the next frame and request a repaint for smooth continuous scrolling.
|
||||
if near_bottom_edge && line_count > 0 {
|
||||
let mut state = output.state;
|
||||
let max_offset = (line_count as f32 * row_height)
|
||||
- output.inner_rect.height()
|
||||
+ EDGE_SCROLL_ZONE;
|
||||
let max_offset =
|
||||
(line_count as f32 * row_height) - output.inner_rect.height() + EDGE_SCROLL_ZONE;
|
||||
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
|
||||
state.store(ui.ctx(), output.id);
|
||||
ui.ctx().request_repaint();
|
||||
@@ -74,7 +72,7 @@ fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||
.get(&TextStyle::Monospace)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
color: Color32::WHITE,
|
||||
color: Color32::WHITE,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,7 @@ pub fn render(
|
||||
input.pointer.button_down(egui::PointerButton::Primary)
|
||||
&& input.pointer.hover_pos().is_some_and(|pos| {
|
||||
let area = ui.max_rect();
|
||||
pos.y > area.bottom() - EDGE_SCROLL_ZONE
|
||||
&& pos.y < area.bottom() + 20.0
|
||||
pos.y > area.bottom() - EDGE_SCROLL_ZONE && pos.y < area.bottom() + 20.0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -57,9 +56,8 @@ pub fn render(
|
||||
// offset for the next frame and request a repaint for smooth continuous scrolling.
|
||||
if near_bottom_edge {
|
||||
let mut state = output.state;
|
||||
let max_offset = (line_count as f32 * row_height)
|
||||
- output.inner_rect.height()
|
||||
+ EDGE_SCROLL_ZONE;
|
||||
let max_offset =
|
||||
(line_count as f32 * row_height) - output.inner_rect.height() + EDGE_SCROLL_ZONE;
|
||||
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
|
||||
state.store(ui.ctx(), output.id);
|
||||
ui.ctx().request_repaint();
|
||||
|
||||
@@ -44,18 +44,14 @@ pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut
|
||||
|
||||
let log_toggled = ui
|
||||
.horizontal(|ui| {
|
||||
let toggled = ui
|
||||
.checkbox(&mut tab.log_enabled, "Log to file")
|
||||
.changed();
|
||||
let toggled = ui.checkbox(&mut tab.log_enabled, "Log to file").changed();
|
||||
ui.checkbox(&mut tab.show_sent, "Show sent data");
|
||||
toggled
|
||||
})
|
||||
.inner;
|
||||
if log_toggled {
|
||||
if tab.log_enabled {
|
||||
tab.log_path = default_log_path(tab.id)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
tab.log_path = default_log_path(tab.id).to_string_lossy().to_string();
|
||||
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
||||
} else {
|
||||
tab.log_writer = None;
|
||||
@@ -63,9 +59,7 @@ pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut
|
||||
}
|
||||
if tab.log_enabled {
|
||||
ui.horizontal(|ui| {
|
||||
let changed = ui
|
||||
.text_edit_singleline(&mut tab.log_path)
|
||||
.lost_focus();
|
||||
let changed = ui.text_edit_singleline(&mut tab.log_path).lost_focus();
|
||||
if changed && !tab.log_path.is_empty() {
|
||||
tab.log_writer = LogWriter::open(&tab.log_path).ok();
|
||||
}
|
||||
@@ -96,13 +90,13 @@ pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut
|
||||
send_clicked = ui.button("Send").clicked();
|
||||
if let Some(status) = &tab.send_status {
|
||||
ui.add(
|
||||
Label::new(
|
||||
egui::RichText::new(status).color(if status.starts_with("Send failed") {
|
||||
Label::new(egui::RichText::new(status).color(
|
||||
if status.starts_with("Send failed") {
|
||||
Color32::RED
|
||||
} else {
|
||||
Color32::GRAY
|
||||
}),
|
||||
)
|
||||
},
|
||||
))
|
||||
.selectable(false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
||||
}
|
||||
if response.changed() {
|
||||
let matches = match tab.view {
|
||||
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Text => tab
|
||||
.console
|
||||
.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Plot => Vec::new(),
|
||||
};
|
||||
@@ -48,7 +50,9 @@ pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
||||
|
||||
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
|
||||
let matches = match tab.view {
|
||||
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Text => tab
|
||||
.console
|
||||
.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Plot => Vec::new(),
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "pipeview-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "pipeview-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pipeview-core = { path = "../pipeview-core" }
|
||||
pipeview-client = { path = "../pipeview-client" }
|
||||
tokio = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing-appender = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
image = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ansitok = "0.3"
|
||||
@@ -1,295 +0,0 @@
|
||||
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Parse ANSI-encoded text and return a Vec of styled Spans.
|
||||
///
|
||||
/// ANSI SGR sequences (colors, bold, italic, underline, strikethrough)
|
||||
/// are converted to ratatui `Style` applied on top of `base_style`.
|
||||
/// Non-SGR sequences (cursor movement, etc.) are silently ignored.
|
||||
pub fn ansi_to_spans(text: &str, base_style: Style) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut style = AnsiStyleStack::default();
|
||||
|
||||
for element in parse_ansi(text) {
|
||||
match element.kind() {
|
||||
ElementKind::Text => {
|
||||
let slice = &text[element.start()..element.end()];
|
||||
if !slice.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
slice.to_string(),
|
||||
style.merge(base_style),
|
||||
));
|
||||
}
|
||||
}
|
||||
ElementKind::Sgr => {
|
||||
let sgr = &text[element.start()..element.end()];
|
||||
apply_sgr_sequence(&mut style, sgr);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
/// Extract visible text from ANSI-encoded string (strips escape sequences).
|
||||
///
|
||||
/// This is the text that a user would actually see on a terminal —
|
||||
/// useful for search, copy, and line counting.
|
||||
pub fn ansi_visible_text(text: &str) -> String {
|
||||
let mut visible = String::new();
|
||||
for element in parse_ansi(text) {
|
||||
if element.kind() == ElementKind::Text {
|
||||
visible.push_str(&text[element.start()..element.end()]);
|
||||
}
|
||||
}
|
||||
visible
|
||||
}
|
||||
|
||||
// ── internal style stack ──
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct AnsiStyleStack {
|
||||
fg: Option<Color>,
|
||||
bg: Option<Color>,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strikethrough: bool,
|
||||
}
|
||||
|
||||
impl AnsiStyleStack {
|
||||
fn apply_sgr_attr(&mut self, attr: VisualAttribute) {
|
||||
match attr {
|
||||
VisualAttribute::Reset(0) => *self = Self::default(),
|
||||
VisualAttribute::Reset(22) => self.bold = false,
|
||||
VisualAttribute::Reset(23) => self.italic = false,
|
||||
VisualAttribute::Reset(24) => self.underline = false,
|
||||
VisualAttribute::Reset(29) => self.strikethrough = false,
|
||||
VisualAttribute::Reset(39) => self.fg = None,
|
||||
VisualAttribute::Reset(49) => self.bg = None,
|
||||
VisualAttribute::Reset(_) => {}
|
||||
VisualAttribute::Bold => self.bold = true,
|
||||
VisualAttribute::Faint => self.bold = false,
|
||||
VisualAttribute::Italic => self.italic = true,
|
||||
VisualAttribute::Underline => self.underline = true,
|
||||
VisualAttribute::Crossedout => self.strikethrough = true,
|
||||
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_ratatui(c)),
|
||||
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_ratatui(c)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_sgr(&mut self, sgr: &str) {
|
||||
// Strip ESC[ ... m wrapper and delegate to ansitok's SGR parser
|
||||
let params = sgr
|
||||
.strip_prefix("\x1b[")
|
||||
.and_then(|s| s.strip_suffix('m'))
|
||||
.unwrap_or(sgr);
|
||||
|
||||
if params.is_empty() {
|
||||
*self = Self::default();
|
||||
return;
|
||||
}
|
||||
|
||||
for output in parse_ansi_sgr(sgr) {
|
||||
if let Some(attr) = output.as_escape() {
|
||||
self.apply_sgr_attr(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(&self, base: Style) -> Style {
|
||||
let mut style = base;
|
||||
|
||||
if let Some(fg) = self.fg {
|
||||
if self.bold {
|
||||
style = style.fg(brighten(fg));
|
||||
} else {
|
||||
style = style.fg(fg);
|
||||
}
|
||||
}
|
||||
if let Some(bg) = self.bg {
|
||||
style = style.bg(bg);
|
||||
}
|
||||
if self.italic {
|
||||
style = style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if self.underline {
|
||||
style = style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
if self.strikethrough {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
if self.bold {
|
||||
// Bold applied only when there's no explicit fg (brighten handles it otherwise)
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
|
||||
style
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_sgr_sequence(style: &mut AnsiStyleStack, sgr: &str) {
|
||||
style.apply_sgr(sgr);
|
||||
}
|
||||
|
||||
// ── color conversion ──
|
||||
|
||||
fn ansi_color_to_ratatui(color: AnsiColor) -> Color {
|
||||
match color {
|
||||
AnsiColor::Bit4(c) => ansi_4bit(c),
|
||||
AnsiColor::Bit8(c) => ansi_256(c),
|
||||
AnsiColor::Bit24 { r, g, b } => Color::Rgb(r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_4bit(code: u8) -> Color {
|
||||
match code {
|
||||
30 => Color::Rgb(0, 0, 0),
|
||||
31 => Color::Rgb(194, 54, 33),
|
||||
32 => Color::Rgb(37, 188, 36),
|
||||
33 => Color::Rgb(173, 173, 39),
|
||||
34 => Color::Rgb(73, 46, 225),
|
||||
35 => Color::Rgb(211, 56, 211),
|
||||
36 => Color::Rgb(51, 187, 200),
|
||||
37 => Color::Rgb(203, 204, 205),
|
||||
|
||||
90 => Color::Rgb(129, 131, 131),
|
||||
91 => Color::Rgb(252, 57, 31),
|
||||
92 => Color::Rgb(49, 231, 34),
|
||||
93 => Color::Rgb(234, 236, 35),
|
||||
94 => Color::Rgb(88, 51, 255),
|
||||
95 => Color::Rgb(249, 53, 248),
|
||||
96 => Color::Rgb(20, 240, 240),
|
||||
97 => Color::Rgb(233, 235, 235),
|
||||
|
||||
// Background colors (same values as foreground but offset by 10)
|
||||
40 => Color::Rgb(0, 0, 0),
|
||||
41 => Color::Rgb(194, 54, 33),
|
||||
42 => Color::Rgb(37, 188, 36),
|
||||
43 => Color::Rgb(173, 173, 39),
|
||||
44 => Color::Rgb(73, 46, 225),
|
||||
45 => Color::Rgb(211, 56, 211),
|
||||
46 => Color::Rgb(51, 187, 200),
|
||||
47 => Color::Rgb(203, 204, 205),
|
||||
|
||||
100 => Color::Rgb(129, 131, 131),
|
||||
101 => Color::Rgb(252, 57, 31),
|
||||
102 => Color::Rgb(49, 231, 34),
|
||||
103 => Color::Rgb(234, 236, 35),
|
||||
104 => Color::Rgb(88, 51, 255),
|
||||
105 => Color::Rgb(249, 53, 248),
|
||||
106 => Color::Rgb(20, 240, 240),
|
||||
107 => Color::Rgb(233, 235, 235),
|
||||
|
||||
_ => Color::Rgb(255, 255, 255),
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_256(code: u8) -> Color {
|
||||
match code {
|
||||
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
|
||||
16..=231 => {
|
||||
let idx = code - 16;
|
||||
let cube = [0, 95, 135, 175, 215, 255];
|
||||
let r = cube[(idx / 36) as usize];
|
||||
let g = cube[((idx / 6) % 6) as usize];
|
||||
let b = cube[(idx % 6) as usize];
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
232..=255 => {
|
||||
let v = (code - 232) * 10 + 8;
|
||||
Color::Rgb(v, v, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn brighten(color: Color) -> Color {
|
||||
match color {
|
||||
Color::Rgb(r, g, b) => Color::Rgb(
|
||||
((r as u32) * 13 / 10).min(255) as u8,
|
||||
((g as u32) * 13 / 10).min(255) as u8,
|
||||
((b as u32) * 13 / 10).min(255) as u8,
|
||||
),
|
||||
_ => color,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::style::Style;
|
||||
|
||||
fn visible<'a>(spans: &[Span<'a>]) -> String {
|
||||
spans.iter().map(|s| s.content.as_ref()).collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_no_ansi() {
|
||||
let spans = ansi_to_spans("hello", Style::default());
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!(spans[0].content, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fg_red_reset() {
|
||||
let spans = ansi_to_spans("\x1b[31mred\x1b[0m plain", Style::default());
|
||||
assert!(spans.len() >= 2);
|
||||
assert_eq!(visible(&spans), "red plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sgr_resets_all() {
|
||||
let base = Style::default().fg(Color::Rgb(1, 2, 3));
|
||||
let spans = ansi_to_spans("\x1b[31mred\x1b[m plain", base);
|
||||
assert_eq!(visible(&spans), "red plain");
|
||||
assert!(spans.len() >= 2);
|
||||
// First span should have red foreground
|
||||
assert_ne!(spans[0].style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||
// Last span should have base foreground
|
||||
assert_eq!(spans.last().unwrap().style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_brightens_color() {
|
||||
let spans = ansi_to_spans("\x1b[1;31mbold red\x1b[0m", Style::default());
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!(visible(&spans), "bold red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_ansi_codes_from_output() {
|
||||
let spans = ansi_to_spans("\x1b[32mgreen\x1b[0m", Style::default());
|
||||
assert_eq!(visible(&spans), "green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_text_strips_ansi() {
|
||||
assert_eq!(ansi_visible_text("plain"), "plain");
|
||||
assert_eq!(ansi_visible_text("\x1b[31mred\x1b[0m"), "red");
|
||||
assert_eq!(
|
||||
ansi_visible_text("\x1b[1;32mbold green\x1b[0m plain"),
|
||||
"bold green plain"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xterm_256_color_cube() {
|
||||
assert_eq!(ansi_256(16), Color::Rgb(0, 0, 0));
|
||||
assert_eq!(ansi_256(21), Color::Rgb(0, 0, 255));
|
||||
assert_eq!(ansi_256(52), Color::Rgb(95, 0, 0));
|
||||
assert_eq!(ansi_256(67), Color::Rgb(95, 135, 175));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_color_24bit() {
|
||||
let spans = ansi_to_spans(
|
||||
"\x1b[38;2;100;200;50mtruecolor\x1b[0m",
|
||||
Style::default(),
|
||||
);
|
||||
assert_eq!(visible(&spans), "truecolor");
|
||||
assert_eq!(spans[0].style.fg, Some(Color::Rgb(100, 200, 50)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,169 +0,0 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pipeview_client::SessionConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::app::{DisplayOptions, View, default_session_config};
|
||||
|
||||
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedTuiState {
|
||||
#[serde(default = "default_session")]
|
||||
pub session: SessionConfig,
|
||||
#[serde(default)]
|
||||
pub active_view: PersistedView,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_timestamp: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_direction: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_pipeline: bool,
|
||||
}
|
||||
|
||||
impl Default for PersistedTuiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session: default_session(),
|
||||
active_view: PersistedView::Text,
|
||||
show_timestamp: true,
|
||||
show_direction: true,
|
||||
show_pipeline: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub enum PersistedView {
|
||||
#[default]
|
||||
Text,
|
||||
Hex,
|
||||
Plot,
|
||||
}
|
||||
|
||||
impl From<View> for PersistedView {
|
||||
fn from(value: View) -> Self {
|
||||
match value {
|
||||
View::Text => Self::Text,
|
||||
View::Hex => Self::Hex,
|
||||
View::Plot => Self::Plot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PersistedView> for View {
|
||||
fn from(value: PersistedView) -> Self {
|
||||
match value {
|
||||
PersistedView::Text => View::Text,
|
||||
PersistedView::Hex => View::Hex,
|
||||
PersistedView::Plot => View::Plot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_tui_state() -> PersistedTuiState {
|
||||
match load_tui_state_from_path(&tui_state_path()) {
|
||||
Ok(mut state) => {
|
||||
if state.session.pipelines.is_empty() {
|
||||
state.session = default_session();
|
||||
}
|
||||
state
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to load persisted TUI state");
|
||||
PersistedTuiState::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_tui_state(session: &SessionConfig, view: View, display: DisplayOptions) {
|
||||
let state = PersistedTuiState {
|
||||
session: session.clone(),
|
||||
active_view: view.into(),
|
||||
show_timestamp: display.show_timestamp,
|
||||
show_direction: display.show_direction,
|
||||
show_pipeline: display.show_pipeline,
|
||||
};
|
||||
|
||||
if let Err(err) = save_tui_state_to_path(&state, &tui_state_path()) {
|
||||
warn!(error = %err, "Failed to persist TUI state");
|
||||
}
|
||||
}
|
||||
|
||||
fn default_session() -> SessionConfig {
|
||||
default_session_config()
|
||||
}
|
||||
|
||||
fn tui_state_path() -> PathBuf {
|
||||
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
tui_state_path()
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn 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(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("pipeview")
|
||||
.join(TUI_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(TUI_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(TUI_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(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PathBuf::from(TUI_STATE_FILE_NAME)
|
||||
}
|
||||
|
||||
fn load_tui_state_from_path(path: &Path) -> io::Result<PersistedTuiState> {
|
||||
let text = fs::read_to_string(path)?;
|
||||
serde_json::from_str(&text).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
fn save_tui_state_to_path(state: &PersistedTuiState, 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
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pipeview_client::{DecodedEntry, RingBuffer};
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
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(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Text(text) = &entry.data {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
text: text.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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Hex(hex) = &entry.data {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
ascii: decode_ascii(hex),
|
||||
hex: hex.clone(),
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, hex: String) {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
ascii: decode_ascii(&hex),
|
||||
hex,
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 self.kind != next_kind {
|
||||
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 bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||
match self.kind {
|
||||
PlotSeriesKind::TimeSeries => self.time_series_bounds(),
|
||||
PlotSeriesKind::XY => self.xy_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_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||
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 (mut min_y, mut max_y) = self.time_series_y_bounds?;
|
||||
if min_y == max_y {
|
||||
min_y -= 1.0;
|
||||
max_y += 1.0;
|
||||
}
|
||||
Some(([min_x, min_y], [max_x, max_y]))
|
||||
}
|
||||
|
||||
fn xy_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||
if self.xy_bounds_dirty {
|
||||
self.recompute_xy_bounds();
|
||||
}
|
||||
let bounds = self.xy_bounds?;
|
||||
let mut min_x = bounds.min_x;
|
||||
let mut max_x = bounds.max_x;
|
||||
let mut min_y = bounds.min_y;
|
||||
let mut max_y = bounds.max_y;
|
||||
if min_x == max_x {
|
||||
min_x -= 1.0;
|
||||
max_x += 1.0;
|
||||
}
|
||||
if min_y == max_y {
|
||||
min_y -= 1.0;
|
||||
max_y += 1.0;
|
||||
}
|
||||
Some(([min_x, min_y], [max_x, max_y]))
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_ascii(hex: &str) -> String {
|
||||
hex::decode(hex.replace(' ', ""))
|
||||
.map(|bytes| {
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_graphic() || byte == b' ' {
|
||||
byte as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|_| String::from("[invalid hex]"))
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
mod ansi;
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::{
|
||||
event::{DisableMouseCapture, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
let _log_guard = init_tracing();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let result = async {
|
||||
let mut app = App::new();
|
||||
app.run(&mut terminal).await?;
|
||||
app.shutdown().await;
|
||||
io::Result::Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn init_tracing() -> Option<WorkerGuard> {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("pipeview_tui=info,pipeview_client=info"));
|
||||
let log_dir = app_state::config_dir();
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let file_appender = tracing_appender::rolling::never(log_dir, "tui.log");
|
||||
let (writer, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.with_ansi(false)
|
||||
.with_writer(writer)
|
||||
.try_init()
|
||||
.ok()
|
||||
.map(|_| guard)
|
||||
}
|
||||
@@ -1,989 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
symbols::Marker,
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Axis, Block, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, Paragraph, Wrap,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::ansi::ansi_to_spans;
|
||||
use crate::app::{
|
||||
App, AppMode, ConfigField, ConnectionStatus, DisplayOptions, FocusPane, HotAction, LineEnding,
|
||||
SendMode, View,
|
||||
};
|
||||
use crate::buffers::{ConsoleLine, HexLine, LineDirection, PlotSeriesKind};
|
||||
|
||||
const BG: Color = Color::Rgb(8, 12, 18);
|
||||
const PANEL: Color = Color::Rgb(12, 20, 28);
|
||||
const PANEL_ALT: Color = Color::Rgb(17, 27, 38);
|
||||
const CYAN: Color = Color::Rgb(55, 214, 230);
|
||||
const GREEN: Color = Color::Rgb(72, 211, 137);
|
||||
const AMBER: Color = Color::Rgb(244, 184, 74);
|
||||
const MAGENTA: Color = Color::Rgb(219, 111, 220);
|
||||
const RED: Color = Color::Rgb(238, 92, 107);
|
||||
const BLUE: Color = Color::Rgb(96, 165, 250);
|
||||
const MUTED: Color = Color::Rgb(120, 134, 153);
|
||||
const TEXT: Color = Color::Rgb(224, 234, 244);
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &mut App) {
|
||||
app.reset_hot_zones();
|
||||
let area = frame.area();
|
||||
frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
|
||||
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(6),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
render_header(frame, app, root[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(34), Constraint::Min(30)])
|
||||
.split(root[1]);
|
||||
render_controls(frame, app, body[0]);
|
||||
render_main(frame, app, body[1]);
|
||||
render_composer(frame, app, root[2]);
|
||||
render_footer(frame, app, root[3]);
|
||||
|
||||
match app.mode {
|
||||
AppMode::Search => render_search_modal(frame, app),
|
||||
AppMode::Config => render_config_modal(frame, app),
|
||||
AppMode::Help => render_help_modal(frame, app),
|
||||
AppMode::Normal | AppMode::EditingSend => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let status = &app.session.status;
|
||||
let status_color = status_color(status);
|
||||
let error = match status {
|
||||
ConnectionStatus::Error(message) => format!(" {message}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let header = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
" PIPEVIEW ",
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"single session telemetry console",
|
||||
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
status.label(),
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(status_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(error, Style::default().fg(RED)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(app.transport_summary(), Style::default().fg(AMBER)),
|
||||
Span::raw(" | pipelines: "),
|
||||
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
|
||||
Span::raw(" | view: "),
|
||||
Span::styled(
|
||||
app.session.view.label(),
|
||||
Style::default().fg(view_color(app.session.view)),
|
||||
),
|
||||
Span::raw(" | rx/tx: "),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{}/{}",
|
||||
app.session.received_messages, app.session.sent_messages
|
||||
),
|
||||
Style::default().fg(GREEN),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(header)
|
||||
.block(panel_block("Status", false, CYAN))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_controls(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
app.add_hot_zone(area, HotAction::Focus(FocusPane::Controls));
|
||||
let focused = app.focus == FocusPane::Controls;
|
||||
let block = panel_block("Control Deck", focused, AMBER);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let connect_label = if app.session.status.is_connectedish() {
|
||||
"[ Disconnect ]"
|
||||
} else {
|
||||
"[ Connect ]"
|
||||
};
|
||||
let ending = next_line_ending(app.session.line_ending);
|
||||
let rows: Vec<(Line<'static>, Option<HotAction>)> = vec![
|
||||
section_line("SESSION"),
|
||||
button_line(
|
||||
connect_label,
|
||||
status_color(&app.session.status),
|
||||
Some(HotAction::ToggleConnection),
|
||||
),
|
||||
button_line("[ Reconnect ]", BLUE, Some(HotAction::Reconnect)),
|
||||
button_line("[ Clear Buffers ]", RED, Some(HotAction::Clear)),
|
||||
button_line("[ Configure ]", MAGENTA, Some(HotAction::OpenConfig)),
|
||||
spacer_line(),
|
||||
section_line("LINES"),
|
||||
toggle_line(
|
||||
"Auto reconnect",
|
||||
app.session.auto_reconnect,
|
||||
Some(HotAction::ToggleAutoReconnect),
|
||||
),
|
||||
toggle_line("DTR", app.session.dtr, Some(HotAction::ToggleDtr)),
|
||||
toggle_line("RTS", app.session.rts, Some(HotAction::ToggleRts)),
|
||||
spacer_line(),
|
||||
section_line("DISPLAY"),
|
||||
toggle_line(
|
||||
"Timestamp",
|
||||
app.display.show_timestamp,
|
||||
Some(HotAction::ToggleTimestamp),
|
||||
),
|
||||
toggle_line(
|
||||
"Direction",
|
||||
app.display.show_direction,
|
||||
Some(HotAction::ToggleDirection),
|
||||
),
|
||||
toggle_line(
|
||||
"Pipeline",
|
||||
app.display.show_pipeline,
|
||||
Some(HotAction::TogglePipeline),
|
||||
),
|
||||
spacer_line(),
|
||||
section_line("SEND"),
|
||||
value_line(
|
||||
"Mode",
|
||||
app.session.send_mode.label(),
|
||||
Some(HotAction::SendMode(match app.session.send_mode {
|
||||
SendMode::Text => SendMode::Hex,
|
||||
SendMode::Hex => SendMode::Text,
|
||||
})),
|
||||
),
|
||||
value_line(
|
||||
"Ending",
|
||||
app.session.line_ending.label(),
|
||||
Some(HotAction::LineEnding(ending)),
|
||||
),
|
||||
button_line("[ Search ]", CYAN, Some(HotAction::OpenSearch)),
|
||||
button_line("[ Help ]", MUTED, Some(HotAction::OpenHelp)),
|
||||
];
|
||||
|
||||
for (index, (_, action)) in rows.iter().enumerate() {
|
||||
if let Some(action) = action {
|
||||
let y = inner.y.saturating_add(index as u16);
|
||||
if y < inner.y.saturating_add(inner.height) {
|
||||
app.add_hot_zone(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
*action,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(rows.into_iter().map(|(line, _)| line).collect::<Vec<_>>())
|
||||
.style(Style::default().fg(TEXT).bg(PANEL)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_main(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
app.add_hot_zone(area, HotAction::Focus(FocusPane::Main));
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(5)])
|
||||
.split(area);
|
||||
|
||||
render_tabs(frame, app, layout[0]);
|
||||
match app.session.view {
|
||||
View::Text => render_text_view(frame, app, layout[1]),
|
||||
View::Hex => render_hex_view(frame, app, layout[1]),
|
||||
View::Plot => render_plot_view(frame, app, layout[1]),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tabs(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
for (idx, view) in View::ALL.into_iter().enumerate() {
|
||||
let selected = app.session.view == view;
|
||||
app.add_hot_zone(chunks[idx], HotAction::View(view));
|
||||
let color = view_color(view);
|
||||
let block = panel_block(view.label(), selected, color);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
view.label(),
|
||||
Style::default()
|
||||
.fg(if selected { Color::Black } else { color })
|
||||
.bg(if selected { color } else { PANEL })
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
match view {
|
||||
View::Text => format!(" {} lines", app.session.console.len()),
|
||||
View::Hex => format!(" {} lines", app.session.hex.len()),
|
||||
View::Plot => format!(
|
||||
" {} series / {} pts",
|
||||
app.session.plot.series_len(),
|
||||
app.session.plot.total_points()
|
||||
),
|
||||
},
|
||||
Style::default().fg(MUTED),
|
||||
),
|
||||
]))
|
||||
.block(block)
|
||||
.alignment(Alignment::Center),
|
||||
chunks[idx],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == FocusPane::Main;
|
||||
let block = panel_block("Text Stream", focused, CYAN);
|
||||
let inner = block.inner(area);
|
||||
let height = inner.height as usize;
|
||||
let total = app.session.console.len();
|
||||
let start = app.visible_start(total, height);
|
||||
let end = start.saturating_add(height).min(total);
|
||||
|
||||
let lines = if total == 0 {
|
||||
vec![Line::styled("no text data", Style::default().fg(MUTED))]
|
||||
} else {
|
||||
(start..end)
|
||||
.filter_map(|index| {
|
||||
app.session
|
||||
.console
|
||||
.get(index)
|
||||
.map(|line| format_console_line(index, line, app.display, app))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_hex_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == FocusPane::Main;
|
||||
let block = panel_block("Hex Stream", focused, AMBER);
|
||||
let inner = block.inner(area);
|
||||
let height = inner.height as usize;
|
||||
let total = app.session.hex.len();
|
||||
let start = app.visible_start(total, height);
|
||||
let end = start.saturating_add(height).min(total);
|
||||
|
||||
let lines = if total == 0 {
|
||||
vec![Line::styled("no hex data", Style::default().fg(MUTED))]
|
||||
} else {
|
||||
(start..end)
|
||||
.filter_map(|index| {
|
||||
app.session
|
||||
.hex
|
||||
.get(index)
|
||||
.map(|line| format_hex_line(index, line, app.display, app))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_plot_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == FocusPane::Main;
|
||||
let block = panel_block("Plot View", focused, GREEN);
|
||||
let inner = block.inner(area);
|
||||
app.add_hot_zone(inner, HotAction::Focus(FocusPane::Main));
|
||||
|
||||
if app.session.plot.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::styled("no plot data", Style::default().fg(MUTED)),
|
||||
Line::from(vec![
|
||||
Span::raw("pipeline: "),
|
||||
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
|
||||
]),
|
||||
])
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((min, max)) = app.plot_bounds() else {
|
||||
frame.render_widget(
|
||||
Paragraph::new("plot bounds unavailable")
|
||||
.block(block)
|
||||
.style(Style::default().fg(MUTED).bg(PANEL_ALT)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let max_points = (inner.width as usize).saturating_mul(2).max(32);
|
||||
let kind = app.session.plot.kind();
|
||||
let series_points: Vec<(String, Vec<(f64, f64)>)> = app
|
||||
.session
|
||||
.plot
|
||||
.iter()
|
||||
.map(|series| {
|
||||
let points = match kind {
|
||||
PlotSeriesKind::TimeSeries => {
|
||||
series.render_points_time_series(min[0], max[0], max_points)
|
||||
}
|
||||
PlotSeriesKind::XY => series.render_points_xy(max_points),
|
||||
};
|
||||
(
|
||||
series.name.clone(),
|
||||
points.into_iter().map(|[x, y]| (x, y)).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let palette = [GREEN, CYAN, AMBER, MAGENTA, BLUE, RED];
|
||||
let datasets = series_points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (name, points))| {
|
||||
Dataset::default()
|
||||
.name(name.as_str())
|
||||
.marker(Marker::Braille)
|
||||
.graph_type(GraphType::Line)
|
||||
.style(Style::default().fg(palette[index % palette.len()]))
|
||||
.data(points)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let chart = Chart::new(datasets)
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.x_axis(axis("X", min[0], max[0], CYAN))
|
||||
.y_axis(axis("Y", min[1], max[1], GREEN));
|
||||
|
||||
frame.render_widget(chart, area);
|
||||
|
||||
let overlay = Rect {
|
||||
x: inner.x.saturating_add(1),
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2).min(72),
|
||||
height: 1,
|
||||
};
|
||||
let controls = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(16),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(overlay);
|
||||
app.add_hot_zone(controls[0], HotAction::PlotFollow);
|
||||
app.add_hot_zone(controls[1], HotAction::PlotZoomIn);
|
||||
app.add_hot_zone(controls[2], HotAction::PlotZoomOut);
|
||||
let mode = match kind {
|
||||
PlotSeriesKind::TimeSeries => "time",
|
||||
PlotSeriesKind::XY => "xy",
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
"[follow {}] ",
|
||||
if app.plot.follow_latest { "on" } else { "off" }
|
||||
),
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(if app.plot.follow_latest { GREEN } else { MUTED })
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("[zoom +] ", Style::default().fg(Color::Black).bg(CYAN)),
|
||||
Span::styled("[zoom -] ", Style::default().fg(Color::Black).bg(AMBER)),
|
||||
Span::styled(format!("{mode} "), Style::default().fg(MUTED)),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{} series / {} pts",
|
||||
app.session.plot.series_len(),
|
||||
app.session.plot.total_points()
|
||||
),
|
||||
Style::default().fg(TEXT),
|
||||
),
|
||||
]))
|
||||
.style(Style::default().bg(PANEL_ALT)),
|
||||
overlay,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_composer(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
app.add_hot_zone(area, HotAction::Focus(FocusPane::Composer));
|
||||
let focused = app.focus == FocusPane::Composer || matches!(app.mode, AppMode::EditingSend);
|
||||
let block = panel_block("Composer", focused, MAGENTA);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(20), Constraint::Length(28)])
|
||||
.split(inner);
|
||||
|
||||
let input_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(if focused { MAGENTA } else { MUTED }))
|
||||
.style(Style::default().bg(PANEL_ALT));
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(input_line(app))
|
||||
.block(input_block)
|
||||
.wrap(Wrap { trim: false }),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let side_rows = vec![
|
||||
value_line(
|
||||
"Mode",
|
||||
app.session.send_mode.label(),
|
||||
Some(HotAction::SendMode(match app.session.send_mode {
|
||||
SendMode::Text => SendMode::Hex,
|
||||
SendMode::Hex => SendMode::Text,
|
||||
})),
|
||||
),
|
||||
value_line(
|
||||
"Ending",
|
||||
app.session.line_ending.label(),
|
||||
Some(HotAction::LineEnding(next_line_ending(
|
||||
app.session.line_ending,
|
||||
))),
|
||||
),
|
||||
button_line("[ Send ]", GREEN, Some(HotAction::Send)),
|
||||
(
|
||||
Line::from(vec![
|
||||
Span::styled("Status ", Style::default().fg(MUTED)),
|
||||
Span::styled(app.session.send_status.clone(), Style::default().fg(TEXT)),
|
||||
]),
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
for (index, (_, action)) in side_rows.iter().enumerate() {
|
||||
if let Some(action) = action {
|
||||
app.add_hot_zone(
|
||||
Rect {
|
||||
x: chunks[1].x,
|
||||
y: chunks[1].y.saturating_add(index as u16),
|
||||
width: chunks[1].width,
|
||||
height: 1,
|
||||
},
|
||||
*action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
side_rows
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.style(Style::default().bg(PANEL)),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let search = if app.search.active && !app.search.query.is_empty() {
|
||||
format!(
|
||||
" search {}/{} '{}'",
|
||||
app.search.display_index(),
|
||||
app.search.count(),
|
||||
app.search.query
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let shortcuts = "1/2/3 view c connect r reconnect e config / search Ctrl-S send q quit";
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", app.notice),
|
||||
Style::default().fg(TEXT).bg(PANEL_ALT),
|
||||
),
|
||||
Span::styled(search, Style::default().fg(AMBER).bg(PANEL_ALT)),
|
||||
Span::styled(" ", Style::default().bg(PANEL_ALT)),
|
||||
Span::styled(shortcuts, Style::default().fg(MUTED).bg(PANEL_ALT)),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_search_modal(frame: &mut Frame, app: &mut App) {
|
||||
let area = centered_rect(70, 7, frame.area());
|
||||
app.add_hot_zone(area, HotAction::CloseModal);
|
||||
frame.render_widget(Clear, area);
|
||||
let block = panel_block("Search", true, CYAN);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let body = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Query: ", Style::default().fg(MUTED)),
|
||||
Span::styled(app.search.query.clone(), Style::default().fg(TEXT)),
|
||||
Span::styled(
|
||||
"_",
|
||||
Style::default().fg(CYAN).add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Matches: ", Style::default().fg(MUTED)),
|
||||
Span::styled(
|
||||
format!("{}/{}", app.search.display_index(), app.search.count()),
|
||||
Style::default().fg(AMBER),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
if app.search.case_sensitive {
|
||||
"case sensitive"
|
||||
} else {
|
||||
"case insensitive"
|
||||
},
|
||||
Style::default().fg(MAGENTA),
|
||||
),
|
||||
]),
|
||||
Line::styled(
|
||||
"Enter close Up/Down navigate Ctrl-C case",
|
||||
Style::default().fg(MUTED),
|
||||
),
|
||||
];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(body)
|
||||
.style(Style::default().bg(PANEL))
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_config_modal(frame: &mut Frame, app: &mut App) {
|
||||
let rows = app.config_form.rows();
|
||||
let height = (rows.len() as u16 + 6).min(frame.area().height.saturating_sub(2));
|
||||
let area = centered_rect(88, height, frame.area());
|
||||
frame.render_widget(Clear, area);
|
||||
let block = panel_block("Session Config", true, MAGENTA);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let footer_height = 3;
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(3), Constraint::Length(footer_height)])
|
||||
.split(inner);
|
||||
|
||||
let items = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let focused = index == app.config_form.focused;
|
||||
let value_style = if row.editable {
|
||||
Style::default().fg(CYAN)
|
||||
} else {
|
||||
Style::default().fg(AMBER)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:<18}", row.label),
|
||||
Style::default().fg(if focused { Color::Black } else { MUTED }),
|
||||
),
|
||||
Span::styled(
|
||||
row.value.clone(),
|
||||
if focused {
|
||||
value_style.bg(MAGENTA).fg(Color::Black)
|
||||
} else {
|
||||
value_style
|
||||
},
|
||||
),
|
||||
]))
|
||||
.style(if focused {
|
||||
Style::default().bg(MAGENTA)
|
||||
} else {
|
||||
Style::default().bg(PANEL)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
let y = layout[0].y.saturating_add(index as u16);
|
||||
if y >= layout[0].y.saturating_add(layout[0].height) {
|
||||
break;
|
||||
}
|
||||
app.add_hot_zone(
|
||||
Rect {
|
||||
x: layout[0].x,
|
||||
y,
|
||||
width: layout[0].width,
|
||||
height: 1,
|
||||
},
|
||||
if row.field == ConfigField::Apply {
|
||||
HotAction::ConfigSubmit
|
||||
} else {
|
||||
HotAction::ConfigFocus(row.field)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
List::new(items).style(Style::default().bg(PANEL)),
|
||||
layout[0],
|
||||
);
|
||||
app.add_hot_zone(layout[1], HotAction::ConfigCancel);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::styled(
|
||||
"Tab move Left/Right change options Enter apply Esc cancel",
|
||||
Style::default().fg(MUTED),
|
||||
),
|
||||
Line::styled(serial_ports_hint(app), Style::default().fg(BLUE)),
|
||||
])
|
||||
.style(Style::default().bg(PANEL)),
|
||||
layout[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_help_modal(frame: &mut Frame, app: &mut App) {
|
||||
let area = centered_rect(74, 12, frame.area());
|
||||
app.add_hot_zone(area, HotAction::CloseModal);
|
||||
frame.render_widget(Clear, area);
|
||||
let block = panel_block("Help", true, BLUE);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::styled(
|
||||
"Keyboard",
|
||||
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::raw("1/2/3 switch views, Tab shift focus, q quit"),
|
||||
Line::raw("c connect, r reconnect, e config, / search, x clear"),
|
||||
Line::raw("Ctrl-S send, Enter edit/send composer, Esc close modal"),
|
||||
Line::styled(
|
||||
"Mouse",
|
||||
Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::raw("Click tabs, buttons, toggles, config rows, and composer."),
|
||||
Line::raw("Wheel scrolls Text/Hex and zooms Plot."),
|
||||
])
|
||||
.style(Style::default().fg(TEXT).bg(PANEL))
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn format_console_line(
|
||||
index: usize,
|
||||
line: &ConsoleLine,
|
||||
display: DisplayOptions,
|
||||
app: &App,
|
||||
) -> Line<'static> {
|
||||
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
|
||||
let base_style = Style::default().fg(TEXT);
|
||||
spans.extend(ansi_to_spans(&line.text, base_style));
|
||||
apply_search_style(index, Line::from(spans), app)
|
||||
}
|
||||
|
||||
fn format_hex_line(
|
||||
index: usize,
|
||||
line: &HexLine,
|
||||
display: DisplayOptions,
|
||||
app: &App,
|
||||
) -> Line<'static> {
|
||||
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
|
||||
spans.push(Span::styled(line.hex.clone(), Style::default().fg(AMBER)));
|
||||
spans.push(Span::styled(" |", Style::default().fg(MUTED)));
|
||||
spans.push(Span::styled(line.ascii.clone(), Style::default().fg(TEXT)));
|
||||
spans.push(Span::styled("|", Style::default().fg(MUTED)));
|
||||
apply_search_style(index, Line::from(spans), app)
|
||||
}
|
||||
|
||||
fn prefix_spans(
|
||||
elapsed: Duration,
|
||||
direction: LineDirection,
|
||||
pipeline: &str,
|
||||
display: DisplayOptions,
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
if display.show_timestamp {
|
||||
spans.push(Span::styled(
|
||||
format!("[{}] ", format_elapsed(elapsed)),
|
||||
Style::default().fg(MUTED),
|
||||
));
|
||||
}
|
||||
if display.show_direction {
|
||||
let (label, color) = match direction {
|
||||
LineDirection::In => ("IN", CYAN),
|
||||
LineDirection::Out => ("OUT", AMBER),
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
format!("[{label}] "),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
spans.push(Span::styled(
|
||||
format!("[{pipeline}] "),
|
||||
Style::default().fg(MAGENTA),
|
||||
));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn apply_search_style(index: usize, line: Line<'static>, app: &App) -> Line<'static> {
|
||||
if !app.search.active || app.search.query.is_empty() {
|
||||
return line;
|
||||
}
|
||||
|
||||
if app.search.current_line() == Some(index) {
|
||||
line.style(Style::default().bg(AMBER))
|
||||
} else if app.search.matches.contains(&index) {
|
||||
line.style(Style::default().bg(Color::Rgb(47, 61, 91)))
|
||||
} else {
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
fn input_line(app: &App) -> Line<'static> {
|
||||
if app.session.send_input.is_empty() {
|
||||
return Line::styled(
|
||||
match app.session.send_mode {
|
||||
SendMode::Text => "type text payload",
|
||||
SendMode::Hex => "hex bytes, e.g. 48 65 6c 6c 6f",
|
||||
},
|
||||
Style::default().fg(MUTED),
|
||||
);
|
||||
}
|
||||
|
||||
if !matches!(app.mode, AppMode::EditingSend) {
|
||||
return Line::styled(app.session.send_input.clone(), Style::default().fg(TEXT));
|
||||
}
|
||||
|
||||
let mut spans = Vec::new();
|
||||
let cursor = app.session.input_cursor;
|
||||
for (index, ch) in app.session.send_input.chars().enumerate() {
|
||||
if index == cursor {
|
||||
spans.push(Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default().fg(Color::Black).bg(MAGENTA),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(ch.to_string(), Style::default().fg(TEXT)));
|
||||
}
|
||||
}
|
||||
if cursor >= app.session.send_input.chars().count() {
|
||||
spans.push(Span::styled(" ", Style::default().bg(MAGENTA)));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn section_line(label: &'static str) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::styled(
|
||||
format!("-- {label} "),
|
||||
Style::default().fg(MUTED).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn spacer_line() -> (Line<'static>, Option<HotAction>) {
|
||||
(Line::raw(""), None)
|
||||
}
|
||||
|
||||
fn button_line(
|
||||
label: &'static str,
|
||||
color: Color,
|
||||
action: Option<HotAction>,
|
||||
) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::from(vec![Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_line(
|
||||
label: &'static str,
|
||||
enabled: bool,
|
||||
action: Option<HotAction>,
|
||||
) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
if enabled { "[x] " } else { "[ ] " },
|
||||
Style::default().fg(if enabled { GREEN } else { MUTED }),
|
||||
),
|
||||
Span::styled(label, Style::default().fg(TEXT)),
|
||||
]),
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
fn value_line(
|
||||
label: &'static str,
|
||||
value: &'static str,
|
||||
action: Option<HotAction>,
|
||||
) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{label:<8}"), Style::default().fg(MUTED)),
|
||||
Span::styled(value.to_string(), Style::default().fg(CYAN)),
|
||||
]),
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
fn panel_block(title: &str, focused: bool, color: Color) -> Block<'_> {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default()
|
||||
.fg(if focused { Color::Black } else { color })
|
||||
.bg(if focused { color } else { PANEL })
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(if focused {
|
||||
color
|
||||
} else {
|
||||
Color::Rgb(45, 58, 74)
|
||||
}))
|
||||
.style(Style::default().bg(PANEL))
|
||||
}
|
||||
|
||||
fn axis(title: &'static str, min: f64, max: f64, color: Color) -> Axis<'static> {
|
||||
Axis::default()
|
||||
.title(title)
|
||||
.style(Style::default().fg(color))
|
||||
.bounds([min, max])
|
||||
.labels(vec![
|
||||
Span::styled(format!("{min:.2}"), Style::default().fg(MUTED)),
|
||||
Span::styled(format!("{max:.2}"), Style::default().fg(MUTED)),
|
||||
])
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(height.min(area.height)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(width.min(area.width)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
fn status_color(status: &ConnectionStatus) -> Color {
|
||||
match status {
|
||||
ConnectionStatus::Connected => GREEN,
|
||||
ConnectionStatus::Disconnected => MUTED,
|
||||
ConnectionStatus::Connecting => AMBER,
|
||||
ConnectionStatus::Error(_) => RED,
|
||||
}
|
||||
}
|
||||
|
||||
fn view_color(view: View) -> Color {
|
||||
match view {
|
||||
View::Text => CYAN,
|
||||
View::Hex => AMBER,
|
||||
View::Plot => GREEN,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_elapsed(elapsed: Duration) -> String {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if secs < 60.0 {
|
||||
format!("{secs:05.2}")
|
||||
} else {
|
||||
format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
fn next_line_ending(current: LineEnding) -> LineEnding {
|
||||
let endings = LineEnding::ALL;
|
||||
let index = endings
|
||||
.iter()
|
||||
.position(|ending| *ending == current)
|
||||
.unwrap_or(0);
|
||||
endings[(index + 1) % endings.len()]
|
||||
}
|
||||
|
||||
fn serial_ports_hint(app: &App) -> String {
|
||||
if app.config_form.available_serial_ports.is_empty() {
|
||||
String::from("Serial ports: none detected")
|
||||
} else {
|
||||
format!(
|
||||
"Serial ports: {}",
|
||||
app.config_form.available_serial_ports.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
158
tools/pv_protocol.py
Normal file
158
tools/pv_protocol.py
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared wire-format helpers for pipeview test tools.
|
||||
|
||||
Keep these functions in sync with:
|
||||
- crates/pipeview-core/src/frame/cobs.rs (cobs_encode)
|
||||
- crates/pipeview-core/src/protocol/mixed.rs (XP plot packet header)
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
|
||||
PLOT_ESCAPE = 0x1E
|
||||
PLOT_MARKER = ord("P")
|
||||
|
||||
DISCONNECT_WINERRORS = {
|
||||
10053, # Software caused connection abort
|
||||
10054, # Connection reset by peer
|
||||
10058, # Socket shutdown race on Windows
|
||||
}
|
||||
|
||||
# XP plot packet header field values (see protocol/mixed.rs)
|
||||
PLOT_PACKET_MAGIC = b"XP"
|
||||
PLOT_PACKET_VERSION = 1
|
||||
PLOT_FORMAT_IDS = {
|
||||
"interleaved": 0,
|
||||
"block": 1,
|
||||
"xy": 2,
|
||||
}
|
||||
SAMPLE_TYPE_F32 = 8
|
||||
ENDIAN_LITTLE = 0
|
||||
|
||||
|
||||
def cobs_encode(payload: bytes) -> bytes:
|
||||
"""Consistent Overhead Byte Stuffing encoder (matches Rust cobs_encode)."""
|
||||
if not payload:
|
||||
return b"\x01"
|
||||
|
||||
out = bytearray([0])
|
||||
code_index = 0
|
||||
code = 1
|
||||
for byte in payload:
|
||||
if byte == 0:
|
||||
out[code_index] = code
|
||||
code_index = len(out)
|
||||
out.append(0)
|
||||
code = 1
|
||||
else:
|
||||
out.append(byte)
|
||||
code += 1
|
||||
if code == 0xFF:
|
||||
out[code_index] = code
|
||||
code_index = len(out)
|
||||
out.append(0)
|
||||
code = 1
|
||||
out[code_index] = code
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_plot_payload(
|
||||
sample_index: int,
|
||||
channels: int,
|
||||
plot_format: str,
|
||||
samples_per_channel: int,
|
||||
amplitude: float,
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
) -> bytes:
|
||||
"""Build a raw little-endian f32 plot payload without any framing."""
|
||||
if channels <= 0:
|
||||
raise ValueError("channels must be >= 1")
|
||||
if plot_format not in PLOT_FORMAT_IDS:
|
||||
raise ValueError(f"unsupported plot format: {plot_format}")
|
||||
if plot_format == "xy" and channels != 2:
|
||||
raise ValueError("xy format requires exactly 2 channels")
|
||||
|
||||
values = []
|
||||
if plot_format == "xy":
|
||||
for offset in range(samples_per_channel):
|
||||
t = (sample_index + offset) / sample_rate_hz
|
||||
values.extend(
|
||||
[
|
||||
amplitude * math.cos(2 * math.pi * frequency_hz * t),
|
||||
amplitude * math.sin(2 * math.pi * frequency_hz * t),
|
||||
]
|
||||
)
|
||||
elif plot_format == "block":
|
||||
for ch in range(channels):
|
||||
phase = 2 * math.pi * ch / channels
|
||||
for offset in range(samples_per_channel):
|
||||
t = (sample_index + offset) / sample_rate_hz
|
||||
values.append(
|
||||
amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
|
||||
)
|
||||
else: # interleaved
|
||||
for offset in range(samples_per_channel):
|
||||
t = (sample_index + offset) / sample_rate_hz
|
||||
for ch in range(channels):
|
||||
phase = 2 * math.pi * ch / channels
|
||||
values.append(
|
||||
amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
|
||||
)
|
||||
|
||||
return struct.pack(f"<{len(values)}f", *values)
|
||||
|
||||
|
||||
def build_plot_packet(
|
||||
payload: bytes,
|
||||
channels: int,
|
||||
samples_per_channel: int,
|
||||
plot_format: str,
|
||||
) -> bytes:
|
||||
"""Build an XP plot packet (the payload inside a MixedTextPlot COBS frame)."""
|
||||
if not 1 <= channels <= 255:
|
||||
raise ValueError("channels must be in 1..255")
|
||||
if not 0 <= samples_per_channel <= 0xFFFF:
|
||||
raise ValueError("samples_per_channel must fit in u16")
|
||||
if plot_format not in PLOT_FORMAT_IDS:
|
||||
raise ValueError(f"unsupported plot format: {plot_format}")
|
||||
|
||||
header = bytearray()
|
||||
header.extend(PLOT_PACKET_MAGIC)
|
||||
header.append(PLOT_PACKET_VERSION)
|
||||
header.append(PLOT_FORMAT_IDS[plot_format])
|
||||
header.append(SAMPLE_TYPE_F32)
|
||||
header.append(ENDIAN_LITTLE)
|
||||
header.append(channels)
|
||||
header.extend(struct.pack("<H", samples_per_channel))
|
||||
header.extend(struct.pack("<I", len(payload)))
|
||||
header.extend(payload)
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def build_mixed_plot_frame(
|
||||
payload: bytes,
|
||||
channels: int,
|
||||
samples_per_channel: int,
|
||||
plot_format: str,
|
||||
) -> bytes:
|
||||
"""Build a complete MixedTextPlot plot frame (escape + marker + COBS packet + 0x00)."""
|
||||
packet = build_plot_packet(
|
||||
payload=payload,
|
||||
channels=channels,
|
||||
samples_per_channel=samples_per_channel,
|
||||
plot_format=plot_format,
|
||||
)
|
||||
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
||||
|
||||
|
||||
def is_disconnect_error(err: OSError) -> bool:
|
||||
"""Return True when the socket error is an expected client-disconnect error."""
|
||||
return isinstance(
|
||||
err,
|
||||
(
|
||||
BrokenPipeError,
|
||||
ConnectionAbortedError,
|
||||
ConnectionResetError,
|
||||
),
|
||||
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pipeview-tauri 极限压力测试脚本
|
||||
pipeview GUI(egui)极限压力测试脚本
|
||||
|
||||
模拟各种高数据量场景,测试 Tauri 前端的渲染性能和稳定性:
|
||||
模拟各种高数据量场景,测试 pipeview GUI 的渲染性能和稳定性:
|
||||
- 文本洪水:大文本行 + ANSI 颜色
|
||||
- 十六进制洪水:大批量 hex dump
|
||||
- 波形洪水:高频 plot 采样点
|
||||
@@ -20,20 +20,11 @@ import argparse
|
||||
import math
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
PLOT_ESCAPE = 0x1E
|
||||
PLOT_MARKER = ord("P")
|
||||
|
||||
DISCONNECT_WINERRORS = {
|
||||
10053, # Software caused connection abort
|
||||
10054, # Connection reset by peer
|
||||
10058, # Socket shutdown race on Windows
|
||||
}
|
||||
import pv_protocol
|
||||
|
||||
# ── ANSI Color Palette ─────────────────────────────────────────────
|
||||
|
||||
@@ -108,57 +99,32 @@ def build_plot_frame(
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
) -> bytes:
|
||||
"""Build a raw COBS-encoded plot frame (matching test_plot.py format)."""
|
||||
values = []
|
||||
if plot_format == "xy":
|
||||
for _ in range(samples_per_channel):
|
||||
t = (sample_index * samples_per_channel + len(values)) / sample_rate_hz
|
||||
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t))
|
||||
values.append(amplitude * math.cos(2 * math.pi * frequency_hz * t))
|
||||
elif plot_format == "block":
|
||||
for ch in range(channels):
|
||||
phase = 2 * math.pi * ch / channels
|
||||
for _ in range(samples_per_channel):
|
||||
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
|
||||
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
|
||||
else: # interleaved
|
||||
for _ in range(samples_per_channel):
|
||||
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
|
||||
for ch in range(channels):
|
||||
phase = 2 * math.pi * ch / channels
|
||||
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
|
||||
|
||||
# Pack as f32 little-endian
|
||||
payload = struct.pack(f"<{len(values)}f", *values)
|
||||
return cobs_encode(payload)
|
||||
|
||||
|
||||
def cobs_encode(data: bytes) -> bytes:
|
||||
"""Consistent Overhead Byte Stuffing encoder."""
|
||||
result = bytearray()
|
||||
block_start = 0
|
||||
while block_start < len(data):
|
||||
end = min(block_start + 254, len(data))
|
||||
block = data[block_start:end]
|
||||
if end < len(data):
|
||||
result.append(len(block) + 1)
|
||||
result.extend(block)
|
||||
else:
|
||||
result.append(len(block) + 1 if len(block) < 254 else 255)
|
||||
result.extend(block)
|
||||
result.append(0)
|
||||
block_start = end
|
||||
return bytes(result)
|
||||
"""Build a raw little-endian f32 plot payload (no framing)."""
|
||||
return pv_protocol.build_plot_payload(
|
||||
sample_index=sample_index,
|
||||
channels=channels,
|
||||
plot_format=plot_format,
|
||||
samples_per_channel=samples_per_channel,
|
||||
amplitude=amplitude,
|
||||
frequency_hz=frequency_hz,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
)
|
||||
|
||||
|
||||
def build_mixed_frame(seq: int, text_rate_per_plot: int, channels: int) -> bytes:
|
||||
"""Build a MixedTextPlot frame: text lines + one COBS plot frame."""
|
||||
"""Build a MixedTextPlot frame: text lines + one XP plot frame."""
|
||||
frames = []
|
||||
for i in range(text_rate_per_plot):
|
||||
frames.append(generate_text_line(seq * text_rate_per_plot + i, ansi=True))
|
||||
# Add one plot frame per text_rate_per_plot text lines
|
||||
plot_data = build_plot_frame(seq, channels, "interleaved", 32, 100.0, 10.0, 1000.0)
|
||||
frames.append(bytes([PLOT_ESCAPE, PLOT_MARKER]) + plot_data)
|
||||
plot_payload = build_plot_frame(seq, channels, "interleaved", 32, 100.0, 10.0, 1000.0)
|
||||
frames.append(
|
||||
pv_protocol.build_mixed_plot_frame(
|
||||
payload=plot_payload,
|
||||
channels=channels,
|
||||
samples_per_channel=32,
|
||||
plot_format="interleaved",
|
||||
)
|
||||
)
|
||||
return b"".join(frames)
|
||||
|
||||
|
||||
@@ -273,7 +239,7 @@ class StressServer:
|
||||
last_count_time = time.time()
|
||||
|
||||
except OSError as e:
|
||||
if hasattr(e, "winerror") and e.winerror in DISCONNECT_WINERRORS:
|
||||
if pv_protocol.is_disconnect_error(e):
|
||||
pass
|
||||
elif not self.running:
|
||||
pass
|
||||
@@ -324,7 +290,7 @@ class StressServer:
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="pipeview-tauri 极限压力测试",
|
||||
description="pipeview GUI(egui)极限压力测试",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
|
||||
@@ -4,115 +4,10 @@
|
||||
import argparse
|
||||
import math
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
PLOT_ESCAPE = 0x1E
|
||||
PLOT_MARKER = ord("P")
|
||||
DISCONNECT_WINERRORS = {
|
||||
10053, # Software caused connection abort
|
||||
10054, # Connection reset by peer
|
||||
10058, # Socket shutdown race on Windows
|
||||
}
|
||||
|
||||
|
||||
def build_frame(
|
||||
sample_index: int,
|
||||
channels: int,
|
||||
plot_format: str,
|
||||
samples_per_channel: int,
|
||||
amplitude: float,
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
) -> bytes:
|
||||
values = []
|
||||
if plot_format == "xy":
|
||||
for offset in range(samples_per_channel):
|
||||
t = (sample_index + offset) / sample_rate_hz
|
||||
x = amplitude * math.cos(2 * math.pi * frequency_hz * t)
|
||||
y = amplitude * math.sin(2 * math.pi * frequency_hz * t)
|
||||
values.extend([x, y])
|
||||
return struct.pack(f"<{len(values)}f", *values)
|
||||
|
||||
for offset in range(samples_per_channel):
|
||||
t = (sample_index + offset) / sample_rate_hz
|
||||
for ch in range(channels):
|
||||
phase = 2 * math.pi * ch / max(channels, 1)
|
||||
value = amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
|
||||
values.append(value)
|
||||
return struct.pack(f"<{len(values)}f", *values)
|
||||
|
||||
|
||||
def cobs_encode(payload: bytes) -> bytes:
|
||||
if not payload:
|
||||
return b"\x01"
|
||||
|
||||
out = bytearray([0])
|
||||
code_index = 0
|
||||
code = 1
|
||||
for byte in payload:
|
||||
if byte == 0:
|
||||
out[code_index] = code
|
||||
code_index = len(out)
|
||||
out.append(0)
|
||||
code = 1
|
||||
else:
|
||||
out.append(byte)
|
||||
code += 1
|
||||
if code == 0xFF:
|
||||
out[code_index] = code
|
||||
code_index = len(out)
|
||||
out.append(0)
|
||||
code = 1
|
||||
out[code_index] = code
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_plot_packet(
|
||||
payload: bytes,
|
||||
channels: int,
|
||||
samples_per_channel: int,
|
||||
plot_format: str,
|
||||
) -> bytes:
|
||||
format_id = {"interleaved": 0, "block": 1, "xy": 2}[plot_format]
|
||||
header = bytearray()
|
||||
header.extend(b"XP")
|
||||
header.append(1) # version
|
||||
header.append(format_id)
|
||||
header.append(8) # f32
|
||||
header.append(0) # little-endian
|
||||
header.append(channels)
|
||||
header.extend(struct.pack("<H", samples_per_channel))
|
||||
header.extend(struct.pack("<I", len(payload)))
|
||||
header.extend(payload)
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def build_mixed_plot_frame(
|
||||
payload: bytes,
|
||||
channels: int,
|
||||
samples_per_channel: int,
|
||||
plot_format: str,
|
||||
) -> bytes:
|
||||
packet = build_plot_packet(
|
||||
payload=payload,
|
||||
channels=channels,
|
||||
samples_per_channel=samples_per_channel,
|
||||
plot_format=plot_format,
|
||||
)
|
||||
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
||||
|
||||
|
||||
def is_disconnect_error(err: OSError) -> bool:
|
||||
return isinstance(
|
||||
err,
|
||||
(
|
||||
BrokenPipeError,
|
||||
ConnectionAbortedError,
|
||||
ConnectionResetError,
|
||||
),
|
||||
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS
|
||||
import pv_protocol
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -217,8 +112,8 @@ def main() -> None:
|
||||
else:
|
||||
print(" framer = Line")
|
||||
print(" decoder = Text")
|
||||
print(" lua test = Lua framer tests/lua_line_framer.lua")
|
||||
print(" Lua decoder tests/lua_text_decoder.lua\n")
|
||||
print(" lua test = Lua framer crates/pipeview-client/tests/fixtures/lua_line_framer.lua")
|
||||
print(" Lua decoder crates/pipeview-client/tests/fixtures/lua_text_decoder.lua\n")
|
||||
if args.wire_format == "text":
|
||||
print(f"sending text only at {args.text_interval:.3f}s intervals")
|
||||
print(f"sample clock: {args.rate:.1f} samples/sec\n")
|
||||
@@ -268,7 +163,7 @@ def main() -> None:
|
||||
next_text_at += args.text_interval
|
||||
continue
|
||||
|
||||
plot_payload = build_frame(
|
||||
plot_payload = pv_protocol.build_plot_payload(
|
||||
sample_index=sample_index,
|
||||
channels=args.channels,
|
||||
plot_format=args.format,
|
||||
@@ -278,7 +173,7 @@ def main() -> None:
|
||||
sample_rate_hz=args.rate,
|
||||
)
|
||||
if args.wire_format == "mixed":
|
||||
frame = build_mixed_plot_frame(
|
||||
frame = pv_protocol.build_mixed_plot_frame(
|
||||
payload=plot_payload,
|
||||
channels=args.channels,
|
||||
samples_per_channel=samples_per_channel,
|
||||
@@ -308,7 +203,7 @@ def main() -> None:
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
except OSError as err:
|
||||
if not is_disconnect_error(err):
|
||||
if not pv_protocol.is_disconnect_error(err):
|
||||
raise
|
||||
if args.wire_format == "text":
|
||||
print(f"[conn -] {addr} ({text_count} text lines)")
|
||||
|
||||
Reference in New Issue
Block a user