From a30e0ef7c27365797f32f1b6a9481992c7613081 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:30:01 +0200 Subject: [PATCH 1/2] Add configurable global hotkeys Adds global hotkeys for pin, show/hide, next monitor and open settings, with a Hotkeys tab to record them. Bindings live in config.json in the shape suggested in #201 and are registered from Rust through tauri-plugin-global-shortcut. Replaces the unused use-keybinds hook. Refs #201 --- apps/desktop/src-tauri/Cargo.lock | 33 + apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/hotkeys.rs | 761 ++++++++++++++++++++ apps/desktop/src-tauri/src/main.rs | 17 +- apps/desktop/src-tauri/src/tray.rs | 25 +- apps/desktop/src/App.tsx | 2 + apps/desktop/src/config.ts | 11 + apps/desktop/src/hooks/use-hotkeys.ts | 120 +++ apps/desktop/src/hooks/use-keybinds.ts | 45 -- apps/desktop/src/utils/hotkeys.ts | 30 + apps/desktop/src/views/settings/hotkeys.tsx | 288 ++++++++ apps/desktop/src/views/settings/index.tsx | 7 +- 12 files changed, 1280 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src-tauri/src/hotkeys.rs create mode 100644 apps/desktop/src/hooks/use-hotkeys.ts delete mode 100644 apps/desktop/src/hooks/use-keybinds.ts create mode 100644 apps/desktop/src/utils/hotkeys.ts create mode 100644 apps/desktop/src/views/settings/hotkeys.tsx diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0a362fe4..f4929a70 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1681,6 +1681,23 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "global-hotkey" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00d88f1be7bf4cd2e61623ce08e84be2dfa4eab458e5d632d3dab95f16c1f64" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 1.0.63", + "windows-sys 0.59.0", + "x11-dl", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2809,6 +2826,7 @@ dependencies = [ "tauri-build", "tauri-nspanel", "tauri-plugin-fs", + "tauri-plugin-global-shortcut", "tauri-plugin-http", "tauri-plugin-log", "tauri-plugin-notification", @@ -4431,6 +4449,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f646a09511e8d283267dcdaa08c2ef27c4116bf271d9114849d9ca215606c3" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.11", +] + [[package]] name = "tauri-plugin-http" version = "2.3.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1d022cf0..20a9f0a2 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -49,6 +49,7 @@ system-notification = { git = "https://github.com/ahkohd/tauri-toolkit", branch [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2.0.0-rc.3" +tauri-plugin-global-shortcut = "=2.2.0" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs new file mode 100644 index 00000000..59aedb23 --- /dev/null +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -0,0 +1,761 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Mutex, + }, +}; +use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + +use crate::{commands, constants::*, HideTaskbarWhenPinned, Pinned, TrayMenu}; + +const HOTKEYS_UPDATE: &str = "hotkeys_update"; +pub const HOTKEY_ACTION: &str = "hotkey_action"; +pub const SHOW_OVERLAY: &str = "show_overlay"; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum HotkeyCommand { + Pin, + ToggleVisibility, + NextMonitor, + OpenSettings, +} + +#[derive(Clone, Deserialize)] +pub struct Binding { + key: String, + command: HotkeyCommand, +} + +#[derive(Clone, Serialize)] +pub struct BindingResult { + key: String, + command: HotkeyCommand, + ok: bool, + error: Option, +} + +#[derive(Clone, Serialize)] +pub struct HotkeysStatus { + bindings: Vec, + supported: bool, + suspended: bool, +} + +#[derive(Default)] +struct Registrations { + bindings: Vec, + results: Vec, + registered: Vec, +} + +#[derive(Default)] +pub struct HotkeysState { + registrations: Mutex, + suspended: AtomicBool, + generation: AtomicU64, +} + +#[derive(Default)] +pub struct OverlayVisibility { + hidden: AtomicBool, +} + +impl OverlayVisibility { + pub fn is_hidden(&self) -> bool { + self.hidden.load(Ordering::Acquire) + } +} + +pub fn supported() -> bool { + let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) + || std::env::var("XDG_SESSION_TYPE") + .is_ok_and(|value| value.trim().eq_ignore_ascii_case("wayland")); + !cfg!(target_os = "linux") || !wayland +} + +fn parse_binding(key: &str) -> Result { + key.parse().map_err(|_| "Invalid shortcut".to_string()) +} + +fn validate_bindings(bindings: &[Binding]) -> Vec> { + let mut seen = HashSet::new(); + bindings + .iter() + .map(|binding| { + let shortcut = parse_binding(&binding.key)?; + if !seen.insert(shortcut.id()) { + return Err("Already used by another binding".to_string()); + } + Ok(shortcut) + }) + .collect() +} + +fn registration_error(error: tauri_plugin_global_shortcut::Error) -> String { + let message = error.to_string(); + if message.starts_with("HotKey already registerd:") { + "Already in use by another app or reserved by the system".to_string() + } else if message.starts_with("Unable to register hotkey:") { + // The macOS backend does not preserve Carbon's error code. + "Couldn't register shortcut. It may already be in use by another app or reserved by the system" + .to_string() + } else { + format!("Couldn't register shortcut: {message}") + } +} + +fn status(state: &HotkeysState, registrations: &Registrations) -> HotkeysStatus { + HotkeysStatus { + bindings: registrations.results.clone(), + supported: supported(), + suspended: state.suspended.load(Ordering::Acquire), + } +} + +fn publish(app: &AppHandle, status: &HotkeysStatus) { + if let Err(error) = app.emit(HOTKEYS_UPDATE, status) { + log::warn!("Could not publish hotkey registration results: {error}"); + } +} + +fn should_dispatch(state: &HotkeysState, event: ShortcutState, generation: u64) -> bool { + event == ShortcutState::Pressed + && !state.suspended.load(Ordering::Acquire) + && state.generation.load(Ordering::Acquire) == generation +} + +fn reconcile_registrations( + state: &HotkeysState, + registrations: &mut Registrations, + supported: bool, + mut unregister: impl FnMut(Shortcut) -> Result<(), String>, + mut register: impl FnMut(Shortcut, HotkeyCommand, u64) -> Result<(), String>, +) -> HotkeysStatus { + let generation = state.generation.fetch_add(1, Ordering::AcqRel) + 1; + let mut unregister_error = None; + // Retain failed removals so the next update can retry them. Their old handlers + // are inert because their generation no longer matches. + registrations.registered.retain(|shortcut| { + if let Err(error) = unregister(*shortcut) { + log::warn!("Could not unregister hotkey {shortcut}: {error}"); + unregister_error = Some("Couldn't unregister previous hotkeys. Try again.".to_string()); + true + } else { + false + } + }); + + registrations.results = registrations + .bindings + .iter() + .zip(validate_bindings(®istrations.bindings)) + .map(|(binding, parsed)| { + let result = if !supported { + Err("Global hotkeys don't work on Wayland.".to_string()) + } else if let Some(error) = &unregister_error { + Err(error.clone()) + } else { + parsed.and_then(|shortcut| { + if state.suspended.load(Ordering::Acquire) { + return Ok(()); + } + register(shortcut, binding.command, generation).map_err(|error| { + log::warn!("Could not register hotkey {}: {error}", binding.key); + error + })?; + registrations.registered.push(shortcut); + Ok(()) + }) + }; + BindingResult { + key: binding.key.clone(), + command: binding.command, + ok: result.is_ok(), + error: result.err(), + } + }) + .collect(); + HotkeysStatus { + supported, + ..status(state, registrations) + } +} + +fn replace_registrations( + app: &AppHandle, + state: &HotkeysState, + registrations: &mut Registrations, +) -> HotkeysStatus { + let status = reconcile_registrations( + state, + registrations, + supported(), + |shortcut| { + app + .global_shortcut() + .unregister(shortcut) + .map_err(|error| error.to_string()) + }, + |shortcut, command, generation| { + app + .global_shortcut() + .on_shortcut(shortcut, move |app, _, event| { + let state = app.state::(); + if should_dispatch(&state, event.state, generation) { + run_action(app, command); + } + }) + .map_err(registration_error) + }, + ); + publish(app, &status); + status +} + +#[tauri::command] +pub async fn set_hotkeys( + window: WebviewWindow, + state: State<'_, HotkeysState>, + bindings: Vec, +) -> Result { + if window.label() != MAIN_WINDOW_NAME { + return Err("Only the overlay window registers hotkeys".to_string()); + } + let mut registrations = state + .registrations + .lock() + .map_err(|error| error.to_string())?; + registrations.bindings = bindings; + Ok(replace_registrations( + window.app_handle(), + &state, + &mut registrations, + )) +} + +#[tauri::command] +pub async fn get_hotkeys(state: State<'_, HotkeysState>) -> Result { + let registrations = state + .registrations + .lock() + .map_err(|error| error.to_string())?; + Ok(status(&state, ®istrations)) +} + +#[tauri::command] +pub async fn suspend_hotkeys( + app: AppHandle, + state: State<'_, HotkeysState>, + suspended: bool, +) -> Result { + let mut registrations = state + .registrations + .lock() + .map_err(|error| error.to_string())?; + state.suspended.store(suspended, Ordering::Release); + let status = replace_registrations(&app, &state, &mut registrations); + if suspended && !registrations.registered.is_empty() { + return Err("Couldn't suspend previous hotkeys. Try again.".to_string()); + } + Ok(status) +} + +fn run_action(app: &AppHandle, command: HotkeyCommand) { + let result = match command { + HotkeyCommand::Pin => toggle_overlay_pin(app), + HotkeyCommand::OpenSettings => { + if let Some(settings) = app.get_webview_window(SETTINGS_WINDOW_NAME) { + for result in [settings.show(), settings.unminimize(), settings.set_focus()] { + if let Err(error) = result { + log::warn!("Could not open settings from hotkey: {error}"); + } + } + Ok(()) + } else { + Err("The settings window is unavailable".to_string()) + } + } + _ => app + .emit_to(MAIN_WINDOW_NAME, HOTKEY_ACTION, command) + .map_err(|error| error.to_string()), + }; + if let Err(error) = result { + log::warn!("Could not run hotkey {command:?}: {error}"); + } +} + +fn update_pin(app: &AppHandle, value: Option) -> Result<(), String> { + let app_handle = app.clone(); + app + .run_on_main_thread(move || { + let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_NAME) else { + log::warn!("Could not change pin: the overlay window is unavailable"); + return; + }; + let pinned = app_handle.state::(); + let menu = app_handle.state::(); + let taskbar = app_handle.state::(); + match value { + Some(value) => commands::set_pin(window.clone(), pinned, menu, taskbar, value), + None => commands::toggle_pin(window.clone(), pinned, menu, taskbar), + } + // Batch the existing pin path and hidden override on the UI thread, before + // another mouse event can be dispatched. Do not change the logical pin state. + if app_handle.state::().is_hidden() { + if let Err(error) = window.set_ignore_cursor_events(true) { + log::warn!("Could not keep the hidden overlay click-through: {error}"); + } + } + }) + .map_err(|error| error.to_string()) +} + +pub fn toggle_overlay_pin(app: &AppHandle) -> Result<(), String> { + update_pin(app, None) +} + +// Keep the existing IPC names/callers, but make every pin entrypoint respect +// temporary visibility without changing commands.rs or persisting a fake pin. +#[tauri::command] +pub fn guarded_toggle_pin(app: AppHandle) -> Result<(), String> { + toggle_overlay_pin(&app) +} + +#[tauri::command] +pub fn guarded_set_pin(app: AppHandle, value: bool) -> Result<(), String> { + update_pin(&app, Some(value)) +} + +// Tauri exports command macros at crate root, so guards need distinct Rust +// names. Aliases keep the existing IPC names and all callers unchanged. +pub use __cmd__guarded_set_pin as __cmd__set_pin; +pub use __cmd__guarded_toggle_pin as __cmd__toggle_pin; +pub use guarded_set_pin as set_pin; +pub use guarded_toggle_pin as toggle_pin; + +fn ignore_cursor_events(visible: bool, pinned: bool) -> bool { + !visible || pinned +} + +#[tauri::command] +pub async fn set_overlay_visible(app: AppHandle, visible: bool) -> Result<(), String> { + let (send, receive) = tokio::sync::oneshot::channel(); + let app_handle = app.clone(); + app + .run_on_main_thread(move || { + let result = (|| { + let window = app_handle + .get_webview_window(MAIN_WINDOW_NAME) + .ok_or_else(|| "The overlay window is unavailable".to_string())?; + let pinned = commands::get_pin(app_handle.state::()); + window + .set_ignore_cursor_events(ignore_cursor_events(visible, pinned)) + .map_err(|error| error.to_string())?; + app_handle + .state::() + .hidden + .store(!visible, Ordering::Release); + Ok(()) + })(); + if let Err(error) = &result { + log::warn!("Could not change overlay visibility: {error}"); + } + let _ = send.send(result); + }) + .map_err(|error| error.to_string())?; + receive.await.map_err(|error| error.to_string())? +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordedKey { + code: String, + ctrl: bool, + alt: bool, + shift: bool, + super_key: bool, +} + +fn accelerator(key: &RecordedKey) -> Result { + let function_key = key + .code + .strip_prefix('F') + .and_then(|number| number.parse::().ok()) + .is_some_and(|number| (1..=24).contains(&number)); + if !(key.ctrl || key.alt || key.shift || key.super_key || function_key) { + return Err("Use at least one modifier, or an F-key.".to_string()); + } + let mut parts = Vec::new(); + for (enabled, name) in [ + (key.ctrl, "Ctrl"), + (key.alt, "Alt"), + (key.shift, "Shift"), + (key.super_key, "Super"), + ] { + if enabled { + parts.push(name); + } + } + parts.push(&key.code); + let value = parts.join("+"); + parse_binding(&value)?; + Ok(value) +} + +#[tauri::command] +pub fn record_hotkey( + key: RecordedKey, + bindings: Vec, + command: HotkeyCommand, +) -> Result { + let value = accelerator(&key)?; + let shortcut = parse_binding(&value)?; + if bindings.iter().any(|binding| { + binding.command != command && parse_binding(&binding.key).is_ok_and(|other| other == shortcut) + }) { + return Err("Already used by another command.".to_string()); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri_plugin_global_shortcut::{Code, Modifiers}; + + fn key(code: &str, ctrl: bool, alt: bool, shift: bool, super_key: bool) -> RecordedKey { + RecordedKey { + code: code.to_string(), + ctrl, + alt, + shift, + super_key, + } + } + + #[test] + fn recorded_codes_produce_accelerators_accepted_by_the_real_parser() { + for (input, expected, code, modifiers) in [ + ( + key("KeyH", true, false, true, false), + "Ctrl+Shift+KeyH", + Code::KeyH, + Modifiers::CONTROL | Modifiers::SHIFT, + ), + ( + key("Digit1", false, true, false, false), + "Alt+Digit1", + Code::Digit1, + Modifiers::ALT, + ), + ( + key("F1", false, false, false, false), + "F1", + Code::F1, + Modifiers::empty(), + ), + ( + key("F24", false, false, false, false), + "F24", + Code::F24, + Modifiers::empty(), + ), + ( + key("Backquote", true, false, false, false), + "Ctrl+Backquote", + Code::Backquote, + Modifiers::CONTROL, + ), + ( + key("KeyZ", true, true, true, true), + "Ctrl+Alt+Shift+Super+KeyZ", + Code::KeyZ, + Modifiers::CONTROL | Modifiers::ALT | Modifiers::SHIFT | Modifiers::SUPER, + ), + ] { + let value = accelerator(&input).expect("valid recorded key"); + assert_eq!(value, expected); + let shortcut = parse_binding(&value).expect("crate parser accepts output"); + assert_eq!(shortcut.key, code); + assert_eq!(shortcut.mods, modifiers); + } + } + + #[test] + fn unsafe_unmodified_keys_and_unsupported_codes_are_rejected() { + for input in [ + key("KeyA", false, false, false, false), + key("Digit1", false, false, false, false), + key("F25", false, false, false, false), + key("ControlLeft", true, false, false, false), + key("IntlBackslash", true, false, false, false), + ] { + assert!(accelerator(&input).is_err()); + } + } + + #[test] + fn duplicate_detection_uses_parsed_identity_not_spelling() { + let bindings = vec![ + Binding { + key: "CommandOrControl+Shift+H".to_string(), + command: HotkeyCommand::Pin, + }, + Binding { + key: "Shift+CommandOrControl+KeyH".to_string(), + command: HotkeyCommand::NextMonitor, + }, + ]; + assert!(validate_bindings(&bindings)[0].is_ok()); + assert!(validate_bindings(&bindings)[1].is_err()); + let primary = cfg!(target_os = "macos"); + let recorded = key("KeyH", !primary, false, true, primary); + assert!(record_hotkey(recorded, bindings.clone(), HotkeyCommand::OpenSettings).is_err()); + assert!(record_hotkey( + key("KeyH", !primary, false, true, primary), + vec![bindings[0].clone()], + HotkeyCommand::Pin + ) + .is_ok()); + let aliases = vec![ + Binding { + key: "Ctrl+Shift+H".to_string(), + command: HotkeyCommand::Pin, + }, + Binding { + key: "shift+control+KeyH".to_string(), + command: HotkeyCommand::OpenSettings, + }, + ]; + assert!(validate_bindings(&aliases)[1].is_err()); + } + + fn binding(key: &str, command: HotkeyCommand) -> Binding { + Binding { + key: key.to_string(), + command, + } + } + + #[test] + fn replacing_bindings_removes_owned_shortcuts_before_registering_the_new_set() { + let state = HotkeysState::default(); + let old = parse_binding("Ctrl+Shift+H").expect("old shortcut"); + let new = parse_binding("Alt+F2").expect("new shortcut"); + let mut registrations = Registrations { + bindings: vec![binding("Alt+F2", HotkeyCommand::NextMonitor)], + registered: vec![old], + ..Registrations::default() + }; + let operations = std::cell::RefCell::new(Vec::new()); + let result = reconcile_registrations( + &state, + &mut registrations, + true, + |shortcut| { + operations.borrow_mut().push(("remove", shortcut)); + Ok(()) + }, + |shortcut, command, generation| { + assert_eq!(command, HotkeyCommand::NextMonitor); + assert_eq!(generation, 1); + operations.borrow_mut().push(("add", shortcut)); + Ok(()) + }, + ); + assert_eq!(*operations.borrow(), vec![("remove", old), ("add", new)]); + assert_eq!(registrations.registered, vec![new]); + assert!(result.bindings[0].ok); + assert!(!should_dispatch(&state, ShortcutState::Pressed, 0)); + registrations.bindings.clear(); + let result = reconcile_registrations( + &state, + &mut registrations, + true, + |shortcut| { + assert_eq!(shortcut, new); + Ok(()) + }, + |_, _, _| panic!("clearing must not register anything"), + ); + assert!(result.bindings.is_empty()); + assert!(registrations.registered.is_empty()); + } + + #[test] + fn suspension_removes_bindings_and_resumption_restores_them() { + let state = HotkeysState::default(); + let shortcut = parse_binding("Ctrl+Shift+H").expect("shortcut"); + let mut registrations = Registrations { + bindings: vec![binding("Ctrl+Shift+H", HotkeyCommand::Pin)], + registered: vec![shortcut], + ..Registrations::default() + }; + state.suspended.store(true, Ordering::Release); + let suspended = reconcile_registrations( + &state, + &mut registrations, + true, + |removed| { + assert_eq!(removed, shortcut); + Ok(()) + }, + |_, _, _| panic!("suspended bindings must not register"), + ); + assert!(suspended.suspended); + assert!(registrations.registered.is_empty()); + assert_eq!(registrations.bindings.len(), 1); + state.suspended.store(false, Ordering::Release); + let resumed = reconcile_registrations( + &state, + &mut registrations, + true, + |_| panic!("nothing remains registered"), + |added, command, _| { + assert_eq!(added, shortcut); + assert_eq!(command, HotkeyCommand::Pin); + Ok(()) + }, + ); + assert!(!resumed.suspended); + assert!(resumed.bindings[0].ok); + assert_eq!(registrations.registered, vec![shortcut]); + } + + #[test] + fn invalid_duplicates_and_registration_failures_have_per_binding_results() { + let state = HotkeysState::default(); + let mut registrations = Registrations { + bindings: vec![ + binding("Ctrl+Shift+H", HotkeyCommand::Pin), + binding("Shift+Control+KeyH", HotkeyCommand::NextMonitor), + binding("not-a-key", HotkeyCommand::OpenSettings), + binding("Alt+F2", HotkeyCommand::ToggleVisibility), + ], + ..Registrations::default() + }; + let mut attempts = Vec::new(); + let result = reconcile_registrations( + &state, + &mut registrations, + true, + |_| panic!("no previous registrations"), + |shortcut, command, _| { + attempts.push(command); + if command == HotkeyCommand::ToggleVisibility { + Err("Already in use by another app".to_string()) + } else { + assert_eq!(shortcut.key, tauri_plugin_global_shortcut::Code::KeyH); + Ok(()) + } + }, + ); + assert_eq!( + attempts, + vec![HotkeyCommand::Pin, HotkeyCommand::ToggleVisibility] + ); + assert_eq!(result.bindings.len(), 4); + assert!(result.bindings[0].ok); + assert_eq!( + result.bindings[1].error.as_deref(), + Some("Already used by another binding") + ); + assert_eq!( + result.bindings[2].error.as_deref(), + Some("Invalid shortcut") + ); + assert_eq!( + result.bindings[3].error.as_deref(), + Some("Already in use by another app") + ); + assert_eq!(registrations.registered.len(), 1); + } + + #[test] + fn failed_removals_are_inert_retained_and_retried() { + let state = HotkeysState::default(); + let old = parse_binding("Ctrl+Shift+H").expect("shortcut"); + let mut registrations = Registrations { + bindings: vec![binding("Alt+F2", HotkeyCommand::Pin)], + registered: vec![old], + ..Registrations::default() + }; + let result = reconcile_registrations( + &state, + &mut registrations, + true, + |_| Err("native removal failed".to_string()), + |_, _, _| panic!("don't add shortcuts while old registrations remain"), + ); + assert!(!result.bindings[0].ok); + assert_eq!(registrations.registered, vec![old]); + assert!(!should_dispatch(&state, ShortcutState::Pressed, 0)); + let result = reconcile_registrations( + &state, + &mut registrations, + true, + |removed| { + assert_eq!(removed, old); + Ok(()) + }, + |_, _, generation| { + assert_eq!(generation, 2); + Ok(()) + }, + ); + assert!(result.bindings[0].ok); + assert_eq!( + registrations.registered, + vec![parse_binding("Alt+F2").expect("new shortcut")] + ); + } + + #[test] + fn wayland_rejects_every_binding_without_entering_the_plugin() { + let state = HotkeysState::default(); + let mut registrations = Registrations { + bindings: vec![ + binding("Ctrl+Shift+H", HotkeyCommand::Pin), + binding("invalid", HotkeyCommand::OpenSettings), + ], + ..Registrations::default() + }; + let result = reconcile_registrations( + &state, + &mut registrations, + false, + |_| panic!("the plugin is absent on Wayland"), + |_, _, _| panic!("the plugin is absent on Wayland"), + ); + assert!(!result.supported); + assert_eq!(result.bindings.len(), 2); + assert!(result.bindings.iter().all(|binding| !binding.ok + && binding.error.as_deref() == Some("Global hotkeys don't work on Wayland."))); + assert!(registrations.registered.is_empty()); + } + + #[test] + fn only_current_unsuspended_key_presses_dispatch() { + let state = HotkeysState::default(); + assert!(should_dispatch(&state, ShortcutState::Pressed, 0)); + assert!(!should_dispatch(&state, ShortcutState::Released, 0)); + state.suspended.store(true, Ordering::Release); + assert!(!should_dispatch(&state, ShortcutState::Pressed, 0)); + state.suspended.store(false, Ordering::Release); + state.generation.store(1, Ordering::Release); + assert!(!should_dispatch(&state, ShortcutState::Pressed, 0)); + assert!(should_dispatch(&state, ShortcutState::Pressed, 1)); + } + + #[test] + fn hidden_is_click_through_and_visible_restores_the_actual_pin() { + assert!(ignore_cursor_events(false, false)); + assert!(ignore_cursor_events(false, true)); + assert!(!ignore_cursor_events(true, false)); + assert!(ignore_cursor_events(true, true)); + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 1e678944..f9f74707 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -10,6 +10,7 @@ extern crate objc; mod app_handle; mod commands; mod constants; +mod hotkeys; mod placement; mod tray; mod window_custom; @@ -118,6 +119,11 @@ fn main() { })); } + #[cfg(desktop)] + if hotkeys::supported() { + app = app.plugin(tauri_plugin_global_shortcut::Builder::new().build()); + } + #[cfg(target_os = "macos")] { app = app.plugin(tauri_nspanel::init()); @@ -127,6 +133,8 @@ fn main() { .manage(Pinned(AtomicBool::new(false))) .manage(HideTaskbarWhenPinned(AtomicBool::new(false))) .manage(placement::PlacementState::default()) + .manage(hotkeys::HotkeysState::default()) + .manage(hotkeys::OverlayVisibility::default()) .setup(move |app| { debug!("starting app..."); let window = app.get_webview_window(MAIN_WINDOW_NAME).unwrap(); @@ -172,9 +180,9 @@ fn main() { Ok(()) }) .invoke_handler(generate_handler![ - toggle_pin, + hotkeys::toggle_pin, get_pin, - set_pin, + hotkeys::set_pin, open_devtools, open_overlay_devtools, simulate_error_screen, @@ -185,6 +193,11 @@ fn main() { placement::get_monitors, placement::apply_placement, placement::release_placement, + hotkeys::set_hotkeys, + hotkeys::get_hotkeys, + hotkeys::suspend_hotkeys, + hotkeys::record_hotkey, + hotkeys::set_overlay_visible, ]); app diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 8633273a..3ebe34c7 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -3,16 +3,16 @@ use std::sync::Mutex; use tauri::{ menu::{Menu, MenuBuilder, MenuEvent}, tray::TrayIconBuilder, - AppHandle, LogicalSize, Manager, Wry, + AppHandle, Emitter, LogicalSize, Manager, Wry, }; use anyhow::Result; use tauri_plugin_window_state::{AppHandleExt, StateFlags}; use crate::{ - commands, toggle_pin, HideTaskbarWhenPinned, Pinned, TrayMenu, MAIN_WINDOW_NAME, OVERLAYED, - SETTINGS_WINDOW_NAME, TRAY_OPEN_DEVTOOLS_MAIN, TRAY_OPEN_DEVTOOLS_SETTINGS, TRAY_QUIT, - TRAY_RELOAD, TRAY_SETTINGS, TRAY_SHOW_APP, TRAY_TOGGLE_PIN, + commands, hotkeys, TrayMenu, MAIN_WINDOW_NAME, OVERLAYED, SETTINGS_WINDOW_NAME, + TRAY_OPEN_DEVTOOLS_MAIN, TRAY_OPEN_DEVTOOLS_SETTINGS, TRAY_QUIT, TRAY_RELOAD, TRAY_SETTINGS, + TRAY_SHOW_APP, TRAY_TOGGLE_PIN, }; pub struct Tray; @@ -50,16 +50,17 @@ impl Tray { pub fn handle_menu_events(app: &AppHandle, event: MenuEvent) { match event.id().as_ref() { TRAY_TOGGLE_PIN => { - let window = app.get_webview_window(MAIN_WINDOW_NAME).unwrap(); - - toggle_pin( - window, - app.state::(), - app.state::(), - app.state::(), - ) + if let Err(error) = hotkeys::toggle_overlay_pin(app) { + log::warn!("Could not toggle overlay pin from tray: {error}"); + } } TRAY_SHOW_APP => { + if app.state::().is_hidden() { + if let Err(error) = app.emit_to(MAIN_WINDOW_NAME, hotkeys::SHOW_OVERLAY, ()) { + log::warn!("Could not restore overlay visibility from tray: {error}"); + } + return; + } let window = app.get_webview_window(MAIN_WINDOW_NAME).unwrap(); window.show().unwrap(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2cd1d973..dc28b1b8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,6 +8,7 @@ import { NavBar } from "./components/nav-bar"; import { usePin } from "./hooks/use-pin"; import { usePlacement } from "./hooks/use-placement"; import { useStartPinned } from "./hooks/use-start-pinned"; +import { useHotkeys } from "./hooks/use-hotkeys"; import { useAlign } from "./hooks/use-align"; import { useDisableWebFeatures } from "./hooks/use-disable-context-menu"; import { useUpdate } from "./hooks/use-update"; @@ -42,6 +43,7 @@ function App() { const { pin } = usePin(); useStartPinned(); + useHotkeys(); const { horizontal, setHorizontalDirection } = useAlign(); const visibleClass = visible ? "opacity-100" : "opacity-0"; const location = useLocation(); diff --git a/apps/desktop/src/config.ts b/apps/desktop/src/config.ts index 69cebe11..7f7df101 100644 --- a/apps/desktop/src/config.ts +++ b/apps/desktop/src/config.ts @@ -7,6 +7,12 @@ export type DirectionTB = "top" | "bottom"; export type OpacityTarget = "all" | "username-box"; +export type HotkeyCommand = "pin" | "toggle-visibility" | "next-monitor" | "open-settings"; +export interface HotkeyBinding { + key: string; + command: HotkeyCommand; +} + export type PlacementMode = "manual" | "anchored"; export type PlacementAnchor = | "top-left" @@ -41,6 +47,7 @@ export interface OverlayedConfig { placementOffsetX: number; placementOffsetY: number; startPinned: boolean; + hotkeys: HotkeyBinding[]; } export type OverlayedConfigKey = keyof OverlayedConfig; @@ -65,6 +72,10 @@ export const DEFAULT_OVERLAYED_CONFIG: OverlayedConfig = { placementOffsetX: 16, placementOffsetY: 16, startPinned: false, + hotkeys: [ + { key: "CommandOrControl+Shift+H", command: "pin" }, + { key: "CommandOrControl+Shift+G", command: "toggle-visibility" }, + ], }; const CONFIG_FILE_NAME = "config.json"; diff --git a/apps/desktop/src/hooks/use-hotkeys.ts b/apps/desktop/src/hooks/use-hotkeys.ts new file mode 100644 index 00000000..35cad682 --- /dev/null +++ b/apps/desktop/src/hooks/use-hotkeys.ts @@ -0,0 +1,120 @@ +import Config, { type HotkeyBinding, type HotkeyCommand, type OverlayedConfig } from "@/config"; +import { useAppStore } from "@/store"; +import { invoke } from "@tauri-apps/api/core"; +import { emit, listen } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { useEffect } from "react"; +import { flushSync } from "react-dom"; +import type { PlacementMonitors } from "./use-placement"; + +export interface HotkeyResult extends HotkeyBinding { + ok: boolean; + error: string | null; +} + +export interface HotkeysStatus { + bindings: HotkeyResult[]; + supported: boolean; + suspended: boolean; +} + +const mainWindow = getCurrentWebviewWindow().label === "main"; + +export const useHotkeys = () => { + useEffect(() => { + if (!mainWindow) return; + let disposed = false; + let config: OverlayedConfig | null = null; + let configSeen = false; + let bindingsKey: string | null = null; + let registrations = Promise.resolve(); + let actions = Promise.resolve(); + + const receiveConfig = (value: OverlayedConfig) => { + config = value; + const key = JSON.stringify(value.hotkeys); + if (key === bindingsKey) return; + bindingsKey = key; + registrations = registrations + .then(async () => { + if (!disposed) await invoke("set_hotkeys", { bindings: value.hotkeys }); + }) + .catch(error => { + bindingsKey = null; + console.warn("Could not update global hotkeys", error); + }); + }; + + const setVisible = async (visible: boolean) => { + if (!visible) { + // Do not hide anything until the native window is click-through. + await invoke("set_overlay_visible", { visible: false }); + useAppStore.getState().setAppVisible(false); + } else { + // Paint first, then restore cursor handling from the current native pin. + // No show(), hide() or focus call: the foreground game stays active. + flushSync(() => useAppStore.getState().setAppVisible(true)); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + await invoke("set_overlay_visible", { visible: true }); + } + }; + + const nextMonitor = async () => { + if (config?.placementMode !== "anchored") return; + const response = await invoke("get_monitors"); + if (!response.positioningSupported || config?.placementMode !== "anchored") return; + const monitors = [...response.monitors].sort((a, b) => a.x - b.x || a.y - b.y); + if (monitors.length < 2) return; + const current = + monitors.find(monitor => monitor.name === config?.placementMonitor) ?? + monitors.find(monitor => monitor.primary) ?? + monitors[0]; + const next = monitors[(monitors.indexOf(current!) + 1) % monitors.length]; + if (!next) return; + await Config.set("placementMonitor", next.name || null); + const value = await Config.getConfig(); + receiveConfig(value); + await emit("config_update", value); + }; + + const enqueue = (action: () => Promise) => { + actions = actions + .then(async () => { + if (!disposed) await action(); + }) + .catch(error => console.warn("Could not handle overlay hotkey", error)); + }; + + const listeners = Promise.all([ + listen("config_update", event => { + configSeen = true; + receiveConfig(event.payload); + }), + listen("hotkey_action", event => { + if (event.payload === "toggle-visibility") enqueue(() => setVisible(!useAppStore.getState().visible)); + if (event.payload === "next-monitor") enqueue(nextMonitor); + }), + listen("show_overlay", () => enqueue(() => setVisible(true))), + ]); + listeners + .then(async unlisteners => { + if (disposed) { + unlisteners.forEach(unlisten => unlisten()); + return; + } + // A webview reload resets the store; reconcile its visibility with the + // native override before registering new bindings. + enqueue(() => setVisible(useAppStore.getState().visible)); + const value = await Config.getConfig(); + if (!disposed && !configSeen) receiveConfig(value); + }) + .catch(error => console.warn("Could not initialize overlay hotkeys", error)); + + return () => { + disposed = true; + listeners + .then(unlisteners => unlisteners.forEach(unlisten => unlisten())) + .catch(error => console.warn("Could not remove overlay hotkey listeners", error)); + }; + }, []); +}; diff --git a/apps/desktop/src/hooks/use-keybinds.ts b/apps/desktop/src/hooks/use-keybinds.ts deleted file mode 100644 index a1ae065b..00000000 --- a/apps/desktop/src/hooks/use-keybinds.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from "react"; -import { register, isRegistered, unregister } from "@tauri-apps/plugin-global-shortcut"; -import { useAppStore } from "@/store"; -import { invoke } from "@tauri-apps/api/core"; - -// TODO: make these configurable? -const HIDE_TOGGLE_KEYBIND = "Command+Shift+G"; -const TOGGLE_PIN_KEYBIND = "Command+Shift+H"; - -// BUG: this is happening on the settings page!!! -// TODO: this might need some rework to be better -export const useKeybinds = () => { - const { setAppVisible, visible } = useAppStore(); - - useEffect(() => { - const registerKeybind = async () => { - if (await isRegistered(HIDE_TOGGLE_KEYBIND)) { - await unregister(HIDE_TOGGLE_KEYBIND); - } - - await register(HIDE_TOGGLE_KEYBIND, () => { - const newVisible = !visible; - - // invert the current visibility - setAppVisible(newVisible); - }); - }; - - registerKeybind(); - }, [visible]); - - useEffect(() => { - const registerKeybind = async () => { - if (await isRegistered(TOGGLE_PIN_KEYBIND)) { - await unregister(TOGGLE_PIN_KEYBIND); - } - - await register(TOGGLE_PIN_KEYBIND, () => { - invoke("toggle_pin"); - }); - }; - - registerKeybind(); - }, []); -}; diff --git a/apps/desktop/src/utils/hotkeys.ts b/apps/desktop/src/utils/hotkeys.ts new file mode 100644 index 00000000..f8b5d9c9 --- /dev/null +++ b/apps/desktop/src/utils/hotkeys.ts @@ -0,0 +1,30 @@ +export const formatHotkey = (value: string, macos: boolean) => + value + .split("+") + .map(part => { + const token = part.trim(); + switch (token.toLowerCase()) { + case "commandorcontrol": + case "commandorctrl": + case "cmdorcontrol": + case "cmdorctrl": + return macos ? "Cmd" : "Ctrl"; + case "command": + case "cmd": + case "super": + return macos ? "Cmd" : "Win"; + case "control": + case "ctrl": + return "Ctrl"; + case "alt": + case "option": + return "Alt"; + case "shift": + return "Shift"; + case "backquote": + return "`"; + default: + return token.replace(/^Key([A-Z])$/i, "$1").replace(/^Digit([0-9])$/i, "$1"); + } + }) + .join(" + "); diff --git a/apps/desktop/src/views/settings/hotkeys.tsx b/apps/desktop/src/views/settings/hotkeys.tsx new file mode 100644 index 00000000..3542246d --- /dev/null +++ b/apps/desktop/src/views/settings/hotkeys.tsx @@ -0,0 +1,288 @@ +import { Button } from "@/components/ui/button"; +import Config, { type HotkeyBinding, type HotkeyCommand, type OverlayedConfig } from "@/config"; +import type { HotkeysStatus } from "@/hooks/use-hotkeys"; +import { formatHotkey } from "@/utils/hotkeys"; +import { invoke } from "@tauri-apps/api/core"; +import { emit, listen } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { platform } from "@tauri-apps/plugin-os"; +import { useEffect, useRef, useState } from "react"; + +const commands: { command: HotkeyCommand; label: string }[] = [ + { command: "pin", label: "Lock / unlock overlay" }, + { command: "toggle-visibility", label: "Show / hide overlay" }, + { command: "next-monitor", label: "Next monitor (fixed position)" }, + { command: "open-settings", label: "Open settings" }, +]; + +export const Hotkeys = () => { + const [bindings, setBindings] = useState([]); + const [status, setStatus] = useState(null); + const [recording, setRecording] = useState(null); + const [busy, setBusy] = useState(false); + const [messages, setMessages] = useState>>({}); + const [error, setError] = useState(null); + const mounted = useRef(false); + const activeRecording = useRef(null); + const suspended = useRef(false); + const pending = useRef(false); + const focused = useRef(true); + const recordingRequest = useRef(0); + const releaseWaiter = useRef<{ code: string; resolve: () => void } | null>(null); + const suspensionWrites = useRef(Promise.resolve(null)); + const latestBindings = useRef(bindings); + latestBindings.current = bindings; + const macos = platform() === "macos"; + + const changeSuspension = (value: boolean) => { + const result = suspensionWrites.current + .catch(() => null) + .then(() => invoke("suspend_hotkeys", { suspended: value })); + suspensionWrites.current = result; + return result; + }; + + const resume = async () => { + if (!suspended.current) return; + await changeSuspension(false); + suspended.current = false; + }; + + const cancel = async () => { + recordingRequest.current++; + releaseWaiter.current?.resolve(); + releaseWaiter.current = null; + activeRecording.current = null; + setRecording(null); + try { + await resume(); + } catch (error) { + console.warn("Could not resume global hotkeys", error); + if (mounted.current) setError("Couldn't resume hotkeys. Return to this tab to try again."); + } + }; + + useEffect(() => { + mounted.current = true; + let disposed = false; + let configSeen = false; + let statusSeen = false; + const listeners = Promise.all([ + listen("config_update", event => { + configSeen = true; + if (mounted.current) setBindings(event.payload.hotkeys); + }), + listen("hotkeys_update", event => { + statusSeen = true; + if (mounted.current) setStatus(event.payload); + }), + getCurrentWebviewWindow().onFocusChanged(event => { + focused.current = event.payload; + if (!event.payload && suspended.current) void cancel(); + }), + ]); + listeners + .then(async () => { + if (disposed) return; + const [config, currentStatus] = await Promise.all([Config.getConfig(), invoke("get_hotkeys")]); + if (disposed) return; + // Opening this tab only reads results. Resume only a recording left + // suspended by an interrupted settings webview. + const status = currentStatus.suspended ? await changeSuspension(false) : currentStatus; + if (disposed) return; + if (!configSeen) setBindings(config.hotkeys); + if (!statusSeen) setStatus(status); + }) + .catch(error => { + console.warn("Could not load global hotkeys", error); + if (mounted.current) setError("Couldn't load hotkeys. Return to this tab to try again."); + }); + return () => { + disposed = true; + mounted.current = false; + recordingRequest.current++; + releaseWaiter.current?.resolve(); + releaseWaiter.current = null; + activeRecording.current = null; + listeners + .then(unlisteners => unlisteners.forEach(unlisten => unlisten())) + .catch(error => console.warn("Could not remove hotkey settings listeners", error)); + resume().catch(error => console.warn("Could not resume global hotkeys on leaving settings", error)); + }; + }, []); + + const save = async (command: HotkeyCommand, key: string | null) => { + const next = latestBindings.current.filter(binding => binding.command !== command); + if (key) next.push({ key, command }); + await Config.set("hotkeys", next); + await emit("config_update", await Config.getConfig()); + if (mounted.current) { + setBindings(next); + setMessages(previous => ({ ...previous, [command]: undefined })); + setError(null); + } + }; + + useEffect(() => { + if (!recording) return; + const keydown = async (event: KeyboardEvent) => { + event.preventDefault(); + event.stopImmediatePropagation(); + if (event.repeat) return; + if (event.code === "Escape") { + await cancel(); + return; + } + if (pending.current) return; + if (/^(Control|Alt|Shift|Meta)(Left|Right)$/.test(event.code)) return; + pending.current = true; + setBusy(true); + const released = new Promise(resolve => { + releaseWaiter.current = { code: event.code, resolve }; + }); + try { + const key = await invoke("record_hotkey", { + key: { + code: event.code, + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + superKey: event.metaKey, + }, + command: recording, + bindings: latestBindings.current, + }); + if (mounted.current && activeRecording.current === recording) { + await save(recording, key); + // Registering while the recorded key is held can fire that binding. + await released; + if (activeRecording.current === recording) await cancel(); + } + } catch (error) { + if (mounted.current) setMessages(previous => ({ ...previous, [recording]: String(error) })); + } finally { + releaseWaiter.current?.resolve(); + releaseWaiter.current = null; + pending.current = false; + if (mounted.current) setBusy(false); + } + }; + const keyup = (event: KeyboardEvent) => { + if (event.code === releaseWaiter.current?.code) { + releaseWaiter.current.resolve(); + releaseWaiter.current = null; + } + }; + window.addEventListener("keydown", keydown, true); + window.addEventListener("keyup", keyup, true); + return () => { + window.removeEventListener("keydown", keydown, true); + window.removeEventListener("keyup", keyup, true); + }; + }, [recording]); + + const startRecording = async (command: HotkeyCommand) => { + const request = ++recordingRequest.current; + pending.current = true; + setBusy(true); + suspended.current = true; + try { + const status = await changeSuspension(true); + if (!mounted.current || !focused.current || request !== recordingRequest.current || !status.supported) { + await resume(); + return; + } + setStatus(status); + setMessages(previous => ({ ...previous, [command]: undefined })); + activeRecording.current = command; + setRecording(command); + } catch (error) { + console.warn("Could not suspend global hotkeys", error); + if (mounted.current) setError("Couldn't suspend hotkeys to record. Try again."); + await resume().catch(error => console.warn("Could not restore global hotkeys", error)); + } finally { + pending.current = false; + if (mounted.current) setBusy(false); + } + }; + + const clear = async (command: HotkeyCommand) => { + pending.current = true; + setBusy(true); + try { + await save(command, null); + } catch (error) { + console.warn("Could not clear hotkey", error); + if (mounted.current) + setMessages(previous => ({ ...previous, [command]: "Couldn't save this hotkey. Try again." })); + } finally { + pending.current = false; + if (mounted.current) setBusy(false); + } + }; + + return ( +
+

The next-monitor hotkey only works with Fixed position.

+ {status?.supported === false &&

Global hotkeys don't work on Wayland.

} + {commands.map(({ command, label }) => { + const binding = bindings.find(binding => binding.command === command); + const result = status?.bindings.find(result => result.command === command && result.key === binding?.key); + // The Wayland note covers every binding without repeating the same + // error four times and pushing the rows into the fixed footer. + const message = messages[command] ?? (status?.suspended || status?.supported === false ? null : result?.error); + const active = recording === command; + return ( +
+
+
+

{label}

+

+ {active + ? "Press a shortcut, or Escape to cancel" + : binding + ? formatHotkey(binding.key, macos) + : "Not set"} +

+
+
+ + +
+
+ {message && ( +

+ {message} +

+ )} +
+ ); + })} + {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/apps/desktop/src/views/settings/index.tsx b/apps/desktop/src/views/settings/index.tsx index 83ca7a9b..e72f9a3b 100644 --- a/apps/desktop/src/views/settings/index.tsx +++ b/apps/desktop/src/views/settings/index.tsx @@ -8,6 +8,7 @@ import { SiX, SiTwitch, SiDiscord, type IconType } from "@icons-pack/react-simpl import type { Update } from "@tauri-apps/plugin-updater"; import { Configuration } from "./configuration"; import { Placement } from "./placement"; +import { Hotkeys } from "./hotkeys"; function Link({ icon: Icon, url }: { icon: IconType; url: string }) { return ( @@ -28,10 +29,11 @@ export const SettingsView = ({ update }: { update: Update | null }) => { setCurrentTab(value); }} > - + General Configuration Position + Hotkeys Join History {canary && ( @@ -52,6 +54,9 @@ export const SettingsView = ({ update }: { update: Update | null }) => { + + +
From 2266892b2811ced4744a6d755283dba99af23196 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:50:20 +0100 Subject: [PATCH 2/2] Use a helper instead of a nested ternary for the binding label --- apps/desktop/src/views/settings/hotkeys.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/views/settings/hotkeys.tsx b/apps/desktop/src/views/settings/hotkeys.tsx index 3542246d..4dedf1ed 100644 --- a/apps/desktop/src/views/settings/hotkeys.tsx +++ b/apps/desktop/src/views/settings/hotkeys.tsx @@ -15,6 +15,12 @@ const commands: { command: HotkeyCommand; label: string }[] = [ { command: "open-settings", label: "Open settings" }, ]; +function bindingLabel(active: boolean, binding: HotkeyBinding | undefined, macos: boolean) { + if (active) return "Press a shortcut, or Escape to cancel"; + if (binding) return formatHotkey(binding.key, macos); + return "Not set"; +} + export const Hotkeys = () => { const [bindings, setBindings] = useState([]); const [status, setStatus] = useState(null); @@ -238,11 +244,7 @@ export const Hotkeys = () => {

{label}

- {active - ? "Press a shortcut, or Escape to cancel" - : binding - ? formatHotkey(binding.key, macos) - : "Not set"} + {bindingLabel(active, binding, macos)}