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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-11 22:41:21 +08:00
parent 8207b5e883
commit 9cdbd5a892
4 changed files with 239 additions and 4 deletions

View File

@@ -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<SessionEvent>,
pending: Vec<SessionEvent>,
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"
);

View File

@@ -3,6 +3,7 @@ mod app_state;
mod buffers;
mod panels;
mod perf;
mod shortcuts;
mod ui_fonts;
use xserial_client::SessionManager;

View File

@@ -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 {

View File

@@ -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<Action> {
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");
}
}
}