From bf71e1a774096d62e0299cf76ff29eb31e9fb674 Mon Sep 17 00:00:00 2001 From: nludwig Date: Mon, 29 Dec 2025 20:42:52 -0800 Subject: [PATCH 01/14] make hotkeys configurable --- justfile | 5 + .../v1/custom | 1 + .../v1/defaults | 50 ++ src/key_bind.rs | 13 +- src/main.rs | 218 +++++++- src/shortcuts.rs | 468 ++++++++++++++++++ src/terminal_box.rs | 21 +- 7 files changed, 765 insertions(+), 11 deletions(-) create mode 100644 res/com.system76.CosmicTerm.Shortcuts/v1/custom create mode 100644 res/com.system76.CosmicTerm.Shortcuts/v1/defaults create mode 100644 src/shortcuts.rs 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> From e520b7b0e316a346309967930024639066a90d56 Mon Sep 17 00:00:00 2001 From: nludwig Date: Mon, 12 Jan 2026 21:40:07 -0800 Subject: [PATCH 02/14] use code fallbacks (now in shortcuts.rs), not `default` config file --- .../v1/defaults | 50 ---------- src/key_bind.rs | 91 +------------------ src/shortcuts.rs | 91 ++++++++++++++++++- 3 files changed, 91 insertions(+), 141 deletions(-) delete mode 100644 res/com.system76.CosmicTerm.Shortcuts/v1/defaults diff --git a/res/com.system76.CosmicTerm.Shortcuts/v1/defaults b/res/com.system76.CosmicTerm.Shortcuts/v1/defaults deleted file mode 100644 index 1eb6d74..0000000 --- a/res/com.system76.CosmicTerm.Shortcuts/v1/defaults +++ /dev/null @@ -1,50 +0,0 @@ -{ - (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 1114426..86f3d39 100644 --- a/src/key_bind.rs +++ b/src/key_bind.rs @@ -1,96 +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; 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 { - ([$($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 + shortcuts.key_binds() } diff --git a/src/shortcuts.rs b/src/shortcuts.rs index 247afea..024a0f5 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -176,15 +176,17 @@ pub struct ShortcutsConfig { impl ShortcutsConfig { pub fn key_binds(&self) -> HashMap { let mut binds = HashMap::new(); - insert_shortcuts(&self.defaults, &mut binds, false); + let defaults = self.defaults_or_fallback(); + insert_shortcuts(&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(); + let defaults = self.defaults_or_fallback(); - for (binding, default_action) in &self.defaults.0 { + for (binding, default_action) in &defaults.0 { if *default_action != action { continue; } @@ -219,6 +221,14 @@ impl ShortcutsConfig { bindings } + + fn defaults_or_fallback(&self) -> Shortcuts { + if self.defaults.0.is_empty() { + fallback_shortcuts() + } else { + self.defaults.clone() + } + } } pub fn load() -> (Option, ShortcutsConfig) { @@ -412,6 +422,83 @@ fn insert_shortcuts( } } +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!([], "Copy", 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!([], "Paste", 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 { "Copy" => Some(Key::Named(Named::Copy)), From d5f1ac4e9190b644f7c8d122ec6a69b8c0e6e9c9 Mon Sep 17 00:00:00 2001 From: nludwig Date: Thu, 29 Jan 2026 20:10:25 -0800 Subject: [PATCH 03/14] place shortcuts in existing config --- justfile | 5 -- .../v1/custom | 1 - src/config.rs | 5 +- src/main.rs | 50 +++++++------------ src/shortcuts.rs | 25 +--------- 5 files changed, 24 insertions(+), 62 deletions(-) delete mode 100644 res/com.system76.CosmicTerm.Shortcuts/v1/custom diff --git a/justfile b/justfile index d1dda03..9835c8e 100644 --- a/justfile +++ b/justfile @@ -23,9 +23,6 @@ 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 @@ -70,8 +67,6 @@ 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 deleted file mode 100644 index 0967ef4..0000000 --- a/res/com.system76.CosmicTerm.Shortcuts/v1/custom +++ /dev/null @@ -1 +0,0 @@ -{} 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/main.rs b/src/main.rs index 70b787c..6fbbcd7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -162,7 +162,10 @@ fn main() -> Result<(), Box> { } }; - let (shortcuts_config_handler, shortcuts_config) = shortcuts::load(); + let shortcuts_config = shortcuts::ShortcutsConfig { + defaults: shortcuts::Shortcuts::default(), + custom: config.shortcuts_custom.clone(), + }; let startup_options = if let Some(shell_program) = shell_program_opt { let options = tty::Options { @@ -192,7 +195,6 @@ fn main() -> Result<(), Box> { let flags = Flags { config_handler, config, - shortcuts_config_handler, shortcuts_config, startup_options, term_config, @@ -220,7 +222,6 @@ Options: pub struct Flags { config_handler: Option, config: Config, - shortcuts_config_handler: Option, shortcuts_config: shortcuts::ShortcutsConfig, startup_options: Option, term_config: term::Config, @@ -351,7 +352,6 @@ pub enum Message { ColorSchemeRenameSubmit, ColorSchemeTabActivate(widget::segmented_button::Entity), Config(Config), - ShortcutsConfig(shortcuts::ShortcutsConfig), Copy(Option), CopyOrSigint(Option), CopyPrimary(Option), @@ -451,7 +451,6 @@ 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, @@ -561,9 +560,13 @@ impl App { } fn save_shortcuts_custom(&mut self) { - match &self.shortcuts_config_handler { + self.config.shortcuts_custom = self.shortcuts_config.custom.clone(); + match &self.config_handler { Some(config_handler) => { - if let Err(err) = config_handler.set("custom", &self.shortcuts_config.custom) { + if let Err(err) = config_handler.set( + "shortcuts_custom", + &self.config.shortcuts_custom, + ) { log::warn!("failed to save shortcuts custom config: {}", err); } } @@ -1705,7 +1708,6 @@ impl Application for App { pane_model, config_handler: flags.config_handler, config: flags.config, - shortcuts_config_handler: flags.shortcuts_config_handler, shortcuts_config: flags.shortcuts_config, key_binds, app_themes, @@ -2023,19 +2025,21 @@ 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 { + defaults: shortcuts::Shortcuts::default(), + custom: self.config.shortcuts_custom.clone(), + }; + self.key_binds = key_binds(&self.shortcuts_config); + } 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()); @@ -3169,7 +3173,6 @@ impl Application for App { fn subscription(&self) -> Subscription { struct ConfigSubscription; - struct ShortcutsConfigSubscription; struct TerminalEventSubscription; Subscription::batch([ @@ -3216,21 +3219,6 @@ 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 index 024a0f5..c65c00f 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -2,7 +2,6 @@ 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, }; @@ -11,9 +10,6 @@ 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, @@ -167,7 +163,7 @@ pub struct ResolvedBinding { pub source: BindingSource, } -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, CosmicConfigEntry)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct ShortcutsConfig { pub defaults: Shortcuts, pub custom: Shortcuts, @@ -231,25 +227,6 @@ impl ShortcutsConfig { } } -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", From cc27b6ab30b9dd55e903bf37da3b026909836206 Mon Sep 17 00:00:00 2001 From: nludwig Date: Mon, 2 Feb 2026 15:20:28 -0800 Subject: [PATCH 04/14] fix localization --- i18n/en/cosmic_term.ftl | 24 +++++++++++ src/main.rs | 12 +++--- src/shortcuts.rs | 90 ++++++++++++++++++++--------------------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 88ed838..6b8ce21 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -62,6 +62,30 @@ advanced = Advanced show-headerbar = Show header show-header-description = Reveal the header from the right-click menu. +### Keyboard shortcuts +keyboard-shortcuts = Keyboard shortcuts +customize-shortcuts = Customize shortcuts +shortcut-capture-hint = Press new shortcut, or Esc to cancel +cancel = Cancel +no-shortcuts = No shortcuts +add-shortcut = + Add +shortcut-group-clipboard = Clipboard +shortcut-group-tabs = Tabs +shortcut-group-window = Window +shortcut-group-zoom = Zoom +shortcut-group-other = Other +unbind = Unbind +copy-or-sigint = Copy or SIGINT +paste-primary = Paste primary +focus-pane-left = Focus pane left +focus-pane-right = Focus pane right +focus-pane-up = Focus pane up +focus-pane-down = Focus pane down +toggle-fullscreen = Toggle fullscreen +close-window = Close window +password-manager = Password manager +tab-activate = Activate tab { $number } + # Find find-placeholder = Find... find-previous = Find previous diff --git a/src/main.rs b/src/main.rs index 6fbbcd7..c37f08a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1286,9 +1286,9 @@ impl App { ); let mut shortcuts_section = widget::settings::section() - .title("Keyboard shortcuts") + .title(fl!("keyboard-shortcuts")) .add( - widget::settings::item::builder("Customize shortcuts").control( + widget::settings::item::builder(fl!("customize-shortcuts")).control( if self.show_keyboard_shortcuts { widget::button::custom(icon_cache_get("go-up-symbolic", 16)) .on_press(Message::KeyboardShortcuts(false)) @@ -1314,10 +1314,10 @@ impl App { if self.shortcut_capture == Some(action) { rows.push( widget::row::with_children(vec![ - widget::text::body("Press new shortcut, or Esc to cancel") + widget::text::body(fl!("shortcut-capture-hint")) .into(), widget::horizontal_space().into(), - widget::button::standard("Cancel") + widget::button::standard(fl!("cancel")) .on_press(Message::ShortcutCaptureCancel) .into(), ]) @@ -1327,7 +1327,7 @@ impl App { } if bindings.is_empty() { - rows.push(widget::text::body("No shortcuts").into()); + rows.push(widget::text::body(fl!("no-shortcuts")).into()); } else { for resolved in bindings { let binding_text = widget::text::body( @@ -1363,7 +1363,7 @@ impl App { rows.push( widget::row::with_children(vec![ widget::horizontal_space().into(), - widget::button::standard("+ Add") + widget::button::standard(fl!("add-shortcut")) .on_press(Message::ShortcutCaptureStart(action)) .into(), ]) diff --git a/src/shortcuts.rs b/src/shortcuts.rs index c65c00f..74c9ac7 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -8,7 +8,7 @@ use cosmic::{ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; -use crate::Action; +use crate::{Action, fl}; #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] pub enum ModifierName { @@ -227,56 +227,56 @@ impl ShortcutsConfig { } } -pub fn action_label(action: KeyBindAction) -> &'static str { +pub fn action_label(action: KeyBindAction) -> String { 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", + KeyBindAction::Unbind => fl!("unbind"), + 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: &'static str, + pub title: String, pub actions: Vec, } pub fn shortcut_groups() -> Vec { let mut groups = Vec::new(); groups.push(ShortcutGroup { - title: "Clipboard", + title: fl!("shortcut-group-clipboard"), actions: vec![ KeyBindAction::SelectAll, KeyBindAction::Copy, @@ -287,7 +287,7 @@ pub fn shortcut_groups() -> Vec { ], }); groups.push(ShortcutGroup { - title: "Tabs", + title: fl!("shortcut-group-tabs"), actions: vec![ KeyBindAction::TabNew, KeyBindAction::TabClose, @@ -305,7 +305,7 @@ pub fn shortcut_groups() -> Vec { ], }); groups.push(ShortcutGroup { - title: "Splits", + title: fl!("splits"), actions: vec![ KeyBindAction::PaneSplitHorizontal, KeyBindAction::PaneSplitVertical, @@ -317,7 +317,7 @@ pub fn shortcut_groups() -> Vec { ], }); groups.push(ShortcutGroup { - title: "Window", + title: fl!("shortcut-group-window"), actions: vec![ KeyBindAction::WindowNew, KeyBindAction::WindowClose, @@ -326,7 +326,7 @@ pub fn shortcut_groups() -> Vec { ], }); groups.push(ShortcutGroup { - title: "Zoom", + title: fl!("shortcut-group-zoom"), actions: vec![ KeyBindAction::ZoomIn, KeyBindAction::ZoomOut, @@ -337,7 +337,7 @@ pub fn shortcut_groups() -> Vec { #[cfg(feature = "password_manager")] other_actions.push(KeyBindAction::PasswordManager); groups.push(ShortcutGroup { - title: "Other", + title: fl!("shortcut-group-other"), actions: other_actions, }); groups From 70e0f5a5f26103ff20441cb0f0a881363955dfe1 Mon Sep 17 00:00:00 2001 From: nludwig Date: Tue, 3 Feb 2026 22:08:34 -0800 Subject: [PATCH 05/14] add dialog box when replacing existing keybind --- i18n/en/cosmic_term.ftl | 3 ++ src/main.rs | 103 +++++++++++++++++++++++++++++++++++++++- src/shortcuts.rs | 12 +++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 6b8ce21..ba0a5bd 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -67,6 +67,9 @@ keyboard-shortcuts = Keyboard shortcuts customize-shortcuts = Customize shortcuts shortcut-capture-hint = Press new shortcut, or Esc to cancel cancel = Cancel +replace = Replace +shortcut-replace-title = Replace shortcut? +shortcut-replace-body = { $binding } is already assigned to { $existing }. Replace it with { $new_action }? no-shortcuts = No shortcuts add-shortcut = + Add shortcut-group-clipboard = Clipboard diff --git a/src/main.rs b/src/main.rs index c37f08a..a627f82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -377,6 +377,8 @@ pub enum Message { Modifiers(Modifiers), ShortcutCaptureCancel, ShortcutCaptureStart(shortcuts::KeyBindAction), + ShortcutConflictCancel, + ShortcutConflictReplace, ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource), MouseEnter(pane_grid::Pane), Opacity(u8), @@ -444,6 +446,13 @@ pub enum ContextPage { 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, @@ -488,6 +497,8 @@ pub struct App { show_advanced_font_settings: bool, show_keyboard_shortcuts: bool, shortcut_capture: Option, + shortcut_conflict: Option, + shortcut_conflict_overlay_restore: Option, modifiers: Modifiers, #[cfg(feature = "password_manager")] password_mgr: password_manager::PasswordManager, @@ -577,6 +588,37 @@ impl App { 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 update_config(&mut self) -> Task { let theme = self.config.app_theme.theme(); @@ -1744,6 +1786,8 @@ impl Application for App { show_advanced_font_settings: false, show_keyboard_shortcuts: false, shortcut_capture: None, + shortcut_conflict: None, + shortcut_conflict_overlay_restore: None, modifiers: Modifiers::empty(), #[cfg(feature = "password_manager")] password_mgr: Default::default(), @@ -2284,6 +2328,12 @@ impl Application for App { config_set!(focus_follow_mouse, focus_follow_mouse); } Message::Key(modifiers, key) => { + if self.shortcut_conflict.is_some() { + if key == Key::Named(Named::Escape) { + self.clear_shortcut_conflict(); + } + return Task::none(); + } if let Some(action) = self.shortcut_capture { if key == Key::Named(Named::Escape) { self.shortcut_capture = None; @@ -2291,8 +2341,20 @@ impl Application for App { } 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(); + 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(); } @@ -2337,6 +2399,15 @@ impl Application for App { 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 => { @@ -2988,6 +3059,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)] } diff --git a/src/shortcuts.rs b/src/shortcuts.rs index 74c9ac7..e5bbd5f 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -218,6 +218,18 @@ impl ShortcutsConfig { bindings } + pub fn action_for_binding(&self, binding: &Binding) -> Option { + if let Some(action) = self.custom.0.get(binding) { + if *action == KeyBindAction::Unbind { + return None; + } + return Some(*action); + } + + let defaults = self.defaults_or_fallback(); + defaults.0.get(binding).copied() + } + fn defaults_or_fallback(&self) -> Shortcuts { if self.defaults.0.is_empty() { fallback_shortcuts() From 1564a77e5feb27f6d180ecbbd70e75a2b22464bd Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 10:34:58 -0700 Subject: [PATCH 06/14] Shortcut design updates and fix escape behavior --- src/main.rs | 246 +++++++++++++++++++++----------------------- src/terminal_box.rs | 6 +- 2 files changed, 117 insertions(+), 135 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8897b33..a60157a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,9 @@ 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::iced_core::keyboard::key::Named; use cosmic::{ Application, ApplicationExt, Element, action, app::{Core, Settings, Task, context_drawer}, @@ -368,7 +368,6 @@ pub enum Message { FindNext, FindPrevious, FindSearchValueChanged(String), - KeyboardShortcuts(bool), MiddleClick(pane_grid::Pane, Option), FocusFollowMouse(bool), Key(Modifiers, Key), @@ -440,6 +439,7 @@ pub enum Message { pub enum ContextPage { About, ColorSchemes(ColorSchemeKind), + KeyboardShortcuts, Profiles, Settings, #[cfg(feature = "password_manager")] @@ -495,7 +495,6 @@ 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, shortcut_conflict: Option, shortcut_conflict_overlay_restore: Option, @@ -574,10 +573,9 @@ impl App { 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, - ) { + if let Err(err) = + config_handler.set("shortcuts_custom", &self.config.shortcuts_custom) + { log::warn!("failed to save shortcuts custom config: {}", err); } } @@ -946,6 +944,88 @@ impl App { widget::settings::view_column(sections).into() } + fn keyboard_shortcuts(&self) -> Element<'_, Message> { + let cosmic_theme::Spacing { + space_xxs, + space_xs, + space_m, + .. + } = self.core().system_theme().cosmic().spacing; + + let pad_m = [space_xxs, space_m]; + let div_m = 16; + let pad_l = [space_xxs, space_m + 32]; + let div_l = div_m + 32; + + let mut groups = Vec::new(); + for group in shortcuts::shortcut_groups() { + let mut list = widget::list::list_column(); + + for action in group.actions { + let bindings = self.shortcuts_config.bindings_for_action(action); + + list = list.list_item_padding(pad_m); + list = list.add( + widget::settings::item::builder(shortcuts::action_label(action)).control( + widget::button::custom(icon_cache_get("list-add-symbolic", 16)) + .class(style::Button::Icon) + .on_press(Message::ShortcutCaptureStart(action)), + ), + ); + list = list.divider_padding(div_m); + + if bindings.is_empty() { + list = list.list_item_padding(pad_l); + list = list.add(widget::text::body(fl!("no-shortcuts"))); + list = list.divider_padding(div_l); + } else { + for resolved in bindings { + list = list.list_item_padding(pad_l); + 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_l); + } + } + + if self.shortcut_capture == Some(action) { + list = list.list_item_padding(pad_l); + list = list.add( + widget::row::with_children(vec![ + widget::text::body(fl!("shortcut-capture-hint")).into(), + widget::horizontal_space().into(), + widget::button::standard(fl!("cancel")) + .on_press(Message::ShortcutCaptureCancel) + .into(), + ]) + .spacing(space_xxs), + ); + list = list.divider_padding(div_l); + } + } + + groups.push( + widget::settings::section::with_column(list) + .title(group.title) + .into(), + ); + } + + widget::column::with_children(groups) + .spacing(space_xs) + .into() + } + fn profiles(&self) -> Element<'_, Message> { let cosmic_theme::Spacing { space_s, @@ -1143,10 +1223,6 @@ 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, @@ -1327,117 +1403,16 @@ impl App { .toggler(self.config.focus_follow_mouse, Message::FocusFollowMouse), ); - let mut shortcuts_section = widget::settings::section() + let shortcuts_section = widget::settings::section() .title(fl!("keyboard-shortcuts")) .add( widget::settings::item::builder(fl!("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), + widget::button::custom(icon_cache_get("go-next-symbolic", 16)) + .on_press(Message::ToggleContextPage(ContextPage::KeyboardShortcuts)) + .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(fl!("shortcut-capture-hint")) - .into(), - widget::horizontal_space().into(), - widget::button::standard(fl!("cancel")) - .on_press(Message::ShortcutCaptureCancel) - .into(), - ]) - .spacing(space_xxs) - .into(), - ); - } - - if bindings.is_empty() { - rows.push(widget::text::body(fl!("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(fl!("add-shortcut")) - .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")) @@ -1784,7 +1759,6 @@ 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, shortcut_conflict: None, shortcut_conflict_overlay_restore: None, @@ -1802,12 +1776,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; @@ -2069,8 +2051,7 @@ impl Application for App { } Message::Config(config) => { if config != self.config { - let shortcuts_changed = - config.shortcuts_custom != self.config.shortcuts_custom; + 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; @@ -2308,12 +2289,6 @@ 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([ @@ -2975,6 +2950,12 @@ impl Application for App { }); } + if let ContextPage::KeyboardShortcuts = context_page { + self.shortcut_capture = None; + self.shortcut_conflict = None; + self.shortcut_conflict_overlay_restore = None; + } + #[cfg(feature = "password_manager")] if ContextPage::PasswordManager == context_page { self.password_mgr.pane = Some(self.pane_model.focused()); @@ -3045,6 +3026,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), diff --git a/src/terminal_box.rs b/src/terminal_box.rs index 7325bb0..3dc1b8d 100644 --- a/src/terminal_box.rs +++ b/src/terminal_box.rs @@ -46,11 +46,7 @@ use std::{ }; use crate::{ - Action, - Terminal, - TerminalScroll, - menu::MenuState, - mouse_reporter::MouseReporter, + Action, Terminal, TerminalScroll, menu::MenuState, mouse_reporter::MouseReporter, terminal::Metadata, }; From 92d22621e4b060df88cead8582454bc168b16fbe Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 11:25:53 -0700 Subject: [PATCH 07/14] Implement shortcut search --- Cargo.lock | 1 + Cargo.toml | 1 + i18n/en/cosmic_term.ftl | 1 + src/main.rs | 106 +++++++++++++++++++++++++++++++++------- 4 files changed, 91 insertions(+), 18 deletions(-) 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 ba0a5bd..b4f8c2d 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -63,6 +63,7 @@ show-headerbar = Show header show-header-description = Reveal the header from the right-click menu. ### Keyboard shortcuts +type-to-search = Type to search... keyboard-shortcuts = Keyboard shortcuts customize-shortcuts = Customize shortcuts shortcut-capture-hint = Press new shortcut, or Esc to cancel diff --git a/src/main.rs b/src/main.rs index a60157a..b730537 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,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, @@ -379,6 +380,7 @@ pub enum Message { ShortcutConflictCancel, ShortcutConflictReplace, ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource), + ShortcutSearch(String), MouseEnter(pane_grid::Pane), Opacity(u8), PaneClicked(pane_grid::Pane), @@ -498,6 +500,10 @@ pub struct App { 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, @@ -617,6 +623,15 @@ impl App { } } + 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(); @@ -712,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() { @@ -946,10 +970,7 @@ impl App { fn keyboard_shortcuts(&self) -> Element<'_, Message> { let cosmic_theme::Spacing { - space_xxs, - space_xs, - space_m, - .. + space_xxs, space_m, .. } = self.core().system_theme().cosmic().spacing; let pad_m = [space_xxs, space_m]; @@ -958,15 +979,33 @@ impl App { let div_l = div_m + 32; 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 = self.shortcuts_config.bindings_for_action(action); list = list.list_item_padding(pad_m); list = list.add( - widget::settings::item::builder(shortcuts::action_label(action)).control( + widget::settings::item::builder(action_label).control( widget::button::custom(icon_cache_get("list-add-symbolic", 16)) .class(style::Button::Icon) .on_press(Message::ShortcutCaptureStart(action)), @@ -1014,16 +1053,16 @@ impl App { } } - groups.push( - widget::settings::section::with_column(list) - .title(group.title) - .into(), - ); + if found_actions { + groups.push( + widget::settings::section::with_column(list) + .title(group.title) + .into(), + ); + } } - widget::column::with_children(groups) - .spacing(space_xs) - .into() + widget::settings::view_column(groups).into() } fn profiles(&self) -> Element<'_, Message> { @@ -1762,13 +1801,21 @@ impl Application for App { 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(), }; app.set_curr_font_weights_and_stretches(); - let command = Task::batch([app.update_config(), app.update_title(None)]); + let command = Task::batch([ + app.update_config(), + app.update_title(None), + app.update(Message::ToggleContextPage(ContextPage::KeyboardShortcuts)), + ]); (app, command) } @@ -2397,6 +2444,26 @@ impl Application for App { } 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)); } @@ -2909,6 +2976,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 { @@ -2951,9 +3022,8 @@ impl Application for App { } if let ContextPage::KeyboardShortcuts = context_page { - self.shortcut_capture = None; - self.shortcut_conflict = None; - self.shortcut_conflict_overlay_restore = None; + self.shortcut_page_toggle(); + return self.update_focus(); } #[cfg(feature = "password_manager")] From 182e1549fd08f37b03f4142c7d8ff78d0872892b Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 11:31:05 -0700 Subject: [PATCH 08/14] Move keyboard shortcuts to menu item --- i18n/en/cosmic_term.ftl | 1 + src/main.rs | 13 ++----------- src/menu.rs | 5 +++++ 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index b4f8c2d..0eda393 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -65,6 +65,7 @@ show-header-description = Reveal the header from the right-click menu. ### Keyboard shortcuts type-to-search = Type to search... keyboard-shortcuts = Keyboard shortcuts +menu-keyboard-shortcuts = Keyboard shortcuts... customize-shortcuts = Customize shortcuts shortcut-capture-hint = Press new shortcut, or Esc to cancel cancel = Cancel diff --git a/src/main.rs b/src/main.rs index b730537..4ead9d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -237,6 +237,7 @@ pub enum Action { CopyOrSigint, CopyPrimary, Find, + KeyboardShortcuts, LaunchUrlByMenu, PaneFocusDown, PaneFocusLeft, @@ -288,6 +289,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), @@ -1442,16 +1444,6 @@ impl App { .toggler(self.config.focus_follow_mouse, Message::FocusFollowMouse), ); - let shortcuts_section = widget::settings::section() - .title(fl!("keyboard-shortcuts")) - .add( - widget::settings::item::builder(fl!("customize-shortcuts")).control( - widget::button::custom(icon_cache_get("go-next-symbolic", 16)) - .on_press(Message::ToggleContextPage(ContextPage::KeyboardShortcuts)) - .class(style::Button::Icon), - ), - ); - let advanced_section = widget::settings::section().title(fl!("advanced")).add( widget::settings::item::builder(fl!("show-headerbar")) .description(fl!("show-header-description")) @@ -1462,7 +1454,6 @@ impl App { appearance_section.into(), font_section.into(), splits_section.into(), - shortcuts_section.into(), advanced_section.into(), ]) .into() 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( From 6752b9a434792bfbfb6c7ab1b938623de48bb62d Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 11:50:32 -0700 Subject: [PATCH 09/14] Do not automatically show keyboard shortcuts --- src/main.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4ead9d5..79b21ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1802,11 +1802,7 @@ impl Application for App { }; app.set_curr_font_weights_and_stretches(); - let command = Task::batch([ - app.update_config(), - app.update_title(None), - app.update(Message::ToggleContextPage(ContextPage::KeyboardShortcuts)), - ]); + let command = Task::batch([app.update_config(), app.update_title(None)]); (app, command) } From 1b980df309c7fd5c25083933bf7361a1e8dece75 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 12:57:56 -0700 Subject: [PATCH 10/14] Implement key bind reset --- i18n/en/cosmic_term.ftl | 40 ++++++++++++------------- res/icons/edit-undo-symbolic.svg | 10 +++++++ src/icon_cache.rs | 1 + src/main.rs | 44 +++++++++++++++++---------- src/shortcuts.rs | 51 +++++++++++++++++++++----------- 5 files changed, 93 insertions(+), 53 deletions(-) create mode 100644 res/icons/edit-undo-symbolic.svg diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 0eda393..4342436 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -63,33 +63,33 @@ show-headerbar = Show header show-header-description = Reveal the header from the right-click menu. ### Keyboard shortcuts -type-to-search = Type to search... -keyboard-shortcuts = Keyboard shortcuts -menu-keyboard-shortcuts = Keyboard shortcuts... -customize-shortcuts = Customize shortcuts -shortcut-capture-hint = Press new shortcut, or Esc to cancel +add-another-keybinding = Add another keybinding cancel = Cancel -replace = Replace -shortcut-replace-title = Replace shortcut? -shortcut-replace-body = { $binding } is already assigned to { $existing }. Replace it with { $new_action }? -no-shortcuts = No shortcuts -add-shortcut = + Add -shortcut-group-clipboard = Clipboard -shortcut-group-tabs = Tabs -shortcut-group-window = Window -shortcut-group-zoom = Zoom -shortcut-group-other = Other -unbind = Unbind +close-window = Close window copy-or-sigint = Copy or SIGINT -paste-primary = Paste primary +focus-pane-down = Focus pane down focus-pane-left = Focus pane left focus-pane-right = Focus pane right focus-pane-up = Focus pane up -focus-pane-down = Focus pane down -toggle-fullscreen = Toggle fullscreen -close-window = Close window +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 new shortcut, or Esc to cancel +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... +unbind = Unbind # Find find-placeholder = Find... 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/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/main.rs b/src/main.rs index 79b21ff..3ca2723 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,10 +163,7 @@ fn main() -> Result<(), Box> { } }; - let shortcuts_config = shortcuts::ShortcutsConfig { - defaults: shortcuts::Shortcuts::default(), - custom: config.shortcuts_custom.clone(), - }; + 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 { @@ -382,6 +379,7 @@ pub enum Message { ShortcutConflictCancel, ShortcutConflictReplace, ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource), + ShortcutReset(shortcuts::KeyBindAction), ShortcutSearch(String), MouseEnter(pane_grid::Pane), Opacity(u8), @@ -1003,16 +1001,28 @@ impl App { } found_actions = true; - let bindings = self.shortcuts_config.bindings_for_action(action); + 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_m); - list = list.add( - widget::settings::item::builder(action_label).control( - widget::button::custom(icon_cache_get("list-add-symbolic", 16)) - .class(style::Button::Icon) - .on_press(Message::ShortcutCaptureStart(action)), - ), - ); + list = list.add(widget::settings::item::builder(action_label).control(buttons)); list = list.divider_padding(div_m); if bindings.is_empty() { @@ -2090,10 +2100,8 @@ impl Application for App { //TODO: update syntax theme by clearing tabs, only if needed self.config = config; if shortcuts_changed { - self.shortcuts_config = shortcuts::ShortcutsConfig { - defaults: shortcuts::Shortcuts::default(), - custom: self.config.shortcuts_custom.clone(), - }; + self.shortcuts_config = + shortcuts::ShortcutsConfig::new(self.config.shortcuts_custom.clone()); self.key_binds = key_binds(&self.shortcuts_config); } return self.update_config(); @@ -2431,6 +2439,10 @@ impl Application for App { } 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; diff --git a/src/shortcuts.rs b/src/shortcuts.rs index e5bbd5f..b9b0d4a 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -163,38 +163,47 @@ pub struct ResolvedBinding { pub source: BindingSource, } -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ShortcutsConfig { - pub defaults: Shortcuts, + 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(); - let defaults = self.defaults_or_fallback(); - insert_shortcuts(&defaults, &mut binds, false); + insert_shortcuts(&self.defaults, &mut binds, false); insert_shortcuts(&self.custom, &mut binds, true); binds } - pub fn bindings_for_action(&self, action: KeyBindAction) -> Vec { + pub fn bindings_for_action(&self, action: KeyBindAction) -> (Vec, bool) { let mut bindings = Vec::new(); - let defaults = self.defaults_or_fallback(); - for (binding, default_action) in &defaults.0 { + 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::Unbind) => (), + Some(KeyBindAction::Unbind) => { + changed = true; + } Some(custom_action) => { if *custom_action == action { bindings.push(ResolvedBinding { binding: binding.clone(), source: BindingSource::Custom, }); + changed = true; } } None => bindings.push(ResolvedBinding { @@ -212,10 +221,11 @@ impl ShortcutsConfig { binding: binding.clone(), source: BindingSource::Custom, }); + changed = true; } } - bindings + (bindings, changed) } pub fn action_for_binding(&self, binding: &Binding) -> Option { @@ -226,16 +236,23 @@ impl ShortcutsConfig { return Some(*action); } - let defaults = self.defaults_or_fallback(); - defaults.0.get(binding).copied() + self.defaults.0.get(binding).copied() } - fn defaults_or_fallback(&self) -> Shortcuts { - if self.defaults.0.is_empty() { - fallback_shortcuts() - } else { - self.defaults.clone() - } + 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 + }); } } From b9b21532307ba04e8f734624c41bbe77cc84c098 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 13:17:18 -0700 Subject: [PATCH 11/14] Adjust to design and hard-code Copy and Paste special keys --- src/main.rs | 58 ++++++++++++++++++++++++++++++------------------ src/shortcuts.rs | 4 ---- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3ca2723..5d8a86b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -970,13 +970,18 @@ impl App { fn keyboard_shortcuts(&self) -> Element<'_, Message> { let cosmic_theme::Spacing { - space_xxs, space_m, .. + space_xxs, + space_s, + space_m, + space_l, + space_xl, + .. } = self.core().system_theme().cosmic().spacing; - let pad_m = [space_xxs, space_m]; - let div_m = 16; - let pad_l = [space_xxs, space_m + 32]; - let div_l = div_m + 32; + 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 @@ -1021,17 +1026,22 @@ impl App { widget::tooltip::Position::Top, )); - list = list.list_item_padding(pad_m); - list = list.add(widget::settings::item::builder(action_label).control(buttons)); - list = list.divider_padding(div_m); + 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_l); + list = list.list_item_padding(pad_binding); list = list.add(widget::text::body(fl!("no-shortcuts"))); - list = list.divider_padding(div_l); + list = list.divider_padding(div_binding); } else { for resolved in bindings { - list = list.list_item_padding(pad_l); + list = list.list_item_padding(pad_binding); list = list.add( widget::settings::item::builder(shortcuts::binding_display( &resolved.binding, @@ -1045,12 +1055,12 @@ impl App { )), ), ); - list = list.divider_padding(div_l); + list = list.divider_padding(div_binding); } } if self.shortcut_capture == Some(action) { - list = list.list_item_padding(pad_l); + list = list.list_item_padding(pad_binding); list = list.add( widget::row::with_children(vec![ widget::text::body(fl!("shortcut-capture-hint")).into(), @@ -1061,7 +1071,7 @@ impl App { ]) .spacing(space_xxs), ); - list = list.divider_padding(div_l); + list = list.divider_padding(div_binding); } } @@ -2345,17 +2355,19 @@ impl Application for App { config_set!(focus_follow_mouse, focus_follow_mouse); } Message::Key(modifiers, key) => { - if self.shortcut_conflict.is_some() { - if key == Key::Named(Named::Escape) { - self.clear_shortcut_conflict(); + // Hard-coded keys + match key { + Key::Named(Named::Copy) => { + return self.update(Message::Copy(None)); } - return Task::none(); + Key::Named(Named::Paste) => { + return self.update(Message::Paste(None)); + } + _ => {} } + + // Handle shortcut capture 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; if let Some(existing_action) = @@ -2375,6 +2387,8 @@ impl Application for App { } 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)); diff --git a/src/shortcuts.rs b/src/shortcuts.rs index b9b0d4a..7e922e9 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -446,14 +446,12 @@ fn fallback_shortcuts() -> Shortcuts { // Standard key bindings bind!([Ctrl, Shift], "A", SelectAll); bind!([Ctrl, Shift], "C", Copy); - bind!([], "Copy", 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!([], "Paste", Paste); bind!([Shift], "Insert", PastePrimary); bind!([Ctrl, Shift], "W", TabClose); bind!([Ctrl], ",", Settings); @@ -507,8 +505,6 @@ fn fallback_shortcuts() -> Shortcuts { 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)), From 7d2f631c5d1e1e1249495eba871d4d1dec72eb85 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 13:36:38 -0700 Subject: [PATCH 12/14] More design adjustments --- i18n/en/cosmic_term.ftl | 2 +- src/main.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 4342436..2becb3c 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -78,7 +78,7 @@ password-manager = Password manager paste-primary = Paste primary replace = Replace reset-to-default = Reset to default -shortcut-capture-hint = Press new shortcut, or Esc to cancel +shortcut-capture-hint = Press the key combination shortcut-group-clipboard = Clipboard shortcut-group-other = Other shortcut-group-tabs = Tabs diff --git a/src/main.rs b/src/main.rs index 5d8a86b..f69aa5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1062,10 +1062,11 @@ impl App { if self.shortcut_capture == Some(action) { list = list.list_item_padding(pad_binding); list = list.add( - widget::row::with_children(vec![ - widget::text::body(fl!("shortcut-capture-hint")).into(), - widget::horizontal_space().into(), - widget::button::standard(fl!("cancel")) + 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(), ]) From 3bb79a36acf63cee67b4c07e9966d00aeb6ca929 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 13:39:32 -0700 Subject: [PATCH 13/14] Fix improper handling of escape --- src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index f69aa5f..58b44e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2364,6 +2364,10 @@ impl Application for App { Key::Named(Named::Paste) => { return self.update(Message::Paste(None)); } + Key::Named(Named::Escape) => { + // Handled by on_escape + return Task::none(); + } _ => {} } From f6f8772f5dd4e14580169069889cae6dfbf29322 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Thu, 5 Feb 2026 13:49:05 -0700 Subject: [PATCH 14/14] Change Unbind action to Disable to match cosmic-settings --- i18n/en/cosmic_term.ftl | 2 +- src/main.rs | 2 +- src/shortcuts.rs | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 2becb3c..00d892c 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -67,6 +67,7 @@ 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 @@ -89,7 +90,6 @@ shortcut-replace-title = Replace shortcut? tab-activate = Activate tab { $number } toggle-fullscreen = Toggle fullscreen type-to-search = Type to search... -unbind = Unbind # Find find-placeholder = Find... diff --git a/src/main.rs b/src/main.rs index 58b44e7..22dcb69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2450,7 +2450,7 @@ impl Application for App { self.shortcuts_config .custom .0 - .insert(binding, shortcuts::KeyBindAction::Unbind); + .insert(binding, shortcuts::KeyBindAction::Disable); } shortcuts::BindingSource::Custom => { self.shortcuts_config.custom.0.remove(&binding); diff --git a/src/shortcuts.rs b/src/shortcuts.rs index 7e922e9..427ed19 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -56,7 +56,7 @@ impl Binding { #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum KeyBindAction { - Unbind, + Disable, ClearScrollback, Copy, CopyOrSigint, @@ -98,7 +98,7 @@ pub enum KeyBindAction { impl KeyBindAction { fn to_action(self) -> Option { match self { - Self::Unbind => None, + Self::Disable => None, Self::ClearScrollback => Some(Action::ClearScrollback), Self::Copy => Some(Action::Copy), Self::CopyOrSigint => Some(Action::CopyOrSigint), @@ -194,7 +194,7 @@ impl ShortcutsConfig { } match self.custom.0.get(binding) { - Some(KeyBindAction::Unbind) => { + Some(KeyBindAction::Disable) => { changed = true; } Some(custom_action) => { @@ -230,7 +230,7 @@ impl ShortcutsConfig { pub fn action_for_binding(&self, binding: &Binding) -> Option { if let Some(action) = self.custom.0.get(binding) { - if *action == KeyBindAction::Unbind { + if *action == KeyBindAction::Disable { return None; } return Some(*action); @@ -258,7 +258,7 @@ impl ShortcutsConfig { pub fn action_label(action: KeyBindAction) -> String { match action { - KeyBindAction::Unbind => fl!("unbind"), + KeyBindAction::Disable => fl!("disable"), KeyBindAction::ClearScrollback => fl!("clear-scrollback"), KeyBindAction::Copy => fl!("copy"), KeyBindAction::CopyOrSigint => fl!("copy-or-sigint"), @@ -406,7 +406,7 @@ pub fn binding_from_key(modifiers: Modifiers, key: Key) -> Option { fn insert_shortcuts( shortcuts: &Shortcuts, binds: &mut HashMap, - allow_unbind: bool, + allow_disable: bool, ) { for (binding, action) in &shortcuts.0 { let key_bind = match binding.to_key_bind() { @@ -416,7 +416,7 @@ fn insert_shortcuts( continue; } }; - if allow_unbind && *action == KeyBindAction::Unbind { + if allow_disable && *action == KeyBindAction::Disable { binds.remove(&key_bind); continue; }