From 6d3bff952eaf1440c4c903d63722742f07966ac4 Mon Sep 17 00:00:00 2001 From: entailz Date: Thu, 30 Apr 2026 23:21:35 -0700 Subject: [PATCH] Make Super_L chordable and implement hyprland-global-shortcuts-v1 --- jay-config/src/_private/client.rs | 4 + jay-config/src/_private/ipc.rs | 4 + jay-config/src/lib.rs | 8 ++ src/compositor.rs | 1 + src/config/handler.rs | 14 ++ src/globals.rs | 2 + src/ifs.rs | 2 + src/ifs/hyprland_global_shortcut_v1.rs | 97 ++++++++++++++ .../hyprland_global_shortcuts_manager_v1.rs | 122 ++++++++++++++++++ src/state.rs | 3 + toml-config/src/config.rs | 4 + toml-config/src/config/parsers/action.rs | 35 +++++ .../src/config/parsers/modified_keysym.rs | 54 +++++--- toml-config/src/lib.rs | 5 + wire/hyprland_global_shortcut_v1.txt | 14 ++ wire/hyprland_global_shortcuts_manager_v1.txt | 10 ++ 16 files changed, 363 insertions(+), 16 deletions(-) create mode 100644 src/ifs/hyprland_global_shortcut_v1.rs create mode 100644 src/ifs/hyprland_global_shortcuts_manager_v1.rs create mode 100644 wire/hyprland_global_shortcut_v1.txt create mode 100644 wire/hyprland_global_shortcuts_manager_v1.txt diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index f31864fe..25e18b14 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -868,6 +868,10 @@ impl ConfigClient { self.send(&ClientMessage::Quit) } + pub fn trigger_global_shortcut(&self, app_id: &str, id: &str) { + self.send(&ClientMessage::TriggerGlobalShortcut { app_id, id }) + } + pub fn switch_to_vt(&self, vtnr: u32) { self.send(&ClientMessage::SwitchTo { vtnr }) } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index acb5ad81..1902c036 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -912,6 +912,10 @@ pub enum ClientMessage<'a> { seat: Seat, right: bool, }, + TriggerGlobalShortcut { + app_id: &'a str, + id: &'a str, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index e25710f9..089175b1 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -108,6 +108,14 @@ pub fn quit() { get!().quit() } +/// Sends a `pressed` event to the client that has registered a Hyprland global +/// shortcut with the given `app_id` and `id`. +/// +/// Has no effect if no client is currently registered for that combination. +pub fn trigger_global_shortcut(app_id: &str, id: &str) { + get!().trigger_global_shortcut(app_id, id) +} + /// Switches to a different VT. pub fn switch_to_vt(n: u32) { get!().switch_to_vt(n) diff --git a/src/compositor.rs b/src/compositor.rs index 45d2a018..61eda51c 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -396,6 +396,7 @@ fn start_compositor2( bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)), virtual_outputs: Default::default(), clean_logs_older_than: Default::default(), + hyprland_global_shortcuts: Default::default(), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); diff --git a/src/config/handler.rs b/src/config/handler.rs index 50d2acbf..b4b4f37b 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -3528,10 +3528,24 @@ impl ConfigProxyHandler { ClientMessage::SeatMoveTab { seat, right } => self .handle_seat_move_tab(seat, right) .wrn("seat_move_tab")?, + ClientMessage::TriggerGlobalShortcut { app_id, id } => { + self.handle_trigger_global_shortcut(app_id, id); + } } Ok(()) } + fn handle_trigger_global_shortcut(&self, app_id: &str, id: &str) { + let key = (app_id.to_string(), id.to_string()); + let Some(shortcut) = self.state.hyprland_global_shortcuts.get(&key) else { + log::debug!( + "no client has registered hyprland global shortcut {app_id:?}:{id:?}" + ); + return; + }; + shortcut.send_pressed_now(); + } + pub fn auto_focus(&self, data: &ToplevelData) -> bool { for matcher in self.window_matcher_no_auto_focus.lock().values() { if matcher.node.pull(data) { diff --git a/src/globals.rs b/src/globals.rs index dbc5a650..9eff11e1 100644 --- a/src/globals.rs +++ b/src/globals.rs @@ -11,6 +11,7 @@ use { ext_session_lock_manager_v1::ExtSessionLockManagerV1Global, head_management::jay_head_manager_v1::JayHeadManagerV1Global, hyprland_focus_grab_manager_v1::HyprlandFocusGrabManagerV1Global, + hyprland_global_shortcuts_manager_v1::HyprlandGlobalShortcutsManagerV1Global, ipc::{ data_control::{ ext_data_control_manager_v1::ExtDataControlManagerV1Global, @@ -208,6 +209,7 @@ singletons! { ZwpRelativePointerManagerV1, ExtSessionLockManagerV1, HyprlandFocusGrabManagerV1, + HyprlandGlobalShortcutsManagerV1, WpViewporter, WpFractionalScaleManagerV1, ZwpPointerConstraintsV1, diff --git a/src/ifs.rs b/src/ifs.rs index f29b0d67..5e26e730 100644 --- a/src/ifs.rs +++ b/src/ifs.rs @@ -12,6 +12,8 @@ pub mod ext_session_lock_v1; pub mod head_management; pub mod hyprland_focus_grab_manager_v1; pub mod hyprland_focus_grab_v1; +pub mod hyprland_global_shortcut_v1; +pub mod hyprland_global_shortcuts_manager_v1; pub mod ipc; pub mod jay_acceptor_request; pub mod jay_client_query; diff --git a/src/ifs/hyprland_global_shortcut_v1.rs b/src/ifs/hyprland_global_shortcut_v1.rs new file mode 100644 index 00000000..53105f5b --- /dev/null +++ b/src/ifs/hyprland_global_shortcut_v1.rs @@ -0,0 +1,97 @@ +use { + crate::{ + client::{Client, ClientError}, + leaks::Tracker, + object::{Object, Version}, + wire::{HyprlandGlobalShortcutV1Id, hyprland_global_shortcut_v1::*}, + }, + std::{rc::Rc, time::SystemTime}, + thiserror::Error, +}; + +pub struct HyprlandGlobalShortcutV1 { + pub id: HyprlandGlobalShortcutV1Id, + pub client: Rc, + pub tracker: Tracker, + pub version: Version, + pub shortcut_id: String, + pub app_id: String, +} + +impl HyprlandGlobalShortcutV1 { + pub fn new( + id: HyprlandGlobalShortcutV1Id, + client: &Rc, + version: Version, + shortcut_id: String, + app_id: String, + ) -> Self { + Self { + id, + client: client.clone(), + tracker: Default::default(), + version, + shortcut_id, + app_id, + } + } + + pub fn send_pressed_now(&self) { + let (sec_hi, sec_lo, nsec) = now_split(); + self.client.event(Pressed { + self_id: self.id, + tv_sec_hi: sec_hi, + tv_sec_lo: sec_lo, + tv_nsec: nsec, + }); + } +} + +fn now_split() -> (u32, u32, u32) { + let dur = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + let secs = dur.as_secs(); + ((secs >> 32) as u32, secs as u32, dur.subsec_nanos()) +} + +impl HyprlandGlobalShortcutV1RequestHandler for HyprlandGlobalShortcutV1 { + type Error = HyprlandGlobalShortcutV1Error; + + fn destroy(&self, _req: Destroy, _slf: &Rc) -> Result<(), Self::Error> { + let key = (self.app_id.clone(), self.shortcut_id.clone()); + if let Some(existing) = self.client.state.hyprland_global_shortcuts.get(&key) + && Rc::as_ptr(&existing) as *const _ == self as *const _ + { + self.client.state.hyprland_global_shortcuts.remove(&key); + } + self.client.remove_obj(self)?; + Ok(()) + } +} + +object_base! { + self = HyprlandGlobalShortcutV1; + version = self.version; +} + +impl Object for HyprlandGlobalShortcutV1 { + fn break_loops(&self) { + let key = (self.app_id.clone(), self.shortcut_id.clone()); + if let Some(existing) = self.client.state.hyprland_global_shortcuts.get(&key) + && Rc::as_ptr(&existing) as *const _ == self as *const _ + { + self.client.state.hyprland_global_shortcuts.remove(&key); + } + } +} + +simple_add_obj!(HyprlandGlobalShortcutV1); + +#[derive(Debug, Error)] +pub enum HyprlandGlobalShortcutV1Error { + #[error(transparent)] + ClientError(Box), +} + +efrom!(HyprlandGlobalShortcutV1Error, ClientError); diff --git a/src/ifs/hyprland_global_shortcuts_manager_v1.rs b/src/ifs/hyprland_global_shortcuts_manager_v1.rs new file mode 100644 index 00000000..cb30a4f0 --- /dev/null +++ b/src/ifs/hyprland_global_shortcuts_manager_v1.rs @@ -0,0 +1,122 @@ +use { + crate::{ + client::{Client, ClientError}, + globals::{Global, GlobalName}, + ifs::hyprland_global_shortcut_v1::HyprlandGlobalShortcutV1, + leaks::Tracker, + object::{Object, Version}, + wire::{HyprlandGlobalShortcutsManagerV1Id, hyprland_global_shortcuts_manager_v1::*}, + }, + std::rc::Rc, + thiserror::Error, +}; + +const ALREADY_TAKEN: u32 = 0; + +pub struct HyprlandGlobalShortcutsManagerV1Global { + pub name: GlobalName, +} + +impl HyprlandGlobalShortcutsManagerV1Global { + pub fn new(name: GlobalName) -> Self { + Self { name } + } + + fn bind_( + self: Rc, + id: HyprlandGlobalShortcutsManagerV1Id, + client: &Rc, + version: Version, + ) -> Result<(), HyprlandGlobalShortcutsManagerV1Error> { + let obj = Rc::new(HyprlandGlobalShortcutsManagerV1 { + id, + client: client.clone(), + tracker: Default::default(), + version, + }); + track!(client, obj); + client.add_client_obj(&obj)?; + Ok(()) + } +} + +pub struct HyprlandGlobalShortcutsManagerV1 { + pub id: HyprlandGlobalShortcutsManagerV1Id, + pub client: Rc, + pub tracker: Tracker, + pub version: Version, +} + +impl HyprlandGlobalShortcutsManagerV1RequestHandler for HyprlandGlobalShortcutsManagerV1 { + type Error = HyprlandGlobalShortcutsManagerV1Error; + + fn destroy(&self, _req: Destroy, _slf: &Rc) -> Result<(), Self::Error> { + self.client.remove_obj(self)?; + Ok(()) + } + + fn register_shortcut( + &self, + req: RegisterShortcut, + _slf: &Rc, + ) -> Result<(), Self::Error> { + let shortcut_id = req.id.to_string(); + let app_id = req.app_id.to_string(); + let key = (app_id.clone(), shortcut_id.clone()); + let registry = &self.client.state.hyprland_global_shortcuts; + if registry.get(&key).is_some() { + self.client.protocol_error( + self, + ALREADY_TAKEN, + &format!( + "global shortcut with app_id={app_id:?} id={shortcut_id:?} is already registered" + ), + ); + return Err(HyprlandGlobalShortcutsManagerV1Error::AlreadyTaken); + } + let shortcut = Rc::new(HyprlandGlobalShortcutV1::new( + req.shortcut, + &self.client, + self.version, + shortcut_id, + app_id, + )); + track!(self.client, shortcut); + self.client.add_client_obj(&shortcut)?; + registry.set(key, shortcut); + Ok(()) + } +} + +global_base!( + HyprlandGlobalShortcutsManagerV1Global, + HyprlandGlobalShortcutsManagerV1, + HyprlandGlobalShortcutsManagerV1Error +); + +impl Global for HyprlandGlobalShortcutsManagerV1Global { + fn version(&self) -> u32 { + 1 + } +} + +simple_add_global!(HyprlandGlobalShortcutsManagerV1Global); + +object_base! { + self = HyprlandGlobalShortcutsManagerV1; + version = self.version; +} + +impl Object for HyprlandGlobalShortcutsManagerV1 {} + +simple_add_obj!(HyprlandGlobalShortcutsManagerV1); + +#[derive(Debug, Error)] +pub enum HyprlandGlobalShortcutsManagerV1Error { + #[error(transparent)] + ClientError(Box), + #[error("the app_id + id combination has already been registered")] + AlreadyTaken, +} + +efrom!(HyprlandGlobalShortcutsManagerV1Error, ClientError); diff --git a/src/state.rs b/src/state.rs index ca60f01e..957ba369 100644 --- a/src/state.rs +++ b/src/state.rs @@ -51,6 +51,7 @@ use { HeadManagers, HeadNames, jay_head_manager_session_v1::{HeadManagerEvent, JayHeadManagerSessionV1}, }, + hyprland_global_shortcut_v1::HyprlandGlobalShortcutV1, ipc::{ DataOfferIds, DataSourceIds, data_control::DataControlDeviceIds, x_data_device::XIpcDeviceIds, @@ -302,6 +303,8 @@ pub struct State { pub bo_drop_queue: Rc>>, pub virtual_outputs: VirtualOutputs, pub clean_logs_older_than: Cell>, + pub hyprland_global_shortcuts: + CopyHashMap<(String, String), Rc>, } // impl Drop for State { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index c82bc252..60a336f7 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -204,6 +204,10 @@ pub enum Action { dx2: i32, dy2: i32, }, + TriggerGlobalShortcut { + app_id: String, + id: String, + }, } #[derive(Debug, Clone, Default)] diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 7581198d..a3b8f852 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -93,6 +93,10 @@ pub enum ActionParserError { UnknownDirection(String), #[error("Exactly one of `output` or `direction` must be specified")] OutputAndDirectionMutuallyExclusive, + #[error("Specify either `name = \"app_id:id\"` or both `app_id` and `id`")] + GlobalShortcutNeedsName, + #[error("Global shortcut `name` must be of the form `app_id:id`, got `{0}`")] + GlobalShortcutBadName(String), } pub struct ActionParser<'a>(pub &'a Context<'a>); @@ -516,6 +520,36 @@ impl ActionParser<'_> { dy2: dy2.despan().unwrap_or(0), }) } + + fn parse_global_shortcut( + &mut self, + span: Span, + ext: &mut Extractor<'_>, + ) -> ParseResult { + let (name_opt, app_id_opt, id_opt) = ext.extract(( + opt(str("name")), + opt(str("app_id")), + opt(str("id")), + ))?; + let (app_id, id) = match (app_id_opt, id_opt, name_opt) { + (Some(a), Some(i), _) => (a.value.to_string(), i.value.to_string()), + (None, None, Some(n)) => match n.value.split_once(':') { + Some((a, i)) if !a.is_empty() && !i.is_empty() => { + (a.to_string(), i.to_string()) + } + _ => { + return Err( + ActionParserError::GlobalShortcutBadName(n.value.to_string()) + .spanned(n.span), + ); + } + }, + _ => { + return Err(ActionParserError::GlobalShortcutNeedsName.spanned(span)); + } + }; + Ok(Action::TriggerGlobalShortcut { app_id, id }) + } } impl Parser for ActionParser<'_> { @@ -578,6 +612,7 @@ impl Parser for ActionParser<'_> { "create-virtual-output" => self.parse_create_virtual_output(&mut ext), "remove-virtual-output" => self.parse_remove_virtual_output(&mut ext), "resize" => self.parse_resize(&mut ext), + "global-shortcut" => self.parse_global_shortcut(span, &mut ext), v => { ext.ignore_unused(); return Err(ActionParserError::UnknownType(v.to_string()).spanned(ty.span)); diff --git a/toml-config/src/config/parsers/modified_keysym.rs b/toml-config/src/config/parsers/modified_keysym.rs index 102a1c22..8ad375c7 100644 --- a/toml-config/src/config/parsers/modified_keysym.rs +++ b/toml-config/src/config/parsers/modified_keysym.rs @@ -9,7 +9,10 @@ use { ALT, CAPS, CTRL, LOCK, LOGO, MOD1, MOD2, MOD3, MOD4, MOD5, Modifiers, NUM, RELEASE, SHIFT, }, - syms::KeySym, + syms::{ + KeySym, SYM_Alt_L, SYM_Alt_R, SYM_Control_L, SYM_Control_R, SYM_Hyper_L, SYM_Hyper_R, + SYM_Meta_L, SYM_Meta_R, SYM_Shift_L, SYM_Shift_R, SYM_Super_L, SYM_Super_R, + }, }, kbvm::Keysym, thiserror::Error, @@ -38,23 +41,28 @@ impl Parser for ModifiedKeysymParser { fn parse_string(&mut self, span: Span, string: &str) -> ParseResult { let mut modifiers = Modifiers(0); - let mut sym = None; + let mut sym: Option = None; for part in string.split("-") { - let modifier = match parse_mod(part) { - Some(m) => m, - _ => match Keysym::from_str(part) { - Some(new) if sym.is_none() => { - sym = Some(KeySym(new.0)); - continue; - } - Some(_) => return Err(ModifiedKeysymParserError::MoreThanOneSym.spanned(span)), - _ => { - return Err(ModifiedKeysymParserError::UnknownKeysym(part.to_string()) - .spanned(span)); - } - }, + if let Some(m) = parse_mod(part) { + modifiers |= m; + continue; + } + let Some(new) = Keysym::from_str(part) else { + return Err( + ModifiedKeysymParserError::UnknownKeysym(part.to_string()).spanned(span), + ); }; - modifiers |= modifier; + let new = KeySym(new.0); + match sym { + None => sym = Some(new), + Some(prev) => { + let Some(m) = modifier_key_to_mod(prev) else { + return Err(ModifiedKeysymParserError::MoreThanOneSym.spanned(span)); + }; + modifiers |= m; + sym = Some(new); + } + } } match sym { Some(s) => Ok(modifiers | s), @@ -63,6 +71,20 @@ impl Parser for ModifiedKeysymParser { } } +#[allow(non_upper_case_globals)] +fn modifier_key_to_mod(sym: KeySym) -> Option { + let m = match sym { + SYM_Super_L | SYM_Super_R => LOGO, + SYM_Alt_L | SYM_Alt_R => ALT, + SYM_Control_L | SYM_Control_R => CTRL, + SYM_Shift_L | SYM_Shift_R => SHIFT, + SYM_Meta_L | SYM_Meta_R => MOD1, + SYM_Hyper_L | SYM_Hyper_R => MOD4, + _ => return None, + }; + Some(m) +} + pub struct ModifiersParser; impl Parser for ModifiersParser { diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 7b596921..4c95fd41 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -508,6 +508,11 @@ impl Action { Action::Resize { dx1, dy1, dx2, dy2 } => { window_or_seat!(s, s.resize(dx1, dy1, dx2, dy2)) } + Action::TriggerGlobalShortcut { app_id, id } => { + b.new(move || { + jay_config::trigger_global_shortcut(&app_id, &id); + }) + } } } } diff --git a/wire/hyprland_global_shortcut_v1.txt b/wire/hyprland_global_shortcut_v1.txt new file mode 100644 index 00000000..8289cb4e --- /dev/null +++ b/wire/hyprland_global_shortcut_v1.txt @@ -0,0 +1,14 @@ +request destroy (destructor) { +} + +event pressed { + tv_sec_hi: u32, + tv_sec_lo: u32, + tv_nsec: u32, +} + +event released { + tv_sec_hi: u32, + tv_sec_lo: u32, + tv_nsec: u32, +} diff --git a/wire/hyprland_global_shortcuts_manager_v1.txt b/wire/hyprland_global_shortcuts_manager_v1.txt new file mode 100644 index 00000000..e271e1b4 --- /dev/null +++ b/wire/hyprland_global_shortcuts_manager_v1.txt @@ -0,0 +1,10 @@ +request register_shortcut { + shortcut: id(hyprland_global_shortcut_v1) (new), + id: str, + app_id: str, + description: str, + trigger_description: str, +} + +request destroy (destructor) { +}