diff --git a/src/app.rs b/src/app.rs index fc14b39..c68927f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -49,6 +49,28 @@ pub enum SettingsTab { Commands, } +#[derive(Debug, Clone, PartialEq)] +pub enum HotkeyTarget { + Toggle, + Clipboard, + Shell(Shelly), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum HotkeyCapture { + Idle, + Recording { + target: HotkeyTarget, + candidate: Option, + }, +} + +impl HotkeyCapture { + pub fn is_recording(&self) -> bool { + matches!(self, Self::Recording { .. }) + } +} + /// Actions that open a native file dialog #[derive(Debug, Clone)] pub enum FileDialogAction { @@ -65,22 +87,10 @@ pub enum ResetField { Placeholder, SearchUrl, DebounceDelay, - StartAtLogin, - AutoUpdate, - HapticFeedback, - ShowMenubarIcon, - ClipboardHistory, - ClipboardPasteOnSelect, - MainPage, - ShowScrollbar, - ClearOnHide, - ClearOnEnter, - ShowIcons, Font, EventDuration, TextColor, BackgroundColor, - ThemeMode, Aliases, Modes, SearchDirs, @@ -169,6 +179,16 @@ pub enum Message { SimulatePaste(i32), OpenSettingsWindow, SettingsWindowOpened(window::Id), + KeyboardEvent { + event: iced::Event, + window: window::Id, + }, + BeginHotkeyCapture(HotkeyTarget), + HotkeyCaptureKeyPressed { + physical_key: iced::keyboard::key::Physical, + modifiers: iced::keyboard::Modifiers, + }, + FinishHotkeyCapture, LoadClipboardData(u32), } @@ -176,8 +196,6 @@ pub enum Message { #[allow(unused)] pub enum SetConfigFields { ToDefault, - ToggleHotkey(String), - ClipboardHotkey(String), SetPosition(Position), PlaceHolder(String), SearchUrl(String), diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 2c8739c..852c18c 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -31,11 +31,12 @@ use crate::app::FileDialogAction; use crate::app::ResetField; use crate::app::SetConfigBufferFields; use crate::app::SetConfigThemeFields; -use crate::app::SettingsTab; +use crate::app::{HotkeyCapture, HotkeyTarget, SettingsTab}; use crate::commands::Function; use crate::config::MainPage; use crate::config::Shelly; use crate::config::ThemeMode; +use crate::platform::macos::launching::Shortcut; use crate::styles::delete_button_style; use crate::styles::settings_add_button_style; use crate::styles::settings_radio_button_style; @@ -53,7 +54,11 @@ const SETTINGS_ITEM_HEIGHT: u32 = 55; const SETTINGS_ITEM_COL_SPACING: u32 = 3; const SETTINGS_INPUT_WIDTH: f32 = 250.0; -pub fn settings_page(config: Config, settings_tab: SettingsTab) -> Element<'static, Message> { +pub fn settings_page( + config: Config, + settings_tab: SettingsTab, + hotkey_capture: HotkeyCapture, +) -> Element<'static, Message> { let config = Box::new(config.clone()); let theme = config.theme.clone(); @@ -83,9 +88,9 @@ pub fn settings_page(config: Config, settings_tab: SettingsTab) -> Element<'stat .align_x(Alignment::Center); let tab_content: Column<'static, Message> = match settings_tab { - SettingsTab::General => general_tab(config.clone(), theme.clone()), + SettingsTab::General => general_tab(config.clone(), theme.clone(), hotkey_capture), SettingsTab::Appearance => appearance_tab(config.clone(), theme.clone()), - SettingsTab::Commands => commands_tab(config.clone(), theme.clone()), + SettingsTab::Commands => commands_tab(config.clone(), theme.clone(), hotkey_capture), }; let contents_column = Column::from_iter([ @@ -166,44 +171,44 @@ fn reset_button(theme: crate::config::Theme, field: ResetField) -> Element<'stat .into() } -fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'static, Message> { - let theme_clone = theme.clone(); +fn general_tab( + config: Box, + theme: crate::config::Theme, + hotkey_capture: HotkeyCapture, +) -> Column<'static, Message> { let hotkey = settings_row_with_reset( settings_item_row([ settings_hint_text( theme.clone(), "Toggle hotkey", - Some("Use \"+\" as a seperator"), + Some("Click the field to record and ESC once done"), ), Space::new().width(Length::Fill).into(), - text_input("Toggle Hotkey", &config.toggle_hotkey) - .on_input(|input| Message::SetConfig(SetConfigFields::ToggleHotkey(input.clone()))) - .on_submit(Message::WriteConfig) - .width(Length::Fixed(SETTINGS_INPUT_WIDTH)) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) - .into(), + hotkey_field( + &config.toggle_hotkey, + HotkeyTarget::Toggle, + &hotkey_capture, + theme.clone(), + ), ]), ResetField::ToggleHotkey, theme.clone(), ); - let theme_clone = theme.clone(); let cb_hotkey = settings_row_with_reset( settings_item_row([ settings_hint_text( theme.clone(), "Clipboard hotkey", - Some("Use \"+\" as a seperator"), + Some("Click the field to record and ESC once done"), ), Space::new().width(Length::Fill).into(), - text_input("Clipboard Hotkey", &config.clipboard_hotkey) - .on_input(|input| { - Message::SetConfig(SetConfigFields::ClipboardHotkey(input.clone())) - }) - .on_submit(Message::WriteConfig) - .width(Length::Fixed(SETTINGS_INPUT_WIDTH)) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) - .into(), + hotkey_field( + &config.clipboard_hotkey, + HotkeyTarget::Clipboard, + &hotkey_capture, + theme.clone(), + ), ]), ResetField::ClipboardHotkey, theme.clone(), @@ -478,6 +483,62 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat .spacing(10) } +fn hotkey_field( + value: &str, + target: HotkeyTarget, + capture: &HotkeyCapture, + theme: crate::config::Theme, +) -> Element<'static, Message> { + let (label, recording) = match capture { + HotkeyCapture::Recording { + target: active_target, + candidate, + } if *active_target == target => ( + candidate + .as_ref() + .map(Shortcut::display_string) + .unwrap_or_else(|| "Press a shortcut…".to_string()), + true, + ), + _ => ( + if value.is_empty() { + "Click to record".to_string() + } else { + Shortcut::parse(value) + .map(|shortcut| shortcut.display_string()) + .unwrap_or_else(|_| value.to_string()) + }, + false, + ), + }; + let theme_clone = theme.clone(); + + Button::new( + Text::new(label) + .font(theme.font()) + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .width(Length::Fixed(SETTINGS_INPUT_WIDTH)) + .height(36) + .padding([0, 12]) + .style(move |_, _| button::Style { + text_color: theme_clone.text_color(1.0), + background: Some(Background::Color(with_alpha( + tint(theme_clone.bg_color(), if recording { 0.16 } else { 0.08 }), + 0.9, + ))), + border: Border { + color: theme_clone.text_color(if recording { 0.8 } else { 0.3 }), + width: if recording { 1.0 } else { 0.2 }, + radius: Radius::new(10), + }, + ..Default::default() + }) + .on_press(Message::BeginHotkeyCapture(target)) + .into() +} + fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'static, Message> { let theme_clone = theme.clone(); let theme_mode_setting = settings_row_without_reset(settings_item_row([ @@ -796,7 +857,11 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s .spacing(10) } -fn commands_tab(config: Box, theme: crate::config::Theme) -> Column<'static, Message> { +fn commands_tab( + config: Box, + theme: crate::config::Theme, + hotkey_capture: HotkeyCapture, +) -> Column<'static, Message> { Column::from_iter([ section_header_with_reset("Aliases", ResetField::Aliases, theme.clone()), aliases_item(config.aliases.clone(), &theme), @@ -806,7 +871,7 @@ fn commands_tab(config: Box, theme: crate::config::Theme) -> Column<'sta search_dirs_item(&theme, config.search_dirs.clone()), Space::new().height(10).into(), section_header_with_reset("Shell commands", ResetField::ShellCommands, theme.clone()), - shell_commands_item(config.shells.clone(), theme.clone()), + shell_commands_item(config.shells.clone(), theme.clone(), hotkey_capture), ]) .spacing(10) } @@ -1129,9 +1194,17 @@ fn dir_adder_button(dir: &str, theme: Theme) -> Button<'static, Message> { .style(move |_, _| settings_add_button_style(&theme.clone())) } -fn shell_commands_item(shells: Vec, theme: Theme) -> Element<'static, Message> { - let mut col = - Column::from_iter(shells.iter().map(|x| x.editable_render(theme.clone()))).spacing(30); +fn shell_commands_item( + shells: Vec, + theme: Theme, + hotkey_capture: HotkeyCapture, +) -> Element<'static, Message> { + let mut col = Column::from_iter( + shells + .iter() + .map(|shell| shell.editable_render(theme.clone(), hotkey_capture.clone())), + ) + .spacing(30); let theme_clone = theme.clone(); @@ -1154,7 +1227,11 @@ fn shell_commands_item(shells: Vec, theme: Theme) -> Element<'static, Me } impl Shelly { - pub fn editable_render(&self, theme: Theme) -> Element<'static, Message> { + pub fn editable_render( + &self, + theme: Theme, + hotkey_capture: HotkeyCapture, + ) -> Element<'static, Message> { let shell = self.to_owned(); Column::from_iter([ tuple_row( @@ -1235,24 +1312,12 @@ impl Shelly { .into(), tuple_row( shellcommand_hint_text(theme.clone(), "Hotkey"), - text_input_cell( - self.hotkey.clone().unwrap_or("".to_string()), - &theme, - "Hotkey", - ) - .on_input({ - let shell = shell.clone(); - move |input| { - let old = shell.clone(); - let mut new = old.clone(); - new.hotkey = Some(input); - Message::SetConfig(SetConfigFields::ShellCommands(Editable::Update { - old, - new, - })) - } - }) - .into(), + hotkey_field( + self.hotkey.as_deref().unwrap_or(""), + HotkeyTarget::Shell(shell.clone()), + &hotkey_capture, + theme.clone(), + ), ) .into(), tuple_row( diff --git a/src/app/tile.rs b/src/app/tile.rs index 2bc6a5a..b81f2e2 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -3,7 +3,7 @@ pub mod elm; pub mod update; use crate::app::apps::App; -use crate::app::{ArrowKey, Message, Move, Page}; +use crate::app::{ArrowKey, HotkeyCapture, Message, Move, Page}; use crate::autoupdate::new_version_available; use crate::clipboard::ClipBoardContentType; use crate::config::{Config, Shelly}; @@ -205,6 +205,7 @@ pub struct Tile { pub settings_tab: crate::app::SettingsTab, debouncer: Debouncer, pub settings_window: Option, + pub hotkey_capture: HotkeyCapture, previous_input_source: Option, conn: Connection, } @@ -279,34 +280,34 @@ impl Tile { /// - Keypresses (escape to close the window) /// - Window focus changes pub fn subscription(&self) -> Subscription { - let keyboard = event::listen_with(|event, _, id| match event { - iced::Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(keyboard::key::Named::Escape), - .. - }) => Some(Message::EscKeyPressed(id)), - iced::Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Character(cha), - modifiers: Modifiers::LOGO, - .. - }) => { - if cha.to_string() == "," { - return Some(Message::OpenSettingsWindow); - } - None - } - _ => None, - }); - Subscription::batch([ - Subscription::run(handle_hot_reloading), - keyboard, - Subscription::run(crate::platform::macos::urlscheme::url_stream), - Subscription::run(handle_recipient), - Subscription::run(reload_events), - Subscription::run(handle_version_and_rankings), - Subscription::run(handle_theme_mode), - Subscription::run(handle_clipboard_history), - Subscription::run(handle_file_search), - window::close_events().map(Message::HideWindow), + let is_recording_hotkey = self.hotkey_capture.is_recording(); + let capture_events = if is_recording_hotkey { + event::listen_with(|event, _, window| match event { + iced::Event::Keyboard(_) => Some(Message::KeyboardEvent { event, window }), + _ => None, + }) + } else { + Subscription::none() + }; + let window_keyboard = if is_recording_hotkey { + Subscription::none() + } else { + event::listen_with(|event, _, id| match event { + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + .. + }) => Some(Message::EscKeyPressed(id)), + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(cha), + modifiers: Modifiers::LOGO, + .. + }) if cha == "," => Some(Message::OpenSettingsWindow), + _ => None, + }) + }; + let keyboard = if is_recording_hotkey { + Subscription::none() + } else { keyboard::listen().filter_map(|event| { if let keyboard::Event::KeyPressed { key, modifiers, .. } = event { match key { @@ -348,7 +349,21 @@ impl Tile { } else { None } - }), + }) + }; + Subscription::batch([ + Subscription::run(handle_hot_reloading), + capture_events, + window_keyboard, + Subscription::run(crate::platform::macos::urlscheme::url_stream), + Subscription::run(handle_recipient), + Subscription::run(reload_events), + Subscription::run(handle_version_and_rankings), + Subscription::run(handle_theme_mode), + Subscription::run(handle_clipboard_history), + Subscription::run(handle_file_search), + window::close_events().map(Message::HideWindow), + keyboard, window::events() .with(self.focused) .filter_map(|(focused, (wid, event))| match event { diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index eeb2fd7..770f6f4 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -19,7 +19,7 @@ use rayon::slice::ParallelSliceMut; use crate::app::pages::emoji::emoji_page; use crate::app::pages::settings::settings_page; use crate::app::tile::{AppIndex, Hotkeys}; -use crate::app::{DEFAULT_WINDOW_HEIGHT, SettingsTab, ToApp, ToApps}; +use crate::app::{DEFAULT_WINDOW_HEIGHT, HotkeyCapture, SettingsTab, ToApp, ToApps}; use crate::config::Theme; use crate::database::load_clipboard; use crate::debounce::Debouncer; @@ -109,6 +109,7 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { settings_tab: SettingsTab::General, debouncer: Debouncer::new(config.debounce_delay), settings_window: None, + hotkey_capture: HotkeyCapture::Idle, previous_input_source: None, conn, }, @@ -122,7 +123,11 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { /// The elm View function that renders the entire rustcast window pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { if tile.settings_window == Some(wid) { - return settings_page(tile.config.clone(), tile.settings_tab); + return settings_page( + tile.config.clone(), + tile.settings_tab, + tile.hotkey_capture.clone(), + ); }; if tile.visible { diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index f4b834e..3abbffe 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -21,6 +21,7 @@ use url::Url; use crate::app::Editable; use crate::app::FileDialogAction; +use crate::app::HotkeyTarget; use crate::app::ResetField; use crate::app::SetConfigBufferFields; use crate::app::SetConfigFields; @@ -36,7 +37,7 @@ use crate::app::menubar::menu_builder; use crate::app::menubar::menu_icon; use crate::app::settings_window_settings; use crate::app::tile::AppIndex; -use crate::app::{Message, Page, tile::Tile}; +use crate::app::{HotkeyCapture, Message, Page, tile::Tile}; use crate::autoupdate::download_latest_app; use crate::clipboard::ClipBoardContentType; @@ -113,6 +114,61 @@ fn message_for_open_command(command: &AppCommand) -> Message { } } +fn refresh_global_handler(tile: &mut Tile) { + let Some(sender) = tile.sender.clone() else { + return; + }; + + tile.hotkeys.handle = None; + match global_handler(sender, tile.hotkeys.all_hotkeys()) { + Ok(handle) => tile.hotkeys.handle = Some(handle), + Err(error) => { + log::error!("Error when registering hotkey: {error}"); + std::process::exit(1); + } + } +} + +fn sync_shell_hotkeys(tile: &mut Tile) { + tile.hotkeys.shells = tile + .config + .shells + .iter() + .filter_map(|shell| { + shell + .hotkey + .as_deref() + .and_then(|hotkey| Shortcut::parse(hotkey).ok()) + .map(|hotkey| (hotkey, shell.clone())) + }) + .collect(); +} + +fn is_available_hotkey( + tile: &Tile, + target: &crate::app::HotkeyTarget, + shortcut: &Shortcut, +) -> bool { + match target { + HotkeyTarget::Toggle => { + shortcut != &tile.hotkeys.clipboard_hotkey + && !tile.hotkeys.shells.contains_key(shortcut) + } + HotkeyTarget::Clipboard => { + shortcut != &tile.hotkeys.toggle && !tile.hotkeys.shells.contains_key(shortcut) + } + HotkeyTarget::Shell(shell) => { + shortcut != &tile.hotkeys.toggle + && shortcut != &tile.hotkeys.clipboard_hotkey + && tile + .hotkeys + .shells + .get(shortcut) + .is_none_or(|existing| existing == shell) + } + } +} + /// Handle the "elm" update pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -210,13 +266,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::SetSender(sender) => { tile.sender = Some(sender.clone()); - match global_handler(sender.clone(), tile.hotkeys.all_hotkeys()) { - Ok(a) => tile.hotkeys.handle = Some(a), - Err(e) => { - log::error!("Error when registering hotkey: {e}"); - std::process::exit(1); - } - }; + refresh_global_handler(tile); if tile.config.show_trayicon { tile.tray_icon = Some(menu_icon(tile.config.clone(), sender)); } @@ -572,7 +622,12 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { return Task::none(); } if tile.settings_window == Some(a) { + let was_recording = tile.hotkey_capture.is_recording(); tile.settings_window = None; + tile.hotkey_capture = HotkeyCapture::Idle; + if was_recording { + refresh_global_handler(tile); + } return Task::none(); } info!("Hiding RustCast window"); @@ -844,10 +899,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } } Message::SetConfig(config) => { + let shell_commands_changed = matches!(&config, SetConfigFields::ShellCommands(_)); let mut final_config = tile.config.clone(); match config.clone() { - SetConfigFields::ToggleHotkey(hk) => final_config.toggle_hotkey = hk, - SetConfigFields::ClipboardHotkey(hk) => final_config.clipboard_hotkey = hk, SetConfigFields::ClipboardHistory(cbhist) => final_config.cbhist = cbhist, SetConfigFields::Modes(Editable::Create((key, value))) => { final_config.modes.insert(key, value); @@ -999,11 +1053,19 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { }; tile.config = final_config; + if shell_commands_changed { + sync_shell_hotkeys(tile); + refresh_global_handler(tile); + } tile.theme = tile.config.theme.clone().into(); Task::none() } Message::ResetField(field) => { let default = Config::default(); + let reset_hotkey = matches!( + field, + ResetField::ToggleHotkey | ResetField::ClipboardHotkey + ); match field { ResetField::ToggleHotkey => tile.config.toggle_hotkey = default.toggle_hotkey, ResetField::ClipboardHotkey => { @@ -1012,30 +1074,6 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { ResetField::Placeholder => tile.config.placeholder = default.placeholder, ResetField::SearchUrl => tile.config.search_url = default.search_url, ResetField::DebounceDelay => tile.config.debounce_delay = default.debounce_delay, - ResetField::StartAtLogin => tile.config.start_at_login = default.start_at_login, - ResetField::AutoUpdate => tile.config.auto_update = default.auto_update, - ResetField::HapticFeedback => tile.config.haptic_feedback = default.haptic_feedback, - ResetField::ShowMenubarIcon => tile.config.show_trayicon = default.show_trayicon, - ResetField::ClipboardHistory => tile.config.cbhist = default.cbhist, - ResetField::MainPage => tile.config.main_page = default.main_page, - ResetField::ThemeMode => { - tile.config.theme.theme_mode = default.theme.theme_mode; - let is_dark = crate::platform::macos::is_dark_mode(); - let (text, bg, secondary) = default.theme.theme_mode.presets(is_dark); - tile.config.theme.text_color = text; - tile.config.theme.background_color = bg; - tile.config.theme.secondary_bg_color = secondary; - } - ResetField::ShowScrollbar => { - tile.config.theme.show_scroll_bar = default.theme.show_scroll_bar - } - ResetField::ClearOnHide => { - tile.config.buffer_rules.clear_on_hide = default.buffer_rules.clear_on_hide - } - ResetField::ClearOnEnter => { - tile.config.buffer_rules.clear_on_enter = default.buffer_rules.clear_on_enter - } - ResetField::ShowIcons => tile.config.theme.show_icons = default.theme.show_icons, ResetField::Font => tile.config.theme.font = default.theme.font, ResetField::EventDuration => tile.config.event_duration = default.event_duration, ResetField::TextColor => tile.config.theme.text_color = default.theme.text_color, @@ -1046,9 +1084,19 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { ResetField::Modes => tile.config.modes = default.modes, ResetField::SearchDirs => tile.config.search_dirs = default.search_dirs, ResetField::ShellCommands => tile.config.shells = default.shells, - ResetField::ClipboardPasteOnSelect => { - tile.config.cbhist_paste_on_select = default.cbhist_paste_on_select + } + if reset_hotkey { + if let Ok(shortcut) = Shortcut::parse(&tile.config.toggle_hotkey) { + tile.hotkeys.toggle = shortcut; + } + if let Ok(shortcut) = Shortcut::parse(&tile.config.clipboard_hotkey) { + tile.hotkeys.clipboard_hotkey = shortcut; } + refresh_global_handler(tile); + } + if field == ResetField::ShellCommands { + sync_shell_hotkeys(tile); + refresh_global_handler(tile); } tile.theme = tile.config.theme.clone().into(); Task::none() @@ -1114,6 +1162,87 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } } Message::SettingsWindowOpened(_id) => Task::none(), + Message::KeyboardEvent { event, window } => { + if tile.hotkey_capture.is_recording() && tile.settings_window == Some(window) { + return match event { + iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + .. + }) => Task::done(Message::FinishHotkeyCapture), + iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + physical_key, + modifiers, + repeat: false, + .. + }) => Task::done(Message::HotkeyCaptureKeyPressed { + physical_key, + modifiers, + }), + _ => Task::none(), + }; + } + Task::none() + } + Message::BeginHotkeyCapture(target) => { + tile.hotkey_capture = HotkeyCapture::Recording { + target, + candidate: None, + }; + tile.hotkeys.handle = None; + Task::none() + } + Message::HotkeyCaptureKeyPressed { + physical_key, + modifiers, + } => { + if let HotkeyCapture::Recording { candidate, .. } = &mut tile.hotkey_capture + && let Some(shortcut) = Shortcut::from_iced(physical_key, modifiers) + { + *candidate = Some(shortcut); + } + Task::none() + } + Message::FinishHotkeyCapture => { + let captured = if let HotkeyCapture::Recording { + target, + candidate: Some(shortcut), + } = &tile.hotkey_capture + { + Some((target.clone(), shortcut.clone())) + } else { + None + }; + + if let Some((target, shortcut)) = captured + && is_available_hotkey(tile, &target, &shortcut) + { + match target { + HotkeyTarget::Toggle => { + tile.config.toggle_hotkey = shortcut.to_config_string(); + tile.hotkeys.toggle = shortcut; + } + HotkeyTarget::Clipboard => { + tile.config.clipboard_hotkey = shortcut.to_config_string(); + tile.hotkeys.clipboard_hotkey = shortcut; + } + HotkeyTarget::Shell(shell) => { + if let Some(updated_shell) = tile + .config + .shells + .iter_mut() + .find(|existing| **existing == shell) + { + updated_shell.hotkey = Some(shortcut.to_config_string()); + tile.hotkeys.shells.retain(|_, existing| existing != &shell); + tile.hotkeys.shells.insert(shortcut, updated_shell.clone()); + } + } + } + } + tile.hotkey_capture = HotkeyCapture::Idle; + refresh_global_handler(tile); + Task::none() + } } } @@ -1473,6 +1602,7 @@ mod tests { settings_tab: crate::app::SettingsTab::General, debouncer: crate::debounce::Debouncer::new(10), settings_window: None, + hotkey_capture: HotkeyCapture::Idle, previous_input_source: None, conn: initialise_database(), } diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs index 68e1a6e..1c69b20 100644 --- a/src/platform/macos/launching.rs +++ b/src/platform/macos/launching.rs @@ -17,6 +17,10 @@ use crate::{ app::{Message, tile::ExtSender}, platform::macos::accessibility::ensure_accessibility_permission, }; +use iced::keyboard::{ + Modifiers, + key::{Code, Physical}, +}; #[derive(Clone, Debug)] pub struct EventTapHandle { @@ -231,6 +235,242 @@ impl Shortcut { if has_mods { Some(mods) } else { None }, )) } + + pub fn from_iced(physical_key: Physical, modifiers: Modifiers) -> Option { + let Physical::Code(code) = physical_key else { + return None; + }; + + let key_code = match code { + Code::KeyA => 0x00, + Code::KeyS => 0x01, + Code::KeyD => 0x02, + Code::KeyF => 0x03, + Code::KeyH => 0x04, + Code::KeyG => 0x05, + Code::KeyZ => 0x06, + Code::KeyX => 0x07, + Code::KeyC => 0x08, + Code::KeyV => 0x09, + Code::KeyB => 0x0b, + Code::KeyQ => 0x0c, + Code::KeyW => 0x0d, + Code::KeyE => 0x0e, + Code::KeyR => 0x0f, + Code::KeyY => 0x10, + Code::KeyT => 0x11, + Code::KeyO => 0x1f, + Code::KeyU => 0x20, + Code::KeyI => 0x22, + Code::KeyP => 0x23, + Code::KeyL => 0x25, + Code::KeyJ => 0x26, + Code::KeyK => 0x28, + Code::KeyN => 0x2d, + Code::KeyM => 0x2e, + Code::Digit1 => 0x12, + Code::Digit2 => 0x13, + Code::Digit3 => 0x14, + Code::Digit4 => 0x15, + Code::Digit5 => 0x17, + Code::Digit6 => 0x16, + Code::Digit7 => 0x1a, + Code::Digit8 => 0x1c, + Code::Digit9 => 0x19, + Code::Digit0 => 0x1d, + Code::Enter => 0x24, + Code::Tab => 0x30, + Code::Space => 0x31, + Code::Backspace => 0x33, + Code::Escape => 0x35, + Code::ArrowLeft => 0x7b, + Code::ArrowRight => 0x7c, + Code::ArrowDown => 0x7d, + Code::ArrowUp => 0x7e, + Code::Home => 0x73, + Code::End => 0x77, + Code::PageUp => 0x74, + Code::PageDown => 0x79, + Code::F1 => 0x7a, + Code::F2 => 0x78, + Code::F3 => 0x63, + Code::F4 => 0x76, + Code::F5 => 0x60, + Code::F6 => 0x61, + Code::F7 => 0x62, + Code::F8 => 0x64, + Code::F9 => 0x65, + Code::F10 => 0x6d, + Code::F11 => 0x67, + Code::F12 => 0x6f, + Code::Minus => 0x1b, + Code::Equal => 0x18, + Code::BracketLeft => 0x21, + Code::BracketRight => 0x1e, + Code::Backslash => 0x2a, + Code::Semicolon => 0x29, + Code::Quote => 0x27, + Code::Backquote => 0x32, + Code::Comma => 0x2b, + Code::Period => 0x2f, + Code::Slash => 0x2c, + _ => return None, + }; + + let mut mods = NSEventModifierFlags::empty(); + if modifiers.logo() { + mods |= NSEventModifierFlags::Command; + } + if modifiers.alt() { + mods |= NSEventModifierFlags::Option; + } + if modifiers.control() { + mods |= NSEventModifierFlags::Control; + } + if modifiers.shift() { + mods |= NSEventModifierFlags::Shift; + } + if mods.is_empty() { + return None; + } + + Some(Self::new(Some(key_code), Some(mods.0))) + } + + pub fn to_config_string(&self) -> String { + let mut parts = Vec::new(); + let mods = self.mods.unwrap_or_default(); + if mods & NSEventModifierFlags::Command.0 != 0 { + parts.push("cmd".to_string()); + } + if mods & NSEventModifierFlags::Option.0 != 0 { + parts.push("option".to_string()); + } + if mods & NSEventModifierFlags::Control.0 != 0 { + parts.push("ctrl".to_string()); + } + if mods & NSEventModifierFlags::Shift.0 != 0 { + parts.push("shift".to_string()); + } + if let Some(key_code) = self.key_code.and_then(keycode_to_name) { + parts.push(key_code.to_string()); + } + parts.join("+") + } + + pub fn display_string(&self) -> String { + let mut display = String::new(); + let mods = self.mods.unwrap_or_default(); + if mods & NSEventModifierFlags::Command.0 != 0 { + display.push('⌘'); + } + if mods & NSEventModifierFlags::Option.0 != 0 { + display.push('⌥'); + } + if mods & NSEventModifierFlags::Control.0 != 0 { + display.push('⌃'); + } + if mods & NSEventModifierFlags::Shift.0 != 0 { + display.push('⇧'); + } + if let Some(key) = self.key_code.and_then(keycode_to_name) { + let key = match key { + "return" => "↩", + "space" => "Space", + "delete" => "⌫", + "escape" => "⎋", + "left" => "←", + "right" => "→", + "down" => "↓", + "up" => "↑", + _ => key, + }; + if key.len() == 1 && key.as_bytes()[0].is_ascii_alphabetic() { + display.push_str(&key.to_ascii_uppercase()); + } else { + display.push_str(key); + } + } + display + } +} + +fn keycode_to_name(code: u16) -> Option<&'static str> { + Some(match code { + 0x00 => "a", + 0x01 => "s", + 0x02 => "d", + 0x03 => "f", + 0x04 => "h", + 0x05 => "g", + 0x06 => "z", + 0x07 => "x", + 0x08 => "c", + 0x09 => "v", + 0x0b => "b", + 0x0c => "q", + 0x0d => "w", + 0x0e => "e", + 0x0f => "r", + 0x10 => "y", + 0x11 => "t", + 0x1f => "o", + 0x20 => "u", + 0x22 => "i", + 0x23 => "p", + 0x25 => "l", + 0x26 => "j", + 0x28 => "k", + 0x2d => "n", + 0x2e => "m", + 0x12 => "1", + 0x13 => "2", + 0x14 => "3", + 0x15 => "4", + 0x17 => "5", + 0x16 => "6", + 0x1a => "7", + 0x1c => "8", + 0x19 => "9", + 0x1d => "0", + 0x24 => "return", + 0x30 => "tab", + 0x31 => "space", + 0x33 => "delete", + 0x35 => "escape", + 0x7b => "left", + 0x7c => "right", + 0x7d => "down", + 0x7e => "up", + 0x73 => "home", + 0x77 => "end", + 0x74 => "pageup", + 0x79 => "pagedown", + 0x7a => "f1", + 0x78 => "f2", + 0x63 => "f3", + 0x76 => "f4", + 0x60 => "f5", + 0x61 => "f6", + 0x62 => "f7", + 0x64 => "f8", + 0x65 => "f9", + 0x6d => "f10", + 0x67 => "f11", + 0x6f => "f12", + 0x1b => "-", + 0x18 => "=", + 0x21 => "[", + 0x1e => "]", + 0x2a => "\\", + 0x29 => ";", + 0x27 => "'", + 0x32 => "`", + 0x2b => ",", + 0x2f => ".", + 0x2c => "/", + _ => return None, + }) } fn str_to_keycode(s: &str) -> Result { @@ -316,9 +556,31 @@ fn str_to_keycode(s: &str) -> Result { "," | "comma" => 0x2b, "." | "period" => 0x2f, "/" | "slash" => 0x2c, - _ => return Err(format!("Unknown key: '{}'", s)), }; Ok(code) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recorded_shortcut_uses_config_format() -> Result<(), String> { + let shortcut = Shortcut::from_iced( + Physical::Code(Code::KeyK), + Modifiers::LOGO | Modifiers::SHIFT, + ) + .ok_or_else(|| "modified shortcut should be recorded".to_string())?; + + assert_eq!(shortcut.to_config_string(), "cmd+shift+k"); + assert_eq!(shortcut.display_string(), "⌘⇧K"); + Ok(()) + } + + #[test] + fn recorded_shortcut_requires_a_modifier() { + assert!(Shortcut::from_iced(Physical::Code(Code::KeyK), Modifiers::NONE).is_none()); + } +}