Compare commits
2 Commits
tui
...
8caf7d6d62
| Author | SHA1 | Date | |
|---|---|---|---|
| 8caf7d6d62 | |||
| fd680858f6 |
134
AGENTS.md
Normal file
134
AGENTS.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Build Prerequisites
|
||||||
|
|
||||||
|
- **Linux**: `libudev-dev` (for `serialport`).
|
||||||
|
- **All platforms**: a C compiler (`cc`, `gcc`, `clang`, or MSVC) — `mlua` uses `features = ["vendored"]` which compiles LuaJIT from source at build time.
|
||||||
|
- **No pinned toolchain**: all crates use `edition = "2024"` (requires Rust ≥ 1.85). No `rust-toolchain.toml`; builds with whatever `rustc` is on `PATH`.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
`pipeview` is a Rust workspace. The root `Cargo.toml` defines four crates in `crates/`, with dependencies flowing:
|
||||||
|
|
||||||
|
```
|
||||||
|
pipeview-core (no internal deps — transport, framing, protocol, pipeline)
|
||||||
|
↑
|
||||||
|
pipeview-client (depends on pipeview-core — sessions, history, commands, Lua)
|
||||||
|
↑ ↑
|
||||||
|
pipeview-gui pipeview-tui
|
||||||
|
(both depend on pipeview-core AND pipeview-client)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Crate | Type | What goes here |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `pipeview-core` | library (`src/lib.rs`) | `transport/`, `frame/`, `protocol/`, `pipeline.rs` — protocol and I/O. Integration tests: `tests/pipeline.rs`. |
|
||||||
|
| `pipeview-client` | library (`src/lib.rs`) | `session.rs`, `manager.rs`, `config.rs`, `history.rs`, `lua/` — state management and Lua bindings. Integration tests: `tests/session_lifecycle.rs`, `tests/lua_tests.rs`. |
|
||||||
|
| `pipeview-gui` | binary (`src/main.rs`) | egui/eframe app. Panels in `src/panels/`. Font helpers in `src/ui_fonts.rs`. Profiling in `src/perf.rs`. |
|
||||||
|
| `pipeview-tui` | binary (`src/main.rs`) | ratatui/crossterm app. **Still a placeholder** — zero tests, not at feature parity with GUI. |
|
||||||
|
|
||||||
|
**Module boundaries**: transport/protocol → `pipeview-core`. Session/Lua → `pipeview-client`. UI state → `pipeview-gui` or `pipeview-tui`. Never put I/O logic in the UI crates, never put UI state in the client crate.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --workspace # fast compile check (all crates)
|
||||||
|
cargo test --workspace # full suite (~266 tests)
|
||||||
|
cargo fmt --all # standard Rust formatting
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings # lint gate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run specific tests:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p pipeview-core -- tcp_line_text_utf8
|
||||||
|
cargo test -p pipeview-client -- session_echo_send_and_read
|
||||||
|
cargo test -p pipeview-core -- --nocapture # show test output
|
||||||
|
|
||||||
|
# Integration tests only
|
||||||
|
cargo test -p pipeview-core --test pipeline
|
||||||
|
cargo test -p pipeview-client --test session_lifecycle
|
||||||
|
cargo test -p pipeview-client --test lua_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Launch:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p pipeview-gui
|
||||||
|
cargo run -p pipeview-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment & Configuration
|
||||||
|
|
||||||
|
### Tracing (all entrypoints)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info cargo run -p pipeview-gui
|
||||||
|
RUST_LOG=pipeview_gui::perf=debug cargo run -p pipeview-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
`tracing_subscriber::EnvFilter::from_default_env()` reads `RUST_LOG`. The workspace dep `tracing-subscriber` has `features = ["env-filter"]`.
|
||||||
|
|
||||||
|
### GUI Profiling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui # custom interval (default 1s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Profiling logs go to target `pipeview_gui::perf`.
|
||||||
|
|
||||||
|
### State & Config File Paths (runtime, not compile-time)
|
||||||
|
|
||||||
|
Uses `env::consts::OS` + env vars — no `cfg(target_os)` gating for path resolution:
|
||||||
|
|
||||||
|
| Platform | Root config path |
|
||||||
|
|----------|-----------------|
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/pipeview/` or `~/.config/pipeview/` |
|
||||||
|
| macOS | `~/Library/Application Support/pipeview/` |
|
||||||
|
| Windows | `%APPDATA%\pipeview\` |
|
||||||
|
|
||||||
|
State files: `gui-state.json`, `tui-state.json`, `font-settings.json`.
|
||||||
|
|
||||||
|
### GUI Plot Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||||
|
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||||
|
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure in GUI: Transport `TCP 127.0.0.1:8091`, Framer `MixedTextPlot`, Decoder `MixedTextPlot`.
|
||||||
|
|
||||||
|
The wire format spec lives in `tools/xs_mixed_plot.h` (C header, reference only — not compiled into Rust).
|
||||||
|
|
||||||
|
## Coding Style
|
||||||
|
|
||||||
|
Standard Rust conventions (`cargo fmt`). No crate-level lint attrs, no `[lints]` table in any `Cargo.toml`. Names: `snake_case` for modules/files/functions/tests, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for constants.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- **Inline unit tests** (`#[cfg(test)] mod tests`) next to implementation — 24 source files with `#[test]` or `#[tokio::test]`.
|
||||||
|
- **Integration tests** in `crates/<crate>/tests/*.rs` — 3 files covering pipeline, session lifecycle, and Lua bindings.
|
||||||
|
- **~266 tests total** — all active (zero `#[ignore]`). Single `#[should_panic]` in `frame/fixed.rs`.
|
||||||
|
- **All tests are self-contained**: TCP/UDP bind to `127.0.0.1:0` (OS-assigned port). No hardcoded ports. No external services. Serial tests use dummy port names and never open real hardware.
|
||||||
|
- **Async tests** use `#[tokio::test]` (no `async-std`). **No benchmarks** exist.
|
||||||
|
- Run `cargo test --workspace` before opening PRs. Add targeted regression tests for transport, framing, parsing, and session-state fixes.
|
||||||
|
|
||||||
|
## Known Quirks & Constraints
|
||||||
|
|
||||||
|
- **No CI** — no `.github/workflows` or other CI config. Agents must self-verify with `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings`.
|
||||||
|
- **No feature flags** — zero `[features]` in any `Cargo.toml`. No `#[cfg(feature)]` in any source.
|
||||||
|
- **No build scripts** — zero `build.rs` files. No codegen. No `include!`/`include_str!`/`include_bytes!`.
|
||||||
|
- **All `unsafe` is test-only** — constructing noop wakers for poll-based unit tests in `transport/`. No non-test `unsafe` anywhere.
|
||||||
|
- **`pipeview-tui` is a stub** — no tests, minimal implementation. GUI is the primary target.
|
||||||
|
- **Platform font directories** use `#[cfg(target_os)]` in `ui_fonts.rs` (three blocks: Windows, macOS, Linux/BSD).
|
||||||
|
- **`tokio` features differ by build**: `pipeview-client` uses `["sync", "time"]` for production, `["full"]` for dev-dependencies (tests need net/IO).
|
||||||
|
|
||||||
|
## Commit & PR Guidelines
|
||||||
|
|
||||||
|
Short, imperative subjects (`Persist GUI font settings`), occasional `feat:` prefix for major additions. PRs: state which crate(s) changed, list validation commands, include screenshots/recordings for GUI/TUI changes. Explicitly call out protocol, transport, or Lua API compatibility impacts.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- `tools/test_plot.py --help` — TCP plot-frame generator options.
|
||||||
|
- `tools/xs_mixed_plot.h` — wire format spec for MixedTextPlot (COBS-framed binary plot + text on one stream).
|
||||||
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -189,16 +189,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ansitok"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
|
|
||||||
dependencies = [
|
|
||||||
"nom 7.1.3",
|
|
||||||
"vte",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -3307,7 +3297,6 @@ dependencies = [
|
|||||||
name = "pipeview-gui"
|
name = "pipeview-gui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansitok",
|
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"egui_plot",
|
"egui_plot",
|
||||||
@@ -3325,7 +3314,6 @@ dependencies = [
|
|||||||
name = "pipeview-tui"
|
name = "pipeview-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansitok",
|
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -4732,16 +4720,6 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vte"
|
|
||||||
version = "0.14.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
|
||||||
dependencies = [
|
|
||||||
"arrayvec",
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vtparse"
|
name = "vtparse"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use pipeview_core::frame::{
|
use pipeview_core::frame::{
|
||||||
Endian, Framer,
|
Endian, Framer,
|
||||||
cobs::CobsFramer,
|
cobs::CobsFramer,
|
||||||
@@ -14,7 +15,6 @@ use pipeview_core::protocol::{
|
|||||||
text::{TextDecoder, TextEncoding},
|
text::{TextDecoder, TextEncoding},
|
||||||
};
|
};
|
||||||
use pipeview_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::lua::codec::{LuaDecoder, LuaFramer};
|
use crate::lua::codec::{LuaDecoder, LuaFramer};
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use std::fs;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
||||||
|
use tracing::warn;
|
||||||
use pipeview_core::frame::Framer;
|
use pipeview_core::frame::Framer;
|
||||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
||||||
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
|
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use std::sync::{
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
||||||
use pipeview_core::protocol::DecodedData;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
|
||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
use crate::lua::LuaRuntime;
|
use crate::lua::LuaRuntime;
|
||||||
|
|||||||
@@ -173,18 +173,22 @@ impl Connection {
|
|||||||
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Connection::Serial(t) => t.set_dtr(state),
|
Connection::Serial(t) => t.set_dtr(state),
|
||||||
Connection::Tcp(_) | Connection::Udp(_) => Err(crate::error::Error::ConnectionFailed(
|
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||||
|
Err(crate::error::Error::ConnectionFailed(
|
||||||
"DTR only supported on Serial connections".into(),
|
"DTR only supported on Serial connections".into(),
|
||||||
)),
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Connection::Serial(t) => t.set_rts(state),
|
Connection::Serial(t) => t.set_rts(state),
|
||||||
Connection::Tcp(_) | Connection::Udp(_) => Err(crate::error::Error::ConnectionFailed(
|
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||||
|
Err(crate::error::Error::ConnectionFailed(
|
||||||
"RTS only supported on Serial connections".into(),
|
"RTS only supported on Serial connections".into(),
|
||||||
)),
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serialport::SerialPort;
|
|
||||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||||
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
||||||
|
use serialport::SerialPort;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::{Transport, TransportType};
|
use super::{Transport, TransportType};
|
||||||
@@ -277,16 +277,10 @@ impl Transport for SerialTransport {
|
|||||||
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
||||||
// transparent data mode and not fall into AT-command / reset state.
|
// transparent data mode and not fall into AT-command / reset state.
|
||||||
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
||||||
warn!(
|
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
|
||||||
"Failed to set DTR({}) on {}: {}",
|
|
||||||
self.dtr, self.port_name, e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if let Err(e) = port.write_request_to_send(self.rts) {
|
if let Err(e) = port.write_request_to_send(self.rts) {
|
||||||
warn!(
|
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
|
||||||
"Failed to set RTS({}) on {}: {}",
|
|
||||||
self.rts, self.port_name, e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Serial port {} opened successfully", self.port_name);
|
debug!("Serial port {} opened successfully", self.port_name);
|
||||||
|
|||||||
@@ -19,4 +19,3 @@ tracing-subscriber = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
ansitok = "0.3"
|
|
||||||
|
|||||||
@@ -1,451 +0,0 @@
|
|||||||
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
|
||||||
use egui::Color32;
|
|
||||||
use egui::text::{LayoutJob, TextFormat};
|
|
||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
pub fn ansi_to_layout_job(text: &str, base_format: TextFormat) -> LayoutJob {
|
|
||||||
let mut job = LayoutJob::default();
|
|
||||||
for segment in ansi_segments(text, &base_format) {
|
|
||||||
job.append(segment.text, 0.0, segment.format);
|
|
||||||
}
|
|
||||||
|
|
||||||
job
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ansi_to_layout_job_highlighted(
|
|
||||||
text: &str,
|
|
||||||
base_format: TextFormat,
|
|
||||||
query: &str,
|
|
||||||
case_sensitive: bool,
|
|
||||||
highlight_format: TextFormat,
|
|
||||||
) -> LayoutJob {
|
|
||||||
if query.is_empty() {
|
|
||||||
return ansi_to_layout_job(text, base_format);
|
|
||||||
}
|
|
||||||
|
|
||||||
let segments = ansi_segments(text, &base_format);
|
|
||||||
let mut visible_text = String::with_capacity(segments.iter().map(|s| s.text.len()).sum());
|
|
||||||
for segment in &segments {
|
|
||||||
visible_text.push_str(segment.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ranges = search_ranges(&visible_text, query, case_sensitive);
|
|
||||||
if ranges.is_empty() {
|
|
||||||
let mut job = LayoutJob::default();
|
|
||||||
for segment in segments {
|
|
||||||
job.append(segment.text, 0.0, segment.format);
|
|
||||||
}
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut job = LayoutJob::default();
|
|
||||||
let mut offset = 0;
|
|
||||||
let mut range_idx = 0;
|
|
||||||
for segment in segments {
|
|
||||||
let len = segment.text.len();
|
|
||||||
append_segment_with_highlights(
|
|
||||||
&mut job,
|
|
||||||
segment.text,
|
|
||||||
segment.format,
|
|
||||||
&highlight_format,
|
|
||||||
offset,
|
|
||||||
&ranges,
|
|
||||||
&mut range_idx,
|
|
||||||
);
|
|
||||||
offset += len;
|
|
||||||
}
|
|
||||||
job
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StyledSegment<'a> {
|
|
||||||
text: &'a str,
|
|
||||||
format: TextFormat,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct AnsiStyle {
|
|
||||||
fg: Option<Color32>,
|
|
||||||
bg: Option<Color32>,
|
|
||||||
bold: bool,
|
|
||||||
italic: bool,
|
|
||||||
underline: bool,
|
|
||||||
strikethrough: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AnsiStyle {
|
|
||||||
fn apply_sgr(&mut self, attr: VisualAttribute) {
|
|
||||||
match attr {
|
|
||||||
VisualAttribute::Reset(0) => self.reset_all(),
|
|
||||||
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_egui(c)),
|
|
||||||
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_egui(c)),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_all(&mut self) {
|
|
||||||
*self = Self::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format(&self, base_format: &TextFormat) -> TextFormat {
|
|
||||||
let mut fmt = base_format.clone();
|
|
||||||
|
|
||||||
if let Some(c) = self.fg {
|
|
||||||
if self.bold {
|
|
||||||
fmt.color = brighten(c);
|
|
||||||
} else {
|
|
||||||
fmt.color = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(c) = self.bg {
|
|
||||||
fmt.background = c;
|
|
||||||
}
|
|
||||||
if self.italic {
|
|
||||||
fmt.italics = true;
|
|
||||||
}
|
|
||||||
if self.underline {
|
|
||||||
fmt.underline = egui::Stroke::new(1.0, fmt.color);
|
|
||||||
}
|
|
||||||
if self.strikethrough {
|
|
||||||
fmt.strikethrough = egui::Stroke::new(1.0, fmt.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_segments<'a>(text: &'a str, base_format: &TextFormat) -> Vec<StyledSegment<'a>> {
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
let mut style = AnsiStyle::default();
|
|
||||||
|
|
||||||
for element in parse_ansi(text) {
|
|
||||||
match element.kind() {
|
|
||||||
ElementKind::Text => {
|
|
||||||
let slice = &text[element.start()..element.end()];
|
|
||||||
if !slice.is_empty() {
|
|
||||||
segments.push(StyledSegment {
|
|
||||||
text: slice,
|
|
||||||
format: style.format(base_format),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ElementKind::Sgr => {
|
|
||||||
let sgr = &text[element.start()..element.end()];
|
|
||||||
apply_sgr_sequence(&mut style, sgr);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
segments
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_sgr_sequence(style: &mut AnsiStyle, sgr: &str) {
|
|
||||||
let params = sgr
|
|
||||||
.strip_prefix("\x1b[")
|
|
||||||
.and_then(|s| s.strip_suffix('m'))
|
|
||||||
.unwrap_or(sgr);
|
|
||||||
|
|
||||||
if params.is_empty() {
|
|
||||||
style.reset_all();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for output in parse_ansi_sgr(sgr) {
|
|
||||||
if let Some(attr) = output.as_escape() {
|
|
||||||
style.apply_sgr(attr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_ranges(text: &str, query: &str, case_sensitive: bool) -> Vec<Range<usize>> {
|
|
||||||
if query.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
if case_sensitive {
|
|
||||||
return text
|
|
||||||
.match_indices(query)
|
|
||||||
.map(|(start, matched)| start..start + matched.len())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (search_text, byte_to_original) = lowercase_with_byte_map(text);
|
|
||||||
let query = query.to_lowercase();
|
|
||||||
let mut ranges = Vec::new();
|
|
||||||
let mut idx = 0;
|
|
||||||
while let Some(pos) = search_text[idx..].find(&query) {
|
|
||||||
let start = idx + pos;
|
|
||||||
let end = start + query.len();
|
|
||||||
let original_start = byte_to_original.get(start).copied().unwrap_or(text.len());
|
|
||||||
let original_end = byte_to_original.get(end).copied().unwrap_or(text.len());
|
|
||||||
if original_start < original_end {
|
|
||||||
ranges.push(original_start..original_end);
|
|
||||||
}
|
|
||||||
idx = end;
|
|
||||||
}
|
|
||||||
ranges
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lowercase_with_byte_map(text: &str) -> (String, Vec<usize>) {
|
|
||||||
let mut lowered = String::new();
|
|
||||||
let mut byte_to_original = Vec::new();
|
|
||||||
|
|
||||||
for (original_idx, ch) in text.char_indices() {
|
|
||||||
for lower_ch in ch.to_lowercase() {
|
|
||||||
lowered.push(lower_ch);
|
|
||||||
for _ in 0..lower_ch.len_utf8() {
|
|
||||||
byte_to_original.push(original_idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
byte_to_original.push(text.len());
|
|
||||||
(lowered, byte_to_original)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_segment_with_highlights(
|
|
||||||
job: &mut LayoutJob,
|
|
||||||
text: &str,
|
|
||||||
format: TextFormat,
|
|
||||||
highlight_format: &TextFormat,
|
|
||||||
segment_start: usize,
|
|
||||||
ranges: &[Range<usize>],
|
|
||||||
range_idx: &mut usize,
|
|
||||||
) {
|
|
||||||
let segment_end = segment_start + text.len();
|
|
||||||
while *range_idx < ranges.len() && ranges[*range_idx].end <= segment_start {
|
|
||||||
*range_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut idx = *range_idx;
|
|
||||||
let mut last = 0;
|
|
||||||
while idx < ranges.len() && ranges[idx].start < segment_end {
|
|
||||||
let range = &ranges[idx];
|
|
||||||
let start = range.start.max(segment_start) - segment_start;
|
|
||||||
let end = range.end.min(segment_end) - segment_start;
|
|
||||||
|
|
||||||
if last < start {
|
|
||||||
job.append(&text[last..start], 0.0, format.clone());
|
|
||||||
}
|
|
||||||
if start < end {
|
|
||||||
job.append(&text[start..end], 0.0, highlight_format.clone());
|
|
||||||
}
|
|
||||||
last = end;
|
|
||||||
|
|
||||||
if range.end <= segment_end {
|
|
||||||
idx += 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*range_idx = idx;
|
|
||||||
if last < text.len() {
|
|
||||||
job.append(&text[last..], 0.0, format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_color_to_egui(color: AnsiColor) -> Color32 {
|
|
||||||
match color {
|
|
||||||
AnsiColor::Bit4(c) => ansi_4bit(c),
|
|
||||||
AnsiColor::Bit8(c) => ansi_256(c),
|
|
||||||
AnsiColor::Bit24 { r, g, b } => Color32::from_rgb(r, g, b),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_4bit(code: u8) -> Color32 {
|
|
||||||
match code {
|
|
||||||
30 => Color32::BLACK,
|
|
||||||
31 => Color32::from_rgb(194, 54, 33),
|
|
||||||
32 => Color32::from_rgb(37, 188, 36),
|
|
||||||
33 => Color32::from_rgb(173, 173, 39),
|
|
||||||
34 => Color32::from_rgb(73, 46, 225),
|
|
||||||
35 => Color32::from_rgb(211, 56, 211),
|
|
||||||
36 => Color32::from_rgb(51, 187, 200),
|
|
||||||
37 => Color32::from_rgb(203, 204, 205),
|
|
||||||
|
|
||||||
90 => Color32::from_rgb(129, 131, 131),
|
|
||||||
91 => Color32::from_rgb(252, 57, 31),
|
|
||||||
92 => Color32::from_rgb(49, 231, 34),
|
|
||||||
93 => Color32::from_rgb(234, 236, 35),
|
|
||||||
94 => Color32::from_rgb(88, 51, 255),
|
|
||||||
95 => Color32::from_rgb(249, 53, 248),
|
|
||||||
96 => Color32::from_rgb(20, 240, 240),
|
|
||||||
97 => Color32::from_rgb(233, 235, 235),
|
|
||||||
|
|
||||||
40 => Color32::BLACK,
|
|
||||||
41 => Color32::from_rgb(194, 54, 33),
|
|
||||||
42 => Color32::from_rgb(37, 188, 36),
|
|
||||||
43 => Color32::from_rgb(173, 173, 39),
|
|
||||||
44 => Color32::from_rgb(73, 46, 225),
|
|
||||||
45 => Color32::from_rgb(211, 56, 211),
|
|
||||||
46 => Color32::from_rgb(51, 187, 200),
|
|
||||||
47 => Color32::from_rgb(203, 204, 205),
|
|
||||||
|
|
||||||
100 => Color32::from_rgb(129, 131, 131),
|
|
||||||
101 => Color32::from_rgb(252, 57, 31),
|
|
||||||
102 => Color32::from_rgb(49, 231, 34),
|
|
||||||
103 => Color32::from_rgb(234, 236, 35),
|
|
||||||
104 => Color32::from_rgb(88, 51, 255),
|
|
||||||
105 => Color32::from_rgb(249, 53, 248),
|
|
||||||
106 => Color32::from_rgb(20, 240, 240),
|
|
||||||
107 => Color32::from_rgb(233, 235, 235),
|
|
||||||
|
|
||||||
_ => Color32::WHITE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ansi_256(code: u8) -> Color32 {
|
|
||||||
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];
|
|
||||||
Color32::from_rgb(r, g, b)
|
|
||||||
}
|
|
||||||
232..=255 => {
|
|
||||||
let v = (code - 232) * 10 + 8;
|
|
||||||
Color32::from_rgb(v, v, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn brighten(color: Color32) -> Color32 {
|
|
||||||
Color32::from_rgb(
|
|
||||||
(color.r() as u32 * 13 / 10).min(255) as u8,
|
|
||||||
(color.g() as u32 * 13 / 10).min(255) as u8,
|
|
||||||
(color.b() as u32 * 13 / 10).min(255) as u8,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn visible_text(job: &LayoutJob) -> String {
|
|
||||||
job.sections
|
|
||||||
.iter()
|
|
||||||
.map(|s| &job.text[s.byte_range.clone()])
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plain_text_no_ansi() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("hello", fmt);
|
|
||||||
assert_eq!(job.sections.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fg_red_reset() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("\x1b[31mred\x1b[0m plain", fmt);
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert!(job.sections[0].format.color != job.sections[1].format.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_sgr_resets_all_styles() {
|
|
||||||
let fmt = TextFormat {
|
|
||||||
color: Color32::from_rgb(1, 2, 3),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let job = ansi_to_layout_job("\x1b[31mred\x1b[m plain", fmt.clone());
|
|
||||||
|
|
||||||
assert_eq!(visible_text(&job), "red plain");
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
|
||||||
assert_eq!(job.sections[1].format.color, fmt.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selective_foreground_reset_keeps_background() {
|
|
||||||
let fmt = TextFormat {
|
|
||||||
color: Color32::from_rgb(1, 2, 3),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let job = ansi_to_layout_job("\x1b[41;37mtext\x1b[39mmore", fmt.clone());
|
|
||||||
|
|
||||||
assert_eq!(visible_text(&job), "textmore");
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
|
||||||
assert_eq!(job.sections[1].format.color, fmt.color);
|
|
||||||
assert_ne!(job.sections[0].format.background, fmt.background);
|
|
||||||
assert_eq!(
|
|
||||||
job.sections[0].format.background,
|
|
||||||
job.sections[1].format.background
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bold_brightens_color() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("\x1b[1;31mbold red\x1b[0m", fmt);
|
|
||||||
assert_eq!(job.sections.len(), 1);
|
|
||||||
let c = job.sections[0].format.color;
|
|
||||||
assert!(c.r() > 200 || c.g() > 50 || c.b() > 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strip_ansi_codes_from_output() {
|
|
||||||
let fmt = TextFormat::default();
|
|
||||||
let job = ansi_to_layout_job("\x1b[32mgreen\x1b[0m", fmt);
|
|
||||||
let text: String = job
|
|
||||||
.sections
|
|
||||||
.iter()
|
|
||||||
.map(|s| &job.text[s.byte_range.clone()])
|
|
||||||
.collect();
|
|
||||||
assert_eq!(text, "green");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighted_search_uses_visible_ansi_text() {
|
|
||||||
let default = TextFormat {
|
|
||||||
color: Color32::WHITE,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let highlight = TextFormat {
|
|
||||||
color: Color32::BLACK,
|
|
||||||
background: Color32::from_rgb(255, 255, 0),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let job = ansi_to_layout_job_highlighted(
|
|
||||||
"\x1b[31mred\x1b[0m plain",
|
|
||||||
default,
|
|
||||||
"red plain",
|
|
||||||
true,
|
|
||||||
highlight.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(visible_text(&job), "red plain");
|
|
||||||
assert!(job.sections.len() >= 2);
|
|
||||||
assert!(
|
|
||||||
job.sections
|
|
||||||
.iter()
|
|
||||||
.all(|section| section.format.background == highlight.background)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn xterm_256_color_cube_values() {
|
|
||||||
assert_eq!(ansi_256(16), Color32::from_rgb(0, 0, 0));
|
|
||||||
assert_eq!(ansi_256(21), Color32::from_rgb(0, 0, 255));
|
|
||||||
assert_eq!(ansi_256(52), Color32::from_rgb(95, 0, 0));
|
|
||||||
assert_eq!(ansi_256(67), Color32::from_rgb(95, 135, 175));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1310,22 +1310,18 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
ui.add_space(3.0);
|
ui.add_space(3.0);
|
||||||
|
|
||||||
let hint = match tab.send_mode {
|
let hint = match tab.send_mode {
|
||||||
SendMode::Text => "Enter to send, Ctrl+Enter for newline",
|
SendMode::Text => "Enter text to send",
|
||||||
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||||
};
|
};
|
||||||
let response = ui.add(
|
let response = ui.add(
|
||||||
TextEdit::multiline(&mut tab.send_input)
|
TextEdit::multiline(&mut tab.send_input)
|
||||||
.desired_rows(6)
|
.desired_rows(6)
|
||||||
.desired_width(f32::INFINITY)
|
.desired_width(f32::INFINITY)
|
||||||
.return_key(egui::KeyboardShortcut::new(
|
|
||||||
egui::Modifiers::COMMAND,
|
|
||||||
egui::Key::Enter,
|
|
||||||
))
|
|
||||||
.hint_text(hint),
|
.hint_text(hint),
|
||||||
);
|
);
|
||||||
|
|
||||||
let wants_submit = response.has_focus()
|
let wants_submit = response.has_focus()
|
||||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.is_none());
|
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only());
|
||||||
|
|
||||||
let mut send_clicked = false;
|
let mut send_clicked = false;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use pipeview_client::config::SessionConfig;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
use pipeview_client::config::SessionConfig;
|
||||||
|
|
||||||
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||||
|
|
||||||
@@ -123,8 +123,8 @@ const fn default_true() -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use pipeview_core::transport::TransportConfig;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use pipeview_core::transport::TransportConfig;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_gui_state_path_uses_appdata() {
|
fn windows_gui_state_path_uses_appdata() {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use egui_plot::PlotBounds;
|
use egui_plot::PlotBounds;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use pipeview_client::RingBuffer;
|
use pipeview_client::RingBuffer;
|
||||||
use pipeview_client::event::DecodedEntry;
|
use pipeview_client::event::DecodedEntry;
|
||||||
use pipeview_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum LineDirection {
|
pub enum LineDirection {
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
mod ansi_render;
|
|
||||||
mod app;
|
mod app;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod buffers;
|
mod buffers;
|
||||||
@@ -12,41 +9,7 @@ mod ui_fonts;
|
|||||||
|
|
||||||
use pipeview_client::SessionManager;
|
use pipeview_client::SessionManager;
|
||||||
|
|
||||||
/// On Linux, when launched from a file manager, a terminal emulator may be spawned
|
|
||||||
/// to run this binary. We re-spawn ourselves detached so the terminal can close
|
|
||||||
/// while the GUI window persists.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn ensure_detached() {
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
use std::process;
|
|
||||||
|
|
||||||
if std::env::var("PIPEVIEW_DETACHED").is_ok() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let exe = match std::env::current_exe() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
||||||
|
|
||||||
let _ = process::Command::new(&exe)
|
|
||||||
.args(&args)
|
|
||||||
.env("PIPEVIEW_DETACHED", "1")
|
|
||||||
.stdin(process::Stdio::null())
|
|
||||||
.stdout(process::Stdio::null())
|
|
||||||
.stderr(process::Stdio::null())
|
|
||||||
.process_group(0)
|
|
||||||
.spawn();
|
|
||||||
|
|
||||||
process::exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
ensure_detached();
|
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
.init();
|
.init();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::ansi_render::{ansi_to_layout_job, ansi_to_layout_job_highlighted};
|
|
||||||
use crate::app::{DisplayOptions, SearchState};
|
use crate::app::{DisplayOptions, SearchState};
|
||||||
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
||||||
use egui::{
|
use egui::{
|
||||||
@@ -32,6 +31,42 @@ pub fn render(
|
|||||||
line_count
|
line_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn highlight_matches(
|
||||||
|
text: &str,
|
||||||
|
query: &str,
|
||||||
|
case_sensitive: bool,
|
||||||
|
default_format: TextFormat,
|
||||||
|
highlight_format: TextFormat,
|
||||||
|
) -> LayoutJob {
|
||||||
|
let search_text = if case_sensitive {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
text.to_lowercase()
|
||||||
|
};
|
||||||
|
let query = if case_sensitive {
|
||||||
|
query.to_string()
|
||||||
|
} else {
|
||||||
|
query.to_lowercase()
|
||||||
|
};
|
||||||
|
let mut job = LayoutJob::default();
|
||||||
|
let mut last_end = 0;
|
||||||
|
let mut idx = 0;
|
||||||
|
while let Some(pos) = search_text[idx..].find(&query) {
|
||||||
|
let start = idx + pos;
|
||||||
|
let end = start + query.len();
|
||||||
|
if last_end < start {
|
||||||
|
job.append(&text[last_end..start], 0.0, default_format.clone());
|
||||||
|
}
|
||||||
|
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||||
|
last_end = end;
|
||||||
|
idx = end;
|
||||||
|
}
|
||||||
|
if last_end < text.len() {
|
||||||
|
job.append(&text[last_end..], 0.0, default_format);
|
||||||
|
}
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
fn monospace_format(style: &egui::Style) -> TextFormat {
|
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||||
TextFormat {
|
TextFormat {
|
||||||
font_id: style
|
font_id: style
|
||||||
@@ -86,13 +121,9 @@ pub fn format_console_line(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match search {
|
match search {
|
||||||
Some(state) if state.active && !state.query.is_empty() => ansi_to_layout_job_highlighted(
|
Some(state) if state.active && !state.query.is_empty() => {
|
||||||
&full_text,
|
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||||
default,
|
}
|
||||||
&state.query,
|
_ => LayoutJob::single_section(full_text, default),
|
||||||
state.case_sensitive,
|
|
||||||
highlight,
|
|
||||||
),
|
|
||||||
_ => ansi_to_layout_job(&full_text, default),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,13 +125,10 @@ fn format_hex_line(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match search {
|
match search {
|
||||||
Some(state) if state.active && !state.query.is_empty() => highlight_matches(
|
Some(state) if state.active && !state.query.is_empty() => {
|
||||||
&full_text,
|
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||||
&state.query,
|
}
|
||||||
state.case_sensitive,
|
|
||||||
default,
|
|
||||||
highlight,
|
|
||||||
),
|
|
||||||
_ => LayoutJob::single_section(full_text, default),
|
_ => LayoutJob::single_section(full_text, default),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
|||||||
),
|
),
|
||||||
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
||||||
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
||||||
(SearchPrev, KeyboardShortcut::new(Modifiers::SHIFT, Key::F3)),
|
(
|
||||||
|
SearchPrev,
|
||||||
|
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ image = { workspace = true }
|
|||||||
mlua = { workspace = true }
|
mlua = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { 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
@@ -3,20 +3,18 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use pipeview_client::SessionConfig;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
use pipeview_client::SessionConfig;
|
||||||
use crate::app::{DisplayOptions, View, default_session_config};
|
|
||||||
|
|
||||||
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct PersistedTuiState {
|
pub struct PersistedTuiState {
|
||||||
#[serde(default = "default_session")]
|
|
||||||
pub session: SessionConfig,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub active_view: PersistedView,
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: usize,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_timestamp: bool,
|
pub show_timestamp: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
@@ -25,54 +23,9 @@ pub struct PersistedTuiState {
|
|||||||
pub show_pipeline: bool,
|
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 {
|
pub fn load_tui_state() -> PersistedTuiState {
|
||||||
match load_tui_state_from_path(&tui_state_path()) {
|
match load_tui_state_from_path(&tui_state_path()) {
|
||||||
Ok(mut state) => {
|
Ok(state) => state,
|
||||||
if state.session.pipelines.is_empty() {
|
|
||||||
state.session = default_session();
|
|
||||||
}
|
|
||||||
state
|
|
||||||
}
|
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = %err, "Failed to load persisted TUI state");
|
warn!(error = %err, "Failed to load persisted TUI state");
|
||||||
@@ -81,35 +34,16 @@ pub fn load_tui_state() -> PersistedTuiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_tui_state(session: &SessionConfig, view: View, display: DisplayOptions) {
|
pub fn save_tui_state(state: &PersistedTuiState) {
|
||||||
let state = PersistedTuiState {
|
if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) {
|
||||||
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");
|
warn!(error = %err, "Failed to persist TUI state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_session() -> SessionConfig {
|
|
||||||
default_session_config()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tui_state_path() -> PathBuf {
|
fn tui_state_path() -> PathBuf {
|
||||||
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
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 {
|
fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
match os {
|
match os {
|
||||||
"windows" => {
|
"windows" => {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use std::collections::VecDeque;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pipeview_client::{DecodedEntry, RingBuffer};
|
use pipeview_client::{DecodedEntry, RingBuffer};
|
||||||
use pipeview_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LineDirection {
|
pub enum LineDirection {
|
||||||
@@ -52,10 +50,6 @@ impl TextBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
|
||||||
self.lines.get(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.lines.len()
|
self.lines.len()
|
||||||
}
|
}
|
||||||
@@ -67,6 +61,10 @@ impl TextBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
self.lines.set_limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn recent(&self, count: usize) -> Vec<ConsoleLine> {
|
||||||
|
self.lines.drain_recent(count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -113,10 +111,6 @@ impl HexBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
|
||||||
self.lines.get(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.lines.len()
|
self.lines.len()
|
||||||
}
|
}
|
||||||
@@ -128,494 +122,9 @@ impl HexBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
self.lines.set_limit(limit);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PlotSeries {
|
pub fn recent(&self, count: usize) -> Vec<HexLine> {
|
||||||
pub name: String,
|
self.lines.drain_recent(count)
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
mod ansi;
|
|
||||||
mod app;
|
mod app;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod buffers;
|
mod buffers;
|
||||||
@@ -7,59 +6,33 @@ mod ui;
|
|||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{DisableMouseCapture, EnableMouseCapture},
|
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
use tracing_appender::non_blocking::WorkerGuard;
|
use pipeview_client::SessionManager;
|
||||||
use tracing_subscriber::{EnvFilter, fmt};
|
|
||||||
|
|
||||||
use crate::app::App;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> io::Result<()> {
|
async fn main() -> io::Result<()> {
|
||||||
let _log_guard = init_tracing();
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let result = async {
|
let manager = SessionManager::new();
|
||||||
let mut app = App::new();
|
let rx = manager.subscribe();
|
||||||
app.run(&mut terminal).await?;
|
|
||||||
app.shutdown().await;
|
let result = app::run(&mut terminal, app::App::new(manager, rx));
|
||||||
io::Result::Ok(())
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)?;
|
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
result
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate ANSI-colored terminal output for testing pipeview ANSI rendering."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import itertools
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
RESET = "\x1b[0m"
|
|
||||||
|
|
||||||
# Standard 3/4-bit foreground
|
|
||||||
FG = {
|
|
||||||
"black": "\x1b[30m", "red": "\x1b[31m", "green": "\x1b[32m",
|
|
||||||
"yellow": "\x1b[33m", "blue": "\x1b[34m", "magenta":"\x1b[35m",
|
|
||||||
"cyan": "\x1b[36m", "white": "\x1b[37m",
|
|
||||||
}
|
|
||||||
FG_BRIGHT = {
|
|
||||||
"black": "\x1b[90m", "red": "\x1b[91m", "green": "\x1b[92m",
|
|
||||||
"yellow": "\x1b[93m", "blue": "\x1b[94m", "magenta":"\x1b[95m",
|
|
||||||
"cyan": "\x1b[96m", "white": "\x1b[97m",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Standard 3/4-bit background
|
|
||||||
BG = {
|
|
||||||
"black": "\x1b[40m", "red": "\x1b[41m", "green": "\x1b[42m",
|
|
||||||
"yellow": "\x1b[43m", "blue": "\x1b[44m", "magenta":"\x1b[45m",
|
|
||||||
"cyan": "\x1b[46m", "white": "\x1b[47m",
|
|
||||||
}
|
|
||||||
BG_BRIGHT = {
|
|
||||||
"black": "\x1b[100m", "red": "\x1b[101m", "green": "\x1b[102m",
|
|
||||||
"yellow": "\x1b[103m", "blue": "\x1b[104m", "magenta":"\x1b[105m",
|
|
||||||
"cyan": "\x1b[106m", "white": "\x1b[107m",
|
|
||||||
}
|
|
||||||
|
|
||||||
BOLD = "\x1b[1m"
|
|
||||||
FAINT = "\x1b[2m"
|
|
||||||
ITALIC = "\x1b[3m"
|
|
||||||
UNDERLINE = "\x1b[4m"
|
|
||||||
STRIKE = "\x1b[9m"
|
|
||||||
|
|
||||||
|
|
||||||
def fg256(n: int) -> str:
|
|
||||||
return f"\x1b[38;5;{n}m"
|
|
||||||
|
|
||||||
def bg256(n: int) -> str:
|
|
||||||
return f"\x1b[48;5;{n}m"
|
|
||||||
|
|
||||||
def fg_rgb(r: int, g: int, b: int) -> str:
|
|
||||||
return f"\x1b[38;2;{r};{g};{b}m"
|
|
||||||
|
|
||||||
def bg_rgb(r: int, g: int, b: int) -> str:
|
|
||||||
return f"\x1b[48;2;{r};{g};{b}m"
|
|
||||||
|
|
||||||
|
|
||||||
# ── test scenes ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def scene_16color(delay: float):
|
|
||||||
"""Show all 8 standard + 8 bright foreground colors."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Standard 16 Foreground Colors ───{RESET}")
|
|
||||||
for name in FG:
|
|
||||||
lines.append(f" {FG[name]}■ {name:>8}{RESET} "
|
|
||||||
f"{FG_BRIGHT[name]}■ bright {name}{RESET}")
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_bg_colors(delay: float):
|
|
||||||
"""Show foreground colors on colored backgrounds."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Background Colors ───{RESET}")
|
|
||||||
for bg_name in BG:
|
|
||||||
line = ""
|
|
||||||
for fg_name in ["white", "black"]:
|
|
||||||
line += f"{BG[bg_name]}{FG[fg_name]} {fg_name} on {bg_name} {RESET} "
|
|
||||||
lines.append(line)
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_styles(delay: float):
|
|
||||||
"""Show bold, italic, underline, strikethrough."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Text Styles ───{RESET}")
|
|
||||||
lines.append(f" {BOLD}Bold text{RESET}")
|
|
||||||
lines.append(f" {ITALIC}Italic text{RESET}")
|
|
||||||
lines.append(f" {UNDERLINE}Underlined text{RESET}")
|
|
||||||
lines.append(f" {STRIKE}Strikethrough text{RESET}")
|
|
||||||
lines.append(f" {BOLD}{ITALIC}Bold + Italic{RESET}")
|
|
||||||
lines.append(f" {BOLD}{FG['red']}Bold Red{RESET} vs {FG['red']}Normal Red{RESET}")
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_256_ramp(delay: float):
|
|
||||||
"""Show a 256-color ramp."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── 256-Color Ramp ───{RESET}")
|
|
||||||
# 16 basic colors
|
|
||||||
line = "Basic: "
|
|
||||||
for n in range(16):
|
|
||||||
line += f"{fg256(n)}██{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
for row in range(3):
|
|
||||||
line = f"Cube {row+1}: "
|
|
||||||
for col in range(6):
|
|
||||||
n = 16 + row * 36 + col * 6
|
|
||||||
line += f"{fg256(n)}██{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
line = "Gray: "
|
|
||||||
for n in range(232, 256):
|
|
||||||
line += f"{fg256(n)}█{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_truecolor(delay: float):
|
|
||||||
"""Show 24-bit truecolor gradient."""
|
|
||||||
lines = []
|
|
||||||
lines.append(f"{BOLD}─── Truecolor (24-bit) ───{RESET}")
|
|
||||||
line = "R→G: "
|
|
||||||
for i in range(32):
|
|
||||||
r = 255 - i * 8
|
|
||||||
g = i * 8
|
|
||||||
line += f"{fg_rgb(r, g, 0)}█{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
line = "B→Y: "
|
|
||||||
for i in range(32):
|
|
||||||
b = 255 - i * 8
|
|
||||||
r = g = i * 8
|
|
||||||
line += f"{fg_rgb(r, g, b)}█{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
line = "Rainbow: "
|
|
||||||
for i in range(48):
|
|
||||||
hue = i / 48.0 * 6.0
|
|
||||||
r, g, b = _hsv_to_rgb(hue, 1.0, 1.0)
|
|
||||||
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
|
||||||
lines.append(line)
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_status_log(delay: float):
|
|
||||||
"""Simulate systemd-style status output."""
|
|
||||||
lines = [
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}System Logging Service{RESET}.",
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}Network Manager{RESET}.",
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Reached target {ITALIC}multi-user.target{RESET}.",
|
|
||||||
f"{FG_BRIGHT['yellow']}[ WARN ]{RESET} Failed to load kernel module {UNDERLINE}nvidia{RESET}.",
|
|
||||||
f"{FG_BRIGHT['red']}[ FAIL ]{RESET} {FG['red']}{BOLD}ssh.service{RESET}{FG['red']} failed to start.{RESET}",
|
|
||||||
f"{FG_BRIGHT['cyan']}[ INFO ]{RESET} Listening on {fg256(33)}0.0.0.0:8080{RESET}.",
|
|
||||||
f" {ITALIC}─ subject=CN=example.com{RESET}",
|
|
||||||
f" {ITALIC}─ fingerprint={FG['yellow']}SHA256:abcd1234{RESET}",
|
|
||||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Mounted {BG['blue']}{FG['white']} /var {RESET} filesystem.",
|
|
||||||
f"{FG_BRIGHT['magenta']}[STATUS]{RESET} CPU: {fg256(46)}32%{RESET} "
|
|
||||||
f"Mem: {fg256(220)}1.2G{RESET}/{fg256(33)}4.0G{RESET} "
|
|
||||||
f"Temp: {_temp_color(58)}58°C{RESET}",
|
|
||||||
]
|
|
||||||
for line in lines:
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_colored_log_stream(delay: float):
|
|
||||||
"""Continuously generate log lines cycling through themes."""
|
|
||||||
themes = [
|
|
||||||
('green', 'INFO ', "Connection accepted from 192.168.1.100"),
|
|
||||||
('cyan', 'DEBUG', "Processing frame #{}"),
|
|
||||||
('yellow', 'WARN ', "Buffer usage at {:.0f}%"),
|
|
||||||
('red', 'ERROR', "CRC mismatch on packet {}"),
|
|
||||||
('white', 'TRACE', "Entering function handle_request()"),
|
|
||||||
('magenta','AUDIT', "User admin performed action {}"),
|
|
||||||
]
|
|
||||||
counter = itertools.count(1)
|
|
||||||
while True:
|
|
||||||
color_name, level, template = random.choice(themes)
|
|
||||||
n = next(counter)
|
|
||||||
msg = template.format(n, random.uniform(60, 95), n)
|
|
||||||
ts = time.strftime("%H:%M:%S")
|
|
||||||
line = (f"{ITALIC}{ts}{RESET} "
|
|
||||||
f"{FG_BRIGHT[color_name]}{BOLD}[{level}]{RESET} "
|
|
||||||
f"{msg}")
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_rainbow_wave(delay: float):
|
|
||||||
"""Animated rainbow wave (for live testing)."""
|
|
||||||
t0 = time.time()
|
|
||||||
while True:
|
|
||||||
t = time.time() - t0
|
|
||||||
line = ""
|
|
||||||
for x in range(60):
|
|
||||||
hue = (x / 60.0 + t * 0.3) % 1.0
|
|
||||||
r, g, b = _hsv_to_rgb(hue * 6.0, 1.0, 0.8 + 0.2 * math.sin(t * 2 + x * 0.3))
|
|
||||||
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
|
||||||
yield line, delay
|
|
||||||
|
|
||||||
|
|
||||||
def scene_system_boot(delay: float):
|
|
||||||
"""Simulate a system boot sequence with progress."""
|
|
||||||
services = [
|
|
||||||
("udev", "Kernel Device Manager", 0.6),
|
|
||||||
("systemd-journald","Journal Service", 0.8),
|
|
||||||
("NetworkManager", "Network Manager", 0.7),
|
|
||||||
("sshd", "OpenSSH Daemon", 0.5),
|
|
||||||
("nginx", "HTTP Server", 0.9),
|
|
||||||
("postgresql", "PostgreSQL 16", 1.2),
|
|
||||||
("docker", "Docker Engine", 1.5),
|
|
||||||
("pipeview", "Pipe Data Monitor", 0.3),
|
|
||||||
]
|
|
||||||
t0 = time.time()
|
|
||||||
for name, desc, startup_time in services:
|
|
||||||
yield (f"{FG_BRIGHT['cyan']}[ .... ]{RESET} Starting {BOLD}{name}{RESET} - {desc}...",
|
|
||||||
startup_time * 0.3)
|
|
||||||
yield (f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}{name}{RESET} - {desc}.",
|
|
||||||
delay)
|
|
||||||
yield (f"\n{FG_BRIGHT['green']}{BOLD}Boot complete.{RESET} "
|
|
||||||
f"({time.time() - t0:.1f}s)", delay)
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
|
|
||||||
"""HSV → RGB, h in [0, 6), s,v in [0, 1]."""
|
|
||||||
c = v * s
|
|
||||||
x = c * (1 - abs((h % 2) - 1))
|
|
||||||
m = v - c
|
|
||||||
r, g, b = {
|
|
||||||
0: (c, x, 0), 1: (x, c, 0), 2: (0, c, x),
|
|
||||||
3: (0, x, c), 4: (x, 0, c), 5: (c, 0, x),
|
|
||||||
}[int(h) % 6]
|
|
||||||
return int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
|
|
||||||
|
|
||||||
|
|
||||||
def _temp_color(temp: float) -> str:
|
|
||||||
if temp < 50: return fg256(46)
|
|
||||||
elif temp < 70: return fg256(220)
|
|
||||||
else: return fg256(196)
|
|
||||||
|
|
||||||
|
|
||||||
# ── scenes registry ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
STATIC_SCENES = [
|
|
||||||
("16color", scene_16color, "Standard 16 foreground colors"),
|
|
||||||
("bg", scene_bg_colors, "Background colors"),
|
|
||||||
("styles", scene_styles, "Bold, italic, underline, strikethrough"),
|
|
||||||
("256ramp", scene_256_ramp, "256-color ramp"),
|
|
||||||
("truecolor", scene_truecolor, "24-bit truecolor gradients"),
|
|
||||||
("status", scene_status_log, "systemd-style status log"),
|
|
||||||
("boot", scene_system_boot, "System boot sequence"),
|
|
||||||
]
|
|
||||||
|
|
||||||
LIVE_SCENES = [
|
|
||||||
("logstream", scene_colored_log_stream, "Continuous colored log stream"),
|
|
||||||
("rainbow", scene_rainbow_wave, "Animated rainbow wave"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ── main ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="ANSI color test data generator for pipeview",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="Scenes:\n" + "\n".join(
|
|
||||||
f" {n:<14} {d}" for n, _, d in STATIC_SCENES + LIVE_SCENES
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument("--host", default="127.0.0.1", help="TCP bind host")
|
|
||||||
parser.add_argument("--port", type=int, default=8099, help="TCP port")
|
|
||||||
parser.add_argument("--scene", choices=[n for n, _, _ in STATIC_SCENES + LIVE_SCENES] + ["all"],
|
|
||||||
default="all", help="Scene to play")
|
|
||||||
parser.add_argument("--delay", type=float, default=0.15, help="Inter-line delay (seconds)")
|
|
||||||
parser.add_argument("--loop", action="store_true", help="Repeat the scene forever")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
scenes = STATIC_SCENES + LIVE_SCENES
|
|
||||||
if args.scene != "all":
|
|
||||||
scenes = [(n, f, d) for n, f, d in scenes if n == args.scene]
|
|
||||||
|
|
||||||
print(f"ANSI color test → {args.host}:{args.port}")
|
|
||||||
print(f"Scene: {args.scene}, delay: {args.delay}s, loop: {args.loop}")
|
|
||||||
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
|
||||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
server.bind((args.host, args.port))
|
|
||||||
server.listen(1)
|
|
||||||
print(f"Listening on {args.host}:{args.port}, waiting for connections...")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
sock, addr = server.accept()
|
|
||||||
print(f"Client connected from {addr}")
|
|
||||||
try:
|
|
||||||
_play_scenes(sock, scenes, args.delay, args.loop)
|
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
pass
|
|
||||||
print("Client disconnected. Waiting for new connection...")
|
|
||||||
|
|
||||||
|
|
||||||
def _play_scenes(sock: socket.socket, scenes, delay: float, loop_scenes: bool):
|
|
||||||
first = True
|
|
||||||
while first or loop_scenes:
|
|
||||||
first = False
|
|
||||||
for name, scene_fn, _desc in scenes:
|
|
||||||
print(f" [{name}]")
|
|
||||||
for line, line_delay in scene_fn(delay):
|
|
||||||
sock.sendall((line + "\n").encode())
|
|
||||||
time.sleep(line_delay)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user