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:
@@ -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"
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ mod app_state;
|
||||
mod buffers;
|
||||
mod panels;
|
||||
mod perf;
|
||||
mod shortcuts;
|
||||
mod ui_fonts;
|
||||
|
||||
use xserial_client::SessionManager;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
129
crates/xserial-gui/src/shortcuts.rs
Normal file
129
crates/xserial-gui/src/shortcuts.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user