From 8c3d3f664659bf2e078c3c262bfc889523349f90 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 3 Jul 2026 16:23:06 +0800 Subject: [PATCH 1/4] feat: add hyperkey to rustcast --- src/app.rs | 1 + src/app/pages/settings.rs | 24 ++++++++++++++++++++++++ src/app/tile.rs | 6 +++++- src/app/tile/update.rs | 22 ++++++++++++++++++++++ src/config.rs | 2 ++ src/main.rs | 4 ++++ src/platform/macos/launching.rs | 2 +- src/platform/macos/mod.rs | 30 ++++++++++++++++++++++++++++++ 8 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index ba74701..7035064 100644 --- a/src/app.rs +++ b/src/app.rs @@ -177,6 +177,7 @@ pub enum SetConfigFields { ToDefault, ToggleHotkey(String), ClipboardHotkey(String), + HyperkeyHotkey(String), PlaceHolder(String), SearchUrl(String), ClipboardHistory(bool), diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 203d21f..909a5ab 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -205,6 +205,29 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat theme.clone(), ); + let theme_clone = theme.clone(); + let hyperkey = settings_row_with_reset( + settings_item_row([ + settings_hint_text( + theme.clone(), + "Hyperkey hotkey", + Some("Simulate CMD+SHIFT+CTRL+ALT with another hotkey"), + ), + Space::new().width(Length::Fill).into(), + text_input( + "Hyperkey Hotkey", + &config.hyperkey_hotkey.clone().unwrap_or("".to_string()), + ) + .on_input(|input| Message::SetConfig(SetConfigFields::HyperkeyHotkey(input.clone()))) + .on_submit(Message::WriteConfig) + .width(Length::Fixed(SETTINGS_INPUT_WIDTH)) + .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .into(), + ]), + ResetField::ToggleHotkey, + theme.clone(), + ); + let theme_clone = theme.clone(); let placeholder_setting = settings_row_with_reset( settings_item_row([ @@ -391,6 +414,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat Column::from_iter([ hotkey, cb_hotkey, + hyperkey, placeholder_setting, search, debounce, diff --git a/src/app/tile.rs b/src/app/tile.rs index fee0de4..8706b46 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -26,7 +26,7 @@ use iced::{event, window}; use log::{info, warn}; use objc2::rc::Retained; -use objc2_app_kit::NSRunningApplication; +use objc2_app_kit::{NSPickerTouchBarItemControlRepresentation, NSRunningApplication}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::slice::ParallelSliceMut; use tokio::io::{AsyncBufReadExt, AsyncRead}; @@ -214,12 +214,16 @@ pub struct Hotkeys { pub handle: Option, pub toggle: Shortcut, pub clipboard_hotkey: Shortcut, + pub hyperkey: Option, pub shells: HashMap, } impl Hotkeys { pub fn all_hotkeys(&self) -> Vec { let mut a = vec![self.toggle.clone(), self.clipboard_hotkey.clone()]; + if let Some(hyperkey_shortcut) = self.hyperkey { + a.push(hyperkey_shortcut); + } a.extend( self.shells .keys() diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 6c29cec..5a918cb 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -44,6 +44,7 @@ use crate::debounce::DebouncePolicy; use crate::platform::macos::events::Event; use crate::platform::macos::launching::Shortcut; use crate::platform::macos::launching::global_handler; +use crate::platform::macos::send_hyperkey_event; use crate::platform::macos::{start_at_login, stop_at_login}; use crate::quit::get_open_apps; use crate::unit_conversion; @@ -363,6 +364,16 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { tile.hotkeys.clipboard_hotkey = hotkey } + if let Some(hyperkey) = &new_config + .hyperkey_hotkey + .as_ref() + .and_then(|x| Shortcut::parse(x).ok()) + { + tile.hotkeys.hyperkey = Some(*hyperkey) + } else { + tile.hotkeys.hyperkey = None + } + if let Ok(hotkey) = Shortcut::parse(&new_config.toggle_hotkey) { tile.hotkeys.toggle = hotkey } @@ -416,9 +427,15 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { ))); } + let is_hyperkey_hotkey = Some(shortcut) == tile.hotkeys.hyperkey; let is_clipboard_hotkey = shortcut == tile.hotkeys.clipboard_hotkey; let is_open_hotkey = shortcut == tile.hotkeys.toggle; + if is_hyperkey_hotkey { + send_hyperkey_event(); + return Task::none(); + } + let clipboard_page_task = if is_clipboard_hotkey { info!("Switching to clipboard page"); Task::done(Message::SwitchToPage(Page::ClipboardHistory)) @@ -815,6 +832,11 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match config.clone() { SetConfigFields::ToggleHotkey(hk) => final_config.toggle_hotkey = hk, SetConfigFields::ClipboardHotkey(hk) => final_config.clipboard_hotkey = hk, + SetConfigFields::HyperkeyHotkey(key) => { + if !key.trim().is_empty() { + final_config.hyperkey_hotkey = Some(key); + } + } SetConfigFields::ClipboardHistory(cbhist) => final_config.cbhist = cbhist, SetConfigFields::Modes(Editable::Create((key, value))) => { final_config.modes.insert(key, value); diff --git a/src/config.rs b/src/config.rs index 1f74021..50c99b6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,7 @@ use crate::{ pub struct Config { pub toggle_hotkey: String, pub clipboard_hotkey: String, + pub hyperkey_hotkey: Option, pub buffer_rules: Buffer, pub event_duration: u32, pub main_page: MainPage, @@ -45,6 +46,7 @@ impl Default for Config { Self { toggle_hotkey: "ALT+SPACE".to_string(), clipboard_hotkey: "SUPER+SHIFT+C".to_string(), + hyperkey_hotkey: None, buffer_rules: Buffer::default(), theme: Theme::default(), start_at_login: true, diff --git a/src/main.rs b/src/main.rs index 1ec7c28..0d9665b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,10 @@ fn main() -> iced::Result { toggle: show_hide, clipboard_hotkey: cbhist, shells: shell_map, + hyperkey: config + .hyperkey_hotkey + .as_ref() + .and_then(|x| Shortcut::parse(&x).ok()), handle: None, }; diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs index 25dcabf..15f78a5 100644 --- a/src/platform/macos/launching.rs +++ b/src/platform/macos/launching.rs @@ -176,7 +176,7 @@ pub fn global_handler(sender: ExtSender, targets: Vec) -> Result, pub mods: Option, diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index 9eeca17..cf374ce 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -8,6 +8,7 @@ pub mod urlscheme; pub mod window; use iced::wgpu::rwh::WindowHandle; +use objc2_core_graphics::CGEventSource; pub(super) use self::discovery::get_installed_apps; pub(super) use self::haptics::perform_haptic; @@ -123,6 +124,35 @@ pub fn simulate_paste(pid: libc::pid_t) { } } +pub fn send_hyperkey_event() { + use objc2_core_graphics::{ + CGEvent, CGEventFlags, CGEventSource, CGEventSourceStateID, CGEventTapLocation, + }; + + let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState); + let source_ref = source.as_deref(); + + // Use a keycode that won't interfere - 0xFF or a null keycode + // Alternatively use a specific key like F18 (0x4F) if you want a real key + let keycode: u16 = 0; // kVK_ANSI_A as placeholder, or use your target key + + let hyper_flags = CGEventFlags::MaskCommand + | CGEventFlags::MaskAlternate // OPT + | CGEventFlags::MaskShift + | CGEventFlags::MaskControl; + + // Key down + if let Some(keydown) = CGEvent::new_keyboard_event(source_ref, keycode, true) { + CGEvent::set_flags(Some(&keydown), hyper_flags); + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&keydown)); + } + + // Key up + if let Some(keyup) = CGEvent::new_keyboard_event(source_ref, keycode, false) { + CGEvent::set_flags(Some(&keyup), hyper_flags); + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&keyup)); + } +} /// This is the function that transforms the process to a UI element, and hides the dock icon /// /// see mostly From 7525fd8847efb45626d94f553bd388e459c817cc Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 3 Jul 2026 16:23:40 +0800 Subject: [PATCH 2/4] chore: clippy --- src/app/menubar.rs | 6 +++--- src/app/tile.rs | 6 +++--- src/app/tile/update.rs | 12 ++++++------ src/config.rs | 8 ++------ src/main.rs | 2 +- src/platform/macos/launching.rs | 2 +- src/platform/macos/mod.rs | 1 - 7 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/app/menubar.rs b/src/app/menubar.rs index cc86b32..99d59f9 100644 --- a/src/app/menubar.rs +++ b/src/app/menubar.rs @@ -89,10 +89,10 @@ fn get_image() -> DynamicImage { fn init_event_handler(sender: ExtSender, shortcut: Shortcut) { let runtime = Runtime::new().unwrap(); - let shortcut = shortcut.clone(); + let shortcut = shortcut; MenuEvent::set_event_handler(Some(move |x: MenuEvent| { - let shortcut = shortcut.clone(); + let shortcut = shortcut; let sender = sender.clone(); let sender = sender.0.clone(); info!("Menubar event called: {}", x.id.0); @@ -113,7 +113,7 @@ fn init_event_handler(sender: ExtSender, shortcut: Shortcut) { runtime.spawn(async move { sender .clone() - .try_send(Message::KeyPressed(shortcut.clone())) + .try_send(Message::KeyPressed(shortcut)) .unwrap(); }); } diff --git a/src/app/tile.rs b/src/app/tile.rs index 8706b46..95e717c 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -26,7 +26,7 @@ use iced::{event, window}; use log::{info, warn}; use objc2::rc::Retained; -use objc2_app_kit::{NSPickerTouchBarItemControlRepresentation, NSRunningApplication}; +use objc2_app_kit::NSRunningApplication; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::slice::ParallelSliceMut; use tokio::io::{AsyncBufReadExt, AsyncRead}; @@ -220,7 +220,7 @@ pub struct Hotkeys { impl Hotkeys { pub fn all_hotkeys(&self) -> Vec { - let mut a = vec![self.toggle.clone(), self.clipboard_hotkey.clone()]; + let mut a = vec![self.toggle, self.clipboard_hotkey]; if let Some(hyperkey_shortcut) = self.hyperkey { a.push(hyperkey_shortcut); } @@ -300,7 +300,7 @@ impl Tile { } else if modifiers.command() { s.parse::() .ok() - .filter(|&n| n >= 1 && n <= 9) + .filter(|&n| (1..=9).contains(&n)) .map(|n| Message::OpenResult((n - 1) as u32)) } else if s == "p" && modifiers.control() { Some(Message::ChangeFocus(ArrowKey::Up, 1)) diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 5a918cb..9f91769 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -513,12 +513,12 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { ]) } Message::RunFunction(command) => { - if let Function::TileWindow(pos) = &command { - if let Some(pid) = tile.frontmost.as_ref().map(|a| a.processIdentifier()) { - let ok = crate::platform::macos::window::tile_focused_window(pid, pos); - if !ok && tile.config.haptic_feedback { - perform_haptic(HapticPattern::Alignment); - } + if let Function::TileWindow(pos) = &command + && let Some(pid) = tile.frontmost.as_ref().map(|a| a.processIdentifier()) + { + let ok = crate::platform::macos::window::tile_focused_window(pid, pos); + if !ok && tile.config.haptic_feedback { + perform_haptic(HapticPattern::Alignment); } } command.execute(&tile.config); diff --git a/src/config.rs b/src/config.rs index 50c99b6..7df7426 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,18 +93,14 @@ impl std::fmt::Display for MainPage { /// The mode for the theme (dark, light, or follow system) #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Copy)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum ThemeMode { + #[default] Dark, Light, System, } -impl Default for ThemeMode { - fn default() -> Self { - ThemeMode::Dark - } -} - impl ThemeMode { /// Return preset text and background colors for this mode. pub fn presets( diff --git a/src/main.rs b/src/main.rs index 0d9665b..da6ec2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,7 +87,7 @@ fn main() -> iced::Result { hyperkey: config .hyperkey_hotkey .as_ref() - .and_then(|x| Shortcut::parse(&x).ok()), + .and_then(|x| Shortcut::parse(x).ok()), handle: None, }; diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs index 15f78a5..60ffe87 100644 --- a/src/platform/macos/launching.rs +++ b/src/platform/macos/launching.rs @@ -121,7 +121,7 @@ extern "C-unwind" fn keyboard_event_callback( _ => return unsafe { event.as_mut() }, }; - if !data.targets.iter().any(|t| *t == shortcut) { + if !data.targets.contains(&shortcut) { return unsafe { event.as_mut() }; } diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index cf374ce..f23acdb 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -8,7 +8,6 @@ pub mod urlscheme; pub mod window; use iced::wgpu::rwh::WindowHandle; -use objc2_core_graphics::CGEventSource; pub(super) use self::discovery::get_installed_apps; pub(super) use self::haptics::perform_haptic; From 1e965c1ae64a0a81677b96d5c14fa1d1c72114ba Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 3 Jul 2026 16:27:48 +0800 Subject: [PATCH 3/4] bug: fix tests --- src/app/tile/update.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 9f91769..4da55f1 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -1440,6 +1440,7 @@ mod tests { hotkeys: Hotkeys { toggle: Shortcut::parse("alt+space").unwrap(), clipboard_hotkey: Shortcut::parse("cmd+shift+c").unwrap(), + hyperkey: None, shells: HashMap::new(), handle: None, }, From 05adf0d06a0af6754752856b753a14f1db19e0a2 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 10 Jul 2026 12:59:25 +0800 Subject: [PATCH 4/4] testing smt --- src/platform/macos/launching.rs | 83 ++++++++++++++++++++++++++++++++- src/platform/macos/mod.rs | 4 +- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs index 60ffe87..b28c440 100644 --- a/src/platform/macos/launching.rs +++ b/src/platform/macos/launching.rs @@ -193,30 +193,85 @@ impl Shortcut { let mut mods: usize = 0; let mut key_code: Option = None; let mut has_mods = false; + let mut modifier_keycode: Option = None; for part in &parts { match part.to_lowercase().as_str() { + // Sided + "lcmd" | "lcommand" | "lsuper" => { + mods |= NSEventModifierFlags::Command.0; + has_mods = true; + modifier_keycode = Some(55); + } + "rcmd" | "rcommand" | "rsuper" => { + mods |= NSEventModifierFlags::Command.0; + has_mods = true; + modifier_keycode = Some(54); + } + "lopt" | "loption" | "lalt" => { + mods |= NSEventModifierFlags::Option.0; + has_mods = true; + modifier_keycode = Some(58); + } + "ropt" | "roption" | "ralt" => { + mods |= NSEventModifierFlags::Option.0; + has_mods = true; + modifier_keycode = Some(61); + } + "lctrl" | "lcontrol" => { + mods |= NSEventModifierFlags::Control.0; + has_mods = true; + modifier_keycode = Some(59); + } + "rctrl" | "rcontrol" => { + mods |= NSEventModifierFlags::Control.0; + has_mods = true; + modifier_keycode = Some(62); + } + "lshift" => { + mods |= NSEventModifierFlags::Shift.0; + has_mods = true; + modifier_keycode = Some(56); + } + "rshift" => { + mods |= NSEventModifierFlags::Shift.0; + has_mods = true; + modifier_keycode = Some(60); + } + + // Unsided — default to left keycode "cmd" | "command" | "super" => { mods |= NSEventModifierFlags::Command.0; has_mods = true; + modifier_keycode = Some(55); } "opt" | "option" | "alt" => { mods |= NSEventModifierFlags::Option.0; has_mods = true; + modifier_keycode = Some(58); } - "capslock" | "caps" | "caps lock" => mods |= NSEventModifierFlags::CapsLock.0, "ctrl" | "control" => { mods |= NSEventModifierFlags::Control.0; has_mods = true; + modifier_keycode = Some(59); } "shift" => { mods |= NSEventModifierFlags::Shift.0; has_mods = true; + modifier_keycode = Some(56); } + + // No sides "fn" | "function" => { mods |= NSEventModifierFlags::Function.0; has_mods = true; + modifier_keycode = Some(63); } + "capslock" | "caps" | "caps lock" => { + mods |= NSEventModifierFlags::CapsLock.0; + modifier_keycode = Some(57); + } + key => { if key_code.is_some() { return Err(format!("Multiple keys specified: '{}'", s)); @@ -226,6 +281,20 @@ impl Shortcut { } } + if key_code.is_none() { + if let Some(kc) = modifier_keycode { + let remaining = mods & !modifier_flag_for_keycode(kc); + return Ok(Shortcut::new( + Some(kc), + if remaining != 0 { + Some(remaining) + } else { + None + }, + )); + } + } + Ok(Shortcut::new( key_code, if has_mods { Some(mods) } else { None }, @@ -233,6 +302,18 @@ impl Shortcut { } } +fn modifier_flag_for_keycode(kc: u16) -> usize { + match kc { + 56 | 60 => NSEventModifierFlags::Shift.0, + 59 | 62 => NSEventModifierFlags::Control.0, + 58 | 61 => NSEventModifierFlags::Option.0, + 55 | 54 => NSEventModifierFlags::Command.0, + 63 => NSEventModifierFlags::Function.0, + 57 => NSEventModifierFlags::CapsLock.0, + _ => 0, + } +} + fn str_to_keycode(s: &str) -> Result { let code = match s.to_lowercase().as_str() { // Letters diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index 15bccb3..f223d75 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -154,12 +154,14 @@ pub fn send_hyperkey_event() { CGEvent, CGEventFlags, CGEventSource, CGEventSourceStateID, CGEventTapLocation, }; + dbg!("Hyperkey event"); + let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState); let source_ref = source.as_deref(); // Use a keycode that won't interfere - 0xFF or a null keycode // Alternatively use a specific key like F18 (0x4F) if you want a real key - let keycode: u16 = 0; // kVK_ANSI_A as placeholder, or use your target key + let keycode: u16 = 256; // kVK_ANSI_A as placeholder, or use your target key let hyper_flags = CGEventFlags::MaskCommand | CGEventFlags::MaskAlternate // OPT