From 8eb662298cfc27d9b99dce4265d6b7f85243ed64 Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:01:55 +0300 Subject: [PATCH 1/8] refactor(linux-rust): centralize app settings --- linux-rust/src/devices/airpods.rs | 13 +--- linux-rust/src/main.rs | 6 +- linux-rust/src/ui/tray.rs | 12 +--- linux-rust/src/ui/window.rs | 98 ++++++++----------------------- linux-rust/src/utils.rs | 37 ++++++++++++ 5 files changed, 68 insertions(+), 98 deletions(-) diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index f0e876cf0..856f6eeaa 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::{Duration, sleep}; -use crate::utils::get_app_settings_path; +use crate::utils::AppSettings; pub struct AirPodsDevice { pub mac_address: Address, @@ -81,16 +81,7 @@ impl AirPodsDevice { error!("Failed to request proximity keys: {}", e); } - let app_settings_path = get_app_settings_path(); - let settings = std::fs::read_to_string(&app_settings_path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()); - let stem_control = settings - .clone() - .and_then(|v| v.get("stem_control").cloned()) - .and_then(|s| serde_json::from_value(s).ok()) - .unwrap_or(false); - + let stem_control = AppSettings::load().stem_control; if stem_control { // Enable stem press detection (double and triple tap) // StemConfig bitmask for the control command: single=0x01, double=0x02, triple=0x04, long=0x08 diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..2fb98c1f4 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -10,7 +10,7 @@ use crate::bluetooth::managers::DeviceManagers; use crate::devices::enums::DeviceData; use crate::ui::messages::BluetoothUIMessage; use crate::ui::tray::MyTray; -use crate::utils::{get_app_settings_path, get_devices_path}; +use crate::utils::get_devices_path; use bluer::{Address, InternalErrorKind}; use clap::Parser; use dbus::arg::{RefArg, Variant}; @@ -19,10 +19,9 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; use dbus::message::MatchRule; use devices::airpods::AirPodsDevice; use ksni::TrayMethods; -use log::{debug, info, warn}; +use log::{info, warn}; use std::collections::HashMap; use std::env; -use std::sync::atomic::{AtomicBool}; use std::sync::Arc; use tokio::sync::RwLock; use tokio::sync::mpsc::unbounded_channel; @@ -82,7 +81,6 @@ fn main() -> iced::Result { let device_managers: Arc>> = Arc::new(RwLock::new(HashMap::new())); - // Load stem_control initial value from settings JSON, then apply CLI override. if args.no_tray { // Run headless without UI info!("Running in headless mode (no GUI)"); diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index b3adbc53a..1afb11845 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc::UnboundedSender; use crate::bluetooth::aacp::{BatteryStatus, ControlCommandIdentifiers}; use crate::ui::messages::BluetoothUIMessage; -use crate::utils::get_app_settings_path; +use crate::utils::AppSettings; #[derive(Debug)] pub struct MyTray { @@ -66,15 +66,7 @@ impl ksni::Tray for MyTray { }; let any_bud_charging = matches!(self.battery_l_status, Some(BatteryStatus::Charging)) || matches!(self.battery_r_status, Some(BatteryStatus::Charging)); - let app_settings_path = get_app_settings_path(); - let settings = std::fs::read_to_string(&app_settings_path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()); - let text_mode = settings - .clone() - .and_then(|v| v.get("tray_text_mode").cloned()) - .and_then(|ttm| serde_json::from_value(ttm).ok()) - .unwrap_or(false); + let text_mode = AppSettings::load().tray_text_mode; let icon = generate_icon(&text, text_mode, any_bud_charging); vec![icon] } diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..1f046e3c5 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -9,8 +9,8 @@ use crate::devices::enums::{ use crate::ui::airpods::airpods_view; use crate::ui::messages::BluetoothUIMessage; use crate::ui::nothing::nothing_view; -use crate::utils::{MyTheme, get_app_settings_path, get_devices_path}; -use bluer::{Address}; +use crate::utils::{AppSettings, MyTheme, get_app_settings_path, get_devices_path}; +use bluer::Address; use iced::border::Radius; use iced::overlay::menu; use iced::widget::button::Style; @@ -19,10 +19,9 @@ use iced::widget::{ Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text, text_input, toggler }; -use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings, Program}; +use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings}; use log::{debug, error}; use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::{Mutex, RwLock}; @@ -65,7 +64,7 @@ pub struct App { panes: pane_grid::State, selected_tab: Tab, theme_state: combo_box::State, - selected_theme: MyTheme, + settings: AppSettings, ui_rx: Arc>>, bluetooth_state: BluetoothState, paired_devices: HashMap, @@ -74,8 +73,6 @@ pub struct App { pending_add_device: Option<(String, Address)>, device_type_state: combo_box::State, selected_device_type: Option, - tray_text_mode: bool, - stem_control: bool, } pub struct BluetoothState { @@ -106,7 +103,7 @@ pub enum Message { ConfirmAddDevice, CancelAddDevice, StateChanged(String, DeviceState), - TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy + TrayTextModeChanged(bool), StemControlChanged(bool), } @@ -147,25 +144,7 @@ impl App { (Some(id), open.map(Message::WindowOpened)) }; - let app_settings_path = get_app_settings_path(); - let settings = std::fs::read_to_string(&app_settings_path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()); - let selected_theme = settings - .clone() - .and_then(|v| v.get("theme").cloned()) - .and_then(|t| serde_json::from_value(t).ok()) - .unwrap_or(MyTheme::Dark); - let tray_text_mode = settings - .clone() - .and_then(|v| v.get("tray_text_mode").cloned()) - .and_then(|ttm| serde_json::from_value(ttm).ok()) - .unwrap_or(false); - let stem_control = settings - .clone() - .and_then(|v| v.get("stem_control").cloned()) - .and_then(|s| serde_json::from_value(s).ok()) - .unwrap_or(false); + let settings = AppSettings::load(); let bluetooth_state = BluetoothState::new(); @@ -206,7 +185,7 @@ impl App { MyTheme::Oxocarbon, MyTheme::Ferra, ]), - selected_theme, + settings, ui_rx, bluetooth_state, paired_devices: HashMap::new(), @@ -215,8 +194,6 @@ impl App { device_type_state: combo_box::State::new(vec![DeviceType::Nothing]), selected_device_type: None, device_managers, - tray_text_mode, - stem_control, }, Task::batch(vec![open_task, wait_task]), ) @@ -226,6 +203,14 @@ impl App { "LibrePods".to_string() } + fn save_settings(&self) { + let path = get_app_settings_path(); + debug!("Writing settings to {}: {:?}", path.display(), self.settings); + if let Err(error) = self.settings.save() { + error!("Failed to write settings to {}: {}", path.display(), error); + } + } + fn update(&mut self, message: Message) -> Task { match message { Message::WindowOpened(id) => { @@ -247,19 +232,8 @@ impl App { Task::none() } Message::ThemeSelected(theme) => { - self.selected_theme = theme; - let app_settings_path = get_app_settings_path(); - let settings = serde_json::json!({ - "theme": self.selected_theme, - "tray_text_mode": self.tray_text_mode, - "stem_control": self.stem_control, - }); - debug!( - "Writing settings to {}: {}", - app_settings_path.to_str().unwrap(), - settings - ); - std::fs::write(app_settings_path, settings.to_string()).ok(); + self.settings.theme = theme; + self.save_settings(); Task::none() } Message::CopyToClipboard(data) => iced::clipboard::write(data), @@ -625,35 +599,13 @@ impl App { Task::none() } Message::TrayTextModeChanged(is_enabled) => { - self.tray_text_mode = is_enabled; - let app_settings_path = get_app_settings_path(); - let settings = serde_json::json!({ - "theme": self.selected_theme, - "tray_text_mode": self.tray_text_mode, - "stem_control": self.stem_control, - }); - debug!( - "Writing settings to {}: {}", - app_settings_path.to_str().unwrap(), - settings - ); - std::fs::write(app_settings_path, settings.to_string()).ok(); + self.settings.tray_text_mode = is_enabled; + self.save_settings(); Task::none() } Message::StemControlChanged(is_enabled) => { - self.stem_control = is_enabled; - let app_settings_path = get_app_settings_path(); - let settings = serde_json::json!({ - "theme": self.selected_theme, - "tray_text_mode": self.tray_text_mode, - "stem_control": self.stem_control, - }); - debug!( - "Writing settings to {}: {}", - app_settings_path.to_str().unwrap(), - settings - ); - std::fs::write(app_settings_path, settings.to_string()).ok(); + self.settings.stem_control = is_enabled; + self.save_settings(); Task::none() } } @@ -939,7 +891,7 @@ impl App { } ).width(Length::Fill) ].width(Length::Fill), - toggler(self.tray_text_mode) + toggler(self.settings.tray_text_mode) .on_toggle(move |is_enabled| { Message::TrayTextModeChanged(is_enabled) }) @@ -991,7 +943,7 @@ impl App { combo_box( &self.theme_state, "Select theme", - Some(&self.selected_theme), + Some(&self.settings.theme), Message::ThemeSelected ) .input_style( @@ -1055,7 +1007,7 @@ impl App { ] .spacing(12); - let stem_control_value = self.stem_control; + let stem_control_value = self.settings.stem_control; let stem_control_toggle = container( row![ column![ @@ -1297,7 +1249,7 @@ impl App { } fn theme(&self, _id: window::Id) -> Theme { - self.selected_theme.into() + self.settings.theme.into() } fn subscription(&self) -> Subscription { diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..84006c9c3 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -3,6 +3,7 @@ use aes::cipher::Array; use aes::cipher::{BlockCipherEncrypt, KeyInit}; use iced::Theme; use serde::{Deserialize, Serialize}; +use std::io; use std::path::PathBuf; pub fn get_devices_path() -> PathBuf { @@ -51,6 +52,42 @@ pub fn get_app_settings_path() -> PathBuf { new_path } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AppSettings { + pub theme: MyTheme, + pub tray_text_mode: bool, + pub stem_control: bool, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + theme: MyTheme::Dark, + tray_text_mode: false, + stem_control: false, + } + } +} + +impl AppSettings { + pub fn load() -> Self { + std::fs::read_to_string(get_app_settings_path()) + .ok() + .and_then(|settings| serde_json::from_str(&settings).ok()) + .unwrap_or_default() + } + + pub fn save(&self) -> io::Result<()> { + let path = get_app_settings_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let settings = serde_json::to_string_pretty(self).map_err(io::Error::other)?; + std::fs::write(path, settings) + } +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse(); From 5b55ad9439aacf5e4a95191116d2dcc1f2ec353d Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:18:36 +0300 Subject: [PATCH 2/8] feat(linux-rust): add remappable window hotkeys --- linux-rust/src/ui/window.rs | 250 +++++++++++++++++++++++++++++++++++- linux-rust/src/utils.rs | 117 +++++++++++++++++ 2 files changed, 362 insertions(+), 5 deletions(-) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 1f046e3c5..e70659a0c 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -9,7 +9,7 @@ use crate::devices::enums::{ use crate::ui::airpods::airpods_view; use crate::ui::messages::BluetoothUIMessage; use crate::ui::nothing::nothing_view; -use crate::utils::{AppSettings, MyTheme, get_app_settings_path, get_devices_path}; +use crate::utils::{AppSettings, Hotkey, MyTheme, get_app_settings_path, get_devices_path}; use bluer::Address; use iced::border::Radius; use iced::overlay::menu; @@ -19,7 +19,7 @@ use iced::widget::{ Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text, text_input, toggler }; -use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings}; +use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, event, keyboard, window, Settings}; use log::{debug, error}; use std::collections::HashMap; use std::sync::Arc; @@ -73,6 +73,7 @@ pub struct App { pending_add_device: Option<(String, Address)>, device_type_state: combo_box::State, selected_device_type: Option, + recording_hotkey: Option, } pub struct BluetoothState { @@ -105,6 +106,14 @@ pub enum Message { StateChanged(String, DeviceState), TrayTextModeChanged(bool), StemControlChanged(bool), + StartHotkeyRecording(HotkeyAction), + HotkeyPressed(keyboard::Key, keyboard::Modifiers), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HotkeyAction { + CloseWindow, + QuitApplication, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -194,6 +203,7 @@ impl App { device_type_state: combo_box::State::new(vec![DeviceType::Nothing]), selected_device_type: None, device_managers, + recording_hotkey: None, }, Task::batch(vec![open_task, wait_task]), ) @@ -608,6 +618,73 @@ impl App { self.save_settings(); Task::none() } + Message::StartHotkeyRecording(action) => { + self.recording_hotkey = Some(action); + Task::none() + } + Message::HotkeyPressed(key, modifiers) => { + if self.recording_hotkey.is_some() + && key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Escape) + { + self.recording_hotkey = None; + return Task::none(); + } + + if key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Backspace) + && let Some(action) = self.recording_hotkey.take() + { + match action { + HotkeyAction::CloseWindow => self.settings.close_window_hotkey = None, + HotkeyAction::QuitApplication => { + self.settings.quit_application_hotkey = None + } + } + self.save_settings(); + return Task::none(); + } + + let Some(key) = hotkey_key(&key) else { + return Task::none(); + }; + + if let Some(action) = self.recording_hotkey.take() { + let hotkey = Hotkey::new( + key, + modifiers.control(), + modifiers.alt(), + modifiers.shift(), + modifiers.logo(), + ); + match action { + HotkeyAction::CloseWindow => { + self.settings.close_window_hotkey = Some(hotkey) + } + HotkeyAction::QuitApplication => { + self.settings.quit_application_hotkey = Some(hotkey) + } + } + self.save_settings(); + return Task::none(); + } + + let matches = |hotkey: &Hotkey| { + hotkey.matches( + &key, + modifiers.control(), + modifiers.alt(), + modifiers.shift(), + modifiers.logo(), + ) + }; + if self.settings.close_window_hotkey.as_ref().is_some_and(matches) { + if let Some(window) = self.window { + return window::close(window); + } + } else if self.settings.quit_application_hotkey.as_ref().is_some_and(matches) { + return iced::exit(); + } + Task::none() + } } } @@ -1068,15 +1145,148 @@ impl App { ] .spacing(12); - container( + let close_hotkey_label = if self.recording_hotkey == Some(HotkeyAction::CloseWindow) { + "Press shortcut...".to_string() + } else { + self.settings.close_window_hotkey.as_ref() + .map(Hotkey::display) + .unwrap_or_else(|| "Not set".to_string()) + }; + let close_window_hotkey_setting = container( + row![ + column![ + text("Close window").size(16), + text("Click to remap. Backspace unbinds; Esc cancels.").size(12).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }) + ] + .width(Length::Fill), + button(text(close_hotkey_label).size(14).center()) + .on_press(Message::StartHotkeyRecording(HotkeyAction::CloseWindow)) + .style(|theme: &Theme, _status| Style { + text_color: theme.palette().text, + background: Some(Background::Color(theme.palette().primary.scale_alpha(0.2))), + border: Border { + width: 1.0, + color: theme.palette().text.scale_alpha(0.3), + radius: Radius::from(4.0), + }, + ..Style::default() + }) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .width(Length::from(160)) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }); + + let quit_hotkey_label = if self.recording_hotkey == Some(HotkeyAction::QuitApplication) { + "Press shortcut...".to_string() + } else { + self.settings.quit_application_hotkey.as_ref() + .map(Hotkey::display) + .unwrap_or_else(|| "Not set".to_string()) + }; + let quit_application_hotkey_setting = container( + row![ + column![ + text("Quit application").size(16), + text("Click to remap. Backspace unbinds; Esc cancels.").size(12).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }) + ] + .width(Length::Fill), + button(text(quit_hotkey_label).size(14).center()) + .on_press(Message::StartHotkeyRecording(HotkeyAction::QuitApplication)) + .style(|theme: &Theme, _status| Style { + text_color: theme.palette().text, + background: Some(Background::Color(theme.palette().primary.scale_alpha(0.2))), + border: Border { + width: 1.0, + color: theme.palette().text.scale_alpha(0.3), + radius: Radius::from(4.0), + }, + ..Style::default() + }) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .width(Length::from(160)) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }); + + let hotkey_settings_col = column![ + container( + text("Keyboard shortcuts").size(20).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + }) + ) + .padding(Padding { + top: 0.0, + bottom: 0.0, + left: 18.0, + right: 18.0, + }), + close_window_hotkey_setting, + quit_application_hotkey_setting, + ] + .spacing(12); + + container(scrollable( column![ appearance_settings_col, Space::new().height(Length::from(20)), tray_text_mode_toggle, Space::new().height(Length::from(20)), controls_settings_col, + Space::new().height(Length::from(20)), + hotkey_settings_col, ] - ) + )) .padding(20) .width(Length::Fill) .height(Length::Fill) @@ -1253,7 +1463,37 @@ impl App { } fn subscription(&self) -> Subscription { - window::close_events().map(Message::WindowClosed) + Subscription::batch([ + window::close_events().map(Message::WindowClosed), + event::listen_with(|event, _status, _window| match event { + event::Event::Keyboard(keyboard::Event::KeyPressed { + key, + modifiers, + repeat: false, + .. + }) => Some(Message::HotkeyPressed(key, modifiers)), + _ => None, + }), + ]) + } +} + +fn hotkey_key(key: &keyboard::Key) -> Option { + match key.as_ref() { + keyboard::Key::Character(character) if !character.is_empty() => { + Some(character.to_lowercase()) + } + keyboard::Key::Named( + keyboard::key::Named::Alt + | keyboard::key::Named::AltGraph + | keyboard::key::Named::Control + | keyboard::key::Named::Meta + | keyboard::key::Named::Shift + | keyboard::key::Named::Super, + ) + | keyboard::Key::Unidentified => None, + keyboard::Key::Named(named) => Some(format!("{named:?}")), + keyboard::Key::Character(_) => None, } } diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 84006c9c3..949852d65 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -58,6 +58,8 @@ pub struct AppSettings { pub theme: MyTheme, pub tray_text_mode: bool, pub stem_control: bool, + pub close_window_hotkey: Option, + pub quit_application_hotkey: Option, } impl Default for AppSettings { @@ -66,10 +68,74 @@ impl Default for AppSettings { theme: MyTheme::Dark, tray_text_mode: false, stem_control: false, + close_window_hotkey: Some(Hotkey::with_control("w")), + quit_application_hotkey: Some(Hotkey::with_control("q")), } } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hotkey { + key: String, + control: bool, + alt: bool, + shift: bool, + logo: bool, +} + +impl Hotkey { + fn with_control(key: &str) -> Self { + Self::new(key.to_string(), true, false, false, false) + } + + pub fn new(key: String, control: bool, alt: bool, shift: bool, logo: bool) -> Self { + Self { + key, + control, + alt, + shift, + logo, + } + } + + pub fn matches( + &self, + key: &str, + control: bool, + alt: bool, + shift: bool, + logo: bool, + ) -> bool { + self.key == key + && self.control == control + && self.alt == alt + && self.shift == shift + && self.logo == logo + } + + pub fn display(&self) -> String { + let mut parts = Vec::new(); + if self.control { + parts.push("Ctrl".to_string()); + } + if self.alt { + parts.push("Alt".to_string()); + } + if self.shift { + parts.push("Shift".to_string()); + } + if self.logo { + parts.push("Super".to_string()); + } + parts.push(if self.key.chars().count() == 1 { + self.key.to_uppercase() + } else { + self.key.clone() + }); + parts.join("+") + } +} + impl AppSettings { pub fn load() -> Self { std::fs::read_to_string(get_app_settings_path()) @@ -88,6 +154,57 @@ impl AppSettings { } } +#[cfg(test)] +mod tests { + use super::{AppSettings, Hotkey, MyTheme}; + + #[test] + fn old_app_settings_use_default_hotkeys() { + let settings: AppSettings = serde_json::from_str( + r#"{"theme":"Nord","tray_text_mode":true,"stem_control":true}"#, + ) + .unwrap(); + + assert_eq!(settings.theme, MyTheme::Nord); + assert!(settings.tray_text_mode); + assert!(settings.stem_control); + assert_eq!(settings.close_window_hotkey, Some(Hotkey::with_control("w"))); + assert_eq!(settings.quit_application_hotkey, Some(Hotkey::with_control("q"))); + } + + #[test] + fn hotkey_formats_modifiers_and_key() { + let hotkey = Hotkey::new("k".to_string(), true, true, true, false); + + assert_eq!(hotkey.display(), "Ctrl+Alt+Shift+K"); + assert!(hotkey.matches("k", true, true, true, false)); + } + + #[test] + fn app_settings_round_trip_custom_hotkeys() { + let expected = AppSettings { + close_window_hotkey: None, + quit_application_hotkey: Some(Hotkey::new( + "F12".to_string(), + false, + true, + false, + false, + )), + ..AppSettings::default() + }; + + let json = serde_json::to_string(&expected).unwrap(); + let actual: AppSettings = serde_json::from_str(&json).unwrap(); + + assert_eq!(actual.close_window_hotkey, expected.close_window_hotkey); + assert_eq!( + actual.quit_application_hotkey, + expected.quit_application_hotkey + ); + } +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse(); From 78df6f88653ea64fba81e00e5770664bb15170dc Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:20:05 +0300 Subject: [PATCH 3/8] refactor(linux-rust): isolate hotkey event processing --- linux-rust/src/ui/window.rs | 231 +++++++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 58 deletions(-) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index e70659a0c..6edba0b43 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -116,6 +116,14 @@ pub enum HotkeyAction { QuitApplication, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HotkeyOutcome { + None, + SettingsChanged, + CloseWindow, + QuitApplication, +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Tab { Device(String), @@ -623,67 +631,22 @@ impl App { Task::none() } Message::HotkeyPressed(key, modifiers) => { - if self.recording_hotkey.is_some() - && key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Escape) - { - self.recording_hotkey = None; - return Task::none(); - } - - if key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Backspace) - && let Some(action) = self.recording_hotkey.take() - { - match action { - HotkeyAction::CloseWindow => self.settings.close_window_hotkey = None, - HotkeyAction::QuitApplication => { - self.settings.quit_application_hotkey = None - } - } - self.save_settings(); - return Task::none(); - } - - let Some(key) = hotkey_key(&key) else { - return Task::none(); - }; - - if let Some(action) = self.recording_hotkey.take() { - let hotkey = Hotkey::new( - key, - modifiers.control(), - modifiers.alt(), - modifiers.shift(), - modifiers.logo(), - ); - match action { - HotkeyAction::CloseWindow => { - self.settings.close_window_hotkey = Some(hotkey) - } - HotkeyAction::QuitApplication => { - self.settings.quit_application_hotkey = Some(hotkey) - } + match process_hotkey_press( + &mut self.settings, + &mut self.recording_hotkey, + &key, + modifiers, + ) { + HotkeyOutcome::None => Task::none(), + HotkeyOutcome::SettingsChanged => { + self.save_settings(); + Task::none() } - self.save_settings(); - return Task::none(); - } - - let matches = |hotkey: &Hotkey| { - hotkey.matches( - &key, - modifiers.control(), - modifiers.alt(), - modifiers.shift(), - modifiers.logo(), - ) - }; - if self.settings.close_window_hotkey.as_ref().is_some_and(matches) { - if let Some(window) = self.window { - return window::close(window); + HotkeyOutcome::CloseWindow => { + self.window.map_or_else(Task::none, window::close) } - } else if self.settings.quit_application_hotkey.as_ref().is_some_and(matches) { - return iced::exit(); + HotkeyOutcome::QuitApplication => iced::exit(), } - Task::none() } } } @@ -1497,6 +1460,70 @@ fn hotkey_key(key: &keyboard::Key) -> Option { } } +fn process_hotkey_press( + settings: &mut AppSettings, + recording_hotkey: &mut Option, + key: &keyboard::Key, + modifiers: keyboard::Modifiers, +) -> HotkeyOutcome { + if recording_hotkey.is_some() + && key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Escape) + { + *recording_hotkey = None; + return HotkeyOutcome::None; + } + + if key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Backspace) + && let Some(action) = recording_hotkey.take() + { + match action { + HotkeyAction::CloseWindow => settings.close_window_hotkey = None, + HotkeyAction::QuitApplication => settings.quit_application_hotkey = None, + } + return HotkeyOutcome::SettingsChanged; + } + + let Some(key) = hotkey_key(key) else { + return HotkeyOutcome::None; + }; + + if let Some(action) = recording_hotkey.take() { + let hotkey = Hotkey::new( + key, + modifiers.control(), + modifiers.alt(), + modifiers.shift(), + modifiers.logo(), + ); + match action { + HotkeyAction::CloseWindow => settings.close_window_hotkey = Some(hotkey), + HotkeyAction::QuitApplication => settings.quit_application_hotkey = Some(hotkey), + } + return HotkeyOutcome::SettingsChanged; + } + + let matches = |hotkey: &Hotkey| { + hotkey.matches( + &key, + modifiers.control(), + modifiers.alt(), + modifiers.shift(), + modifiers.logo(), + ) + }; + if settings.close_window_hotkey.as_ref().is_some_and(matches) { + HotkeyOutcome::CloseWindow + } else if settings + .quit_application_hotkey + .as_ref() + .is_some_and(matches) + { + HotkeyOutcome::QuitApplication + } else { + HotkeyOutcome::None + } +} + async fn wait_for_message(ui_rx: Arc>>) -> Message { let mut rx = ui_rx.lock().await; match rx.recv().await { @@ -1508,6 +1535,94 @@ async fn wait_for_message(ui_rx: Arc } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_named_shortcut() { + let mut settings = AppSettings::default(); + let expected = Hotkey::new("Enter".to_string(), false, false, false, false); + let mut recording = Some(HotkeyAction::CloseWindow); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Named(keyboard::key::Named::Enter), + keyboard::Modifiers::NONE, + ); + + assert_eq!(outcome, HotkeyOutcome::SettingsChanged); + assert_eq!(settings.close_window_hotkey, Some(expected)); + assert_eq!(recording, None); + } + + #[test] + fn escape_cancels_recording_without_changing_binding() { + let mut settings = AppSettings::default(); + let original = settings.close_window_hotkey.clone(); + let mut recording = Some(HotkeyAction::CloseWindow); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Named(keyboard::key::Named::Escape), + keyboard::Modifiers::NONE, + ); + + assert_eq!(outcome, HotkeyOutcome::None); + assert_eq!(recording, None); + assert_eq!(settings.close_window_hotkey, original); + } + + #[test] + fn backspace_unbinds_recorded_action() { + let mut settings = AppSettings::default(); + let mut recording = Some(HotkeyAction::QuitApplication); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Named(keyboard::key::Named::Backspace), + keyboard::Modifiers::NONE, + ); + + assert_eq!(outcome, HotkeyOutcome::SettingsChanged); + assert_eq!(recording, None); + assert_eq!(settings.quit_application_hotkey, None); + } + + #[test] + fn quit_shortcut_returns_quit_outcome() { + let mut settings = AppSettings::default(); + + let outcome = process_hotkey_press( + &mut settings, + &mut None, + &keyboard::Key::Character("q".into()), + keyboard::Modifiers::CTRL, + ); + + assert_eq!(outcome, HotkeyOutcome::QuitApplication); + } + + #[test] + fn modifier_only_press_keeps_recording_active() { + let mut settings = AppSettings::default(); + let mut recording = Some(HotkeyAction::CloseWindow); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Named(keyboard::key::Named::Control), + keyboard::Modifiers::CTRL, + ); + + assert_eq!(outcome, HotkeyOutcome::None); + assert_eq!(recording, Some(HotkeyAction::CloseWindow)); + } +} + // async fn load_paired_devices() -> HashMap { // let mut devices = HashMap::new(); // From e024c1c8bcefc1f0e5f387598913833673b32ded Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:20:49 +0300 Subject: [PATCH 4/8] fix(linux-rust): ignore captured hotkey events --- linux-rust/src/ui/window.rs | 62 ++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 6edba0b43..8eaaea450 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -107,7 +107,7 @@ pub enum Message { TrayTextModeChanged(bool), StemControlChanged(bool), StartHotkeyRecording(HotkeyAction), - HotkeyPressed(keyboard::Key, keyboard::Modifiers), + HotkeyPressed(keyboard::Key, keyboard::Modifiers, event::Status), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -630,12 +630,13 @@ impl App { self.recording_hotkey = Some(action); Task::none() } - Message::HotkeyPressed(key, modifiers) => { + Message::HotkeyPressed(key, modifiers, status) => { match process_hotkey_press( &mut self.settings, &mut self.recording_hotkey, &key, modifiers, + status, ) { HotkeyOutcome::None => Task::none(), HotkeyOutcome::SettingsChanged => { @@ -1428,13 +1429,13 @@ impl App { fn subscription(&self) -> Subscription { Subscription::batch([ window::close_events().map(Message::WindowClosed), - event::listen_with(|event, _status, _window| match event { + event::listen_with(|event, status, _window| match event { event::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, repeat: false, .. - }) => Some(Message::HotkeyPressed(key, modifiers)), + }) => Some(Message::HotkeyPressed(key, modifiers, status)), _ => None, }), ]) @@ -1465,7 +1466,12 @@ fn process_hotkey_press( recording_hotkey: &mut Option, key: &keyboard::Key, modifiers: keyboard::Modifiers, + status: event::Status, ) -> HotkeyOutcome { + if recording_hotkey.is_none() && status == event::Status::Captured { + return HotkeyOutcome::None; + } + if recording_hotkey.is_some() && key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Escape) { @@ -1550,6 +1556,7 @@ mod tests { &mut recording, &keyboard::Key::Named(keyboard::key::Named::Enter), keyboard::Modifiers::NONE, + event::Status::Ignored, ); assert_eq!(outcome, HotkeyOutcome::SettingsChanged); @@ -1568,6 +1575,7 @@ mod tests { &mut recording, &keyboard::Key::Named(keyboard::key::Named::Escape), keyboard::Modifiers::NONE, + event::Status::Ignored, ); assert_eq!(outcome, HotkeyOutcome::None); @@ -1585,6 +1593,7 @@ mod tests { &mut recording, &keyboard::Key::Named(keyboard::key::Named::Backspace), keyboard::Modifiers::NONE, + event::Status::Ignored, ); assert_eq!(outcome, HotkeyOutcome::SettingsChanged); @@ -1601,6 +1610,7 @@ mod tests { &mut None, &keyboard::Key::Character("q".into()), keyboard::Modifiers::CTRL, + event::Status::Ignored, ); assert_eq!(outcome, HotkeyOutcome::QuitApplication); @@ -1616,11 +1626,55 @@ mod tests { &mut recording, &keyboard::Key::Named(keyboard::key::Named::Control), keyboard::Modifiers::CTRL, + event::Status::Ignored, ); assert_eq!(outcome, HotkeyOutcome::None); assert_eq!(recording, Some(HotkeyAction::CloseWindow)); } + + #[test] + fn captured_keys_do_not_trigger_shortcuts() { + let mut settings = AppSettings { + close_window_hotkey: Some(Hotkey::new( + "Enter".to_string(), + false, + false, + false, + false, + )), + ..AppSettings::default() + }; + + let outcome = process_hotkey_press( + &mut settings, + &mut None, + &keyboard::Key::Named(keyboard::key::Named::Enter), + keyboard::Modifiers::NONE, + event::Status::Captured, + ); + + assert_eq!(outcome, HotkeyOutcome::None); + } + + #[test] + fn captured_keys_can_be_recorded() { + let mut settings = AppSettings::default(); + let expected = Hotkey::new("Enter".to_string(), false, false, false, false); + let mut recording = Some(HotkeyAction::CloseWindow); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Named(keyboard::key::Named::Enter), + keyboard::Modifiers::NONE, + event::Status::Captured, + ); + + assert_eq!(outcome, HotkeyOutcome::SettingsChanged); + assert_eq!(settings.close_window_hotkey, Some(expected)); + assert_eq!(recording, None); + } } // async fn load_paired_devices() -> HashMap { From 0efc0cf90443e7f7d7f1c9d66a470a2db29429e1 Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:21:44 +0300 Subject: [PATCH 5/8] fix(linux-rust): normalize hotkeys across layouts --- linux-rust/src/ui/window.rs | 92 +++++++++++++++++++++++++++++++++---- linux-rust/src/utils.rs | 3 +- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 8eaaea450..f3d7c7be9 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -107,7 +107,12 @@ pub enum Message { TrayTextModeChanged(bool), StemControlChanged(bool), StartHotkeyRecording(HotkeyAction), - HotkeyPressed(keyboard::Key, keyboard::Modifiers, event::Status), + HotkeyPressed( + keyboard::Key, + keyboard::key::Physical, + keyboard::Modifiers, + event::Status, + ), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -630,11 +635,12 @@ impl App { self.recording_hotkey = Some(action); Task::none() } - Message::HotkeyPressed(key, modifiers, status) => { + Message::HotkeyPressed(key, physical_key, modifiers, status) => { match process_hotkey_press( &mut self.settings, &mut self.recording_hotkey, &key, + physical_key, modifiers, status, ) { @@ -1432,21 +1438,24 @@ impl App { event::listen_with(|event, status, _window| match event { event::Event::Keyboard(keyboard::Event::KeyPressed { key, + physical_key, modifiers, repeat: false, .. - }) => Some(Message::HotkeyPressed(key, modifiers, status)), + }) => Some(Message::HotkeyPressed(key, physical_key, modifiers, status)), _ => None, }), ]) } } -fn hotkey_key(key: &keyboard::Key) -> Option { +fn hotkey_key(key: &keyboard::Key, physical_key: keyboard::key::Physical) -> Option { match key.as_ref() { - keyboard::Key::Character(character) if !character.is_empty() => { - Some(character.to_lowercase()) - } + keyboard::Key::Character(character) if !character.is_empty() => Some( + key.to_latin(physical_key) + .map(|character| character.to_lowercase().collect()) + .unwrap_or_else(|| character.to_lowercase()), + ), keyboard::Key::Named( keyboard::key::Named::Alt | keyboard::key::Named::AltGraph @@ -1465,6 +1474,7 @@ fn process_hotkey_press( settings: &mut AppSettings, recording_hotkey: &mut Option, key: &keyboard::Key, + physical_key: keyboard::key::Physical, modifiers: keyboard::Modifiers, status: event::Status, ) -> HotkeyOutcome { @@ -1489,7 +1499,7 @@ fn process_hotkey_press( return HotkeyOutcome::SettingsChanged; } - let Some(key) = hotkey_key(key) else { + let Some(key) = hotkey_key(key, physical_key) else { return HotkeyOutcome::None; }; @@ -1544,6 +1554,7 @@ async fn wait_for_message(ui_rx: Arc #[cfg(test)] mod tests { use super::*; + use iced::keyboard::key::{Code, Physical}; #[test] fn records_named_shortcut() { @@ -1555,6 +1566,7 @@ mod tests { &mut settings, &mut recording, &keyboard::Key::Named(keyboard::key::Named::Enter), + Physical::Code(Code::Enter), keyboard::Modifiers::NONE, event::Status::Ignored, ); @@ -1574,6 +1586,7 @@ mod tests { &mut settings, &mut recording, &keyboard::Key::Named(keyboard::key::Named::Escape), + Physical::Code(Code::Escape), keyboard::Modifiers::NONE, event::Status::Ignored, ); @@ -1592,6 +1605,7 @@ mod tests { &mut settings, &mut recording, &keyboard::Key::Named(keyboard::key::Named::Backspace), + Physical::Code(Code::Backspace), keyboard::Modifiers::NONE, event::Status::Ignored, ); @@ -1609,6 +1623,7 @@ mod tests { &mut settings, &mut None, &keyboard::Key::Character("q".into()), + Physical::Code(Code::KeyQ), keyboard::Modifiers::CTRL, event::Status::Ignored, ); @@ -1625,6 +1640,7 @@ mod tests { &mut settings, &mut recording, &keyboard::Key::Named(keyboard::key::Named::Control), + Physical::Code(Code::ControlLeft), keyboard::Modifiers::CTRL, event::Status::Ignored, ); @@ -1650,6 +1666,7 @@ mod tests { &mut settings, &mut None, &keyboard::Key::Named(keyboard::key::Named::Enter), + Physical::Code(Code::Enter), keyboard::Modifiers::NONE, event::Status::Captured, ); @@ -1667,6 +1684,7 @@ mod tests { &mut settings, &mut recording, &keyboard::Key::Named(keyboard::key::Named::Enter), + Physical::Code(Code::Enter), keyboard::Modifiers::NONE, event::Status::Captured, ); @@ -1675,6 +1693,64 @@ mod tests { assert_eq!(settings.close_window_hotkey, Some(expected)); assert_eq!(recording, None); } + + #[test] + fn latin_shortcuts_work_with_non_latin_layouts() { + let mut settings = AppSettings::default(); + + let outcome = process_hotkey_press( + &mut settings, + &mut None, + &keyboard::Key::Character("ц".into()), + Physical::Code(Code::KeyW), + keyboard::Modifiers::CTRL, + event::Status::Ignored, + ); + + assert_eq!(outcome, HotkeyOutcome::CloseWindow); + } + + #[test] + fn latin_shortcuts_are_case_insensitive() { + let mut settings = AppSettings::default(); + + let outcome = process_hotkey_press( + &mut settings, + &mut None, + &keyboard::Key::Character("W".into()), + Physical::Code(Code::KeyW), + keyboard::Modifiers::CTRL, + event::Status::Ignored, + ); + + assert_eq!(outcome, HotkeyOutcome::CloseWindow); + } + + #[test] + fn recorded_shortcuts_survive_keyboard_layout_changes() { + let mut settings = AppSettings::default(); + let mut recording = Some(HotkeyAction::CloseWindow); + + let recorded = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Character("ц".into()), + Physical::Code(Code::KeyW), + keyboard::Modifiers::CTRL, + event::Status::Ignored, + ); + let matched = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Character("w".into()), + Physical::Code(Code::KeyW), + keyboard::Modifiers::CTRL, + event::Status::Ignored, + ); + + assert_eq!(recorded, HotkeyOutcome::SettingsChanged); + assert_eq!(matched, HotkeyOutcome::CloseWindow); + } } // async fn load_paired_devices() -> HashMap { diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 949852d65..0cabf65f5 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -106,7 +106,7 @@ impl Hotkey { shift: bool, logo: bool, ) -> bool { - self.key == key + self.key.to_lowercase() == key.to_lowercase() && self.control == control && self.alt == alt && self.shift == shift @@ -178,6 +178,7 @@ mod tests { assert_eq!(hotkey.display(), "Ctrl+Alt+Shift+K"); assert!(hotkey.matches("k", true, true, true, false)); + assert!(hotkey.matches("K", true, true, true, false)); } #[test] From fe32e5f9bb97b15410a0f785fb45a3d7d9d88b53 Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:22:18 +0300 Subject: [PATCH 6/8] fix(linux-rust): cancel stale hotkey recording --- linux-rust/src/ui/window.rs | 51 ++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index f3d7c7be9..fa2c646f8 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -241,9 +241,7 @@ impl App { Task::none() } Message::WindowClosed(id) => { - if self.window == Some(id) { - self.window = None; - } + handle_window_closed(&mut self.window, &mut self.recording_hotkey, id); Task::none() } Message::Resized(event) => { @@ -251,7 +249,7 @@ impl App { Task::none() } Message::SelectTab(tab) => { - self.selected_tab = tab; + select_tab(&mut self.selected_tab, &mut self.recording_hotkey, tab); Task::none() } Message::ThemeSelected(theme) => { @@ -1470,6 +1468,24 @@ fn hotkey_key(key: &keyboard::Key, physical_key: keyboard::key::Physical) -> Opt } } +fn handle_window_closed( + window: &mut Option, + recording_hotkey: &mut Option, + closed_window: window::Id, +) { + if *window == Some(closed_window) { + *window = None; + *recording_hotkey = None; + } +} + +fn select_tab(selected_tab: &mut Tab, recording_hotkey: &mut Option, tab: Tab) { + if !matches!(tab, Tab::Settings) { + *recording_hotkey = None; + } + *selected_tab = tab; +} + fn process_hotkey_press( settings: &mut AppSettings, recording_hotkey: &mut Option, @@ -1751,6 +1767,33 @@ mod tests { assert_eq!(recorded, HotkeyOutcome::SettingsChanged); assert_eq!(matched, HotkeyOutcome::CloseWindow); } + + #[test] + fn leaving_settings_cancels_hotkey_recording() { + let mut selected_tab = Tab::Settings; + let mut recording = Some(HotkeyAction::CloseWindow); + + select_tab( + &mut selected_tab, + &mut recording, + Tab::Device("device".to_string()), + ); + + assert_eq!(recording, None); + assert_eq!(selected_tab, Tab::Device("device".to_string())); + } + + #[test] + fn closing_window_cancels_hotkey_recording() { + let window = window::Id::unique(); + let mut current_window = Some(window); + let mut recording = Some(HotkeyAction::QuitApplication); + + handle_window_closed(&mut current_window, &mut recording, window); + + assert_eq!(current_window, None); + assert_eq!(recording, None); + } } // async fn load_paired_devices() -> HashMap { From 481d83f1a4a905d8f728efa16cda6537d18d41c7 Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:23:22 +0300 Subject: [PATCH 7/8] fix(linux-rust): reject duplicate hotkey bindings --- linux-rust/src/ui/window.rs | 91 +++++++++++++++++++++++++++++++++++++ linux-rust/src/utils.rs | 45 +++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index fa2c646f8..cab7c3ece 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -125,6 +125,7 @@ pub enum HotkeyAction { enum HotkeyOutcome { None, SettingsChanged, + Conflict, CloseWindow, QuitApplication, } @@ -647,6 +648,10 @@ impl App { self.save_settings(); Task::none() } + HotkeyOutcome::Conflict => { + self.recording_hotkey = None; + Task::none() + } HotkeyOutcome::CloseWindow => { self.window.map_or_else(Task::none, window::close) } @@ -1527,6 +1532,21 @@ fn process_hotkey_press( modifiers.shift(), modifiers.logo(), ); + let conflicts = match action { + HotkeyAction::CloseWindow => settings + .quit_application_hotkey + .as_ref() + .is_some_and(|existing| existing.same_binding(&hotkey)), + HotkeyAction::QuitApplication => settings + .close_window_hotkey + .as_ref() + .is_some_and(|existing| existing.same_binding(&hotkey)), + }; + if conflicts { + *recording_hotkey = Some(action); + return HotkeyOutcome::Conflict; + } + match action { HotkeyAction::CloseWindow => settings.close_window_hotkey = Some(hotkey), HotkeyAction::QuitApplication => settings.quit_application_hotkey = Some(hotkey), @@ -1794,6 +1814,77 @@ mod tests { assert_eq!(current_window, None); assert_eq!(recording, None); } + + #[test] + fn assigning_duplicate_shortcut_is_rejected() { + let mut settings = AppSettings::default(); + let original_close = settings.close_window_hotkey.clone(); + let original_quit = settings.quit_application_hotkey.clone(); + let mut recording = Some(HotkeyAction::QuitApplication); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Character("w".into()), + Physical::Code(Code::KeyW), + keyboard::Modifiers::CTRL, + event::Status::Ignored, + ); + + assert_eq!(outcome, HotkeyOutcome::Conflict); + assert_eq!(settings.close_window_hotkey, original_close); + assert_eq!(settings.quit_application_hotkey, original_quit); + assert_eq!(recording, Some(HotkeyAction::QuitApplication)); + } + + #[test] + fn assigning_quit_shortcut_to_close_action_is_rejected() { + let mut settings = AppSettings::default(); + let original_close = settings.close_window_hotkey.clone(); + let original_quit = settings.quit_application_hotkey.clone(); + let mut recording = Some(HotkeyAction::CloseWindow); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Character("q".into()), + Physical::Code(Code::KeyQ), + keyboard::Modifiers::CTRL, + event::Status::Ignored, + ); + + assert_eq!(outcome, HotkeyOutcome::Conflict); + assert_eq!(settings.close_window_hotkey, original_close); + assert_eq!(settings.quit_application_hotkey, original_quit); + assert_eq!(recording, Some(HotkeyAction::CloseWindow)); + } + + #[test] + fn duplicate_shortcut_check_is_case_insensitive() { + let mut settings = AppSettings { + close_window_hotkey: Some(Hotkey::new( + "W".to_string(), + true, + false, + false, + false, + )), + ..AppSettings::default() + }; + let mut recording = Some(HotkeyAction::QuitApplication); + + let outcome = process_hotkey_press( + &mut settings, + &mut recording, + &keyboard::Key::Character("w".into()), + Physical::Code(Code::KeyW), + keyboard::Modifiers::CTRL, + event::Status::Ignored, + ); + + assert_eq!(outcome, HotkeyOutcome::Conflict); + assert_eq!(recording, Some(HotkeyAction::QuitApplication)); + } } // async fn load_paired_devices() -> HashMap { diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 0cabf65f5..e66d80685 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -113,6 +113,16 @@ impl Hotkey { && self.logo == logo } + pub(crate) fn same_binding(&self, other: &Self) -> bool { + self.matches( + &other.key, + other.control, + other.alt, + other.shift, + other.logo, + ) + } + pub fn display(&self) -> String { let mut parts = Vec::new(); if self.control { @@ -138,10 +148,12 @@ impl Hotkey { impl AppSettings { pub fn load() -> Self { - std::fs::read_to_string(get_app_settings_path()) + let mut settings: Self = std::fs::read_to_string(get_app_settings_path()) .ok() .and_then(|settings| serde_json::from_str(&settings).ok()) - .unwrap_or_default() + .unwrap_or_default(); + settings.remove_duplicate_hotkeys(); + settings } pub fn save(&self) -> io::Result<()> { @@ -152,6 +164,20 @@ impl AppSettings { let settings = serde_json::to_string_pretty(self).map_err(io::Error::other)?; std::fs::write(path, settings) } + + fn remove_duplicate_hotkeys(&mut self) { + if self + .close_window_hotkey + .as_ref() + .zip(self.quit_application_hotkey.as_ref()) + .is_some_and(|(close, quit)| close.same_binding(quit)) + { + log::warn!( + "Duplicate persisted hotkeys detected; preserving close binding and unbinding quit" + ); + self.quit_application_hotkey = None; + } + } } #[cfg(test)] @@ -204,6 +230,21 @@ mod tests { expected.quit_application_hotkey ); } + + #[test] + fn duplicate_app_settings_unbind_quit_action() { + let close = Hotkey::with_control("W"); + let mut settings = AppSettings { + close_window_hotkey: Some(close.clone()), + quit_application_hotkey: Some(Hotkey::with_control("w")), + ..AppSettings::default() + }; + + settings.remove_duplicate_hotkeys(); + + assert_eq!(settings.close_window_hotkey, Some(close)); + assert_eq!(settings.quit_application_hotkey, None); + } } fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { From f77073931c363f3666912a273a04b19868b70f27 Mon Sep 17 00:00:00 2001 From: AF_Askar <68832286+abo3skr2019@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:24:28 +0300 Subject: [PATCH 8/8] fix(linux-rust): expire hotkey conflict feedback --- linux-rust/src/ui/window.rs | 148 +++++++++++++++++++++++++++++++----- 1 file changed, 131 insertions(+), 17 deletions(-) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index cab7c3ece..e06500b34 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -23,6 +23,7 @@ use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Sub use log::{debug, error}; use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::{Mutex, RwLock}; @@ -74,6 +75,8 @@ pub struct App { device_type_state: combo_box::State, selected_device_type: Option, recording_hotkey: Option, + hotkey_conflict: bool, + hotkey_conflict_generation: u64, } pub struct BluetoothState { @@ -113,6 +116,7 @@ pub enum Message { keyboard::Modifiers, event::Status, ), + HotkeyConflictExpired(HotkeyAction, u64), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -218,6 +222,8 @@ impl App { selected_device_type: None, device_managers, recording_hotkey: None, + hotkey_conflict: false, + hotkey_conflict_generation: 0, }, Task::batch(vec![open_task, wait_task]), ) @@ -243,6 +249,9 @@ impl App { } Message::WindowClosed(id) => { handle_window_closed(&mut self.window, &mut self.recording_hotkey, id); + if self.recording_hotkey.is_none() { + self.hotkey_conflict = false; + } Task::none() } Message::Resized(event) => { @@ -251,6 +260,9 @@ impl App { } Message::SelectTab(tab) => { select_tab(&mut self.selected_tab, &mut self.recording_hotkey, tab); + if self.recording_hotkey.is_none() { + self.hotkey_conflict = false; + } Task::none() } Message::ThemeSelected(theme) => { @@ -632,6 +644,7 @@ impl App { } Message::StartHotkeyRecording(action) => { self.recording_hotkey = Some(action); + self.hotkey_conflict = false; Task::none() } Message::HotkeyPressed(key, physical_key, modifiers, status) => { @@ -643,14 +656,33 @@ impl App { modifiers, status, ) { - HotkeyOutcome::None => Task::none(), + HotkeyOutcome::None => { + if self.recording_hotkey.is_none() { + self.hotkey_conflict = false; + } + Task::none() + } HotkeyOutcome::SettingsChanged => { + self.hotkey_conflict = false; self.save_settings(); Task::none() } HotkeyOutcome::Conflict => { - self.recording_hotkey = None; - Task::none() + self.hotkey_conflict = true; + self.hotkey_conflict_generation = + self.hotkey_conflict_generation.wrapping_add(1); + let generation = self.hotkey_conflict_generation; + if let Some(action) = self.recording_hotkey { + Task::perform( + async { + tokio::time::sleep(Duration::from_secs(2)).await; + }, + move |_| Message::HotkeyConflictExpired(action, generation), + ) + } else { + error!("Hotkey conflict without an active recording"); + Task::none() + } } HotkeyOutcome::CloseWindow => { self.window.map_or_else(Task::none, window::close) @@ -658,6 +690,16 @@ impl App { HotkeyOutcome::QuitApplication => iced::exit(), } } + Message::HotkeyConflictExpired(action, generation) => { + expire_hotkey_conflict( + &mut self.recording_hotkey, + &mut self.hotkey_conflict, + self.hotkey_conflict_generation, + action, + generation, + ); + Task::none() + } } } @@ -1118,13 +1160,11 @@ impl App { ] .spacing(12); - let close_hotkey_label = if self.recording_hotkey == Some(HotkeyAction::CloseWindow) { - "Press shortcut...".to_string() - } else { - self.settings.close_window_hotkey.as_ref() - .map(Hotkey::display) - .unwrap_or_else(|| "Not set".to_string()) - }; + let close_hotkey_label = hotkey_label( + self.settings.close_window_hotkey.as_ref(), + self.recording_hotkey == Some(HotkeyAction::CloseWindow), + self.hotkey_conflict, + ); let close_window_hotkey_setting = container( row![ column![ @@ -1174,13 +1214,11 @@ impl App { style }); - let quit_hotkey_label = if self.recording_hotkey == Some(HotkeyAction::QuitApplication) { - "Press shortcut...".to_string() - } else { - self.settings.quit_application_hotkey.as_ref() - .map(Hotkey::display) - .unwrap_or_else(|| "Not set".to_string()) - }; + let quit_hotkey_label = hotkey_label( + self.settings.quit_application_hotkey.as_ref(), + self.recording_hotkey == Some(HotkeyAction::QuitApplication), + self.hotkey_conflict, + ); let quit_application_hotkey_setting = container( row![ column![ @@ -1491,6 +1529,39 @@ fn select_tab(selected_tab: &mut Tab, recording_hotkey: &mut Option, recording: bool, conflict: bool) -> String { + if recording { + if conflict { + "Already in use".to_string() + } else { + "Press shortcut...".to_string() + } + } else { + hotkey + .map(Hotkey::display) + .unwrap_or_else(|| "Not set".to_string()) + } +} + +fn expire_hotkey_conflict( + recording_hotkey: &mut Option, + hotkey_conflict: &mut bool, + current_generation: u64, + action: HotkeyAction, + generation: u64, +) -> bool { + if *hotkey_conflict + && *recording_hotkey == Some(action) + && current_generation == generation + { + *hotkey_conflict = false; + *recording_hotkey = None; + true + } else { + false + } +} + fn process_hotkey_press( settings: &mut AppSettings, recording_hotkey: &mut Option, @@ -1885,6 +1956,49 @@ mod tests { assert_eq!(outcome, HotkeyOutcome::Conflict); assert_eq!(recording, Some(HotkeyAction::QuitApplication)); } + + #[test] + fn conflicting_hotkey_label_reports_error() { + assert_eq!(hotkey_label(None, true, true), "Already in use"); + assert_eq!(hotkey_label(None, true, false), "Press shortcut..."); + } + + #[test] + fn conflict_expiry_restores_previous_hotkey_label() { + let hotkey = Hotkey::new("w".to_string(), true, false, false, false); + let mut recording = Some(HotkeyAction::CloseWindow); + let mut conflict = true; + + assert_eq!(hotkey_label(Some(&hotkey), true, conflict), "Already in use"); + assert!(expire_hotkey_conflict( + &mut recording, + &mut conflict, + 4, + HotkeyAction::CloseWindow, + 4, + )); + + assert_eq!(recording, None); + assert!(!conflict); + assert_eq!(hotkey_label(Some(&hotkey), false, conflict), "Ctrl+W"); + } + + #[test] + fn stale_conflict_expiry_does_not_clear_newer_warning() { + let mut recording = Some(HotkeyAction::QuitApplication); + let mut conflict = true; + + assert!(!expire_hotkey_conflict( + &mut recording, + &mut conflict, + 5, + HotkeyAction::QuitApplication, + 4, + )); + + assert_eq!(recording, Some(HotkeyAction::QuitApplication)); + assert!(conflict); + } } // async fn load_paired_devices() -> HashMap {