diff --git a/justfile b/justfile index 9835c8e..d1dda03 100644 --- a/justfile +++ b/justfile @@ -23,6 +23,9 @@ metainfo-dst := clean(rootdir / prefix) / 'share' / 'metainfo' / metainfo icons-src := 'res' / 'icons' / 'hicolor' icons-dst := clean(rootdir / prefix) / 'share' / 'icons' / 'hicolor' +shortcuts-src := 'res' / 'com.system76.CosmicTerm.Shortcuts' / 'v1' +shortcuts-dst := clean(rootdir / prefix) / 'share' / 'cosmic' / 'com.system76.CosmicTerm.Shortcuts' / 'v1' + # Default recipe which runs `just build-release` default: build-release @@ -67,6 +70,8 @@ install: install -Dm0755 {{bin-src}} {{bin-dst}} install -Dm0644 {{desktop-src}} {{desktop-dst}} install -Dm0644 {{metainfo-src}} {{metainfo-dst}} + install -Dm0644 {{shortcuts-src}}/defaults {{shortcuts-dst}}/defaults + install -Dm0644 {{shortcuts-src}}/custom {{shortcuts-dst}}/custom for size in `ls {{icons-src}}`; do \ install -Dm0644 "{{icons-src}}/$size/apps/{{APPID}}.svg" "{{icons-dst}}/$size/apps/{{APPID}}.svg"; \ done diff --git a/res/com.system76.CosmicTerm.Shortcuts/v1/custom b/res/com.system76.CosmicTerm.Shortcuts/v1/custom new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/res/com.system76.CosmicTerm.Shortcuts/v1/custom @@ -0,0 +1 @@ +{} diff --git a/res/com.system76.CosmicTerm.Shortcuts/v1/defaults b/res/com.system76.CosmicTerm.Shortcuts/v1/defaults new file mode 100644 index 0000000..1eb6d74 --- /dev/null +++ b/res/com.system76.CosmicTerm.Shortcuts/v1/defaults @@ -0,0 +1,50 @@ +{ + (modifiers: [Ctrl, Shift], key: "A"): SelectAll, + (modifiers: [Ctrl, Shift], key: "C"): Copy, + (modifiers: [], key: "Copy"): Copy, + (modifiers: [Ctrl], key: "c"): CopyOrSigint, + (modifiers: [Ctrl, Shift], key: "F"): Find, + (modifiers: [Ctrl, Shift], key: "N"): WindowNew, + (modifiers: [Ctrl, Shift], key: "Q"): WindowClose, + (modifiers: [Ctrl, Shift], key: "T"): TabNew, + (modifiers: [Ctrl, Shift], key: "V"): Paste, + (modifiers: [], key: "Paste"): Paste, + (modifiers: [Shift], key: "Insert"): PastePrimary, + (modifiers: [Ctrl, Shift], key: "W"): TabClose, + (modifiers: [Ctrl], key: ","): Settings, + (modifiers: [], key: "F11"): ToggleFullscreen, + + (modifiers: [Ctrl, Alt], key: "d"): PaneSplitHorizontal, + (modifiers: [Ctrl, Alt], key: "r"): PaneSplitVertical, + (modifiers: [Ctrl, Shift], key: "X"): PaneToggleMaximized, + (modifiers: [Ctrl, Alt], key: "p"): PasswordManager, + + (modifiers: [Ctrl], key: "Tab"): TabNext, + (modifiers: [Ctrl, Shift], key: "Tab"): TabPrev, + + (modifiers: [Ctrl, Shift], key: "1"): TabActivate0, + (modifiers: [Ctrl, Shift], key: "2"): TabActivate1, + (modifiers: [Ctrl, Shift], key: "3"): TabActivate2, + (modifiers: [Ctrl, Shift], key: "4"): TabActivate3, + (modifiers: [Ctrl, Shift], key: "5"): TabActivate4, + (modifiers: [Ctrl, Shift], key: "6"): TabActivate5, + (modifiers: [Ctrl, Shift], key: "7"): TabActivate6, + (modifiers: [Ctrl, Shift], key: "8"): TabActivate7, + (modifiers: [Ctrl, Shift], key: "9"): TabActivate8, + + (modifiers: [Ctrl], key: "0"): ZoomReset, + (modifiers: [Ctrl], key: "-"): ZoomOut, + (modifiers: [Ctrl], key: "="): ZoomIn, + (modifiers: [Ctrl], key: "+"): ZoomIn, + + (modifiers: [Ctrl, Shift], key: "ArrowLeft"): PaneFocusLeft, + (modifiers: [Ctrl, Shift], key: "H"): PaneFocusLeft, + (modifiers: [Ctrl, Shift], key: "ArrowDown"): PaneFocusDown, + (modifiers: [Ctrl, Shift], key: "J"): PaneFocusDown, + (modifiers: [Ctrl, Shift], key: "ArrowUp"): PaneFocusUp, + (modifiers: [Ctrl, Shift], key: "K"): PaneFocusUp, + (modifiers: [Ctrl, Shift], key: "ArrowRight"): PaneFocusRight, + (modifiers: [Ctrl, Shift], key: "L"): PaneFocusRight, + + (modifiers: [Ctrl, Alt], key: "L"): ClearScrollback, +} diff --git a/src/key_bind.rs b/src/key_bind.rs index 900f7c9..1114426 100644 --- a/src/key_bind.rs +++ b/src/key_bind.rs @@ -3,9 +3,18 @@ use cosmic::{iced::keyboard::Key, iced_core::keyboard::key::Named}; use std::collections::HashMap; use crate::Action; +use crate::shortcuts::ShortcutsConfig; -//TODO: load from config -pub fn key_binds() -> HashMap { +pub fn key_binds(shortcuts: &ShortcutsConfig) -> HashMap { + let key_binds = shortcuts.key_binds(); + if key_binds.is_empty() { + fallback_key_binds() + } else { + key_binds + } +} + +fn fallback_key_binds() -> HashMap { let mut key_binds = HashMap::new(); macro_rules! bind { diff --git a/src/main.rs b/src/main.rs index 33b717b..70b787c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use alacritty_terminal::{event::Event as TermEvent, term, term::color::Colors as use cosmic::iced::clipboard::dnd::DndAction; use cosmic::widget::menu::action::MenuAction; use cosmic::widget::menu::key_bind::KeyBind; +use cosmic::iced_core::keyboard::key::Named; use cosmic::{ Application, ApplicationExt, Element, action, app::{Core, Settings, Task, context_drawer}, @@ -52,6 +53,8 @@ mod icon_cache; use key_bind::key_binds; mod key_bind; +mod shortcuts; + mod localize; use menu::menu_bar; @@ -159,6 +162,8 @@ fn main() -> Result<(), Box> { } }; + let (shortcuts_config_handler, shortcuts_config) = shortcuts::load(); + 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 +192,8 @@ fn main() -> Result<(), Box> { let flags = Flags { config_handler, config, + shortcuts_config_handler, + shortcuts_config, startup_options, term_config, }; @@ -213,6 +220,8 @@ Options: pub struct Flags { config_handler: Option, config: Config, + shortcuts_config_handler: Option, + shortcuts_config: shortcuts::ShortcutsConfig, startup_options: Option, term_config: term::Config, } @@ -342,6 +351,7 @@ pub enum Message { ColorSchemeRenameSubmit, ColorSchemeTabActivate(widget::segmented_button::Entity), Config(Config), + ShortcutsConfig(shortcuts::ShortcutsConfig), Copy(Option), CopyOrSigint(Option), CopyPrimary(Option), @@ -358,12 +368,16 @@ pub enum Message { FindNext, FindPrevious, FindSearchValueChanged(String), + KeyboardShortcuts(bool), MiddleClick(pane_grid::Pane, Option), FocusFollowMouse(bool), Key(Modifiers, Key), LaunchUrl(String), LaunchUrlByMenu, Modifiers(Modifiers), + ShortcutCaptureCancel, + ShortcutCaptureStart(shortcuts::KeyBindAction), + ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource), MouseEnter(pane_grid::Pane), Opacity(u8), PaneClicked(pane_grid::Pane), @@ -437,6 +451,8 @@ pub struct App { pane_model: TerminalPaneGrid, config_handler: Option, config: Config, + shortcuts_config_handler: Option, + shortcuts_config: shortcuts::ShortcutsConfig, key_binds: HashMap, app_themes: Vec, font_names: Vec, @@ -471,6 +487,8 @@ pub struct App { color_scheme_tab_model: widget::segmented_button::SingleSelectModel, profile_expanded: Option, show_advanced_font_settings: bool, + show_keyboard_shortcuts: bool, + shortcut_capture: Option, modifiers: Modifiers, #[cfg(feature = "password_manager")] password_mgr: password_manager::PasswordManager, @@ -542,6 +560,20 @@ impl App { } } + fn save_shortcuts_custom(&mut self) { + match &self.shortcuts_config_handler { + Some(config_handler) => { + if let Err(err) = config_handler.set("custom", &self.shortcuts_config.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 update_config(&mut self) -> Task { let theme = self.config.app_theme.theme(); @@ -1066,6 +1098,10 @@ impl App { } fn settings(&self) -> Element<'_, Message> { + let cosmic_theme::Spacing { + space_xxs, space_xs, .. + } = self.core().system_theme().cosmic().spacing; + let app_theme_selected = match self.config.app_theme { AppTheme::Dark => 1, AppTheme::Light => 2, @@ -1246,6 +1282,117 @@ impl App { .toggler(self.config.focus_follow_mouse, Message::FocusFollowMouse), ); + let mut shortcuts_section = widget::settings::section() + .title("Keyboard shortcuts") + .add( + widget::settings::item::builder("Customize shortcuts").control( + if self.show_keyboard_shortcuts { + widget::button::custom(icon_cache_get("go-up-symbolic", 16)) + .on_press(Message::KeyboardShortcuts(false)) + } else { + widget::button::custom(icon_cache_get("go-down-symbolic", 16)) + .on_press(Message::KeyboardShortcuts(true)) + } + .class(style::Button::Icon), + ), + ); + + if self.show_keyboard_shortcuts { + let shortcuts_content = || { + let mut groups = Vec::new(); + + for group in shortcuts::shortcut_groups() { + let mut group_section = widget::settings::section().title(group.title); + + for action in group.actions { + let bindings = self.shortcuts_config.bindings_for_action(action); + let mut rows: Vec> = Vec::new(); + + if self.shortcut_capture == Some(action) { + rows.push( + widget::row::with_children(vec![ + widget::text::body("Press new shortcut, or Esc to cancel") + .into(), + widget::horizontal_space().into(), + widget::button::standard("Cancel") + .on_press(Message::ShortcutCaptureCancel) + .into(), + ]) + .spacing(space_xxs) + .into(), + ); + } + + if bindings.is_empty() { + rows.push(widget::text::body("No shortcuts").into()); + } else { + for resolved in bindings { + let binding_text = widget::text::body( + shortcuts::binding_display(&resolved.binding), + ) + .width(Length::Fill) + .align_x(Alignment::End); + let binding_chip = widget::container( + widget::row::with_children(vec![ + binding_text.into(), + widget::button::custom(icon_cache_get( + "edit-delete-symbolic", + 16, + )) + .class(style::Button::Icon) + .on_press(Message::ShortcutRemove( + resolved.binding.clone(), + resolved.source, + )) + .into(), + ]) + .spacing(space_xxs) + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .padding(Padding::new(6.0)) + .class(style::Container::Background) + .width(Length::Fill); + rows.push(binding_chip.into()); + } + } + + rows.push( + widget::row::with_children(vec![ + widget::horizontal_space().into(), + widget::button::standard("+ Add") + .on_press(Message::ShortcutCaptureStart(action)) + .into(), + ]) + .into(), + ); + + let bindings_column = widget::column::with_children(rows) + .spacing(space_xxs) + .width(Length::Fill); + + group_section = group_section.add( + widget::settings::item::builder(shortcuts::action_label(action)) + .control(bindings_column), + ); + } + + groups.push(group_section.into()); + } + + widget::column::with_children(groups).spacing(space_xs) + }; + + let padding = Padding { + top: 0.0, + bottom: 0.0, + left: 12.0, + right: 12.0, + }; + shortcuts_section = + shortcuts_section.add(widget::container(shortcuts_content()).padding(padding)); + } + let advanced_section = widget::settings::section().title(fl!("advanced")).add( widget::settings::item::builder(fl!("show-headerbar")) .description(fl!("show-header-description")) @@ -1256,6 +1403,7 @@ impl App { appearance_section.into(), font_section.into(), splits_section.into(), + shortcuts_section.into(), advanced_section.into(), ]) .into() @@ -1550,13 +1698,16 @@ 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_handler: flags.shortcuts_config_handler, + shortcuts_config: flags.shortcuts_config, + key_binds, app_themes, font_names, font_size_names, @@ -1589,6 +1740,8 @@ impl Application for App { color_scheme_tab_model: widget::segmented_button::Model::default(), profile_expanded: None, show_advanced_font_settings: false, + show_keyboard_shortcuts: false, + shortcut_capture: None, modifiers: Modifiers::empty(), #[cfg(feature = "password_manager")] password_mgr: Default::default(), @@ -1876,6 +2029,13 @@ impl Application for App { return self.update_config(); } } + Message::ShortcutsConfig(config) => { + if config != self.shortcuts_config { + log::info!("update shortcuts config"); + self.shortcuts_config = config; + self.key_binds = key_binds(&self.shortcuts_config); + } + } Message::Copy(entity_opt) => { if let Some(tab_model) = self.pane_model.active() { let entity = entity_opt.unwrap_or_else(|| tab_model.active()); @@ -2100,6 +2260,12 @@ impl Application for App { Message::FindSearchValueChanged(value) => { self.find_search_value = value; } + Message::KeyboardShortcuts(show) => { + self.show_keyboard_shortcuts = show; + if !show { + self.shortcut_capture = None; + } + } Message::MiddleClick(pane, entity_opt) => { self.pane_model.set_focus(pane); return Task::batch([ @@ -2114,6 +2280,18 @@ impl Application for App { config_set!(focus_follow_mouse, focus_follow_mouse); } Message::Key(modifiers, key) => { + if let Some(action) = self.shortcut_capture { + if key == Key::Named(Named::Escape) { + self.shortcut_capture = None; + return Task::none(); + } + if let Some(binding) = shortcuts::binding_from_key(modifiers, key) { + self.shortcut_capture = None; + self.shortcuts_config.custom.0.insert(binding, action); + self.save_shortcuts_custom(); + } + return Task::none(); + } for (key_bind, action) in &self.key_binds { if key_bind.matches(modifiers, &key) { return self.update(action.message(None)); @@ -2149,6 +2327,26 @@ 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::ShortcutRemove(binding, source) => { + match source { + shortcuts::BindingSource::Default => { + self.shortcuts_config + .custom + .0 + .insert(binding, shortcuts::KeyBindAction::Unbind); + } + shortcuts::BindingSource::Custom => { + self.shortcuts_config.custom.0.remove(&binding); + } + } + self.save_shortcuts_custom(); + } Message::Opacity(opacity) => { config_set!(opacity, cmp::min(100, opacity)); } @@ -2836,7 +3034,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)) @@ -2971,6 +3169,7 @@ impl Application for App { fn subscription(&self) -> Subscription { struct ConfigSubscription; + struct ShortcutsConfigSubscription; struct TerminalEventSubscription; Subscription::batch([ @@ -3017,6 +3216,21 @@ impl Application for App { } Message::Config(update.config) }), + cosmic_config::config_subscription::<_, shortcuts::ShortcutsConfig>( + TypeId::of::(), + shortcuts::SHORTCUTS_CONFIG_ID.into(), + shortcuts::SHORTCUTS_CONFIG_VERSION, + ) + .map(|update| { + if !update.errors.is_empty() { + log::debug!( + "errors loading shortcuts config {:?}: {:?}", + update.keys, + update.errors + ); + } + Message::ShortcutsConfig(update.config) + }), match &self.dialog_opt { Some(dialog) => dialog.subscription(), None => Subscription::none(), diff --git a/src/shortcuts.rs b/src/shortcuts.rs new file mode 100644 index 0000000..247afea --- /dev/null +++ b/src/shortcuts.rs @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::widget::menu::key_bind::{KeyBind, Modifier}; +use cosmic::{ + cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}, + iced::keyboard::{Key, Modifiers}, + iced_core::keyboard::key::Named, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; + +use crate::Action; + +pub const SHORTCUTS_CONFIG_ID: &str = "com.system76.CosmicTerm.Shortcuts"; +pub const SHORTCUTS_CONFIG_VERSION: u64 = 1; + +#[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 { + Unbind, + 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::Unbind => 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, Default, Deserialize, Eq, PartialEq, Serialize, CosmicConfigEntry)] +pub struct ShortcutsConfig { + pub defaults: Shortcuts, + pub custom: Shortcuts, +} + +impl ShortcutsConfig { + 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 { + let mut bindings = Vec::new(); + + for (binding, default_action) in &self.defaults.0 { + if *default_action != action { + continue; + } + + match self.custom.0.get(binding) { + Some(KeyBindAction::Unbind) => (), + Some(custom_action) => { + if *custom_action == action { + bindings.push(ResolvedBinding { + binding: binding.clone(), + source: BindingSource::Custom, + }); + } + } + 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, + }); + } + } + + bindings + } +} + +pub fn load() -> (Option, ShortcutsConfig) { + match cosmic_config::Config::new(SHORTCUTS_CONFIG_ID, SHORTCUTS_CONFIG_VERSION) { + Ok(config_handler) => { + let config = match ShortcutsConfig::get_entry(&config_handler) { + Ok(config) => config, + Err((errors, config)) => { + log::info!("errors loading shortcuts config: {:?}", errors); + config + } + }; + (Some(config_handler), config) + } + Err(err) => { + log::error!("failed to create shortcuts config handler: {}", err); + (None, ShortcutsConfig::default()) + } + } +} + +pub fn action_label(action: KeyBindAction) -> &'static str { + match action { + KeyBindAction::Unbind => "Unbind", + KeyBindAction::ClearScrollback => "Clear scrollback", + KeyBindAction::Copy => "Copy", + KeyBindAction::CopyOrSigint => "Copy or SIGINT", + KeyBindAction::Find => "Find", + KeyBindAction::PaneFocusDown => "Focus pane down", + KeyBindAction::PaneFocusLeft => "Focus pane left", + KeyBindAction::PaneFocusRight => "Focus pane right", + KeyBindAction::PaneFocusUp => "Focus pane up", + KeyBindAction::PaneSplitHorizontal => "Split pane horizontally", + KeyBindAction::PaneSplitVertical => "Split pane vertically", + KeyBindAction::PaneToggleMaximized => "Toggle pane maximized", + KeyBindAction::Paste => "Paste", + KeyBindAction::PastePrimary => "Paste primary", + KeyBindAction::PasswordManager => "Password manager", + KeyBindAction::SelectAll => "Select all", + KeyBindAction::Settings => "Settings", + KeyBindAction::TabActivate0 => "Activate tab 1", + KeyBindAction::TabActivate1 => "Activate tab 2", + KeyBindAction::TabActivate2 => "Activate tab 3", + KeyBindAction::TabActivate3 => "Activate tab 4", + KeyBindAction::TabActivate4 => "Activate tab 5", + KeyBindAction::TabActivate5 => "Activate tab 6", + KeyBindAction::TabActivate6 => "Activate tab 7", + KeyBindAction::TabActivate7 => "Activate tab 8", + KeyBindAction::TabActivate8 => "Activate tab 9", + KeyBindAction::TabClose => "Close tab", + KeyBindAction::TabNew => "New tab", + KeyBindAction::TabNext => "Next tab", + KeyBindAction::TabPrev => "Previous tab", + KeyBindAction::ToggleFullscreen => "Toggle fullscreen", + KeyBindAction::WindowClose => "Close window", + KeyBindAction::WindowNew => "New window", + KeyBindAction::ZoomIn => "Zoom in", + KeyBindAction::ZoomOut => "Zoom out", + KeyBindAction::ZoomReset => "Reset zoom", + } +} + +pub struct ShortcutGroup { + pub title: &'static str, + pub actions: Vec, +} + +pub fn shortcut_groups() -> Vec { + let mut groups = Vec::new(); + groups.push(ShortcutGroup { + title: "Clipboard", + actions: vec![ + KeyBindAction::SelectAll, + KeyBindAction::Copy, + KeyBindAction::CopyOrSigint, + KeyBindAction::Paste, + KeyBindAction::PastePrimary, + KeyBindAction::Find, + ], + }); + groups.push(ShortcutGroup { + title: "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: "Splits", + actions: vec![ + KeyBindAction::PaneSplitHorizontal, + KeyBindAction::PaneSplitVertical, + KeyBindAction::PaneToggleMaximized, + KeyBindAction::PaneFocusLeft, + KeyBindAction::PaneFocusRight, + KeyBindAction::PaneFocusUp, + KeyBindAction::PaneFocusDown, + ], + }); + groups.push(ShortcutGroup { + title: "Window", + actions: vec![ + KeyBindAction::WindowNew, + KeyBindAction::WindowClose, + KeyBindAction::ToggleFullscreen, + KeyBindAction::Settings, + ], + }); + groups.push(ShortcutGroup { + title: "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: "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_unbind: 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_unbind && *action == KeyBindAction::Unbind { + 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 key_from_string(value: &str) -> Option { + match value { + "Copy" => Some(Key::Named(Named::Copy)), + "Paste" => Some(Key::Named(Named::Paste)), + "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 6bb4669..e671da0 100644 --- a/src/terminal_box.rs +++ b/src/terminal_box.rs @@ -46,8 +46,12 @@ 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 +126,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 +135,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 +149,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 +240,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>