Compare commits
9 Commits
e600418da8
...
f9be74257a
| Author | SHA1 | Date | |
|---|---|---|---|
| f9be74257a | |||
| 84563ecfc8 | |||
| 1da034c9b1 | |||
| 37f7713938 | |||
| 5923ad0143 | |||
| cd3f778502 | |||
| 5035e73d7e | |||
| ee65e284c4 | |||
| 4e4c9cee65 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,3 +6,10 @@ tools/test_text_c
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
/AGENTS.md
|
||||
/.agents
|
||||
/.codex
|
||||
/.omo
|
||||
/.opencode
|
||||
/.trellis
|
||||
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -189,6 +189,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -3297,6 +3307,7 @@ dependencies = [
|
||||
name = "pipeview-gui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ansitok",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_plot",
|
||||
@@ -3314,6 +3325,7 @@ dependencies = [
|
||||
name = "pipeview-tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ansitok",
|
||||
"clap",
|
||||
"crossterm 0.28.1",
|
||||
"hex",
|
||||
@@ -4720,6 +4732,16 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "vtparse"
|
||||
version = "0.6.2"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use pipeview_core::frame::{
|
||||
Endian, Framer,
|
||||
cobs::CobsFramer,
|
||||
@@ -15,6 +14,7 @@ use pipeview_core::protocol::{
|
||||
text::{TextDecoder, TextEncoding},
|
||||
};
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::lua::codec::{LuaDecoder, LuaFramer};
|
||||
|
||||
@@ -2,10 +2,10 @@ use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
||||
use tracing::warn;
|
||||
use pipeview_core::frame::Framer;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
||||
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ use std::sync::{
|
||||
use std::time::Duration;
|
||||
|
||||
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
|
||||
use crate::config::SessionConfig;
|
||||
use crate::lua::LuaRuntime;
|
||||
|
||||
@@ -173,22 +173,18 @@ impl Connection {
|
||||
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||
match self {
|
||||
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(),
|
||||
))
|
||||
}
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||
match self {
|
||||
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(),
|
||||
))
|
||||
}
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use serialport::SerialPort;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
||||
use serialport::SerialPort;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{Transport, TransportType};
|
||||
@@ -277,10 +277,16 @@ impl Transport for SerialTransport {
|
||||
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
||||
// transparent data mode and not fall into AT-command / reset state.
|
||||
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
||||
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
|
||||
warn!(
|
||||
"Failed to set DTR({}) on {}: {}",
|
||||
self.dtr, self.port_name, e
|
||||
);
|
||||
}
|
||||
if let Err(e) = port.write_request_to_send(self.rts) {
|
||||
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
|
||||
warn!(
|
||||
"Failed to set RTS({}) on {}: {}",
|
||||
self.rts, self.port_name, e
|
||||
);
|
||||
}
|
||||
|
||||
debug!("Serial port {} opened successfully", self.port_name);
|
||||
|
||||
@@ -19,3 +19,4 @@ tracing-subscriber = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
hex = "0.4"
|
||||
ansitok = "0.3"
|
||||
|
||||
451
crates/pipeview-gui/src/ansi_render.rs
Normal file
451
crates/pipeview-gui/src/ansi_render.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||
use crate::logging::{self, LogWriter};
|
||||
use crate::panels::{config, console, hex_view, plot_view, 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, FontChoice, UiFontSettings};
|
||||
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
||||
use crate::ui_fonts::{self, FontCandidate, UiFontSettings};
|
||||
use egui::{Color32, Label, Layout, Panel, Pos2, Rect, UiBuilder};
|
||||
use pipeview_client::SessionManager;
|
||||
use pipeview_client::config::SessionConfig;
|
||||
use pipeview_client::session::SessionEvent;
|
||||
@@ -18,160 +17,29 @@ use pipeview_core::transport::TransportConfig;
|
||||
|
||||
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl ConnectionStatus {
|
||||
fn badge(&self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Self::Connected => ("[connected]", "Connected"),
|
||||
Self::Disconnected => ("[disconnected]", "Disconnected"),
|
||||
Self::Connecting => ("[connecting]", "Connecting"),
|
||||
Self::Error(_) => ("[error]", "Error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum View {
|
||||
Text,
|
||||
Hex,
|
||||
Plot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SendMode {
|
||||
Text,
|
||||
Hex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum LineEnding {
|
||||
None,
|
||||
LF,
|
||||
CR,
|
||||
CRLF,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LineEnding {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::None => write!(f, "None"),
|
||||
Self::LF => write!(f, "LF (\\n)"),
|
||||
Self::CR => write!(f, "CR (\\r)"),
|
||||
Self::CRLF => write!(f, "CRLF (\\r\\n)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DisplayOptions {
|
||||
pub show_timestamp: bool,
|
||||
pub show_direction: bool,
|
||||
pub show_pipeline: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SearchState {
|
||||
pub query: String,
|
||||
pub matches: Vec<usize>,
|
||||
pub current_match: usize,
|
||||
pub case_sensitive: bool,
|
||||
pub active: bool,
|
||||
pub just_opened: bool,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
pub fn clear(&mut self) {
|
||||
self.query.clear();
|
||||
self.matches.clear();
|
||||
self.current_match = 0;
|
||||
self.active = false;
|
||||
self.just_opened = false;
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if !self.matches.is_empty() {
|
||||
self.current_match = (self.current_match + 1) % self.matches.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if !self.matches.is_empty() {
|
||||
self.current_match = if self.current_match == 0 {
|
||||
self.matches.len() - 1
|
||||
} else {
|
||||
self.current_match - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// pub fn current_line_index(&self) -> Option<usize> {
|
||||
// self.matches.get(self.current_match).copied()
|
||||
// }
|
||||
|
||||
pub fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
pub fn current_display(&self) -> usize {
|
||||
if self.matches.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.current_match + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionTab {
|
||||
pub id: u64,
|
||||
pub session_config: SessionConfig,
|
||||
pub status: ConnectionStatus,
|
||||
pub console: TextBuffer,
|
||||
pub hex: HexBuffer,
|
||||
pub plot: PlotBuffer,
|
||||
pub plot_view: plot_view::PlotViewState,
|
||||
pub view: View,
|
||||
pub auto_reconnect: bool,
|
||||
pub dtr: bool,
|
||||
pub rts: bool,
|
||||
pub send_input: String,
|
||||
pub send_mode: SendMode,
|
||||
pub line_ending: LineEnding,
|
||||
pub send_status: Option<String>,
|
||||
pub search: SearchState,
|
||||
pub log_enabled: bool,
|
||||
pub log_path: String,
|
||||
pub log_writer: Option<LogWriter>,
|
||||
}
|
||||
pub use crate::models::*;
|
||||
|
||||
pub struct XserialApp {
|
||||
manager: SessionManager,
|
||||
tabs: Vec<SessionTab>,
|
||||
active: usize,
|
||||
display: DisplayOptions,
|
||||
font_settings_open: bool,
|
||||
font_candidates: Vec<FontCandidate>,
|
||||
font_settings: UiFontSettings,
|
||||
primary_font_search: String,
|
||||
fallback_font_search: String,
|
||||
primary_filtered_fonts: Vec<usize>,
|
||||
fallback_filtered_fonts: Vec<usize>,
|
||||
primary_filter_cache_key: String,
|
||||
fallback_filter_cache_key: String,
|
||||
config_open: bool,
|
||||
config_target: Option<u64>,
|
||||
config_form: config::ConfigForm,
|
||||
event_rx: mpsc::Receiver<SessionEvent>,
|
||||
pending: Vec<SessionEvent>,
|
||||
profiler: GuiProfiler,
|
||||
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||
pub(crate) manager: SessionManager,
|
||||
pub(crate) tabs: Vec<SessionTab>,
|
||||
pub(crate) active: usize,
|
||||
pub(crate) display: DisplayOptions,
|
||||
pub(crate) font_settings_open: bool,
|
||||
pub(crate) font_candidates: Vec<FontCandidate>,
|
||||
pub(crate) font_settings: UiFontSettings,
|
||||
pub(crate) primary_font_search: String,
|
||||
pub(crate) fallback_font_search: String,
|
||||
pub(crate) primary_filtered_fonts: Vec<usize>,
|
||||
pub(crate) fallback_filtered_fonts: Vec<usize>,
|
||||
pub(crate) primary_filter_cache_key: String,
|
||||
pub(crate) fallback_filter_cache_key: String,
|
||||
pub(crate) config_open: bool,
|
||||
pub(crate) config_target: Option<u64>,
|
||||
pub(crate) config_form: config::ConfigForm,
|
||||
pub(crate) event_rx: mpsc::Receiver<SessionEvent>,
|
||||
pub(crate) pending: Vec<SessionEvent>,
|
||||
pub(crate) profiler: GuiProfiler,
|
||||
pub(crate) shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||
}
|
||||
|
||||
impl XserialApp {
|
||||
@@ -412,6 +280,7 @@ impl XserialApp {
|
||||
search: SearchState::default(),
|
||||
log_enabled: false,
|
||||
log_path: String::new(),
|
||||
show_sent: true,
|
||||
log_writer: None,
|
||||
});
|
||||
}
|
||||
@@ -668,7 +537,7 @@ impl XserialApp {
|
||||
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
if self.tabs.is_empty() {
|
||||
ui.heading("No sessions.");
|
||||
ui.add(Label::new(egui::RichText::new("No sessions.").heading()).selectable(false));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -688,10 +557,10 @@ impl XserialApp {
|
||||
let (badge, status_text) = tab.status.badge();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(format!("Session {}", tab.id));
|
||||
ui.label(badge);
|
||||
ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false));
|
||||
ui.add(Label::new(badge).selectable(false));
|
||||
ui.separator();
|
||||
ui.label(status_text);
|
||||
ui.add(Label::new(status_text).selectable(false));
|
||||
ui.separator();
|
||||
if ui
|
||||
.selectable_label(tab.view == View::Text, "Text")
|
||||
@@ -710,12 +579,12 @@ impl XserialApp {
|
||||
}
|
||||
});
|
||||
|
||||
let auto_reconnect_changed = 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;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show:");
|
||||
ui.add(Label::new("Show:").selectable(false));
|
||||
display_changed |= ui
|
||||
.checkbox(&mut display.show_timestamp, "Timestamp")
|
||||
.changed();
|
||||
@@ -726,10 +595,10 @@ impl XserialApp {
|
||||
});
|
||||
persist_state |= display_changed;
|
||||
|
||||
render_search_bar(ui, tab);
|
||||
session_controls::render_search_bar(ui, tab);
|
||||
|
||||
if let ConnectionStatus::Error(message) = &tab.status {
|
||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||
ui.add(Label::new(egui::RichText::new(message).color(Color32::RED)).selectable(false));
|
||||
}
|
||||
|
||||
let full = ui.available_rect_before_wrap();
|
||||
@@ -760,11 +629,22 @@ impl XserialApp {
|
||||
}
|
||||
View::Plot => {
|
||||
if tab.plot_view.detached {
|
||||
ui.heading(format!(
|
||||
ui.add(
|
||||
Label::new(
|
||||
egui::RichText::new(format!(
|
||||
"Plot window detached for Session {}",
|
||||
tab.id
|
||||
));
|
||||
ui.label("The plot is currently shown in a floating window.");
|
||||
tab.id,
|
||||
))
|
||||
.heading(),
|
||||
)
|
||||
.selectable(false),
|
||||
);
|
||||
ui.add(
|
||||
Label::new(
|
||||
"The plot is currently shown in a floating window.",
|
||||
)
|
||||
.selectable(false),
|
||||
);
|
||||
if ui.button("Dock Plot Back").clicked() {
|
||||
tab.plot_view.detached = false;
|
||||
}
|
||||
@@ -789,7 +669,7 @@ impl XserialApp {
|
||||
UiBuilder::new()
|
||||
.max_rect(send_rect)
|
||||
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
||||
|ui| render_send_panel(ui, &manager, tab),
|
||||
|ui| send_panel::render_send_panel(ui, &manager, tab),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -851,7 +731,7 @@ impl XserialApp {
|
||||
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
|
||||
Panel::top("top_bar").show_inside(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.heading("pipeview");
|
||||
ui.add(Label::new(egui::RichText::new("pipeview").heading()).selectable(false));
|
||||
ui.separator();
|
||||
// ui.label(format!(
|
||||
// "Fonts: {} + {} {:.1} pt",
|
||||
@@ -873,106 +753,10 @@ impl XserialApp {
|
||||
}
|
||||
|
||||
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
|
||||
if !self.font_settings_open {
|
||||
return;
|
||||
font_settings::render_font_settings_window(self, ctx);
|
||||
}
|
||||
|
||||
let mut open = self.font_settings_open;
|
||||
let mut changed = false;
|
||||
egui::Window::new("UI Settings")
|
||||
.open(&mut open)
|
||||
.default_width(520.0)
|
||||
.resizable(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Fonts");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Primary:");
|
||||
ui.monospace(ui_fonts::font_choice_label(
|
||||
&self.font_settings.primary_choice,
|
||||
&self.font_candidates,
|
||||
));
|
||||
ui.label("Fallback:");
|
||||
ui.monospace(ui_fonts::font_choice_label(
|
||||
&self.font_settings.fallback_choice,
|
||||
&self.font_candidates,
|
||||
));
|
||||
if ui.button("Refresh").clicked() {
|
||||
self.font_candidates = ui_fonts::discover_font_candidates();
|
||||
self.invalidate_font_filters();
|
||||
}
|
||||
});
|
||||
ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph.");
|
||||
ui.add_space(6.0);
|
||||
render_font_selector(
|
||||
ui,
|
||||
"Primary font",
|
||||
"primary_font_choice",
|
||||
&mut self.font_settings.primary_choice,
|
||||
&mut self.primary_font_search,
|
||||
&self.font_candidates,
|
||||
&mut self.primary_filtered_fonts,
|
||||
&mut self.primary_filter_cache_key,
|
||||
true,
|
||||
true,
|
||||
180.0,
|
||||
&mut changed,
|
||||
);
|
||||
ui.separator();
|
||||
render_font_selector(
|
||||
ui,
|
||||
"Fallback font",
|
||||
"fallback_font_choice",
|
||||
&mut self.font_settings.fallback_choice,
|
||||
&mut self.fallback_font_search,
|
||||
&self.font_candidates,
|
||||
&mut self.fallback_filtered_fonts,
|
||||
&mut self.fallback_filter_cache_key,
|
||||
true,
|
||||
true,
|
||||
140.0,
|
||||
&mut changed,
|
||||
);
|
||||
ui.separator();
|
||||
ui.heading("Sizes");
|
||||
ui.label("UI font size");
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::Slider::new(&mut self.font_settings.ui_font_size, 10.0..=28.0)
|
||||
.suffix(" pt"),
|
||||
)
|
||||
.changed();
|
||||
ui.label("Monospace font size");
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::Slider::new(
|
||||
&mut self.font_settings.monospace_font_size,
|
||||
10.0..=28.0,
|
||||
)
|
||||
.suffix(" pt"),
|
||||
)
|
||||
.changed();
|
||||
ui.label("Heading size");
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::Slider::new(&mut self.font_settings.heading_font_size, 14.0..=40.0)
|
||||
.suffix(" pt"),
|
||||
)
|
||||
.changed();
|
||||
ui.separator();
|
||||
ui.heading("Preview");
|
||||
ui.label("The quick brown fox jumps over the lazy dog.");
|
||||
ui.label("中文预览:串口、网络、绘图、十六进制、会话管理。");
|
||||
ui.monospace("Monospace preview: 0123456789 ABCDEF deadbeef");
|
||||
});
|
||||
|
||||
if changed {
|
||||
ui_fonts::apply_font_settings(ctx, &self.font_settings, &self.font_candidates);
|
||||
ui_fonts::save_font_settings(&self.font_settings);
|
||||
}
|
||||
self.font_settings_open = open;
|
||||
}
|
||||
|
||||
fn invalidate_font_filters(&mut self) {
|
||||
pub(crate) fn invalidate_font_filters(&mut self) {
|
||||
self.primary_filtered_fonts.clear();
|
||||
self.fallback_filtered_fonts.clear();
|
||||
self.primary_filter_cache_key = String::from("\0");
|
||||
@@ -994,88 +778,6 @@ fn transport_summary(transport: &TransportConfig) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_font_selector(
|
||||
ui: &mut egui::Ui,
|
||||
title: &str,
|
||||
id_prefix: &str,
|
||||
choice: &mut FontChoice,
|
||||
search: &mut String,
|
||||
candidates: &[FontCandidate],
|
||||
filtered: &mut Vec<usize>,
|
||||
cache_key: &mut String,
|
||||
allow_auto: bool,
|
||||
allow_default: bool,
|
||||
max_height: f32,
|
||||
changed: &mut bool,
|
||||
) {
|
||||
ui.label(title);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Search:");
|
||||
ui.text_edit_singleline(search);
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if allow_auto {
|
||||
*changed |= ui
|
||||
.selectable_value(choice, FontChoice::Auto, "Auto")
|
||||
.changed();
|
||||
}
|
||||
if allow_default {
|
||||
*changed |= ui
|
||||
.selectable_value(choice, FontChoice::Default, "Default")
|
||||
.changed();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
refresh_font_filter(search, candidates, filtered, cache_key);
|
||||
ui.small(format!("{} fonts", filtered.len()));
|
||||
let row_height = ui.spacing().interact_size.y;
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(format!("{id_prefix}_scroll"))
|
||||
.max_height(max_height)
|
||||
.auto_shrink([false, false])
|
||||
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
|
||||
for row in row_range {
|
||||
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index))
|
||||
{
|
||||
let response = ui.selectable_value(
|
||||
choice,
|
||||
FontChoice::System(candidate.id.clone()),
|
||||
candidate.display_label.as_str(),
|
||||
);
|
||||
*changed |= response.changed();
|
||||
response.on_hover_text(&candidate.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_font_filter(
|
||||
search: &str,
|
||||
candidates: &[FontCandidate],
|
||||
filtered: &mut Vec<usize>,
|
||||
cache_key: &mut String,
|
||||
) {
|
||||
let needle = search.trim().to_lowercase();
|
||||
if *cache_key == needle {
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.clear();
|
||||
if needle.is_empty() {
|
||||
filtered.extend(0..candidates.len());
|
||||
} else {
|
||||
filtered.extend(
|
||||
candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, candidate)| candidate.search_key.contains(&needle))
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
}
|
||||
*cache_key = needle;
|
||||
}
|
||||
|
||||
impl eframe::App for XserialApp {
|
||||
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||
|
||||
@@ -1105,332 +807,6 @@ impl eframe::App for XserialApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
||||
if !tab.search.active {
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let response = ui.add(
|
||||
TextEdit::singleline(&mut tab.search.query)
|
||||
.hint_text("Search...")
|
||||
.desired_width(200.0),
|
||||
);
|
||||
if tab.search.just_opened {
|
||||
response.request_focus();
|
||||
tab.search.just_opened = false;
|
||||
}
|
||||
if response.changed() {
|
||||
let matches = match tab.view {
|
||||
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(),
|
||||
};
|
||||
tab.search.matches = matches;
|
||||
tab.search.current_match = 0;
|
||||
}
|
||||
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
tab.search.next();
|
||||
}
|
||||
|
||||
ui.label(format!(
|
||||
"{}/{}",
|
||||
tab.search.current_display(),
|
||||
tab.search.match_count()
|
||||
));
|
||||
|
||||
if ui.button("▲").clicked() {
|
||||
tab.search.prev();
|
||||
}
|
||||
if ui.button("▼").clicked() {
|
||||
tab.search.next();
|
||||
}
|
||||
|
||||
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::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Plot => Vec::new(),
|
||||
};
|
||||
tab.search.matches = matches;
|
||||
tab.search.current_match = 0;
|
||||
}
|
||||
|
||||
if ui.button("✕").clicked() {
|
||||
tab.search.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_session_controls(
|
||||
ui: &mut egui::Ui,
|
||||
manager: &SessionManager,
|
||||
tab: &mut SessionTab,
|
||||
) -> bool {
|
||||
let mut auto_reconnect_changed = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let connected = matches!(
|
||||
tab.status,
|
||||
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||
);
|
||||
let connect_label = if connected { "Disconnect" } else { "Connect" };
|
||||
if ui.button(connect_label).clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
if connected {
|
||||
tab.status = ConnectionStatus::Disconnected;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.disconnect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.connect().await;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
// if ui.button("Reconnect").clicked() {
|
||||
// if let Some(handle) = manager.get(tab.id) {
|
||||
// tab.status = ConnectionStatus::Connecting;
|
||||
// tokio::spawn(async move {
|
||||
// let _ = handle.reconnect().await;
|
||||
// });
|
||||
// } else {
|
||||
// tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
// }
|
||||
// }
|
||||
|
||||
if ui.button("Clear").clicked() {
|
||||
tab.console.clear();
|
||||
tab.hex.clear();
|
||||
tab.plot.clear();
|
||||
tab.send_status = Some(String::from("Cleared"));
|
||||
}
|
||||
|
||||
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
||||
if response.changed() {
|
||||
tab.session_config.auto_reconnect = tab.auto_reconnect;
|
||||
auto_reconnect_changed = true;
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
let enabled = tab.auto_reconnect;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.set_auto_reconnect(enabled).await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
if matches!(tab.status, ConnectionStatus::Connected)
|
||||
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
|
||||
{
|
||||
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
|
||||
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
|
||||
if dtr_changed || rts_changed {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
let dtr = tab.dtr;
|
||||
let rts = tab.rts;
|
||||
tokio::spawn(async move {
|
||||
if dtr_changed {
|
||||
let _ = handle.set_dtr(dtr).await;
|
||||
}
|
||||
if rts_changed {
|
||||
let _ = handle.set_rts(rts).await;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
auto_reconnect_changed
|
||||
}
|
||||
|
||||
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.heading("Send");
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
|
||||
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
|
||||
if tab.send_mode == SendMode::Text {
|
||||
ui.add_space(6.0);
|
||||
egui::ComboBox::from_label("Line ending")
|
||||
.selected_text(tab.line_ending.to_string())
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::None,
|
||||
LineEnding::None.to_string(),
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::LF,
|
||||
LineEnding::LF.to_string(),
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::CR,
|
||||
LineEnding::CR.to_string(),
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::CRLF,
|
||||
LineEnding::CRLF.to_string(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
let log_toggled = ui
|
||||
.checkbox(&mut tab.log_enabled, "Log to file")
|
||||
.changed();
|
||||
if log_toggled {
|
||||
if tab.log_enabled {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if tab.log_enabled {
|
||||
ui.horizontal(|ui| {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
|
||||
let hint = match tab.send_mode {
|
||||
SendMode::Text => "Enter text to send",
|
||||
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||
};
|
||||
let response = ui.add(
|
||||
TextEdit::multiline(&mut tab.send_input)
|
||||
.desired_rows(6)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(hint),
|
||||
);
|
||||
|
||||
let wants_submit = response.has_focus()
|
||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only());
|
||||
|
||||
let mut send_clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
send_clicked = ui.button("Send").clicked();
|
||||
if let Some(status) = &tab.send_status {
|
||||
ui.label(
|
||||
egui::RichText::new(status).color(if status.starts_with("Send failed") {
|
||||
Color32::RED
|
||||
} else {
|
||||
Color32::GRAY
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if !(send_clicked || wants_submit) {
|
||||
return;
|
||||
}
|
||||
|
||||
match build_payload(tab) {
|
||||
Ok(Some(payload)) => {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
match tab.send_mode {
|
||||
SendMode::Text => {
|
||||
let mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||
if !text.is_empty() {
|
||||
match tab.line_ending {
|
||||
LineEnding::None => {}
|
||||
LineEnding::LF => text.push('\n'),
|
||||
LineEnding::CR => text.push('\r'),
|
||||
LineEnding::CRLF => text.push_str("\r\n"),
|
||||
}
|
||||
tab.console.push_outbound(text.clone());
|
||||
if let Some(ref writer) = tab.log_writer {
|
||||
writer.write_line(&logging::format_sent_log(&text));
|
||||
}
|
||||
}
|
||||
}
|
||||
SendMode::Hex => {
|
||||
let hex = tab
|
||||
.send_input
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !hex.is_empty() {
|
||||
tab.hex.push_outbound(hex.clone());
|
||||
if let Some(ref writer) = tab.log_writer {
|
||||
writer.write_line(&logging::format_sent_log(&hex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.send(payload).await;
|
||||
});
|
||||
tab.send_input.clear();
|
||||
tab.send_status = Some(String::from("Sent"));
|
||||
} else {
|
||||
tab.send_status = Some(String::from("Send failed: session not found"));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tab.send_status = Some(String::from("Nothing to send"));
|
||||
}
|
||||
Err(message) => {
|
||||
tab.send_status = Some(format!("Send failed: {message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_path(session_id: u64) -> PathBuf {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let dir = app_state::config_dir().join("logs");
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
dir.join(format!("session_{session_id}_{ts}.log"))
|
||||
}
|
||||
|
||||
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
||||
let trimmed = tab.send_input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match tab.send_mode {
|
||||
SendMode::Text => {
|
||||
let mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||
match tab.line_ending {
|
||||
LineEnding::None => {}
|
||||
LineEnding::LF => text.push('\n'),
|
||||
LineEnding::CR => text.push('\r'),
|
||||
LineEnding::CRLF => text.push_str("\r\n"),
|
||||
}
|
||||
Ok(Some(text.into_bytes()))
|
||||
}
|
||||
SendMode::Hex => {
|
||||
let compact: String = trimmed
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_ascii_whitespace())
|
||||
.collect();
|
||||
hex::decode(compact)
|
||||
.map(Some)
|
||||
.map_err(|err| format!("invalid hex input ({err})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::transport_summary;
|
||||
|
||||
@@ -3,9 +3,9 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pipeview_client::config::SessionConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use pipeview_client::config::SessionConfig;
|
||||
|
||||
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||
|
||||
@@ -123,8 +123,8 @@ const fn default_true() -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn windows_gui_state_path_uses_appdata() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use egui_plot::PlotBounds;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
use pipeview_client::RingBuffer;
|
||||
use pipeview_client::event::DecodedEntry;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum LineDirection {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
mod ansi_render;
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod logging;
|
||||
mod models;
|
||||
mod panels;
|
||||
mod perf;
|
||||
mod shortcuts;
|
||||
|
||||
134
crates/pipeview-gui/src/models.rs
Normal file
134
crates/pipeview-gui/src/models.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||
use crate::logging::LogWriter;
|
||||
use crate::panels::plot_view;
|
||||
use pipeview_client::config::SessionConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl ConnectionStatus {
|
||||
pub fn badge(&self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Self::Connected => ("[connected]", "Connected"),
|
||||
Self::Disconnected => ("[disconnected]", "Disconnected"),
|
||||
Self::Connecting => ("[connecting]", "Connecting"),
|
||||
Self::Error(_) => ("[error]", "Error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum View {
|
||||
Text,
|
||||
Hex,
|
||||
Plot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SendMode {
|
||||
Text,
|
||||
Hex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum LineEnding {
|
||||
None,
|
||||
LF,
|
||||
CR,
|
||||
CRLF,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LineEnding {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::None => write!(f, "None"),
|
||||
Self::LF => write!(f, "LF (\\n)"),
|
||||
Self::CR => write!(f, "CR (\\r)"),
|
||||
Self::CRLF => write!(f, "CRLF (\\r\\n)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DisplayOptions {
|
||||
pub show_timestamp: bool,
|
||||
pub show_direction: bool,
|
||||
pub show_pipeline: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SearchState {
|
||||
pub query: String,
|
||||
pub matches: Vec<usize>,
|
||||
pub current_match: usize,
|
||||
pub case_sensitive: bool,
|
||||
pub active: bool,
|
||||
pub just_opened: bool,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
pub fn clear(&mut self) {
|
||||
self.query.clear();
|
||||
self.matches.clear();
|
||||
self.current_match = 0;
|
||||
self.active = false;
|
||||
self.just_opened = false;
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if !self.matches.is_empty() {
|
||||
self.current_match = (self.current_match + 1) % self.matches.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if !self.matches.is_empty() {
|
||||
self.current_match = if self.current_match == 0 {
|
||||
self.matches.len() - 1
|
||||
} else {
|
||||
self.current_match - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
pub fn current_display(&self) -> usize {
|
||||
if self.matches.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.current_match + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionTab {
|
||||
pub id: u64,
|
||||
pub session_config: SessionConfig,
|
||||
pub status: ConnectionStatus,
|
||||
pub console: TextBuffer,
|
||||
pub hex: HexBuffer,
|
||||
pub plot: PlotBuffer,
|
||||
pub plot_view: plot_view::PlotViewState,
|
||||
pub view: View,
|
||||
pub auto_reconnect: bool,
|
||||
pub dtr: bool,
|
||||
pub rts: bool,
|
||||
pub send_input: String,
|
||||
pub send_mode: SendMode,
|
||||
pub line_ending: LineEnding,
|
||||
pub send_status: Option<String>,
|
||||
pub search: SearchState,
|
||||
pub log_enabled: bool,
|
||||
pub log_path: String,
|
||||
pub show_sent: bool,
|
||||
pub log_writer: Option<LogWriter>,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::ansi_render::{ansi_to_layout_job, ansi_to_layout_job_highlighted};
|
||||
use crate::app::{DisplayOptions, SearchState};
|
||||
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
||||
use egui::{
|
||||
@@ -5,6 +6,11 @@ use egui::{
|
||||
text::{LayoutJob, TextFormat},
|
||||
};
|
||||
|
||||
/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag
|
||||
const EDGE_SCROLL_NUDGE: f32 = 4.0;
|
||||
/// Distance from the bottom edge (pixels) that triggers auto-scroll
|
||||
const EDGE_SCROLL_ZONE: f32 = 30.0;
|
||||
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
buf: &TextBuffer,
|
||||
@@ -13,12 +19,30 @@ pub fn render(
|
||||
) -> usize {
|
||||
let line_count = buf.len();
|
||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||
ScrollArea::both().stick_to_bottom(true).show_rows(
|
||||
ui,
|
||||
row_height,
|
||||
line_count,
|
||||
|ui, row_range| {
|
||||
for row in row_range {
|
||||
let mut near_bottom_edge = false;
|
||||
|
||||
let scroll_area = ScrollArea::both()
|
||||
.id_salt("console_text_area")
|
||||
.stick_to_bottom(true);
|
||||
|
||||
let output = scroll_area.show_viewport(ui, |ui, viewport| {
|
||||
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
|
||||
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
|
||||
|
||||
// Check if user is drag-selecting near the bottom edge of the scroll area.
|
||||
// We compare pointer position against the screen-space bottom of the
|
||||
// allocated scroll area (ui.max_rect) rather than the content viewport,
|
||||
// so that any overflow content area counts.
|
||||
near_bottom_edge = ui.ctx().input(|input| {
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
for row in start_row..end_row {
|
||||
if let Some(line) = buf.get(row) {
|
||||
ui.add(
|
||||
Label::new(format_console_line(line, display, search, ui.style()))
|
||||
@@ -26,45 +50,21 @@ pub fn render(
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
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());
|
||||
// If the user is dragging a selection near the bottom edge, nudge the scroll
|
||||
// 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;
|
||||
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();
|
||||
}
|
||||
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
|
||||
|
||||
line_count
|
||||
}
|
||||
|
||||
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||
@@ -121,9 +121,13 @@ pub fn format_console_line(
|
||||
};
|
||||
|
||||
match search {
|
||||
Some(state) if state.active && !state.query.is_empty() => {
|
||||
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||
}
|
||||
_ => LayoutJob::single_section(full_text, default),
|
||||
Some(state) if state.active && !state.query.is_empty() => ansi_to_layout_job_highlighted(
|
||||
&full_text,
|
||||
default,
|
||||
&state.query,
|
||||
state.case_sensitive,
|
||||
highlight,
|
||||
),
|
||||
_ => ansi_to_layout_job(&full_text, default),
|
||||
}
|
||||
}
|
||||
|
||||
184
crates/pipeview-gui/src/panels/font_settings.rs
Normal file
184
crates/pipeview-gui/src/panels/font_settings.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use crate::app::XserialApp;
|
||||
use crate::ui_fonts::{self, FontCandidate, FontChoice};
|
||||
|
||||
pub fn render_font_settings_window(app: &mut XserialApp, ctx: &egui::Context) {
|
||||
if !app.font_settings_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut open = app.font_settings_open;
|
||||
let mut changed = false;
|
||||
egui::Window::new("UI Settings")
|
||||
.open(&mut open)
|
||||
.default_width(520.0)
|
||||
.resizable(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Fonts");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Primary:");
|
||||
ui.monospace(ui_fonts::font_choice_label(
|
||||
&app.font_settings.primary_choice,
|
||||
&app.font_candidates,
|
||||
));
|
||||
ui.label("Fallback:");
|
||||
ui.monospace(ui_fonts::font_choice_label(
|
||||
&app.font_settings.fallback_choice,
|
||||
&app.font_candidates,
|
||||
));
|
||||
if ui.button("Refresh").clicked() {
|
||||
app.font_candidates = ui_fonts::discover_font_candidates();
|
||||
app.invalidate_font_filters();
|
||||
}
|
||||
});
|
||||
ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph.");
|
||||
ui.add_space(6.0);
|
||||
render_font_selector(
|
||||
ui,
|
||||
"Primary font",
|
||||
"primary_font_choice",
|
||||
&mut app.font_settings.primary_choice,
|
||||
&mut app.primary_font_search,
|
||||
&app.font_candidates,
|
||||
&mut app.primary_filtered_fonts,
|
||||
&mut app.primary_filter_cache_key,
|
||||
true,
|
||||
true,
|
||||
180.0,
|
||||
&mut changed,
|
||||
);
|
||||
ui.separator();
|
||||
render_font_selector(
|
||||
ui,
|
||||
"Fallback font",
|
||||
"fallback_font_choice",
|
||||
&mut app.font_settings.fallback_choice,
|
||||
&mut app.fallback_font_search,
|
||||
&app.font_candidates,
|
||||
&mut app.fallback_filtered_fonts,
|
||||
&mut app.fallback_filter_cache_key,
|
||||
true,
|
||||
true,
|
||||
140.0,
|
||||
&mut changed,
|
||||
);
|
||||
ui.separator();
|
||||
ui.heading("Sizes");
|
||||
ui.label("UI font size");
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::Slider::new(&mut app.font_settings.ui_font_size, 10.0..=28.0)
|
||||
.suffix(" pt"),
|
||||
)
|
||||
.changed();
|
||||
ui.label("Monospace font size");
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::Slider::new(
|
||||
&mut app.font_settings.monospace_font_size,
|
||||
10.0..=28.0,
|
||||
)
|
||||
.suffix(" pt"),
|
||||
)
|
||||
.changed();
|
||||
ui.label("Heading size");
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::Slider::new(&mut app.font_settings.heading_font_size, 14.0..=40.0)
|
||||
.suffix(" pt"),
|
||||
)
|
||||
.changed();
|
||||
ui.separator();
|
||||
ui.heading("Preview");
|
||||
ui.label("The quick brown fox jumps over the lazy dog.");
|
||||
ui.label("中文预览:串口、网络、绘图、十六进制、会话管理。");
|
||||
ui.monospace("Monospace preview: 0123456789 ABCDEF deadbeef");
|
||||
});
|
||||
|
||||
if changed {
|
||||
ui_fonts::apply_font_settings(ctx, &app.font_settings, &app.font_candidates);
|
||||
ui_fonts::save_font_settings(&app.font_settings);
|
||||
}
|
||||
app.font_settings_open = open;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_font_selector(
|
||||
ui: &mut egui::Ui,
|
||||
title: &str,
|
||||
id_prefix: &str,
|
||||
choice: &mut FontChoice,
|
||||
search: &mut String,
|
||||
candidates: &[FontCandidate],
|
||||
filtered: &mut Vec<usize>,
|
||||
cache_key: &mut String,
|
||||
allow_auto: bool,
|
||||
allow_default: bool,
|
||||
max_height: f32,
|
||||
changed: &mut bool,
|
||||
) {
|
||||
ui.label(title);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Search:");
|
||||
ui.text_edit_singleline(search);
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if allow_auto {
|
||||
*changed |= ui
|
||||
.selectable_value(choice, FontChoice::Auto, "Auto")
|
||||
.changed();
|
||||
}
|
||||
if allow_default {
|
||||
*changed |= ui
|
||||
.selectable_value(choice, FontChoice::Default, "Default")
|
||||
.changed();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
refresh_font_filter(search, candidates, filtered, cache_key);
|
||||
ui.small(format!("{} fonts", filtered.len()));
|
||||
let row_height = ui.spacing().interact_size.y;
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(format!("{id_prefix}_scroll"))
|
||||
.max_height(max_height)
|
||||
.auto_shrink([false, false])
|
||||
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
|
||||
for row in row_range {
|
||||
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index))
|
||||
{
|
||||
let response = ui.selectable_value(
|
||||
choice,
|
||||
FontChoice::System(candidate.id.clone()),
|
||||
candidate.display_label.as_str(),
|
||||
);
|
||||
*changed |= response.changed();
|
||||
response.on_hover_text(&candidate.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_font_filter(
|
||||
search: &str,
|
||||
candidates: &[FontCandidate],
|
||||
filtered: &mut Vec<usize>,
|
||||
cache_key: &mut String,
|
||||
) {
|
||||
let needle = search.trim().to_lowercase();
|
||||
if *cache_key == needle {
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.clear();
|
||||
if needle.is_empty() {
|
||||
filtered.extend(0..candidates.len());
|
||||
} else {
|
||||
filtered.extend(
|
||||
candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, candidate)| candidate.search_key.contains(&needle))
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
}
|
||||
*cache_key = needle;
|
||||
}
|
||||
@@ -5,6 +5,11 @@ use egui::{
|
||||
text::{LayoutJob, TextFormat},
|
||||
};
|
||||
|
||||
/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag
|
||||
const EDGE_SCROLL_NUDGE: f32 = 4.0;
|
||||
/// Distance from the bottom edge (pixels) that triggers auto-scroll
|
||||
const EDGE_SCROLL_ZONE: f32 = 30.0;
|
||||
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
buf: &HexBuffer,
|
||||
@@ -13,16 +18,32 @@ pub fn render(
|
||||
) -> usize {
|
||||
let line_count = buf.len();
|
||||
if line_count == 0 {
|
||||
ui.label("no hex data");
|
||||
ui.add(Label::new("no hex data").selectable(false));
|
||||
return 0;
|
||||
}
|
||||
|
||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||
ScrollArea::both().stick_to_bottom(true).show_rows(
|
||||
ui,
|
||||
row_height,
|
||||
line_count,
|
||||
|ui, row_range| {
|
||||
for row in row_range {
|
||||
let mut near_bottom_edge = false;
|
||||
|
||||
let scroll_area = ScrollArea::both()
|
||||
.id_salt("hex_text_area")
|
||||
.stick_to_bottom(true);
|
||||
|
||||
let output = scroll_area.show_viewport(ui, |ui, viewport| {
|
||||
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
|
||||
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
|
||||
|
||||
// Check if user is drag-selecting near the bottom edge of the scroll area
|
||||
near_bottom_edge = ui.ctx().input(|input| {
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
for row in start_row..end_row {
|
||||
if let Some(line) = buf.get(row) {
|
||||
ui.add(
|
||||
Label::new(format_hex_line(line, display, search, ui.style()))
|
||||
@@ -30,8 +51,20 @@ pub fn render(
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// If the user is dragging a selection near the bottom edge, nudge the scroll
|
||||
// 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;
|
||||
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();
|
||||
}
|
||||
|
||||
line_count
|
||||
}
|
||||
|
||||
@@ -125,10 +158,13 @@ fn format_hex_line(
|
||||
};
|
||||
|
||||
match search {
|
||||
Some(state) if state.active && !state.query.is_empty() => {
|
||||
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||
}
|
||||
Some(state) if state.active && !state.query.is_empty() => highlight_matches(
|
||||
&full_text,
|
||||
&state.query,
|
||||
state.case_sensitive,
|
||||
default,
|
||||
highlight,
|
||||
),
|
||||
_ => LayoutJob::single_section(full_text, default),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod console;
|
||||
pub mod font_settings;
|
||||
pub mod hex_view;
|
||||
pub mod plot_view;
|
||||
pub mod send_panel;
|
||||
pub mod session_controls;
|
||||
pub mod sidebar;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
||||
use crate::perf::PlotRenderStats;
|
||||
use egui::{Button, Ui, vec2};
|
||||
use egui::{Button, Label, Ui, vec2};
|
||||
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -46,7 +46,7 @@ pub fn render(
|
||||
if ui.button(toggle_label).clicked() {
|
||||
toggle_detached = true;
|
||||
}
|
||||
ui.label("no plot data");
|
||||
ui.add(Label::new("no plot data").selectable(false));
|
||||
});
|
||||
return PlotRenderOutput {
|
||||
stats: PlotRenderStats {
|
||||
|
||||
207
crates/pipeview-gui/src/panels/send_panel.rs
Normal file
207
crates/pipeview-gui/src/panels/send_panel.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_state;
|
||||
use crate::logging::{self, LogWriter};
|
||||
use crate::models::{LineEnding, SendMode, SessionTab};
|
||||
use egui::{Color32, Label, TextEdit};
|
||||
use pipeview_client::SessionManager;
|
||||
|
||||
pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.add(Label::new(egui::RichText::new("Send").heading()).selectable(false));
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
|
||||
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
|
||||
if tab.send_mode == SendMode::Text {
|
||||
ui.add_space(6.0);
|
||||
egui::ComboBox::from_label("Line ending")
|
||||
.selected_text(tab.line_ending.to_string())
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::None,
|
||||
LineEnding::None.to_string(),
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::LF,
|
||||
LineEnding::LF.to_string(),
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::CR,
|
||||
LineEnding::CR.to_string(),
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut tab.line_ending,
|
||||
LineEnding::CRLF,
|
||||
LineEnding::CRLF.to_string(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
let log_toggled = ui
|
||||
.horizontal(|ui| {
|
||||
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_writer = LogWriter::open(&tab.log_path).ok();
|
||||
} else {
|
||||
tab.log_writer = None;
|
||||
}
|
||||
}
|
||||
if tab.log_enabled {
|
||||
ui.horizontal(|ui| {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
|
||||
let hint = match tab.send_mode {
|
||||
SendMode::Text => "Enter to send, Ctrl+Enter for newline",
|
||||
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||
};
|
||||
let response = ui.add(
|
||||
TextEdit::multiline(&mut tab.send_input)
|
||||
.desired_rows(6)
|
||||
.desired_width(f32::INFINITY)
|
||||
.return_key(egui::KeyboardShortcut::new(
|
||||
egui::Modifiers::COMMAND,
|
||||
egui::Key::Enter,
|
||||
))
|
||||
.hint_text(hint),
|
||||
);
|
||||
|
||||
let wants_submit = response.has_focus()
|
||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.is_none());
|
||||
|
||||
let mut send_clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
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") {
|
||||
Color32::RED
|
||||
} else {
|
||||
Color32::GRAY
|
||||
}),
|
||||
)
|
||||
.selectable(false),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if !(send_clicked || wants_submit) {
|
||||
return;
|
||||
}
|
||||
|
||||
match build_payload(tab) {
|
||||
Ok(Some(payload)) => {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
match tab.send_mode {
|
||||
SendMode::Text => {
|
||||
let mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||
if !text.is_empty() {
|
||||
match tab.line_ending {
|
||||
LineEnding::None => {}
|
||||
LineEnding::LF => text.push('\n'),
|
||||
LineEnding::CR => text.push('\r'),
|
||||
LineEnding::CRLF => text.push_str("\r\n"),
|
||||
}
|
||||
if tab.show_sent {
|
||||
tab.console.push_outbound(text.clone());
|
||||
}
|
||||
if let Some(ref writer) = tab.log_writer {
|
||||
writer.write_line(&logging::format_sent_log(&text));
|
||||
}
|
||||
}
|
||||
}
|
||||
SendMode::Hex => {
|
||||
let hex = tab
|
||||
.send_input
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !hex.is_empty() {
|
||||
if tab.show_sent {
|
||||
tab.hex.push_outbound(hex.clone());
|
||||
}
|
||||
if let Some(ref writer) = tab.log_writer {
|
||||
writer.write_line(&logging::format_sent_log(&hex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.send(payload).await;
|
||||
});
|
||||
tab.send_input.clear();
|
||||
tab.send_status = Some(String::from("Sent"));
|
||||
} else {
|
||||
tab.send_status = Some(String::from("Send failed: session not found"));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tab.send_status = Some(String::from("Nothing to send"));
|
||||
}
|
||||
Err(message) => {
|
||||
tab.send_status = Some(format!("Send failed: {message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_log_path(session_id: u64) -> PathBuf {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let dir = app_state::config_dir().join("logs");
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
dir.join(format!("session_{session_id}_{ts}.log"))
|
||||
}
|
||||
|
||||
pub fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
||||
let trimmed = tab.send_input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match tab.send_mode {
|
||||
SendMode::Text => {
|
||||
let mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||
match tab.line_ending {
|
||||
LineEnding::None => {}
|
||||
LineEnding::LF => text.push('\n'),
|
||||
LineEnding::CR => text.push('\r'),
|
||||
LineEnding::CRLF => text.push_str("\r\n"),
|
||||
}
|
||||
Ok(Some(text.into_bytes()))
|
||||
}
|
||||
SendMode::Hex => {
|
||||
let compact: String = trimmed
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_ascii_whitespace())
|
||||
.collect();
|
||||
hex::decode(compact)
|
||||
.map(Some)
|
||||
.map_err(|err| format!("invalid hex input ({err})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
150
crates/pipeview-gui/src/panels/session_controls.rs
Normal file
150
crates/pipeview-gui/src/panels/session_controls.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use crate::models::{ConnectionStatus, SessionTab, View};
|
||||
use egui::{Label, TextEdit};
|
||||
use pipeview_client::SessionManager;
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
|
||||
pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
||||
if !tab.search.active {
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let response = ui.add(
|
||||
TextEdit::singleline(&mut tab.search.query)
|
||||
.hint_text("Search...")
|
||||
.desired_width(200.0),
|
||||
);
|
||||
if tab.search.just_opened {
|
||||
response.request_focus();
|
||||
tab.search.just_opened = false;
|
||||
}
|
||||
if response.changed() {
|
||||
let matches = match tab.view {
|
||||
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(),
|
||||
};
|
||||
tab.search.matches = matches;
|
||||
tab.search.current_match = 0;
|
||||
}
|
||||
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
tab.search.next();
|
||||
}
|
||||
|
||||
ui.add(
|
||||
Label::new(format!(
|
||||
"{}/{}",
|
||||
tab.search.current_display(),
|
||||
tab.search.match_count()
|
||||
))
|
||||
.selectable(false),
|
||||
);
|
||||
|
||||
if ui.button("▲").clicked() {
|
||||
tab.search.prev();
|
||||
}
|
||||
if ui.button("▼").clicked() {
|
||||
tab.search.next();
|
||||
}
|
||||
|
||||
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::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Plot => Vec::new(),
|
||||
};
|
||||
tab.search.matches = matches;
|
||||
tab.search.current_match = 0;
|
||||
}
|
||||
|
||||
if ui.button("✕").clicked() {
|
||||
tab.search.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn render_session_controls(
|
||||
ui: &mut egui::Ui,
|
||||
manager: &SessionManager,
|
||||
tab: &mut SessionTab,
|
||||
) -> bool {
|
||||
let mut auto_reconnect_changed = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let connected = matches!(
|
||||
tab.status,
|
||||
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||
);
|
||||
let connect_label = if connected { "Disconnect" } else { "Connect" };
|
||||
if ui.button(connect_label).clicked() {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
if connected {
|
||||
tab.status = ConnectionStatus::Disconnected;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.disconnect().await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Connecting;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.connect().await;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
|
||||
// if ui.button("Reconnect").clicked() {
|
||||
// if let Some(handle) = manager.get(tab.id) {
|
||||
// tab.status = ConnectionStatus::Connecting;
|
||||
// tokio::spawn(async move {
|
||||
// let _ = handle.reconnect().await;
|
||||
// });
|
||||
// } else {
|
||||
// tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
// }
|
||||
// }
|
||||
|
||||
if ui.button("Clear").clicked() {
|
||||
tab.console.clear();
|
||||
tab.hex.clear();
|
||||
tab.plot.clear();
|
||||
tab.send_status = Some(String::from("Cleared"));
|
||||
}
|
||||
|
||||
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
|
||||
if response.changed() {
|
||||
tab.session_config.auto_reconnect = tab.auto_reconnect;
|
||||
auto_reconnect_changed = true;
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
let enabled = tab.auto_reconnect;
|
||||
tokio::spawn(async move {
|
||||
let _ = handle.set_auto_reconnect(enabled).await;
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
if matches!(tab.status, ConnectionStatus::Connected)
|
||||
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
|
||||
{
|
||||
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
|
||||
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
|
||||
if dtr_changed || rts_changed {
|
||||
if let Some(handle) = manager.get(tab.id) {
|
||||
let dtr = tab.dtr;
|
||||
let rts = tab.rts;
|
||||
tokio::spawn(async move {
|
||||
if dtr_changed {
|
||||
let _ = handle.set_dtr(dtr).await;
|
||||
}
|
||||
if rts_changed {
|
||||
let _ = handle.set_rts(rts).await;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
auto_reconnect_changed
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::app::ConnectionStatus;
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use egui::{Color32, Label, RichText, Ui};
|
||||
|
||||
pub struct SessionListItem {
|
||||
pub id: u64,
|
||||
@@ -15,7 +15,7 @@ pub fn render(
|
||||
on_edit: &mut Option<usize>,
|
||||
on_delete: &mut Option<usize>,
|
||||
) {
|
||||
ui.heading("Sessions");
|
||||
ui.add(Label::new(egui::RichText::new("Sessions").heading()).selectable(false));
|
||||
|
||||
if ui
|
||||
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
||||
|
||||
@@ -56,10 +56,7 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||
),
|
||||
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
||||
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
||||
(
|
||||
SearchPrev,
|
||||
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
|
||||
),
|
||||
(SearchPrev, KeyboardShortcut::new(Modifiers::SHIFT, Key::F3)),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -22,3 +22,4 @@ image = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ansitok = "0.3"
|
||||
|
||||
295
crates/pipeview-tui/src/ansi.rs
Normal file
295
crates/pipeview-tui/src/ansi.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
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,18 +3,20 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pipeview_client::SessionConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use pipeview_client::SessionConfig;
|
||||
|
||||
use crate::app::{DisplayOptions, View, default_session_config};
|
||||
|
||||
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedTuiState {
|
||||
#[serde(default = "default_session")]
|
||||
pub session: SessionConfig,
|
||||
#[serde(default)]
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
#[serde(default)]
|
||||
pub active: usize,
|
||||
pub active_view: PersistedView,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_timestamp: bool,
|
||||
#[serde(default = "default_true")]
|
||||
@@ -23,9 +25,54 @@ pub struct PersistedTuiState {
|
||||
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(state) => state,
|
||||
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");
|
||||
@@ -34,16 +81,35 @@ pub fn load_tui_state() -> PersistedTuiState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_tui_state(state: &PersistedTuiState) {
|
||||
if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) {
|
||||
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" => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 {
|
||||
@@ -50,6 +52,10 @@ impl TextBuffer {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
||||
self.lines.get(index)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
@@ -61,10 +67,6 @@ impl TextBuffer {
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
|
||||
pub fn recent(&self, count: usize) -> Vec<ConsoleLine> {
|
||||
self.lines.drain_recent(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -111,6 +113,10 @@ impl HexBuffer {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
||||
self.lines.get(index)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
@@ -122,9 +128,494 @@ impl HexBuffer {
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recent(&self, count: usize) -> Vec<HexLine> {
|
||||
self.lines.drain_recent(count)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod ansi;
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
@@ -6,33 +7,59 @@ 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 pipeview_client::SessionManager;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
let _log_guard = init_tracing();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let rx = manager.subscribe();
|
||||
|
||||
let result = app::run(&mut terminal, app::App::new(manager, rx));
|
||||
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)?;
|
||||
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)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
399
tools/stress_test.py
Executable file
399
tools/stress_test.py
Executable file
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pipeview-tauri 极限压力测试脚本
|
||||
|
||||
模拟各种高数据量场景,测试 Tauri 前端的渲染性能和稳定性:
|
||||
- 文本洪水:大文本行 + ANSI 颜色
|
||||
- 十六进制洪水:大批量 hex dump
|
||||
- 波形洪水:高频 plot 采样点
|
||||
- 混合模式:同时发送文本 + 波形(MixedTextPlot)
|
||||
- 突发模式:间歇性峰值流量
|
||||
|
||||
用法:
|
||||
python tools/stress_test.py --mode text --rate 5000 --port 8091
|
||||
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
|
||||
python tools/stress_test.py --mode mixed --rate 1000 --port 8093
|
||||
python tools/stress_test.py --mode burst --rate 10000 --burst-size 500 --port 8094
|
||||
"""
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# ── ANSI Color Palette ─────────────────────────────────────────────
|
||||
|
||||
ANSI_COLORS = [
|
||||
"\033[31m", "\033[32m", "\033[33m", "\033[34m", "\033[35m", "\033[36m",
|
||||
"\033[91m", "\033[92m", "\033[93m", "\033[94m", "\033[95m", "\033[96m",
|
||||
"\033[1;31m", "\033[1;32m", "\033[1;33m", "\033[1;34m",
|
||||
]
|
||||
ANSI_RESET = "\033[0m"
|
||||
|
||||
# ── Lorem ipsum lines for text stress testing ──────────────────────
|
||||
|
||||
LOREM_LINES = [
|
||||
"[INFO] Sensor data received: temperature={temp:.2f}°C humidity={hum:.1f}% pressure={pres:.1f}hPa",
|
||||
"[DEBUG] Frame #{n:06d} decoded pipeline={pipe} latency={lat:.3f}ms",
|
||||
"[WARN] Buffer utilization {pct:.1f}% — threshold approaching",
|
||||
"[ERROR] Checksum mismatch at offset {off:#x} expected={exp:#x} got={got:#x}",
|
||||
"[TRACE] I2C transaction: addr={addr:#x} reg={reg:#x} val={val:#x}",
|
||||
"[METRIC] throughput={tput:.1f} lines/s memory={mem:.1f}MB active_sessions={sess}",
|
||||
"[EVENT] Session {sid} state changed: {old} -> {new}",
|
||||
"[DATA] {ts} | CH{ch:02d} | {v0:+08.4f} | {v1:+08.4f} | {v2:+08.4f}",
|
||||
]
|
||||
|
||||
# ── Text stress generator ───────────────────────────────────────────
|
||||
|
||||
def generate_text_line(seq: int, ansi: bool = False, payload_size: int = 80) -> bytes:
|
||||
"""Generate a single text line with optional ANSI colors."""
|
||||
template = random.choice(LOREM_LINES)
|
||||
line = template.format(
|
||||
temp=20.0 + 10 * math.sin(seq * 0.1),
|
||||
hum=45.0 + 20 * math.cos(seq * 0.07),
|
||||
pres=1013.0 + random.uniform(-5, 5),
|
||||
n=seq, pipe=f"pipe_{seq % 4}", lat=random.uniform(0.1, 5.0),
|
||||
pct=random.uniform(10, 95), off=seq * 16,
|
||||
exp=random.randint(0, 255), got=random.randint(0, 255),
|
||||
addr=0x40 + (seq % 8), reg=0x00 + (seq % 16), val=random.randint(0, 65535),
|
||||
tput=random.uniform(100, 10000), mem=random.uniform(50, 500), sess=random.randint(1, 8),
|
||||
sid=seq % 8 + 1, old="disconnected", new="connected",
|
||||
ts=time.strftime("%H:%M:%S", time.localtime()),
|
||||
ch=seq % 8, v0=random.uniform(-10, 10), v1=random.uniform(-10, 10), v2=random.uniform(-10, 10),
|
||||
)
|
||||
|
||||
# Pad or trim to target payload size
|
||||
if len(line) < payload_size:
|
||||
line += " " + "x" * (payload_size - len(line) - 1)
|
||||
elif len(line) > payload_size:
|
||||
line = line[:payload_size]
|
||||
|
||||
if ansi:
|
||||
color = random.choice(ANSI_COLORS)
|
||||
line = f"{color}{line}{ANSI_RESET}"
|
||||
|
||||
return (line + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def generate_hex_line(seq: int, bytes_per_line: int = 32) -> bytes:
|
||||
"""Generate a hex dump line (as text, mimicking hex view data)."""
|
||||
data = bytes([(seq * bytes_per_line + i) % 256 for i in range(bytes_per_line)])
|
||||
hex_part = " ".join(f"{b:02X}" for b in data)
|
||||
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in data)
|
||||
return f"[{seq:06d}] {hex_part} |{ascii_part}|\n".encode("utf-8")
|
||||
|
||||
|
||||
# ── Plot stress generator ───────────────────────────────────────────
|
||||
|
||||
def build_plot_frame(
|
||||
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 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)
|
||||
|
||||
|
||||
def build_mixed_frame(seq: int, text_rate_per_plot: int, channels: int) -> bytes:
|
||||
"""Build a MixedTextPlot frame: text lines + one COBS 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)
|
||||
return b"".join(frames)
|
||||
|
||||
|
||||
# ── TCP Server ──────────────────────────────────────────────────────
|
||||
|
||||
class StressServer:
|
||||
def __init__(self, host: str, port: int, mode: str, rate: float,
|
||||
payload_size: int, channels: int, burst_size: int,
|
||||
duration: float, ansi: bool):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.mode = mode
|
||||
self.rate = rate # lines or frames per second
|
||||
self.payload_size = payload_size
|
||||
self.channels = channels
|
||||
self.burst_size = burst_size
|
||||
self.duration = duration
|
||||
self.ansi = ansi
|
||||
self.stats = defaultdict(int)
|
||||
self.running = False
|
||||
self.clients = []
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((self.host, self.port))
|
||||
sock.listen(5)
|
||||
sock.settimeout(1.0)
|
||||
|
||||
print(f"[STRESS] {self.mode.upper()} mode | {self.rate} lines/s")
|
||||
print(f"[STRESS] Listening on {self.host}:{self.port}")
|
||||
if self.duration > 0:
|
||||
print(f"[STRESS] Duration: {self.duration}s")
|
||||
print(f"[STRESS] Payload: {self.payload_size}B | Channels: {self.channels}")
|
||||
if self.mode == "burst":
|
||||
print(f"[STRESS] Burst size: {self.burst_size} lines")
|
||||
print()
|
||||
|
||||
stats_thread = threading.Thread(target=self._print_stats, daemon=True)
|
||||
stats_thread.start()
|
||||
|
||||
accept_thread = threading.Thread(target=self._accept_loop, args=(sock,), daemon=True)
|
||||
accept_thread.start()
|
||||
|
||||
try:
|
||||
while self.running:
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n[STRESS] Shutting down...")
|
||||
finally:
|
||||
self.running = False
|
||||
sock.close()
|
||||
|
||||
def _accept_loop(self, sock):
|
||||
while self.running:
|
||||
try:
|
||||
conn, addr = sock.accept()
|
||||
print(f"[STRESS] Client connected: {addr[0]}:{addr[1]}")
|
||||
t = threading.Thread(target=self._client_loop, args=(conn, addr), daemon=True)
|
||||
t.start()
|
||||
self.clients.append(t)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
|
||||
def _client_loop(self, conn: socket.socket, addr):
|
||||
seq = 0
|
||||
start_time = time.time()
|
||||
last_count_time = start_time
|
||||
local_count = 0
|
||||
|
||||
# Pre-compute interval
|
||||
if self.mode == "burst":
|
||||
interval = 0
|
||||
burst_interval = max(0.016, self.burst_size / max(self.rate, 1))
|
||||
else:
|
||||
interval = 1.0 / max(self.rate, 1) if self.rate > 0 else 0.016
|
||||
|
||||
try:
|
||||
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
while self.running:
|
||||
elapsed = time.time() - start_time
|
||||
if self.duration > 0 and elapsed >= self.duration:
|
||||
break
|
||||
|
||||
if self.mode == "burst":
|
||||
self._send_burst(conn, seq)
|
||||
seq += self.burst_size
|
||||
local_count += self.burst_size
|
||||
time.sleep(burst_interval)
|
||||
else:
|
||||
data = self._generate(seq)
|
||||
conn.sendall(data)
|
||||
seq += 1
|
||||
local_count += 1
|
||||
|
||||
if interval > 0:
|
||||
# Spin-wait for precise timing at high rates
|
||||
target = start_time + (seq / self.rate)
|
||||
sleep_time = target - time.time()
|
||||
if sleep_time > 0:
|
||||
time.sleep(min(sleep_time, interval))
|
||||
|
||||
# Periodic stats update
|
||||
if time.time() - last_count_time >= 1.0:
|
||||
with threading.Lock():
|
||||
self.stats["lines"] += local_count
|
||||
self.stats["bytes"] += local_count * self.payload_size # approximate
|
||||
local_count = 0
|
||||
last_count_time = time.time()
|
||||
|
||||
except OSError as e:
|
||||
if hasattr(e, "winerror") and e.winerror in DISCONNECT_WINERRORS:
|
||||
pass
|
||||
elif not self.running:
|
||||
pass
|
||||
else:
|
||||
self.stats["errors"] += 1
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
print(f"[STRESS] Client disconnected: {addr[0]}:{addr[1]}")
|
||||
|
||||
def _send_burst(self, conn, start_seq):
|
||||
"""Send burst_size lines as fast as possible."""
|
||||
data = b"".join(self._generate(start_seq + i) for i in range(self.burst_size))
|
||||
conn.sendall(data)
|
||||
|
||||
def _generate(self, seq: int) -> bytes:
|
||||
if self.mode == "text":
|
||||
return generate_text_line(seq, ansi=self.ansi, payload_size=self.payload_size)
|
||||
elif self.mode == "hex":
|
||||
return generate_hex_line(seq, bytes_per_line=max(4, self.payload_size // 3))
|
||||
elif self.mode == "plot":
|
||||
return build_plot_frame(
|
||||
seq, self.channels, "interleaved",
|
||||
samples_per_channel=32, amplitude=100.0,
|
||||
frequency_hz=10.0, sample_rate_hz=1000.0,
|
||||
)
|
||||
elif self.mode == "mixed":
|
||||
return build_mixed_frame(seq, text_rate_per_plot=5, channels=self.channels)
|
||||
else:
|
||||
return generate_text_line(seq, ansi=True, payload_size=self.payload_size)
|
||||
|
||||
def _print_stats(self):
|
||||
while self.running:
|
||||
time.sleep(2.0)
|
||||
with threading.Lock():
|
||||
lines = self.stats["lines"]
|
||||
errors = self.stats["errors"]
|
||||
b = self.stats["bytes"]
|
||||
if lines > 0:
|
||||
kb_s = b / 2048 # KB/s over 2 seconds
|
||||
print(f"[STATS] {lines:>8d} lines | {kb_s:>8.1f} KB/s | {errors:>4d} errors")
|
||||
self.stats.clear()
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="pipeview-tauri 极限压力测试",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 文本洪水:5000 行/秒,1KB 行宽,ANSI 彩色
|
||||
python tools/stress_test.py --mode text --rate 5000 --payload 1024 --ansi
|
||||
|
||||
# 波形洪水:200 帧/秒,8 通道
|
||||
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
|
||||
|
||||
# 突发模式:每秒爆发一次 2000 行峰值
|
||||
python tools/stress_test.py --mode burst --rate 10000 --burst-size 2000
|
||||
|
||||
# 混合模式:文本 + 波形(MixedTextPlot 帧)
|
||||
python tools/stress_test.py --mode mixed --rate 1000 --channels 4
|
||||
|
||||
# 压测 60 秒后自动停止
|
||||
python tools/stress_test.py --mode text --rate 10000 --duration 60
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1", help="绑定地址 (default: 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=8091, help="端口 (default: 8091)")
|
||||
parser.add_argument(
|
||||
"--mode", choices=["text", "hex", "plot", "mixed", "burst"],
|
||||
default="text", help="测试模式 (default: text)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rate", type=float, default=1000,
|
||||
help="目标速率 (lines/s 或 frames/s, default: 1000)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--payload", type=int, default=256,
|
||||
help="文本行/hex 行目标字节数 (default: 256)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--channels", type=int, default=4,
|
||||
help="Plot 通道数 (default: 4)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--burst-size", type=int, default=500,
|
||||
help="突发模式每次发送的行数 (default: 500)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration", type=float, default=0,
|
||||
help="运行时长(秒),0 表示无限制 (default: 0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ansi", action="store_true",
|
||||
help="文本模式启用 ANSI 颜色"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == "plot" and args.rate > 500:
|
||||
print("[WARN] Plot rate > 500 may overwhelm the renderer. Consider --rate 100-200.")
|
||||
print()
|
||||
|
||||
server = StressServer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
mode=args.mode,
|
||||
rate=args.rate,
|
||||
payload_size=args.payload,
|
||||
channels=args.channels,
|
||||
burst_size=args.burst_size,
|
||||
duration=args.duration,
|
||||
ansi=args.ansi,
|
||||
)
|
||||
server.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
317
tools/test_ansi.py
Executable file
317
tools/test_ansi.py
Executable file
@@ -0,0 +1,317 @@
|
||||
#!/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