refactor(gui): split app.rs into models and panel modules
Extract types (models.rs), font settings, session controls, and send panel into separate modules. Reduces app.rs from 1488 to 848 lines. Zero behavior change. - models.rs: ConnectionStatus, View, SendMode, LineEnding, DisplayOptions, SearchState, SessionTab - panels/font_settings.rs: font selector and settings window - panels/session_controls.rs: session connect/disconnect UI - panels/send_panel.rs: send input and payload building
This commit is contained in:
@@ -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, 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 {
|
||||
@@ -710,7 +578,7 @@ 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;
|
||||
@@ -726,7 +594,7 @@ 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));
|
||||
@@ -789,7 +657,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -873,106 +741,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 +766,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,336 +795,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)]
|
||||
mod tests {
|
||||
use super::transport_summary;
|
||||
|
||||
@@ -5,6 +5,7 @@ mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod logging;
|
||||
mod models;
|
||||
mod panels;
|
||||
mod perf;
|
||||
mod shortcuts;
|
||||
|
||||
133
crates/pipeview-gui/src/models.rs
Normal file
133
crates/pipeview-gui/src/models.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
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 log_writer: Option<LogWriter>,
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
194
crates/pipeview-gui/src/panels/send_panel.rs
Normal file
194
crates/pipeview-gui/src/panels/send_panel.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_state;
|
||||
use crate::logging::{self, LogWriter};
|
||||
use crate::models::{LineEnding, SendMode, SessionTab};
|
||||
use egui::{Color32, 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.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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
147
crates/pipeview-gui/src/panels/session_controls.rs
Normal file
147
crates/pipeview-gui/src/panels/session_controls.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use crate::models::{ConnectionStatus, SessionTab, View};
|
||||
use egui::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.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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user