diff --git a/Cargo.lock b/Cargo.lock
index cf375ab..ac17f42 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1586,6 +1586,7 @@ dependencies = [
"open",
"palette",
"paste",
+ "regex",
"ron 0.11.0",
"rust-embed",
"secret-service",
diff --git a/Cargo.toml b/Cargo.toml
index c6c3f33..842dd09 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ log = "0.4"
open = "5.3.2"
palette = { version = "0.7", features = ["serde"] }
paste = "1.0"
+regex = "1"
ron = "0.11"
serde = { version = "1", features = ["serde_derive"] }
shlex = "1"
diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl
index 88ed838..00d892c 100644
--- a/i18n/en/cosmic_term.ftl
+++ b/i18n/en/cosmic_term.ftl
@@ -62,6 +62,35 @@ advanced = Advanced
show-headerbar = Show header
show-header-description = Reveal the header from the right-click menu.
+### Keyboard shortcuts
+add-another-keybinding = Add another keybinding
+cancel = Cancel
+close-window = Close window
+copy-or-sigint = Copy or SIGINT
+disable = Disable
+focus-pane-down = Focus pane down
+focus-pane-left = Focus pane left
+focus-pane-right = Focus pane right
+focus-pane-up = Focus pane up
+keyboard-shortcuts = Keyboard shortcuts
+menu-keyboard-shortcuts = Keyboard shortcuts...
+no-shortcuts = No shortcuts
+password-manager = Password manager
+paste-primary = Paste primary
+replace = Replace
+reset-to-default = Reset to default
+shortcut-capture-hint = Press the key combination
+shortcut-group-clipboard = Clipboard
+shortcut-group-other = Other
+shortcut-group-tabs = Tabs
+shortcut-group-window = Window
+shortcut-group-zoom = Zoom
+shortcut-replace-body = { $binding } is already assigned to { $existing }. Replace it with { $new_action }?
+shortcut-replace-title = Replace shortcut?
+tab-activate = Activate tab { $number }
+toggle-fullscreen = Toggle fullscreen
+type-to-search = Type to search...
+
# Find
find-placeholder = Find...
find-previous = Find previous
diff --git a/res/icons/edit-undo-symbolic.svg b/res/icons/edit-undo-symbolic.svg
new file mode 100644
index 0000000..7400969
--- /dev/null
+++ b/res/icons/edit-undo-symbolic.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/config.rs b/src/config.rs
index 9963994..39e6568 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::OnceLock;
-use crate::{fl, localize::LANGUAGE_SORTER};
+use crate::{fl, localize::LANGUAGE_SORTER, shortcuts::Shortcuts};
pub const CONFIG_VERSION: u64 = 1;
pub const COSMIC_THEME_DARK: &str = "COSMIC Dark";
@@ -236,6 +236,8 @@ pub struct Config {
pub syntax_theme_light: String,
pub focus_follow_mouse: bool,
pub default_profile: Option,
+ #[serde(default)]
+ pub shortcuts_custom: Shortcuts,
}
impl Default for Config {
@@ -259,6 +261,7 @@ impl Default for Config {
syntax_theme_light: COSMIC_THEME_LIGHT.to_string(),
use_bright_bold: false,
default_profile: None,
+ shortcuts_custom: Shortcuts::default(),
}
}
}
diff --git a/src/icon_cache.rs b/src/icon_cache.rs
index 59c9e0b..21a7dc9 100644
--- a/src/icon_cache.rs
+++ b/src/icon_cache.rs
@@ -33,6 +33,7 @@ impl IconCache {
bundle!("dialog-error-symbolic", 16);
bundle!("edit-clear-symbolic", 16);
bundle!("edit-delete-symbolic", 16);
+ bundle!("edit-undo-symbolic", 16);
bundle!("list-add-symbolic", 16);
bundle!("go-down-symbolic", 16);
bundle!("go-up-symbolic", 16);
diff --git a/src/key_bind.rs b/src/key_bind.rs
index 900f7c9..86f3d39 100644
--- a/src/key_bind.rs
+++ b/src/key_bind.rs
@@ -1,87 +1,9 @@
-use cosmic::widget::menu::key_bind::{KeyBind, Modifier};
-use cosmic::{iced::keyboard::Key, iced_core::keyboard::key::Named};
+use cosmic::widget::menu::key_bind::KeyBind;
use std::collections::HashMap;
use crate::Action;
+use crate::shortcuts::ShortcutsConfig;
-//TODO: load from config
-pub fn key_binds() -> HashMap {
- let mut key_binds = HashMap::new();
-
- macro_rules! bind {
- ([$($modifier:ident),* $(,)?], $key:expr, $action:ident) => {{
- key_binds.insert(
- KeyBind {
- modifiers: vec![$(Modifier::$modifier),*],
- key: $key,
- },
- Action::$action,
- );
- }};
- }
-
- // Standard key bindings
- bind!([Ctrl, Shift], Key::Character("A".into()), SelectAll);
- bind!([Ctrl, Shift], Key::Character("C".into()), Copy);
- bind!([], Key::Named(Named::Copy), Copy);
- bind!([Ctrl], Key::Character("c".into()), CopyOrSigint);
- bind!([Ctrl, Shift], Key::Character("F".into()), Find);
- bind!([Ctrl, Shift], Key::Character("N".into()), WindowNew);
- bind!([Ctrl, Shift], Key::Character("Q".into()), WindowClose);
- bind!([Ctrl, Shift], Key::Character("T".into()), TabNew);
- bind!([Ctrl, Shift], Key::Character("V".into()), Paste);
- bind!([], Key::Named(Named::Paste), Paste);
- bind!([Shift], Key::Named(Named::Insert), PastePrimary);
- bind!([Ctrl, Shift], Key::Character("W".into()), TabClose);
- bind!([Ctrl], Key::Character(",".into()), Settings);
- bind!([], Key::Named(Named::F11), ToggleFullscreen);
-
- // Ctrl+Alt+D splits horizontally, Ctrl+Alt+R splits vertically, Ctrl+Shift+X maximizes split
- //TODO: Adjust bindings as desired by UX
- bind!([Ctrl, Alt], Key::Character("d".into()), PaneSplitHorizontal);
- bind!([Ctrl, Alt], Key::Character("r".into()), PaneSplitVertical);
- bind!(
- [Ctrl, Shift],
- Key::Character("X".into()),
- PaneToggleMaximized
- );
- #[cfg(feature = "password_manager")]
- bind!([Ctrl, Alt], Key::Character("p".into()), PasswordManager);
-
- // Ctrl+Tab and Ctrl+Shift+Tab cycle through tabs
- // Ctrl+Tab is not a special key for terminals and is free to use
- bind!([Ctrl], Key::Named(Named::Tab), TabNext);
- bind!([Ctrl, Shift], Key::Named(Named::Tab), TabPrev);
-
- // Ctrl+Shift+# activates tabs by index
- bind!([Ctrl, Shift], Key::Character("1".into()), TabActivate0);
- bind!([Ctrl, Shift], Key::Character("2".into()), TabActivate1);
- bind!([Ctrl, Shift], Key::Character("3".into()), TabActivate2);
- bind!([Ctrl, Shift], Key::Character("4".into()), TabActivate3);
- bind!([Ctrl, Shift], Key::Character("5".into()), TabActivate4);
- bind!([Ctrl, Shift], Key::Character("6".into()), TabActivate5);
- bind!([Ctrl, Shift], Key::Character("7".into()), TabActivate6);
- bind!([Ctrl, Shift], Key::Character("8".into()), TabActivate7);
- bind!([Ctrl, Shift], Key::Character("9".into()), TabActivate8);
-
- // Ctrl+0, Ctrl+-, and Ctrl+= are not special keys for terminals and are free to use
- bind!([Ctrl], Key::Character("0".into()), ZoomReset);
- bind!([Ctrl], Key::Character("-".into()), ZoomOut);
- bind!([Ctrl], Key::Character("=".into()), ZoomIn);
- bind!([Ctrl], Key::Character("+".into()), ZoomIn);
-
- // Ctrl+Arrows and Ctrl+HJKL move between splits
- bind!([Ctrl, Shift], Key::Named(Named::ArrowLeft), PaneFocusLeft);
- bind!([Ctrl, Shift], Key::Character("H".into()), PaneFocusLeft);
- bind!([Ctrl, Shift], Key::Named(Named::ArrowDown), PaneFocusDown);
- bind!([Ctrl, Shift], Key::Character("J".into()), PaneFocusDown);
- bind!([Ctrl, Shift], Key::Named(Named::ArrowUp), PaneFocusUp);
- bind!([Ctrl, Shift], Key::Character("K".into()), PaneFocusUp);
- bind!([Ctrl, Shift], Key::Named(Named::ArrowRight), PaneFocusRight);
- bind!([Ctrl, Shift], Key::Character("L".into()), PaneFocusRight);
-
- // CTRL+Alt+L clears the scrollback.
- bind!([Ctrl, Alt], Key::Character("L".into()), ClearScrollback);
-
- key_binds
+pub fn key_binds(shortcuts: &ShortcutsConfig) -> HashMap {
+ shortcuts.key_binds()
}
diff --git a/src/main.rs b/src/main.rs
index 422fe57..22dcb69 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@
use alacritty_terminal::tty::Options;
use alacritty_terminal::{event::Event as TermEvent, term, term::color::Colors as TermColors, tty};
use cosmic::iced::clipboard::dnd::DndAction;
+use cosmic::iced_core::keyboard::key::Named;
use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::{
@@ -29,6 +30,7 @@ use cosmic_text::{Family, Stretch, Weight, fontdb::FaceInfo};
use localize::LANGUAGE_SORTER;
use std::{
any::TypeId,
+ cell::Cell,
cmp,
collections::{BTreeMap, BTreeSet, HashMap},
env,
@@ -52,6 +54,8 @@ mod icon_cache;
use key_bind::key_binds;
mod key_bind;
+mod shortcuts;
+
mod localize;
use menu::menu_bar;
@@ -159,6 +163,8 @@ fn main() -> Result<(), Box> {
}
};
+ let shortcuts_config = shortcuts::ShortcutsConfig::new(config.shortcuts_custom.clone());
+
let startup_options = if let Some(shell_program) = shell_program_opt {
let options = tty::Options {
shell: Some(tty::Shell::new(shell_program, shell_args)),
@@ -187,6 +193,7 @@ fn main() -> Result<(), Box> {
let flags = Flags {
config_handler,
config,
+ shortcuts_config,
startup_options,
term_config,
};
@@ -213,6 +220,7 @@ Options:
pub struct Flags {
config_handler: Option,
config: Config,
+ shortcuts_config: shortcuts::ShortcutsConfig,
startup_options: Option,
term_config: term::Config,
}
@@ -226,6 +234,7 @@ pub enum Action {
CopyOrSigint,
CopyPrimary,
Find,
+ KeyboardShortcuts,
LaunchUrlByMenu,
PaneFocusDown,
PaneFocusLeft,
@@ -277,6 +286,7 @@ impl Action {
Self::CopyOrSigint => Message::CopyOrSigint(entity_opt),
Self::CopyPrimary => Message::CopyPrimary(entity_opt),
Self::Find => Message::Find(true),
+ Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts),
Self::LaunchUrlByMenu => Message::LaunchUrlByMenu,
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
@@ -364,6 +374,13 @@ pub enum Message {
LaunchUrl(String),
LaunchUrlByMenu,
Modifiers(Modifiers),
+ ShortcutCaptureCancel,
+ ShortcutCaptureStart(shortcuts::KeyBindAction),
+ ShortcutConflictCancel,
+ ShortcutConflictReplace,
+ ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource),
+ ShortcutReset(shortcuts::KeyBindAction),
+ ShortcutSearch(String),
MouseEnter(pane_grid::Pane),
Opacity(u8),
PaneClicked(pane_grid::Pane),
@@ -424,12 +441,20 @@ pub enum Message {
pub enum ContextPage {
About,
ColorSchemes(ColorSchemeKind),
+ KeyboardShortcuts,
Profiles,
Settings,
#[cfg(feature = "password_manager")]
PasswordManager,
}
+#[derive(Clone, Debug)]
+struct ShortcutConflict {
+ binding: shortcuts::Binding,
+ existing_action: shortcuts::KeyBindAction,
+ new_action: shortcuts::KeyBindAction,
+}
+
/// The [`App`] stores application-specific state.
pub struct App {
core: Core,
@@ -437,6 +462,7 @@ pub struct App {
pane_model: TerminalPaneGrid,
config_handler: Option,
config: Config,
+ shortcuts_config: shortcuts::ShortcutsConfig,
key_binds: HashMap,
app_themes: Vec,
font_names: Vec,
@@ -471,6 +497,13 @@ pub struct App {
color_scheme_tab_model: widget::segmented_button::SingleSelectModel,
profile_expanded: Option,
show_advanced_font_settings: bool,
+ shortcut_capture: Option,
+ shortcut_conflict: Option,
+ shortcut_conflict_overlay_restore: Option,
+ shortcut_search_focus: Cell,
+ shortcut_search_id: widget::Id,
+ shortcut_search_regex: Option,
+ shortcut_search_value: String,
modifiers: Modifiers,
#[cfg(feature = "password_manager")]
password_mgr: password_manager::PasswordManager,
@@ -542,6 +575,63 @@ impl App {
}
}
+ fn save_shortcuts_custom(&mut self) {
+ self.config.shortcuts_custom = self.shortcuts_config.custom.clone();
+ match &self.config_handler {
+ Some(config_handler) => {
+ if let Err(err) =
+ config_handler.set("shortcuts_custom", &self.config.shortcuts_custom)
+ {
+ log::warn!("failed to save shortcuts custom config: {}", err);
+ }
+ }
+ None => {
+ log::warn!("failed to save shortcuts custom config: no config handler");
+ }
+ }
+ self.key_binds = key_binds(&self.shortcuts_config);
+ }
+
+ fn apply_shortcut_binding(
+ &mut self,
+ binding: shortcuts::Binding,
+ action: shortcuts::KeyBindAction,
+ ) {
+ self.shortcuts_config.custom.0.insert(binding, action);
+ self.save_shortcuts_custom();
+ }
+
+ fn set_context_overlay(&mut self, overlay: bool) {
+ if self.core.window.context_is_overlay != overlay {
+ self.core.window.context_is_overlay = overlay;
+ self.core.set_show_context(self.core.window.show_context);
+ }
+ }
+
+ fn begin_shortcut_conflict(&mut self, conflict: ShortcutConflict) {
+ if self.shortcut_conflict.is_none() {
+ self.shortcut_conflict_overlay_restore = Some(self.core.window.context_is_overlay);
+ self.set_context_overlay(false);
+ }
+ self.shortcut_conflict = Some(conflict);
+ }
+
+ fn clear_shortcut_conflict(&mut self) {
+ self.shortcut_conflict = None;
+ if let Some(overlay) = self.shortcut_conflict_overlay_restore.take() {
+ self.set_context_overlay(overlay);
+ }
+ }
+
+ fn shortcut_page_toggle(&mut self) {
+ self.shortcut_capture = None;
+ self.clear_shortcut_conflict();
+ self.shortcut_search_focus
+ .set(self.core.window.show_context);
+ self.shortcut_search_regex = None;
+ self.shortcut_search_value.clear();
+ }
+
fn update_config(&mut self) -> Task {
let theme = self.config.app_theme.theme();
@@ -637,7 +727,16 @@ impl App {
if self.find {
widget::text_input::focus(self.find_search_id.clone())
} else if self.core.window.show_context {
- // TODO focus the context page?
+ match self.context_page {
+ ContextPage::KeyboardShortcuts => {
+ if self.shortcut_search_focus.get() {
+ self.shortcut_search_focus.set(false);
+ return widget::text_input::focus(self.shortcut_search_id.clone());
+ }
+ }
+ // TODO focus for other context pages?
+ _ => {}
+ }
Task::none()
} else if let Some(terminal_id) = self.terminal_ids.get(&self.pane_model.focused()).cloned()
{
@@ -869,6 +968,126 @@ impl App {
widget::settings::view_column(sections).into()
}
+ fn keyboard_shortcuts(&self) -> Element<'_, Message> {
+ let cosmic_theme::Spacing {
+ space_xxs,
+ space_s,
+ space_m,
+ space_l,
+ space_xl,
+ ..
+ } = self.core().system_theme().cosmic().spacing;
+
+ let pad_action = [space_xxs, space_m];
+ let div_action = space_s;
+ let pad_binding = [space_xxs, space_xl];
+ let div_binding = space_l;
+
+ let mut groups = Vec::new();
+ //TODO: fix text input focus going outside bounds
+ groups.push(widget::horizontal_space().into());
+ groups.push(
+ widget::text_input::search_input(fl!("type-to-search"), &self.shortcut_search_value)
+ .id(self.shortcut_search_id.clone())
+ .on_input(Message::ShortcutSearch)
+ .into(),
+ );
+
+ for group in shortcuts::shortcut_groups() {
+ let mut list = widget::list::list_column();
+
+ let mut found_actions = false;
+ for action in group.actions {
+ let action_label = shortcuts::action_label(action);
+ if let Some(regex) = &self.shortcut_search_regex {
+ if regex.find(&action_label).is_none() {
+ continue;
+ }
+ }
+ found_actions = true;
+
+ let (bindings, changed) = self.shortcuts_config.bindings_for_action(action);
+
+ let mut buttons = widget::row::with_capacity(2);
+ if changed {
+ buttons = buttons.push(widget::tooltip(
+ widget::button::custom(icon_cache_get("edit-undo-symbolic", 16))
+ .class(style::Button::Icon)
+ .on_press(Message::ShortcutReset(action)),
+ widget::text::body(fl!("reset-to-default")),
+ widget::tooltip::Position::Top,
+ ));
+ }
+ buttons = buttons.push(widget::tooltip(
+ widget::button::custom(icon_cache_get("list-add-symbolic", 16))
+ .class(style::Button::Icon)
+ .on_press(Message::ShortcutCaptureStart(action)),
+ widget::text::body(fl!("add-another-keybinding")),
+ widget::tooltip::Position::Top,
+ ));
+
+ list = list.list_item_padding(pad_action);
+ list = list.divider_padding(div_action);
+ list = list.add(widget::settings::item_row(vec![
+ widget::text::heading(action_label)
+ .width(Length::Fill)
+ .into(),
+ buttons.into(),
+ ]));
+
+ if bindings.is_empty() {
+ list = list.list_item_padding(pad_binding);
+ list = list.add(widget::text::body(fl!("no-shortcuts")));
+ list = list.divider_padding(div_binding);
+ } else {
+ for resolved in bindings {
+ list = list.list_item_padding(pad_binding);
+ list = list.add(
+ widget::settings::item::builder(shortcuts::binding_display(
+ &resolved.binding,
+ ))
+ .control(
+ widget::button::custom(icon_cache_get("edit-delete-symbolic", 16))
+ .class(style::Button::Icon)
+ .on_press(Message::ShortcutRemove(
+ resolved.binding.clone(),
+ resolved.source,
+ )),
+ ),
+ );
+ list = list.divider_padding(div_binding);
+ }
+ }
+
+ if self.shortcut_capture == Some(action) {
+ list = list.list_item_padding(pad_binding);
+ list = list.add(
+ widget::settings::item_row(vec![
+ widget::text::body(fl!("shortcut-capture-hint"))
+ .width(Length::Fill)
+ .into(),
+ widget::button::text(fl!("cancel"))
+ .on_press(Message::ShortcutCaptureCancel)
+ .into(),
+ ])
+ .spacing(space_xxs),
+ );
+ list = list.divider_padding(div_binding);
+ }
+ }
+
+ if found_actions {
+ groups.push(
+ widget::settings::section::with_column(list)
+ .title(group.title)
+ .into(),
+ );
+ }
+ }
+
+ widget::settings::view_column(groups).into()
+ }
+
fn profiles(&self) -> Element<'_, Message> {
let cosmic_theme::Spacing {
space_s,
@@ -1550,13 +1769,15 @@ impl Application for App {
),
]);
+ let key_binds = key_binds(&flags.shortcuts_config);
let mut app = Self {
core,
about,
pane_model,
config_handler: flags.config_handler,
config: flags.config,
- key_binds: key_binds(),
+ shortcuts_config: flags.shortcuts_config,
+ key_binds,
app_themes,
font_names,
font_size_names,
@@ -1589,6 +1810,13 @@ impl Application for App {
color_scheme_tab_model: widget::segmented_button::Model::default(),
profile_expanded: None,
show_advanced_font_settings: false,
+ shortcut_capture: None,
+ shortcut_conflict: None,
+ shortcut_conflict_overlay_restore: None,
+ shortcut_search_focus: Cell::new(true),
+ shortcut_search_id: widget::Id::unique(),
+ shortcut_search_regex: None,
+ shortcut_search_value: String::new(),
modifiers: Modifiers::empty(),
#[cfg(feature = "password_manager")]
password_mgr: Default::default(),
@@ -1603,12 +1831,20 @@ impl Application for App {
//TODO: currently the first escape unfocuses, and the second calls this function
fn on_escape(&mut self) -> Task {
if self.core.window.show_context {
- // Close context drawer if open
- self.core.window.show_context = false;
- #[cfg(feature = "password_manager")]
- if self.context_page == ContextPage::PasswordManager {
- self.password_mgr.clear();
+ // Handle keyboard shortcut page escape
+ if let ContextPage::KeyboardShortcuts = self.context_page {
+ // Cancel shortcut capture
+ if self.shortcut_capture.take().is_some() {
+ return Task::none();
+ }
+
+ // Cancel shortcut conflict dialog
+ if self.shortcut_conflict.take().is_some() {
+ return Task::none();
+ }
}
+
+ return self.update(Message::ToggleContextPage(self.context_page));
} else if self.find {
// Close find if open
self.find = false;
@@ -1870,9 +2106,15 @@ impl Application for App {
}
Message::Config(config) => {
if config != self.config {
+ let shortcuts_changed = config.shortcuts_custom != self.config.shortcuts_custom;
log::info!("update config");
//TODO: update syntax theme by clearing tabs, only if needed
self.config = config;
+ if shortcuts_changed {
+ self.shortcuts_config =
+ shortcuts::ShortcutsConfig::new(self.config.shortcuts_custom.clone());
+ self.key_binds = key_binds(&self.shortcuts_config);
+ }
return self.update_config();
}
}
@@ -2114,6 +2356,44 @@ impl Application for App {
config_set!(focus_follow_mouse, focus_follow_mouse);
}
Message::Key(modifiers, key) => {
+ // Hard-coded keys
+ match key {
+ Key::Named(Named::Copy) => {
+ return self.update(Message::Copy(None));
+ }
+ Key::Named(Named::Paste) => {
+ return self.update(Message::Paste(None));
+ }
+ Key::Named(Named::Escape) => {
+ // Handled by on_escape
+ return Task::none();
+ }
+ _ => {}
+ }
+
+ // Handle shortcut capture
+ if let Some(action) = self.shortcut_capture {
+ if let Some(binding) = shortcuts::binding_from_key(modifiers, key) {
+ self.shortcut_capture = None;
+ if let Some(existing_action) =
+ self.shortcuts_config.action_for_binding(&binding)
+ {
+ if existing_action != action {
+ self.begin_shortcut_conflict(ShortcutConflict {
+ binding,
+ existing_action,
+ new_action: action,
+ });
+ return Task::none();
+ }
+ return Task::none();
+ }
+ self.apply_shortcut_binding(binding, action);
+ }
+ return Task::none();
+ }
+
+ // Handle configurable keys
for (key_bind, action) in &self.key_binds {
if key_bind.matches(modifiers, &key) {
return self.update(action.message(None));
@@ -2149,6 +2429,59 @@ impl Application for App {
self.pane_model.set_focus(pane);
return self.update_focus();
}
+ Message::ShortcutCaptureCancel => {
+ self.shortcut_capture = None;
+ }
+ Message::ShortcutCaptureStart(action) => {
+ self.shortcut_capture = Some(action);
+ }
+ Message::ShortcutConflictCancel => {
+ self.clear_shortcut_conflict();
+ }
+ Message::ShortcutConflictReplace => {
+ if let Some(conflict) = self.shortcut_conflict.clone() {
+ self.apply_shortcut_binding(conflict.binding, conflict.new_action);
+ }
+ self.clear_shortcut_conflict();
+ }
+ Message::ShortcutRemove(binding, source) => {
+ match source {
+ shortcuts::BindingSource::Default => {
+ self.shortcuts_config
+ .custom
+ .0
+ .insert(binding, shortcuts::KeyBindAction::Disable);
+ }
+ shortcuts::BindingSource::Custom => {
+ self.shortcuts_config.custom.0.remove(&binding);
+ }
+ }
+ self.save_shortcuts_custom();
+ }
+ Message::ShortcutReset(reset_action) => {
+ self.shortcuts_config.reset_action(reset_action);
+ self.save_shortcuts_custom();
+ }
+ Message::ShortcutSearch(search) => {
+ self.shortcut_search_focus.set(true);
+ self.shortcut_search_regex = None;
+ if !search.is_empty() {
+ let pattern = regex::escape(&search);
+ match regex::RegexBuilder::new(&pattern)
+ .case_insensitive(true)
+ .build()
+ {
+ Ok(regex) => {
+ self.shortcut_search_regex = Some(regex);
+ }
+ Err(err) => {
+ log::warn!("failed to parse regex {:?}: {}", pattern, err);
+ }
+ };
+ }
+ self.shortcut_search_value = search;
+ return self.update_focus();
+ }
Message::Opacity(opacity) => {
config_set!(opacity, cmp::min(100, opacity));
}
@@ -2661,6 +2994,10 @@ impl Application for App {
self.core.window.show_context = !self.core.window.show_context;
self.pane_model.update_terminal_focus();
+ if let ContextPage::KeyboardShortcuts = context_page {
+ self.shortcut_page_toggle();
+ }
+
#[cfg(feature = "password_manager")]
if ContextPage::PasswordManager == context_page {
if self.core.window.show_context {
@@ -2702,6 +3039,11 @@ impl Application for App {
});
}
+ if let ContextPage::KeyboardShortcuts = context_page {
+ self.shortcut_page_toggle();
+ return self.update_focus();
+ }
+
#[cfg(feature = "password_manager")]
if ContextPage::PasswordManager == context_page {
self.password_mgr.pane = Some(self.pane_model.focused());
@@ -2772,6 +3114,11 @@ impl Application for App {
Message::ToggleContextPage(ContextPage::ColorSchemes(color_scheme_kind)),
)
.title(fl!("color-schemes")),
+ ContextPage::KeyboardShortcuts => context_drawer::context_drawer(
+ self.keyboard_shortcuts(),
+ Message::ToggleContextPage(ContextPage::KeyboardShortcuts),
+ )
+ .title(fl!("keyboard-shortcuts")),
ContextPage::Profiles => context_drawer::context_drawer(
self.profiles(),
Message::ToggleContextPage(ContextPage::Profiles),
@@ -2791,6 +3138,34 @@ impl Application for App {
})
}
+ fn dialog(&self) -> Option> {
+ let conflict = self.shortcut_conflict.as_ref()?;
+ let binding = shortcuts::binding_display(&conflict.binding);
+ let existing = shortcuts::action_label(conflict.existing_action);
+ let new_action = shortcuts::action_label(conflict.new_action);
+ let body = fl!(
+ "shortcut-replace-body",
+ binding = binding.as_str(),
+ existing = existing.as_str(),
+ new_action = new_action.as_str()
+ );
+
+ Some(
+ widget::dialog()
+ .title(fl!("shortcut-replace-title"))
+ .body(body)
+ .primary_action(
+ widget::button::suggested(fl!("replace"))
+ .on_press(Message::ShortcutConflictReplace),
+ )
+ .secondary_action(
+ widget::button::standard(fl!("cancel"))
+ .on_press(Message::ShortcutConflictCancel),
+ )
+ .into(),
+ )
+ }
+
fn header_start(&self) -> Vec> {
vec![menu_bar(&self.core, &self.config, &self.key_binds)]
}
@@ -2841,7 +3216,7 @@ impl Application for App {
.cloned()
.unwrap_or_else(widget::Id::unique);
if let Some(terminal) = tab_model.data::>(entity) {
- let mut terminal_box = terminal_box(terminal)
+ let mut terminal_box = terminal_box(terminal, &self.key_binds)
.id(terminal_id)
.disabled(self.core.window.show_context)
.on_context_menu(move |menu_state| Message::TabContextMenu(pane, menu_state))
diff --git a/src/menu.rs b/src/menu.rs
index 0e11dd2..520a608 100644
--- a/src/menu.rs
+++ b/src/menu.rs
@@ -265,6 +265,11 @@ pub fn menu_bar<'a>(
None,
Action::ColorSchemes(config.color_scheme_kind()),
),
+ MenuItem::Button(
+ fl!("menu-keyboard-shortcuts"),
+ None,
+ Action::KeyboardShortcuts,
+ ),
MenuItem::Button(fl!("menu-settings"), None, Action::Settings),
#[cfg(feature = "password_manager")]
MenuItem::Button(
diff --git a/src/shortcuts.rs b/src/shortcuts.rs
new file mode 100644
index 0000000..427ed19
--- /dev/null
+++ b/src/shortcuts.rs
@@ -0,0 +1,557 @@
+// SPDX-License-Identifier: GPL-3.0-only
+
+use cosmic::widget::menu::key_bind::{KeyBind, Modifier};
+use cosmic::{
+ iced::keyboard::{Key, Modifiers},
+ iced_core::keyboard::key::Named,
+};
+use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, HashMap};
+
+use crate::{Action, fl};
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
+pub enum ModifierName {
+ Ctrl,
+ Shift,
+ Alt,
+ Super,
+}
+
+impl ModifierName {
+ fn to_modifier(self) -> Modifier {
+ match self {
+ Self::Ctrl => Modifier::Ctrl,
+ Self::Shift => Modifier::Shift,
+ Self::Alt => Modifier::Alt,
+ Self::Super => Modifier::Super,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
+pub struct Binding {
+ pub modifiers: Vec,
+ pub key: String,
+}
+
+impl Binding {
+ fn to_key_bind(&self) -> Option {
+ let key = key_from_string(&self.key)?;
+ let mut modifiers = Vec::new();
+ for modifier in [
+ ModifierName::Ctrl,
+ ModifierName::Shift,
+ ModifierName::Alt,
+ ModifierName::Super,
+ ] {
+ if self.modifiers.contains(&modifier) {
+ modifiers.push(modifier.to_modifier());
+ }
+ }
+
+ Some(KeyBind { modifiers, key })
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub enum KeyBindAction {
+ Disable,
+ ClearScrollback,
+ Copy,
+ CopyOrSigint,
+ Find,
+ PaneFocusDown,
+ PaneFocusLeft,
+ PaneFocusRight,
+ PaneFocusUp,
+ PaneSplitHorizontal,
+ PaneSplitVertical,
+ PaneToggleMaximized,
+ Paste,
+ PastePrimary,
+ #[cfg_attr(not(feature = "password_manager"), allow(dead_code))]
+ PasswordManager,
+ SelectAll,
+ Settings,
+ TabActivate0,
+ TabActivate1,
+ TabActivate2,
+ TabActivate3,
+ TabActivate4,
+ TabActivate5,
+ TabActivate6,
+ TabActivate7,
+ TabActivate8,
+ TabClose,
+ TabNew,
+ TabNext,
+ TabPrev,
+ ToggleFullscreen,
+ WindowClose,
+ WindowNew,
+ ZoomIn,
+ ZoomOut,
+ ZoomReset,
+}
+
+impl KeyBindAction {
+ fn to_action(self) -> Option {
+ match self {
+ Self::Disable => None,
+ Self::ClearScrollback => Some(Action::ClearScrollback),
+ Self::Copy => Some(Action::Copy),
+ Self::CopyOrSigint => Some(Action::CopyOrSigint),
+ Self::Find => Some(Action::Find),
+ Self::PaneFocusDown => Some(Action::PaneFocusDown),
+ Self::PaneFocusLeft => Some(Action::PaneFocusLeft),
+ Self::PaneFocusRight => Some(Action::PaneFocusRight),
+ Self::PaneFocusUp => Some(Action::PaneFocusUp),
+ Self::PaneSplitHorizontal => Some(Action::PaneSplitHorizontal),
+ Self::PaneSplitVertical => Some(Action::PaneSplitVertical),
+ Self::PaneToggleMaximized => Some(Action::PaneToggleMaximized),
+ Self::Paste => Some(Action::Paste),
+ Self::PastePrimary => Some(Action::PastePrimary),
+ Self::SelectAll => Some(Action::SelectAll),
+ Self::Settings => Some(Action::Settings),
+ Self::TabActivate0 => Some(Action::TabActivate0),
+ Self::TabActivate1 => Some(Action::TabActivate1),
+ Self::TabActivate2 => Some(Action::TabActivate2),
+ Self::TabActivate3 => Some(Action::TabActivate3),
+ Self::TabActivate4 => Some(Action::TabActivate4),
+ Self::TabActivate5 => Some(Action::TabActivate5),
+ Self::TabActivate6 => Some(Action::TabActivate6),
+ Self::TabActivate7 => Some(Action::TabActivate7),
+ Self::TabActivate8 => Some(Action::TabActivate8),
+ Self::TabClose => Some(Action::TabClose),
+ Self::TabNew => Some(Action::TabNew),
+ Self::TabNext => Some(Action::TabNext),
+ Self::TabPrev => Some(Action::TabPrev),
+ Self::ToggleFullscreen => Some(Action::ToggleFullscreen),
+ Self::WindowClose => Some(Action::WindowClose),
+ Self::WindowNew => Some(Action::WindowNew),
+ Self::ZoomIn => Some(Action::ZoomIn),
+ Self::ZoomOut => Some(Action::ZoomOut),
+ Self::ZoomReset => Some(Action::ZoomReset),
+ Self::PasswordManager => {
+ #[cfg(feature = "password_manager")]
+ {
+ Some(Action::PasswordManager)
+ }
+ #[cfg(not(feature = "password_manager"))]
+ {
+ None
+ }
+ }
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(transparent)]
+pub struct Shortcuts(pub BTreeMap);
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum BindingSource {
+ Default,
+ Custom,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResolvedBinding {
+ pub binding: Binding,
+ pub source: BindingSource,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct ShortcutsConfig {
+ defaults: Shortcuts,
+ pub custom: Shortcuts,
+}
+
+impl ShortcutsConfig {
+ pub fn new(custom: Shortcuts) -> Self {
+ Self {
+ defaults: fallback_shortcuts(),
+ custom,
+ }
+ }
+
+ pub fn key_binds(&self) -> HashMap {
+ let mut binds = HashMap::new();
+ insert_shortcuts(&self.defaults, &mut binds, false);
+ insert_shortcuts(&self.custom, &mut binds, true);
+ binds
+ }
+
+ pub fn bindings_for_action(&self, action: KeyBindAction) -> (Vec, bool) {
+ let mut bindings = Vec::new();
+
+ let mut changed = false;
+ for (binding, default_action) in &self.defaults.0 {
+ if *default_action != action {
+ continue;
+ }
+
+ match self.custom.0.get(binding) {
+ Some(KeyBindAction::Disable) => {
+ changed = true;
+ }
+ Some(custom_action) => {
+ if *custom_action == action {
+ bindings.push(ResolvedBinding {
+ binding: binding.clone(),
+ source: BindingSource::Custom,
+ });
+ changed = true;
+ }
+ }
+ None => bindings.push(ResolvedBinding {
+ binding: binding.clone(),
+ source: BindingSource::Default,
+ }),
+ }
+ }
+
+ for (binding, custom_action) in &self.custom.0 {
+ if *custom_action == action
+ && !bindings.iter().any(|resolved| resolved.binding == *binding)
+ {
+ bindings.push(ResolvedBinding {
+ binding: binding.clone(),
+ source: BindingSource::Custom,
+ });
+ changed = true;
+ }
+ }
+
+ (bindings, changed)
+ }
+
+ pub fn action_for_binding(&self, binding: &Binding) -> Option {
+ if let Some(action) = self.custom.0.get(binding) {
+ if *action == KeyBindAction::Disable {
+ return None;
+ }
+ return Some(*action);
+ }
+
+ self.defaults.0.get(binding).copied()
+ }
+
+ pub fn reset_action(&mut self, reset_action: KeyBindAction) {
+ self.custom.0.retain(|binding, action| {
+ if *action == reset_action {
+ // Remove any matching bindings
+ return false;
+ }
+ if let Some(default_action) = self.defaults.0.get(binding) {
+ if *default_action == reset_action {
+ // Remove binding that overrode a default
+ return false;
+ }
+ }
+ true
+ });
+ }
+}
+
+pub fn action_label(action: KeyBindAction) -> String {
+ match action {
+ KeyBindAction::Disable => fl!("disable"),
+ KeyBindAction::ClearScrollback => fl!("clear-scrollback"),
+ KeyBindAction::Copy => fl!("copy"),
+ KeyBindAction::CopyOrSigint => fl!("copy-or-sigint"),
+ KeyBindAction::Find => fl!("find"),
+ KeyBindAction::PaneFocusDown => fl!("focus-pane-down"),
+ KeyBindAction::PaneFocusLeft => fl!("focus-pane-left"),
+ KeyBindAction::PaneFocusRight => fl!("focus-pane-right"),
+ KeyBindAction::PaneFocusUp => fl!("focus-pane-up"),
+ KeyBindAction::PaneSplitHorizontal => fl!("split-horizontal"),
+ KeyBindAction::PaneSplitVertical => fl!("split-vertical"),
+ KeyBindAction::PaneToggleMaximized => fl!("pane-toggle-maximize"),
+ KeyBindAction::Paste => fl!("paste"),
+ KeyBindAction::PastePrimary => fl!("paste-primary"),
+ KeyBindAction::PasswordManager => fl!("password-manager"),
+ KeyBindAction::SelectAll => fl!("select-all"),
+ KeyBindAction::Settings => fl!("settings"),
+ KeyBindAction::TabActivate0 => fl!("tab-activate", number = 1),
+ KeyBindAction::TabActivate1 => fl!("tab-activate", number = 2),
+ KeyBindAction::TabActivate2 => fl!("tab-activate", number = 3),
+ KeyBindAction::TabActivate3 => fl!("tab-activate", number = 4),
+ KeyBindAction::TabActivate4 => fl!("tab-activate", number = 5),
+ KeyBindAction::TabActivate5 => fl!("tab-activate", number = 6),
+ KeyBindAction::TabActivate6 => fl!("tab-activate", number = 7),
+ KeyBindAction::TabActivate7 => fl!("tab-activate", number = 8),
+ KeyBindAction::TabActivate8 => fl!("tab-activate", number = 9),
+ KeyBindAction::TabClose => fl!("close-tab"),
+ KeyBindAction::TabNew => fl!("new-tab"),
+ KeyBindAction::TabNext => fl!("next-tab"),
+ KeyBindAction::TabPrev => fl!("previous-tab"),
+ KeyBindAction::ToggleFullscreen => fl!("toggle-fullscreen"),
+ KeyBindAction::WindowClose => fl!("close-window"),
+ KeyBindAction::WindowNew => fl!("new-window"),
+ KeyBindAction::ZoomIn => fl!("zoom-in"),
+ KeyBindAction::ZoomOut => fl!("zoom-out"),
+ KeyBindAction::ZoomReset => fl!("zoom-reset"),
+ }
+}
+
+pub struct ShortcutGroup {
+ pub title: String,
+ pub actions: Vec,
+}
+
+pub fn shortcut_groups() -> Vec {
+ let mut groups = Vec::new();
+ groups.push(ShortcutGroup {
+ title: fl!("shortcut-group-clipboard"),
+ actions: vec![
+ KeyBindAction::SelectAll,
+ KeyBindAction::Copy,
+ KeyBindAction::CopyOrSigint,
+ KeyBindAction::Paste,
+ KeyBindAction::PastePrimary,
+ KeyBindAction::Find,
+ ],
+ });
+ groups.push(ShortcutGroup {
+ title: fl!("shortcut-group-tabs"),
+ actions: vec![
+ KeyBindAction::TabNew,
+ KeyBindAction::TabClose,
+ KeyBindAction::TabNext,
+ KeyBindAction::TabPrev,
+ KeyBindAction::TabActivate0,
+ KeyBindAction::TabActivate1,
+ KeyBindAction::TabActivate2,
+ KeyBindAction::TabActivate3,
+ KeyBindAction::TabActivate4,
+ KeyBindAction::TabActivate5,
+ KeyBindAction::TabActivate6,
+ KeyBindAction::TabActivate7,
+ KeyBindAction::TabActivate8,
+ ],
+ });
+ groups.push(ShortcutGroup {
+ title: fl!("splits"),
+ actions: vec![
+ KeyBindAction::PaneSplitHorizontal,
+ KeyBindAction::PaneSplitVertical,
+ KeyBindAction::PaneToggleMaximized,
+ KeyBindAction::PaneFocusLeft,
+ KeyBindAction::PaneFocusRight,
+ KeyBindAction::PaneFocusUp,
+ KeyBindAction::PaneFocusDown,
+ ],
+ });
+ groups.push(ShortcutGroup {
+ title: fl!("shortcut-group-window"),
+ actions: vec![
+ KeyBindAction::WindowNew,
+ KeyBindAction::WindowClose,
+ KeyBindAction::ToggleFullscreen,
+ KeyBindAction::Settings,
+ ],
+ });
+ groups.push(ShortcutGroup {
+ title: fl!("shortcut-group-zoom"),
+ actions: vec![
+ KeyBindAction::ZoomIn,
+ KeyBindAction::ZoomOut,
+ KeyBindAction::ZoomReset,
+ ],
+ });
+ let mut other_actions = vec![KeyBindAction::ClearScrollback];
+ #[cfg(feature = "password_manager")]
+ other_actions.push(KeyBindAction::PasswordManager);
+ groups.push(ShortcutGroup {
+ title: fl!("shortcut-group-other"),
+ actions: other_actions,
+ });
+ groups
+}
+
+pub fn binding_display(binding: &Binding) -> String {
+ binding
+ .to_key_bind()
+ .map(|key_bind| key_bind.to_string())
+ .unwrap_or_else(|| binding.key.clone())
+}
+
+pub fn binding_from_key(modifiers: Modifiers, key: Key) -> Option {
+ if is_modifier_only_key(&key) {
+ return None;
+ }
+ let key = key_to_string(&key)?;
+ let mut binding_modifiers = Vec::new();
+ if modifiers.control() {
+ binding_modifiers.push(ModifierName::Ctrl);
+ }
+ if modifiers.shift() {
+ binding_modifiers.push(ModifierName::Shift);
+ }
+ if modifiers.alt() {
+ binding_modifiers.push(ModifierName::Alt);
+ }
+ if modifiers.logo() {
+ binding_modifiers.push(ModifierName::Super);
+ }
+ Some(Binding {
+ modifiers: binding_modifiers,
+ key,
+ })
+}
+
+fn insert_shortcuts(
+ shortcuts: &Shortcuts,
+ binds: &mut HashMap,
+ allow_disable: bool,
+) {
+ for (binding, action) in &shortcuts.0 {
+ let key_bind = match binding.to_key_bind() {
+ Some(key_bind) => key_bind,
+ None => {
+ log::warn!("invalid key binding: {:?}", binding);
+ continue;
+ }
+ };
+ if allow_disable && *action == KeyBindAction::Disable {
+ binds.remove(&key_bind);
+ continue;
+ }
+ let Some(action) = action.to_action() else {
+ log::warn!("unsupported shortcut action: {:?}", action);
+ continue;
+ };
+ binds.insert(key_bind, action);
+ }
+}
+
+fn fallback_shortcuts() -> Shortcuts {
+ let mut shortcuts = BTreeMap::new();
+
+ macro_rules! bind {
+ ([$($modifier:ident),* $(,)?], $key:expr, $action:ident) => {{
+ shortcuts.insert(
+ Binding {
+ modifiers: vec![$(ModifierName::$modifier),*],
+ key: $key.to_string(),
+ },
+ KeyBindAction::$action,
+ );
+ }};
+ }
+
+ // Standard key bindings
+ bind!([Ctrl, Shift], "A", SelectAll);
+ bind!([Ctrl, Shift], "C", Copy);
+ bind!([Ctrl], "c", CopyOrSigint);
+ bind!([Ctrl, Shift], "F", Find);
+ bind!([Ctrl, Shift], "N", WindowNew);
+ bind!([Ctrl, Shift], "Q", WindowClose);
+ bind!([Ctrl, Shift], "T", TabNew);
+ bind!([Ctrl, Shift], "V", Paste);
+ bind!([Shift], "Insert", PastePrimary);
+ bind!([Ctrl, Shift], "W", TabClose);
+ bind!([Ctrl], ",", Settings);
+ bind!([], "F11", ToggleFullscreen);
+
+ // Ctrl+Alt+D splits horizontally, Ctrl+Alt+R splits vertically, Ctrl+Shift+X maximizes split
+ //TODO: Adjust bindings as desired by UX
+ bind!([Ctrl, Alt], "d", PaneSplitHorizontal);
+ bind!([Ctrl, Alt], "r", PaneSplitVertical);
+ bind!([Ctrl, Shift], "X", PaneToggleMaximized);
+ #[cfg(feature = "password_manager")]
+ bind!([Ctrl, Alt], "p", PasswordManager);
+
+ // Ctrl+Tab and Ctrl+Shift+Tab cycle through tabs
+ // Ctrl+Tab is not a special key for terminals and is free to use
+ bind!([Ctrl], "Tab", TabNext);
+ bind!([Ctrl, Shift], "Tab", TabPrev);
+
+ // Ctrl+Shift+# activates tabs by index
+ bind!([Ctrl, Shift], "1", TabActivate0);
+ bind!([Ctrl, Shift], "2", TabActivate1);
+ bind!([Ctrl, Shift], "3", TabActivate2);
+ bind!([Ctrl, Shift], "4", TabActivate3);
+ bind!([Ctrl, Shift], "5", TabActivate4);
+ bind!([Ctrl, Shift], "6", TabActivate5);
+ bind!([Ctrl, Shift], "7", TabActivate6);
+ bind!([Ctrl, Shift], "8", TabActivate7);
+ bind!([Ctrl, Shift], "9", TabActivate8);
+
+ // Ctrl+0, Ctrl+-, and Ctrl+= are not special keys for terminals and are free to use
+ bind!([Ctrl], "0", ZoomReset);
+ bind!([Ctrl], "-", ZoomOut);
+ bind!([Ctrl], "=", ZoomIn);
+ bind!([Ctrl], "+", ZoomIn);
+
+ // Ctrl+Arrows and Ctrl+HJKL move between splits
+ bind!([Ctrl, Shift], "ArrowLeft", PaneFocusLeft);
+ bind!([Ctrl, Shift], "H", PaneFocusLeft);
+ bind!([Ctrl, Shift], "ArrowDown", PaneFocusDown);
+ bind!([Ctrl, Shift], "J", PaneFocusDown);
+ bind!([Ctrl, Shift], "ArrowUp", PaneFocusUp);
+ bind!([Ctrl, Shift], "K", PaneFocusUp);
+ bind!([Ctrl, Shift], "ArrowRight", PaneFocusRight);
+ bind!([Ctrl, Shift], "L", PaneFocusRight);
+
+ // CTRL+Alt+L clears the scrollback.
+ bind!([Ctrl, Alt], "L", ClearScrollback);
+
+ Shortcuts(shortcuts)
+}
+
+fn key_from_string(value: &str) -> Option {
+ match value {
+ "Insert" => Some(Key::Named(Named::Insert)),
+ "Tab" => Some(Key::Named(Named::Tab)),
+ "F11" => Some(Key::Named(Named::F11)),
+ "ArrowLeft" | "Left" => Some(Key::Named(Named::ArrowLeft)),
+ "ArrowRight" | "Right" => Some(Key::Named(Named::ArrowRight)),
+ "ArrowUp" | "Up" => Some(Key::Named(Named::ArrowUp)),
+ "ArrowDown" | "Down" => Some(Key::Named(Named::ArrowDown)),
+ "Space" | "space" => Some(Key::Character(" ".into())),
+ _ if !value.is_empty() => Some(Key::Character(value.into())),
+ _ => None,
+ }
+}
+
+fn key_to_string(key: &Key) -> Option {
+ match key {
+ Key::Character(c) => {
+ if c == " " {
+ Some("Space".to_string())
+ } else if c.len() == 1 && c.chars().all(|ch| ch.is_ascii_alphabetic()) {
+ Some(c.to_uppercase())
+ } else {
+ Some(c.to_string())
+ }
+ }
+ Key::Named(named) => Some(format!("{named:?}")),
+ _ => None,
+ }
+}
+
+fn is_modifier_only_key(key: &Key) -> bool {
+ matches!(
+ key,
+ Key::Named(
+ Named::Alt
+ | Named::AltGraph
+ | Named::CapsLock
+ | Named::Control
+ | Named::Fn
+ | Named::FnLock
+ | Named::NumLock
+ | Named::ScrollLock
+ | Named::Shift
+ | Named::Symbol
+ | Named::SymbolLock
+ | Named::Meta
+ | Named::Hyper
+ | Named::Super
+ )
+ )
+}
diff --git a/src/terminal_box.rs b/src/terminal_box.rs
index 38327ec..3dc1b8d 100644
--- a/src/terminal_box.rs
+++ b/src/terminal_box.rs
@@ -46,8 +46,8 @@ use std::{
};
use crate::{
- Action, Terminal, TerminalScroll, key_bind::key_binds, menu::MenuState,
- mouse_reporter::MouseReporter, terminal::Metadata,
+ Action, Terminal, TerminalScroll, menu::MenuState, mouse_reporter::MouseReporter,
+ terminal::Metadata,
};
const AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -122,7 +122,7 @@ pub struct TerminalBox<'a, Message> {
on_open_hyperlink: Option Message + 'a>>,
on_window_focused: Option Message + 'a>>,
on_window_unfocused: Option Message + 'a>>,
- key_binds: HashMap,
+ key_binds: &'a HashMap,
sharp_corners: bool,
disabled: bool,
}
@@ -131,7 +131,7 @@ impl<'a, Message> TerminalBox<'a, Message>
where
Message: Clone,
{
- pub fn new(terminal: &'a Mutex) -> Self {
+ pub fn new(terminal: &'a Mutex, key_binds: &'a HashMap) -> Self {
Self {
terminal,
id: None,
@@ -145,7 +145,7 @@ where
opacity: None,
mouse_inside_boundary: None,
on_middle_click: None,
- key_binds: key_binds(),
+ key_binds,
on_open_hyperlink: None,
on_window_focused: None,
on_window_unfocused: None,
@@ -236,11 +236,14 @@ where
}
}
-pub fn terminal_box(terminal: &Mutex) -> TerminalBox<'_, Message>
+pub fn terminal_box<'a, Message>(
+ terminal: &'a Mutex,
+ key_binds: &'a HashMap,
+) -> TerminalBox<'a, Message>
where
Message: Clone,
{
- TerminalBox::new(terminal)
+ TerminalBox::new(terminal, key_binds)
}
impl<'a, Message> Widget for TerminalBox<'a, Message>