diff --git a/docs/config.md b/docs/config.md index 345b35b0..266bc349 100644 --- a/docs/config.md +++ b/docs/config.md @@ -208,6 +208,23 @@ The right-hand side should be an action. See [spec.generated.md](../toml-spec/spec/spec.generated.md) for a full list of actions. +### Complex Shortcuts + +If you need more control over shortcut execution, you can use the `complex-shortcuts` table. + +```toml +[complex-shortcuts.alt-x] +action = { type = "exec", exec = ["pactl", "set-sink-mute", "0", "1"] } +latch = { type = "exec", exec = ["pactl", "set-sink-mute", "0", "0"] } +``` + +This mutes the audio output while the key is pressed and un-mutes once the `x` key is released. +The order in which `alt` and `x` are released does not matter for this. + +This can also be used to implement push to talk. + +See the specification for more details. + ### Running Multiple Actions In every place that accepts an action, you can also run multiple actions by wrapping them diff --git a/docs/features.md b/docs/features.md index d9348a9e..5c01f338 100644 --- a/docs/features.md +++ b/docs/features.md @@ -108,6 +108,10 @@ By default, applications only have access to unprivileged protocols. You can explicitly opt into giving applications access to privileged protocols via the Jay CLI or shortcuts. +## Push to Talk + +Jay's shortcut system allows you to execute an action when a key is pressed and to execute a different action when the key is released. + ## Protocol Support Jay supports the following wayland protocols: diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 3fc480e5..fcc42e91 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -13,6 +13,7 @@ use { input::{acceleration::AccelProfile, capability::Capability, InputDevice, Seat}, keyboard::{ mods::{Modifiers, RELEASE}, + syms::KeySym, Keymap, }, logging::LogLevel, @@ -68,8 +69,10 @@ fn ignore_panic(name: &str, f: impl FnOnce()) { } struct KeyHandler { - mask: Modifiers, - cb: Callback, + registered_mask: Modifiers, + cb_mask: Modifiers, + cb: Option, + latched: Vec>, } pub(crate) struct Client { @@ -96,6 +99,9 @@ pub(crate) struct Client { tasks: Tasks, status_task: Cell>>, i3bar_separator: RefCell>>, + pressed_keysym: Cell>, + + feat_mod_mask: Cell, } struct Interest { @@ -220,6 +226,8 @@ pub unsafe extern "C" fn init( tasks: Default::default(), status_task: Default::default(), i3bar_separator: Default::default(), + pressed_keysym: Cell::new(None), + feat_mod_mask: Cell::new(false), }); let init = slice::from_raw_parts(init, size); client.handle_init_msg(init); @@ -315,17 +323,16 @@ impl Client { pub fn unbind>(&self, seat: Seat, mod_sym: T) { let mod_sym = mod_sym.into(); - let deregister = self - .key_handlers - .borrow_mut() - .remove(&(seat, mod_sym)) - .is_some(); - if deregister { - self.send(&ClientMessage::RemoveShortcut { - seat, - mods: mod_sym.mods, - sym: mod_sym.sym, - }) + if let Entry::Occupied(mut oe) = self.key_handlers.borrow_mut().entry((seat, mod_sym)) { + oe.get_mut().cb = None; + if oe.get().latched.is_empty() { + oe.remove(); + self.send(&ClientMessage::RemoveShortcut { + seat, + mods: mod_sym.mods, + sym: mod_sym.sym, + }) + } } } @@ -923,6 +930,46 @@ impl Client { keymap } + pub fn latch(&self, seat: Seat, f: F) { + if !self.feat_mod_mask.get() { + log::error!("compositor does not support latching"); + return; + } + let Some(keysym) = self.pressed_keysym.get() else { + log::error!("latch called while not executing shortcut"); + return; + }; + let mods = RELEASE; + let f = Box::new(f); + let register = { + let mut kh = self.key_handlers.borrow_mut(); + match kh.entry((seat, mods | keysym)) { + Entry::Occupied(mut o) => { + let o = o.get_mut(); + o.latched.push(f); + mem::replace(&mut o.registered_mask, mods) != mods + } + Entry::Vacant(v) => { + v.insert(KeyHandler { + cb_mask: mods, + registered_mask: mods, + cb: None, + latched: vec![f], + }); + true + } + } + }; + if register { + self.send(&ClientMessage::AddShortcut2 { + seat, + mods, + mod_mask: mods, + sym: keysym, + }); + } + } + pub fn bind_masked( &self, seat: Seat, @@ -937,27 +984,37 @@ impl Client { match kh.entry((seat, mod_sym)) { Entry::Occupied(mut o) => { let o = o.get_mut(); - o.cb = cb; - mem::replace(&mut o.mask, mod_mask) != mod_mask + o.cb = Some(cb); + o.cb_mask = mod_mask; + let register = o.latched.is_empty() && o.registered_mask != o.cb_mask; + if register { + o.registered_mask = o.cb_mask; + } + register } Entry::Vacant(v) => { - v.insert(KeyHandler { mask: mod_mask, cb }); + v.insert(KeyHandler { + cb_mask: mod_mask, + registered_mask: mod_mask, + cb: Some(cb), + latched: vec![], + }); true } } }; if register { - let msg = if !mod_mask.0 == 0 { - ClientMessage::AddShortcut { + let msg = if self.feat_mod_mask.get() { + ClientMessage::AddShortcut2 { seat, mods: mod_sym.mods, + mod_mask, sym: mod_sym.sym, } } else { - ClientMessage::AddShortcut2 { + ClientMessage::AddShortcut { seat, mods: mod_sym.mods, - mod_mask, sym: mod_sym.sym, } }; @@ -1103,6 +1160,61 @@ impl Client { self.tasks.tasks.borrow_mut().remove(&id); } + fn handle_invoke_shortcut( + &self, + seat: Seat, + unmasked_mods: Modifiers, + mods: Modifiers, + sym: KeySym, + ) { + let ms = ModifiedKeySym { mods, sym }; + let handler = self + .key_handlers + .borrow_mut() + .get_mut(&(seat, ms)) + .map(|kh| { + let cb = if kh.cb_mask & unmasked_mods == mods { + kh.cb.clone() + } else { + None + }; + (mem::take(&mut kh.latched), cb) + }); + let Some((latched, handler)) = handler else { + return; + }; + let was_latched = !latched.is_empty(); + if (mods & RELEASE).0 == 0 { + self.pressed_keysym.set(Some(sym)); + } + for latched in latched { + ignore_panic("latch", latched); + } + if let Some(handler) = handler { + run_cb("shortcut", &handler, ()); + } + self.pressed_keysym.set(None); + if was_latched { + if let Entry::Occupied(mut oe) = self.key_handlers.borrow_mut().entry((seat, ms)) { + let o = oe.get_mut(); + if o.latched.is_empty() { + if o.cb.is_none() { + self.send(&ClientMessage::RemoveShortcut { seat, mods, sym }); + oe.remove(); + } else if o.cb_mask != o.registered_mask { + o.registered_mask = o.cb_mask; + self.send(&ClientMessage::AddShortcut2 { + seat, + mods: ms.mods, + mod_mask: o.cb_mask, + sym: ms.sym, + }); + } + } + } + } + } + fn handle_msg2(&self, msg: &[u8]) { let res = bincode_ops().deserialize::(msg); let msg = match res { @@ -1123,15 +1235,15 @@ impl Client { self.response.borrow_mut().push(response); } ServerMessage::InvokeShortcut { seat, mods, sym } => { - let ms = ModifiedKeySym { mods, sym }; - let handler = self - .key_handlers - .borrow_mut() - .get(&(seat, ms)) - .map(|k| k.cb.clone()); - if let Some(handler) = handler { - run_cb("shortcut", &handler, ()); - } + self.handle_invoke_shortcut(seat, mods, mods, sym); + } + ServerMessage::InvokeShortcut2 { + seat, + unmasked_mods, + effective_mods, + sym, + } => { + self.handle_invoke_shortcut(seat, unmasked_mods, effective_mods, sym); } ServerMessage::NewInputDevice { device } => { let handler = self.on_new_input_device.borrow_mut().clone(); @@ -1208,6 +1320,7 @@ impl Client { for feat in features { match feat { ServerFeature::NONE => {} + ServerFeature::MOD_MASK => self.feat_mod_mask.set(true), _ => {} } } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 67821e87..4d170c9a 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -19,6 +19,7 @@ pub struct ServerFeature(u16); impl ServerFeature { pub const NONE: Self = Self(0); + pub const MOD_MASK: Self = Self(1); } #[derive(Serialize, Deserialize, Debug)] @@ -73,6 +74,12 @@ pub enum ServerMessage { Features { features: Vec, }, + InvokeShortcut2 { + seat: Seat, + unmasked_mods: Modifiers, + effective_mods: Modifiers, + sym: KeySym, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 2d18d7e5..e42b5787 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -216,6 +216,16 @@ impl Seat { get!().bind_masked(self, mod_mask, mod_sym.into(), f) } + /// Registers a callback to be executed when the currently pressed key is released. + /// + /// This should only be called in callbacks for key-press binds. + /// + /// The callback will be executed once when the key is released regardless of any + /// modifiers. + pub fn latch(self, f: F) { + get!().latch(self, f) + } + /// Unbinds a hotkey. pub fn unbind>(self, mod_sym: T) { get!().unbind(self, mod_sym.into()) diff --git a/src/config.rs b/src/config.rs index 8334d924..0305a4ad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,11 +17,11 @@ use { jay_config::{ _private::{ bincode_ops, - ipc::{InitMessage, ServerMessage, V1InitMessage}, + ipc::{InitMessage, ServerFeature, ServerMessage, V1InitMessage}, ConfigEntry, VERSION, }, input::{InputDevice, Seat}, - keyboard::ModifiedKeySym, + keyboard::{mods::Modifiers, syms::KeySym}, video::{Connector, DrmDevice}, }, libloading::Library, @@ -63,12 +63,22 @@ impl ConfigProxy { } } - pub fn invoke_shortcut(&self, seat: SeatId, modsym: &ModifiedKeySym) { - self.send(&ServerMessage::InvokeShortcut { - seat: Seat(seat.raw() as _), - mods: modsym.mods, - sym: modsym.sym, - }); + pub fn invoke_shortcut(&self, seat: SeatId, shortcut: &InvokedShortcut) { + let msg = if shortcut.unmasked_mods == shortcut.effective_mods { + ServerMessage::InvokeShortcut { + seat: Seat(seat.raw() as _), + mods: shortcut.effective_mods, + sym: shortcut.sym, + } + } else { + ServerMessage::InvokeShortcut2 { + seat: Seat(seat.raw() as _), + unmasked_mods: shortcut.unmasked_mods, + effective_mods: shortcut.effective_mods, + sym: shortcut.sym, + } + }; + self.send(&msg); } pub fn new_drm_dev(&self, dev: DrmDeviceId) { @@ -203,7 +213,9 @@ impl ConfigProxy { } pub fn configure(&self, reload: bool) { - self.send(&ServerMessage::Features { features: vec![] }); + self.send(&ServerMessage::Features { + features: vec![ServerFeature::MOD_MASK], + }); self.send(&ServerMessage::Configure { reload }); } @@ -288,3 +300,9 @@ unsafe extern "C" fn handle_msg(data: *const u8, msg: *const u8, size: usize) { rc.handle_request(msg); mem::forget(rc); } + +pub struct InvokedShortcut { + pub unmasked_mods: Modifiers, + pub effective_mods: Modifiers, + pub sym: KeySym, +} diff --git a/src/ifs/wl_seat/event_handling.rs b/src/ifs/wl_seat/event_handling.rs index 614bc96e..50377725 100644 --- a/src/ifs/wl_seat/event_handling.rs +++ b/src/ifs/wl_seat/event_handling.rs @@ -2,6 +2,7 @@ use { crate::{ backend::{ConnectorId, InputEvent, KeyState, AXIS_120}, client::ClientId, + config::InvokedShortcut, fixed::Fixed, ifs::{ ipc::{ @@ -39,7 +40,6 @@ use { jay_config::keyboard::{ mods::{Modifiers, CAPS, NUM, RELEASE}, syms::KeySym, - ModifiedKeySym, }, smallvec::SmallVec, std::{cell::RefCell, collections::hash_map::Entry, rc::Rc}, @@ -386,8 +386,9 @@ impl WlSeatGlobal { if let Some(key_mods) = scs.get(&sym) { for (key_mods, mask) in key_mods { if mods & mask == key_mods { - shortcuts.push(ModifiedKeySym { - mods: Modifiers(key_mods), + shortcuts.push(InvokedShortcut { + unmasked_mods: Modifiers(mods), + effective_mods: Modifiers(key_mods), sym: KeySym(sym), }); } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 00e18649..b4b047f7 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -285,6 +285,7 @@ pub struct Shortcut { pub mask: Modifiers, pub keysym: ModifiedKeySym, pub action: Action, + pub latch: Option, } #[derive(Debug, Clone)] diff --git a/toml-config/src/config/parsers/shortcuts.rs b/toml-config/src/config/parsers/shortcuts.rs index dc44967c..9bc63a83 100644 --- a/toml-config/src/config/parsers/shortcuts.rs +++ b/toml-config/src/config/parsers/shortcuts.rs @@ -34,6 +34,8 @@ pub enum ShortcutsParserError { ModMask(#[source] ModifiedKeysymParserError), #[error("Could not parse the action")] ActionParserError(#[source] ActionParserError), + #[error("Could not parse the latch action")] + LatchError(#[source] ActionParserError), } pub struct ShortcutsParser<'a, 'b> { @@ -65,6 +67,7 @@ impl Parser for ShortcutsParser<'_, '_> { mask: Modifiers(!0), keysym, action, + latch: None, }); } Ok(()) @@ -129,7 +132,8 @@ impl Parser for ComplexShortcutParser<'_> { table: &IndexMap, Spanned>, ) -> ParseResult { let mut ext = Extractor::new(self.cx, span, table); - let (mod_mask_val, action_val) = ext.extract((opt(str("mod-mask")), opt(val("action"))))?; + let (mod_mask_val, action_val, latch_val) = + ext.extract((opt(str("mod-mask")), opt(val("action")), opt(val("latch"))))?; let mod_mask = match mod_mask_val { None => Modifiers(!0), Some(v) => ModifiersParser @@ -144,10 +148,18 @@ impl Parser for ComplexShortcutParser<'_> { .parse(&mut ActionParser(self.cx)) .map_spanned_err(ShortcutsParserError::ActionParserError)?, }; + let mut latch = None; + if let Some(v) = latch_val { + latch = Some( + v.parse(&mut ActionParser(self.cx)) + .map_spanned_err(ShortcutsParserError::LatchError)?, + ); + } Ok(Shortcut { mask: mod_mask, keysym: self.keysym, action, + latch, }) } } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index e4de826e..16738249 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -37,51 +37,75 @@ fn default_seat() -> Seat { get_seat("default") } +trait FnBuilder: Sized { + fn new(f: F) -> Self; +} + +impl FnBuilder for Box { + fn new(f: F) -> Self { + Box::new(f) + } +} + +impl FnBuilder for Rc { + fn new(f: F) -> Self { + Rc::new(f) + } +} + impl Action { - fn into_fn(self, state: &Rc) -> Box { + fn into_fn(self, state: &Rc) -> Box { + self.into_fn_impl(state) + } + + fn into_rc_fn(self, state: &Rc) -> Rc { + self.into_fn_impl(state) + } + + fn into_fn_impl(self, state: &Rc) -> B { let s = state.persistent.seat; match self { Action::SimpleCommand { cmd } => match cmd { - SimpleCommand::Focus(dir) => Box::new(move || s.focus(dir)), - SimpleCommand::Move(dir) => Box::new(move || s.move_(dir)), - SimpleCommand::Split(axis) => Box::new(move || s.create_split(axis)), - SimpleCommand::ToggleSplit => Box::new(move || s.toggle_split()), - SimpleCommand::ToggleMono => Box::new(move || s.toggle_mono()), - SimpleCommand::ToggleFullscreen => Box::new(move || s.toggle_fullscreen()), - SimpleCommand::FocusParent => Box::new(move || s.focus_parent()), - SimpleCommand::Close => Box::new(move || s.close()), + SimpleCommand::Focus(dir) => B::new(move || s.focus(dir)), + SimpleCommand::Move(dir) => B::new(move || s.move_(dir)), + SimpleCommand::Split(axis) => B::new(move || s.create_split(axis)), + SimpleCommand::ToggleSplit => B::new(move || s.toggle_split()), + SimpleCommand::ToggleMono => B::new(move || s.toggle_mono()), + SimpleCommand::ToggleFullscreen => B::new(move || s.toggle_fullscreen()), + SimpleCommand::FocusParent => B::new(move || s.focus_parent()), + SimpleCommand::Close => B::new(move || s.close()), SimpleCommand::DisablePointerConstraint => { - Box::new(move || s.disable_pointer_constraint()) + B::new(move || s.disable_pointer_constraint()) } - SimpleCommand::ToggleFloating => Box::new(move || s.toggle_floating()), - SimpleCommand::Quit => Box::new(quit), + SimpleCommand::ToggleFloating => B::new(move || s.toggle_floating()), + SimpleCommand::Quit => B::new(quit), SimpleCommand::ReloadConfigToml => { let persistent = state.persistent.clone(); - Box::new(move || load_config(false, &persistent)) + B::new(move || load_config(false, &persistent)) } - SimpleCommand::ReloadConfigSo => Box::new(reload), - SimpleCommand::None => Box::new(|| ()), - SimpleCommand::Forward(bool) => Box::new(move || s.set_forward(bool)), + SimpleCommand::ReloadConfigSo => B::new(reload), + SimpleCommand::None => B::new(|| ()), + SimpleCommand::Forward(bool) => B::new(move || s.set_forward(bool)), }, Action::Multi { actions } => { - let mut actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); - Box::new(move || { - for action in &mut actions { + let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); + B::new(move || { + for action in &actions { action(); } }) } - Action::Exec { exec } => Box::new(move || create_command(&exec).spawn()), - Action::SwitchToVt { num } => Box::new(move || switch_to_vt(num)), + Action::Exec { exec } => B::new(move || create_command(&exec).spawn()), + Action::SwitchToVt { num } => B::new(move || switch_to_vt(num)), Action::ShowWorkspace { name } => { let workspace = get_workspace(&name); - Box::new(move || s.show_workspace(workspace)) + B::new(move || s.show_workspace(workspace)) } Action::MoveToWorkspace { name } => { let workspace = get_workspace(&name); - Box::new(move || s.set_workspace(workspace)) + B::new(move || s.set_workspace(workspace)) } - Action::ConfigureConnector { con } => Box::new(move || { + Action::ConfigureConnector { con } => B::new(move || { for c in connectors() { if con.match_.matches(c) { con.apply(c); @@ -90,7 +114,7 @@ impl Action { }), Action::ConfigureInput { input } => { let state = state.clone(); - Box::new(move || { + B::new(move || { for c in input_devices() { if input.match_.matches(c, &state) { input.apply(c, &state); @@ -100,7 +124,7 @@ impl Action { } Action::ConfigureOutput { out } => { let state = state.clone(); - Box::new(move || { + B::new(move || { for c in connectors() { if out.match_.matches(c, &state) { out.apply(c); @@ -108,36 +132,36 @@ impl Action { } }) } - Action::SetEnv { env } => Box::new(move || { + Action::SetEnv { env } => B::new(move || { for (k, v) in &env { set_env(k, v); } }), - Action::UnsetEnv { env } => Box::new(move || { + Action::UnsetEnv { env } => B::new(move || { for k in &env { unset_env(k); } }), Action::SetKeymap { map } => { let state = state.clone(); - Box::new(move || state.set_keymap(&map)) + B::new(move || state.set_keymap(&map)) } Action::SetStatus { status } => { let state = state.clone(); - Box::new(move || state.set_status(&status)) + B::new(move || state.set_status(&status)) } Action::SetTheme { theme } => { let state = state.clone(); - Box::new(move || state.apply_theme(&theme)) + B::new(move || state.apply_theme(&theme)) } - Action::SetLogLevel { level } => Box::new(move || set_log_level(level)), - Action::SetGfxApi { api } => Box::new(move || set_gfx_api(api)), + Action::SetLogLevel { level } => B::new(move || set_log_level(level)), + Action::SetGfxApi { api } => B::new(move || set_gfx_api(api)), Action::ConfigureDirectScanout { enabled } => { - Box::new(move || set_direct_scanout_enabled(enabled)) + B::new(move || set_direct_scanout_enabled(enabled)) } Action::ConfigureDrmDevice { dev } => { let state = state.clone(); - Box::new(move || { + B::new(move || { for d in drm_devices() { if dev.match_.matches(d, &state) { dev.apply(d); @@ -147,7 +171,7 @@ impl Action { } Action::SetRenderDevice { dev } => { let state = state.clone(); - Box::new(move || { + B::new(move || { for d in drm_devices() { if dev.matches(d, &state) { d.make_render_device(); @@ -155,10 +179,10 @@ impl Action { } }) } - Action::ConfigureIdle { idle } => Box::new(move || set_idle(Some(idle))), + Action::ConfigureIdle { idle } => B::new(move || set_idle(Some(idle))), Action::MoveToOutput { output, workspace } => { let state = state.clone(); - Box::new(move || { + B::new(move || { let output = 'get_output: { for connector in connectors() { if connector.connected() && output.matches(connector, &state) { @@ -174,7 +198,7 @@ impl Action { }) } Action::SetRepeatRate { rate } => { - Box::new(move || s.set_repeat_rate(rate.rate, rate.delay)) + B::new(move || s.set_repeat_rate(rate.rate, rate.delay)) } } } @@ -548,16 +572,26 @@ impl State { cmd: SimpleCommand::None, } = shortcut.action { - self.persistent.seat.unbind(shortcut.keysym); - binds.remove(&shortcut.keysym); - } else { - self.persistent.seat.bind_masked( - shortcut.mask, - shortcut.keysym, - shortcut.action.into_fn(self), - ); - binds.insert(shortcut.keysym); - } + if shortcut.latch.is_none() { + self.persistent.seat.unbind(shortcut.keysym); + binds.remove(&shortcut.keysym); + continue; + } + } + let mut f = shortcut.action.into_fn(self); + if let Some(l) = shortcut.latch { + let l = l.into_rc_fn(self); + let s = self.persistent.seat; + f = Box::new(move || { + f(); + let l = l.clone(); + s.latch(move || l()); + }); + } + self.persistent + .seat + .bind_masked(shortcut.mask, shortcut.keysym, move || f()); + binds.insert(shortcut.keysym); } } diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 65f42372..2714a324 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -438,6 +438,10 @@ "action": { "description": "The action to execute.\n\nOmitting this is the same as setting it to `\"none\"`.\n", "$ref": "#/$defs/Action" + }, + "latch": { + "description": "An action to execute when the key is released.\n\nThis registers an action to be executed when the key triggering the shortcut is\nreleased. The active modifiers are ignored for this purpose.\n\n- Example:\n\n To mute audio while the key is pressed:\n\n ```toml\n [complex-shortcuts.alt-x]\n action = { type = \"exec\", exec = [\"pactl\", \"set-sink-mute\", \"0\", \"1\"] }\n latch = { type = \"exec\", exec = [\"pactl\", \"set-sink-mute\", \"0\", \"0\"] }\n ```\n\n Audio will be un-muted once `x` key is released, regardless of any other keys\n that are pressed at the time.\n", + "$ref": "#/$defs/Action" } }, "required": [] diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 4360c099..bb3a52e1 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -637,6 +637,28 @@ The table has the following fields: The value of this field should be a [Action](#types-Action). +- `latch` (optional): + + An action to execute when the key is released. + + This registers an action to be executed when the key triggering the shortcut is + released. The active modifiers are ignored for this purpose. + + - Example: + + To mute audio while the key is pressed: + + ```toml + [complex-shortcuts.alt-x] + action = { type = "exec", exec = ["pactl", "set-sink-mute", "0", "1"] } + latch = { type = "exec", exec = ["pactl", "set-sink-mute", "0", "0"] } + ``` + + Audio will be un-muted once `x` key is released, regardless of any other keys + that are pressed at the time. + + The value of this field should be a [Action](#types-Action). + ### `Config` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index d225800e..0ae8add6 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -2129,3 +2129,24 @@ ComplexShortcut: The action to execute. Omitting this is the same as setting it to `"none"`. + latch: + ref: Action + required: false + description: | + An action to execute when the key is released. + + This registers an action to be executed when the key triggering the shortcut is + released. The active modifiers are ignored for this purpose. + + - Example: + + To mute audio while the key is pressed: + + ```toml + [complex-shortcuts.alt-x] + action = { type = "exec", exec = ["pactl", "set-sink-mute", "0", "1"] } + latch = { type = "exec", exec = ["pactl", "set-sink-mute", "0", "0"] } + ``` + + Audio will be un-muted once `x` key is released, regardless of any other keys + that are pressed at the time.