Compare commits
6 Commits
5035e73d7e
...
f9be74257a
| Author | SHA1 | Date | |
|---|---|---|---|
| f9be74257a | |||
| 84563ecfc8 | |||
| 1da034c9b1 | |||
| 37f7713938 | |||
| 5923ad0143 | |||
| cd3f778502 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,3 +6,10 @@ tools/test_text_c
|
|||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
/AGENTS.md
|
||||||
|
/.agents
|
||||||
|
/.codex
|
||||||
|
/.omo
|
||||||
|
/.opencode
|
||||||
|
/.trellis
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
|
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||||
use crate::logging::{self, LogWriter};
|
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::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
||||||
use crate::shortcuts::{self, default_bindings};
|
use crate::shortcuts::{self, default_bindings};
|
||||||
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
use crate::ui_fonts::{self, FontCandidate, UiFontSettings};
|
||||||
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
use egui::{Color32, Label, Layout, Panel, Pos2, Rect, UiBuilder};
|
||||||
use pipeview_client::SessionManager;
|
use pipeview_client::SessionManager;
|
||||||
use pipeview_client::config::SessionConfig;
|
use pipeview_client::config::SessionConfig;
|
||||||
use pipeview_client::session::SessionEvent;
|
use pipeview_client::session::SessionEvent;
|
||||||
@@ -18,160 +17,29 @@ use pipeview_core::transport::TransportConfig;
|
|||||||
|
|
||||||
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
|
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
|
||||||
|
|
||||||
#[derive(Clone)]
|
pub use crate::models::*;
|
||||||
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 struct XserialApp {
|
pub struct XserialApp {
|
||||||
manager: SessionManager,
|
pub(crate) manager: SessionManager,
|
||||||
tabs: Vec<SessionTab>,
|
pub(crate) tabs: Vec<SessionTab>,
|
||||||
active: usize,
|
pub(crate) active: usize,
|
||||||
display: DisplayOptions,
|
pub(crate) display: DisplayOptions,
|
||||||
font_settings_open: bool,
|
pub(crate) font_settings_open: bool,
|
||||||
font_candidates: Vec<FontCandidate>,
|
pub(crate) font_candidates: Vec<FontCandidate>,
|
||||||
font_settings: UiFontSettings,
|
pub(crate) font_settings: UiFontSettings,
|
||||||
primary_font_search: String,
|
pub(crate) primary_font_search: String,
|
||||||
fallback_font_search: String,
|
pub(crate) fallback_font_search: String,
|
||||||
primary_filtered_fonts: Vec<usize>,
|
pub(crate) primary_filtered_fonts: Vec<usize>,
|
||||||
fallback_filtered_fonts: Vec<usize>,
|
pub(crate) fallback_filtered_fonts: Vec<usize>,
|
||||||
primary_filter_cache_key: String,
|
pub(crate) primary_filter_cache_key: String,
|
||||||
fallback_filter_cache_key: String,
|
pub(crate) fallback_filter_cache_key: String,
|
||||||
config_open: bool,
|
pub(crate) config_open: bool,
|
||||||
config_target: Option<u64>,
|
pub(crate) config_target: Option<u64>,
|
||||||
config_form: config::ConfigForm,
|
pub(crate) config_form: config::ConfigForm,
|
||||||
event_rx: mpsc::Receiver<SessionEvent>,
|
pub(crate) event_rx: mpsc::Receiver<SessionEvent>,
|
||||||
pending: Vec<SessionEvent>,
|
pub(crate) pending: Vec<SessionEvent>,
|
||||||
profiler: GuiProfiler,
|
pub(crate) profiler: GuiProfiler,
|
||||||
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
pub(crate) shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XserialApp {
|
impl XserialApp {
|
||||||
@@ -412,6 +280,7 @@ impl XserialApp {
|
|||||||
search: SearchState::default(),
|
search: SearchState::default(),
|
||||||
log_enabled: false,
|
log_enabled: false,
|
||||||
log_path: String::new(),
|
log_path: String::new(),
|
||||||
|
show_sent: true,
|
||||||
log_writer: None,
|
log_writer: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -668,7 +537,7 @@ impl XserialApp {
|
|||||||
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
|
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
|
||||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||||
if self.tabs.is_empty() {
|
if self.tabs.is_empty() {
|
||||||
ui.heading("No sessions.");
|
ui.add(Label::new(egui::RichText::new("No sessions.").heading()).selectable(false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,10 +557,10 @@ impl XserialApp {
|
|||||||
let (badge, status_text) = tab.status.badge();
|
let (badge, status_text) = tab.status.badge();
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.heading(format!("Session {}", tab.id));
|
ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false));
|
||||||
ui.label(badge);
|
ui.add(Label::new(badge).selectable(false));
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(status_text);
|
ui.add(Label::new(status_text).selectable(false));
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(tab.view == View::Text, "Text")
|
.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;
|
persist_state |= auto_reconnect_changed;
|
||||||
|
|
||||||
let mut display_changed = false;
|
let mut display_changed = false;
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label("Show:");
|
ui.add(Label::new("Show:").selectable(false));
|
||||||
display_changed |= ui
|
display_changed |= ui
|
||||||
.checkbox(&mut display.show_timestamp, "Timestamp")
|
.checkbox(&mut display.show_timestamp, "Timestamp")
|
||||||
.changed();
|
.changed();
|
||||||
@@ -726,10 +595,10 @@ impl XserialApp {
|
|||||||
});
|
});
|
||||||
persist_state |= display_changed;
|
persist_state |= display_changed;
|
||||||
|
|
||||||
render_search_bar(ui, tab);
|
session_controls::render_search_bar(ui, tab);
|
||||||
|
|
||||||
if let ConnectionStatus::Error(message) = &tab.status {
|
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();
|
let full = ui.available_rect_before_wrap();
|
||||||
@@ -760,11 +629,22 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
View::Plot => {
|
View::Plot => {
|
||||||
if tab.plot_view.detached {
|
if tab.plot_view.detached {
|
||||||
ui.heading(format!(
|
ui.add(
|
||||||
"Plot window detached for Session {}",
|
Label::new(
|
||||||
tab.id
|
egui::RichText::new(format!(
|
||||||
));
|
"Plot window detached for Session {}",
|
||||||
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() {
|
if ui.button("Dock Plot Back").clicked() {
|
||||||
tab.plot_view.detached = false;
|
tab.plot_view.detached = false;
|
||||||
}
|
}
|
||||||
@@ -789,7 +669,7 @@ impl XserialApp {
|
|||||||
UiBuilder::new()
|
UiBuilder::new()
|
||||||
.max_rect(send_rect)
|
.max_rect(send_rect)
|
||||||
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
.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) {
|
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
|
||||||
Panel::top("top_bar").show_inside(ui, |ui| {
|
Panel::top("top_bar").show_inside(ui, |ui| {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.heading("pipeview");
|
ui.add(Label::new(egui::RichText::new("pipeview").heading()).selectable(false));
|
||||||
ui.separator();
|
ui.separator();
|
||||||
// ui.label(format!(
|
// ui.label(format!(
|
||||||
// "Fonts: {} + {} {:.1} pt",
|
// "Fonts: {} + {} {:.1} pt",
|
||||||
@@ -873,106 +753,10 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
|
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
|
||||||
if !self.font_settings_open {
|
font_settings::render_font_settings_window(self, ctx);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.primary_filtered_fonts.clear();
|
||||||
self.fallback_filtered_fonts.clear();
|
self.fallback_filtered_fonts.clear();
|
||||||
self.primary_filter_cache_key = String::from("\0");
|
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 {
|
impl eframe::App for XserialApp {
|
||||||
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||||
|
|
||||||
@@ -1105,336 +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 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.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::transport_summary;
|
use super::transport_summary;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod app;
|
|||||||
mod app_state;
|
mod app_state;
|
||||||
mod buffers;
|
mod buffers;
|
||||||
mod logging;
|
mod logging;
|
||||||
|
mod models;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod perf;
|
mod perf;
|
||||||
mod shortcuts;
|
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>,
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ use egui::{
|
|||||||
text::{LayoutJob, TextFormat},
|
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(
|
pub fn render(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
buf: &TextBuffer,
|
buf: &TextBuffer,
|
||||||
@@ -14,21 +19,51 @@ pub fn render(
|
|||||||
) -> usize {
|
) -> usize {
|
||||||
let line_count = buf.len();
|
let line_count = buf.len();
|
||||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||||
ScrollArea::both().stick_to_bottom(true).show_rows(
|
let mut near_bottom_edge = false;
|
||||||
ui,
|
|
||||||
row_height,
|
let scroll_area = ScrollArea::both()
|
||||||
line_count,
|
.id_salt("console_text_area")
|
||||||
|ui, row_range| {
|
.stick_to_bottom(true);
|
||||||
for row in row_range {
|
|
||||||
if let Some(line) = buf.get(row) {
|
let output = scroll_area.show_viewport(ui, |ui, viewport| {
|
||||||
ui.add(
|
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
|
||||||
Label::new(format_console_line(line, display, search, ui.style()))
|
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
|
||||||
.wrap_mode(TextWrapMode::Extend),
|
|
||||||
);
|
// 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()))
|
||||||
|
.wrap_mode(TextWrapMode::Extend),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
line_count
|
line_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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},
|
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(
|
pub fn render(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
buf: &HexBuffer,
|
buf: &HexBuffer,
|
||||||
@@ -13,25 +18,53 @@ pub fn render(
|
|||||||
) -> usize {
|
) -> usize {
|
||||||
let line_count = buf.len();
|
let line_count = buf.len();
|
||||||
if line_count == 0 {
|
if line_count == 0 {
|
||||||
ui.label("no hex data");
|
ui.add(Label::new("no hex data").selectable(false));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||||
ScrollArea::both().stick_to_bottom(true).show_rows(
|
let mut near_bottom_edge = false;
|
||||||
ui,
|
|
||||||
row_height,
|
let scroll_area = ScrollArea::both()
|
||||||
line_count,
|
.id_salt("hex_text_area")
|
||||||
|ui, row_range| {
|
.stick_to_bottom(true);
|
||||||
for row in row_range {
|
|
||||||
if let Some(line) = buf.get(row) {
|
let output = scroll_area.show_viewport(ui, |ui, viewport| {
|
||||||
ui.add(
|
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
|
||||||
Label::new(format_hex_line(line, display, search, ui.style()))
|
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
|
||||||
.wrap_mode(TextWrapMode::Extend),
|
|
||||||
);
|
// 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()))
|
||||||
|
.wrap_mode(TextWrapMode::Extend),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
|
// 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
|
line_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod console;
|
pub mod console;
|
||||||
|
pub mod font_settings;
|
||||||
pub mod hex_view;
|
pub mod hex_view;
|
||||||
pub mod plot_view;
|
pub mod plot_view;
|
||||||
|
pub mod send_panel;
|
||||||
|
pub mod session_controls;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
||||||
use crate::perf::PlotRenderStats;
|
use crate::perf::PlotRenderStats;
|
||||||
use egui::{Button, Ui, vec2};
|
use egui::{Button, Label, Ui, vec2};
|
||||||
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -46,7 +46,7 @@ pub fn render(
|
|||||||
if ui.button(toggle_label).clicked() {
|
if ui.button(toggle_label).clicked() {
|
||||||
toggle_detached = true;
|
toggle_detached = true;
|
||||||
}
|
}
|
||||||
ui.label("no plot data");
|
ui.add(Label::new("no plot data").selectable(false));
|
||||||
});
|
});
|
||||||
return PlotRenderOutput {
|
return PlotRenderOutput {
|
||||||
stats: PlotRenderStats {
|
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 crate::app::ConnectionStatus;
|
||||||
use egui::{Color32, RichText, Ui};
|
use egui::{Color32, Label, RichText, Ui};
|
||||||
|
|
||||||
pub struct SessionListItem {
|
pub struct SessionListItem {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -15,7 +15,7 @@ pub fn render(
|
|||||||
on_edit: &mut Option<usize>,
|
on_edit: &mut Option<usize>,
|
||||||
on_delete: &mut Option<usize>,
|
on_delete: &mut Option<usize>,
|
||||||
) {
|
) {
|
||||||
ui.heading("Sessions");
|
ui.add(Label::new(egui::RichText::new("Sessions").heading()).selectable(false));
|
||||||
|
|
||||||
if ui
|
if ui
|
||||||
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
.button(RichText::new("+ New Session").color(Color32::GREEN))
|
||||||
|
|||||||
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()
|
||||||
Reference in New Issue
Block a user