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::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||||
use crate::panels::{config, console, hex_view, plot_view, sidebar};
|
use crate::panels::{config, console, hex_view, plot_view, sidebar};
|
||||||
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
||||||
|
use crate::shortcuts::{self, default_bindings};
|
||||||
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
||||||
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
||||||
use xserial_client::SessionManager;
|
use xserial_client::SessionManager;
|
||||||
@@ -92,6 +93,7 @@ pub struct XserialApp {
|
|||||||
event_rx: mpsc::Receiver<SessionEvent>,
|
event_rx: mpsc::Receiver<SessionEvent>,
|
||||||
pending: Vec<SessionEvent>,
|
pending: Vec<SessionEvent>,
|
||||||
profiler: GuiProfiler,
|
profiler: GuiProfiler,
|
||||||
|
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XserialApp {
|
impl XserialApp {
|
||||||
@@ -162,11 +164,105 @@ impl XserialApp {
|
|||||||
event_rx,
|
event_rx,
|
||||||
pending: Vec::new(),
|
pending: Vec::new(),
|
||||||
profiler,
|
profiler,
|
||||||
|
shortcut_bindings: default_bindings(),
|
||||||
};
|
};
|
||||||
app.restore_saved_sessions(saved_state);
|
app.restore_saved_sessions(saved_state);
|
||||||
app
|
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) {
|
fn open_create_config(&mut self) {
|
||||||
self.config_target = None;
|
self.config_target = None;
|
||||||
self.config_form = config::ConfigForm::default();
|
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 update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||||
|
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _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();
|
let frame_started = Instant::now();
|
||||||
self.drain_events();
|
self.drain_events();
|
||||||
if self.wants_live_plot_repaint() {
|
if self.wants_live_plot_repaint() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod app_state;
|
|||||||
mod buffers;
|
mod buffers;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod perf;
|
mod perf;
|
||||||
|
mod shortcuts;
|
||||||
mod ui_fonts;
|
mod ui_fonts;
|
||||||
|
|
||||||
use xserial_client::SessionManager;
|
use xserial_client::SessionManager;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
||||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||||
|
use xserial_core::protocol::Endian;
|
||||||
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
||||||
use xserial_core::protocol::text::TextEncoding;
|
use xserial_core::protocol::text::TextEncoding;
|
||||||
use xserial_core::protocol::Endian;
|
use xserial_core::transport::TransportConfig;
|
||||||
use xserial_core::transport::serial::{
|
use xserial_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
||||||
};
|
};
|
||||||
use xserial_core::transport::TransportConfig;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ConfigForm {
|
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