From 9cdbd5a89209a8e7a1cd03dc25bffb7667c05d4e Mon Sep 17 00:00:00 2001 From: FallenSigh Date: Thu, 11 Jun 2026 22:41:21 +0800 Subject: [PATCH] Add keyboard shortcut system to xserial-gui Introduce a centralized shortcut system with ShortcutAction enum and bindings table in shortcuts.rs. Global shortcuts are processed at the top of XserialApp::ui() before any widget rendering. Text input focus is detected via wants_keyboard_input() to suppress shortcuts when typing. Also fix pre-existing import ordering in panels/config.rs from cargo fmt. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/xserial-gui/src/app.rs | 109 +++++++++++++++++++- crates/xserial-gui/src/main.rs | 1 + crates/xserial-gui/src/panels/config.rs | 4 +- crates/xserial-gui/src/shortcuts.rs | 129 ++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 crates/xserial-gui/src/shortcuts.rs diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index c462876..020f5d7 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant}; use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer}; use crate::panels::{config, console, hex_view, plot_view, 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 xserial_client::SessionManager; @@ -92,6 +93,7 @@ pub struct XserialApp { event_rx: mpsc::Receiver, pending: Vec, profiler: GuiProfiler, + shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>, } impl XserialApp { @@ -162,11 +164,105 @@ impl XserialApp { event_rx, pending: Vec::new(), profiler, + shortcut_bindings: default_bindings(), }; app.restore_saved_sessions(saved_state); app } + fn execute(&mut self, action: shortcuts::Action) { + use shortcuts::Action::*; + + match action { + NextTab => { + if self.tabs.len() > 1 { + self.active = (self.active + 1) % self.tabs.len(); + self.persist_gui_state(); + } + } + PrevTab => { + if self.tabs.len() > 1 { + self.active = if self.active == 0 { + self.tabs.len() - 1 + } else { + self.active - 1 + }; + self.persist_gui_state(); + } + } + ViewText => { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.view = View::Text; + } + } + ViewHex => { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.view = View::Hex; + } + } + ViewPlot => { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.view = View::Plot; + } + } + NewSession => self.open_create_config(), + EditSession => { + if let Some(tab) = self.tabs.get(self.active) { + self.open_edit_config(tab.id); + } + } + DeleteSession => { + if !self.tabs.is_empty() { + let id = self.tabs[self.active].id; + self.manager.remove(id); + self.tabs.remove(self.active); + if self.active >= self.tabs.len() && !self.tabs.is_empty() { + self.active = self.tabs.len() - 1; + } + self.persist_gui_state(); + } + } + ToggleConnect => { + if let Some(tab) = self.tabs.get_mut(self.active) { + let connected = matches!( + tab.status, + ConnectionStatus::Connected | ConnectionStatus::Connecting + ); + if let Some(handle) = self.manager.get(tab.id) { + if connected { + tab.status = ConnectionStatus::Disconnected; + let handle = handle.clone(); + tokio::spawn(async move { + let _ = handle.disconnect().await; + }); + } else { + tab.status = ConnectionStatus::Connecting; + let handle = handle.clone(); + tokio::spawn(async move { + let _ = handle.connect().await; + }); + } + } else { + tab.status = ConnectionStatus::Error(String::from("session not found")); + } + } + } + Clear => { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.console.clear(); + tab.hex.clear(); + tab.plot.clear(); + tab.send_status = Some(String::from("Cleared")); + } + } + UiSettings => self.font_settings_open = true, + CloseOverlay => { + self.config_open = false; + self.font_settings_open = false; + } + } + } + fn open_create_config(&mut self) { self.config_target = None; self.config_form = config::ConfigForm::default(); @@ -851,6 +947,15 @@ impl eframe::App for XserialApp { fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {} fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + { + let supress = ui.ctx().egui_wants_keyboard_input(); + let actions = shortcuts::process(ui.ctx(), supress, &self.shortcut_bindings); + + for action in actions { + self.execute(action); + } + } + let frame_started = Instant::now(); self.drain_events(); if self.wants_live_plot_repaint() { @@ -1092,8 +1197,8 @@ mod tests { parity: SerialParity::None, stop_bits: SerialStopBits::One, flow_control: SerialFlowControl::None, - dtr: false, - rts: false, + dtr: false, + rts: false, }), "Serial COM7" ); diff --git a/crates/xserial-gui/src/main.rs b/crates/xserial-gui/src/main.rs index 4fefd8d..08ace89 100644 --- a/crates/xserial-gui/src/main.rs +++ b/crates/xserial-gui/src/main.rs @@ -3,6 +3,7 @@ mod app_state; mod buffers; mod panels; mod perf; +mod shortcuts; mod ui_fonts; use xserial_client::SessionManager; diff --git a/crates/xserial-gui/src/panels/config.rs b/crates/xserial-gui/src/panels/config.rs index 0187830..ec06419 100644 --- a/crates/xserial-gui/src/panels/config.rs +++ b/crates/xserial-gui/src/panels/config.rs @@ -1,12 +1,12 @@ use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui}; use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig}; +use xserial_core::protocol::Endian; use xserial_core::protocol::plot::{PlotFormat, SampleType}; use xserial_core::protocol::text::TextEncoding; -use xserial_core::protocol::Endian; +use xserial_core::transport::TransportConfig; use xserial_core::transport::serial::{ SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport, }; -use xserial_core::transport::TransportConfig; #[derive(Clone)] pub struct ConfigForm { diff --git a/crates/xserial-gui/src/shortcuts.rs b/crates/xserial-gui/src/shortcuts.rs new file mode 100644 index 0000000..aaa1b20 --- /dev/null +++ b/crates/xserial-gui/src/shortcuts.rs @@ -0,0 +1,129 @@ +use egui::{Context, Key, KeyboardShortcut, Modifiers}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + NextTab, + PrevTab, + + ViewText, + ViewHex, + ViewPlot, + + NewSession, + EditSession, + DeleteSession, + ToggleConnect, + Clear, + + UiSettings, + CloseOverlay, +} + +pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> { + use Action::*; + + vec![ + (NextTab, KeyboardShortcut::new(Modifiers::CTRL, Key::Tab)), + ( + PrevTab, + KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab), + ), + (ViewText, KeyboardShortcut::new(Modifiers::CTRL, Key::T)), + (ViewHex, KeyboardShortcut::new(Modifiers::CTRL, Key::H)), + (ViewPlot, KeyboardShortcut::new(Modifiers::CTRL, Key::P)), + (NewSession, KeyboardShortcut::new(Modifiers::CTRL, Key::N)), + (EditSession, KeyboardShortcut::new(Modifiers::CTRL, Key::E)), + ( + DeleteSession, + KeyboardShortcut::new(Modifiers::CTRL, Key::W), + ), + ( + ToggleConnect, + KeyboardShortcut::new(Modifiers::CTRL, Key::F5), + ), + (Clear, KeyboardShortcut::new(Modifiers::CTRL, Key::L)), + ( + UiSettings, + KeyboardShortcut::new(Modifiers::CTRL, Key::Comma), + ), + ( + CloseOverlay, + KeyboardShortcut::new(Modifiers::NONE, Key::Escape), + ), + ] +} + +pub fn process( + ctx: &Context, + suppress_char_shortcuts: bool, + bindings: &[(Action, KeyboardShortcut)], +) -> Vec { + let mut actions = Vec::new(); + + ctx.input_mut(|input| { + for (action, shortcut) in bindings { + if !input.consume_shortcut(shortcut) { + continue; + } + + if suppress_char_shortcuts && is_char_based(*action) { + continue; + } + + actions.push(*action); + } + }); + + actions +} + +fn is_char_based(action: Action) -> bool { + use Action::*; + !matches!(action, CloseOverlay) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindings_contain_no_duplicate_shortcuts() { + let bindings = default_bindings(); + for (i, (_, a)) in bindings.iter().enumerate() { + for (j, (_, b)) in bindings.iter().enumerate() { + if i != j { + assert_ne!(a, b, "Duplicate shortcut at indices {i} and {j}"); + } + } + } + } + + #[test] + fn bindings_non_empty() { + assert!(!default_bindings().is_empty()); + } + + #[test] + fn close_overlay_not_char_based() { + assert!(!is_char_based(Action::CloseOverlay)); + } + + #[test] + fn all_other_actions_are_char_based() { + for action in [ + Action::NextTab, + Action::PrevTab, + Action::ViewText, + Action::ViewHex, + Action::ViewPlot, + Action::NewSession, + Action::EditSession, + Action::DeleteSession, + Action::ToggleConnect, + Action::Clear, + Action::UiSettings, + ] { + assert!(is_char_based(action), "{action:?} should be char-based"); + } + } +}