1
0
Fork 0
forked from wry/wry

config: implement shortcut latching

This commit is contained in:
Julian Orth 2024-04-16 18:47:40 +02:00
parent 90dbde99ab
commit 6f55675bdb
14 changed files with 367 additions and 93 deletions

View file

@ -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

View file

@ -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:

View file

@ -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<Callback>,
latched: Vec<Box<dyn FnOnce()>>,
}
pub(crate) struct Client {
@ -96,6 +99,9 @@ pub(crate) struct Client {
tasks: Tasks,
status_task: Cell<Vec<JoinHandle<()>>>,
i3bar_separator: RefCell<Option<Rc<String>>>,
pressed_keysym: Cell<Option<KeySym>>,
feat_mod_mask: Cell<bool>,
}
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<T: Into<ModifiedKeySym>>(&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<F: FnOnce() + 'static>(&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<F: FnMut() + 'static>(
&self,
seat: Seat,
@ -937,29 +984,39 @@ 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 {
seat,
mods: mod_sym.mods,
sym: mod_sym.sym,
}
} else {
let msg = if self.feat_mod_mask.get() {
ClientMessage::AddShortcut2 {
seat,
mods: mod_sym.mods,
mod_mask,
sym: mod_sym.sym,
}
} else {
ClientMessage::AddShortcut {
seat,
mods: mod_sym.mods,
sym: mod_sym.sym,
}
};
self.send(&msg);
}
@ -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::<ServerMessage>(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),
_ => {}
}
}

View file

@ -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<ServerFeature>,
},
InvokeShortcut2 {
seat: Seat,
unmasked_mods: Modifiers,
effective_mods: Modifiers,
sym: KeySym,
},
}
#[derive(Serialize, Deserialize, Debug)]

View file

@ -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<F: FnOnce() + 'static>(self, f: F) {
get!().latch(self, f)
}
/// Unbinds a hotkey.
pub fn unbind<T: Into<ModifiedKeySym>>(self, mod_sym: T) {
get!().unbind(self, mod_sym.into())

View file

@ -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,
}

View file

@ -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),
});
}

View file

@ -95,6 +95,16 @@ unsafe extern "C" fn handle_msg(data: *const u8, msg: *const u8, size: usize) {
tc.invoked_shortcuts
.set((SeatId::from_raw(seat.0 as _), mods | sym), ());
}
ServerMessage::InvokeShortcut2 {
seat,
unmasked_mods,
effective_mods,
sym,
} => {
let _ = unmasked_mods;
tc.invoked_shortcuts
.set((SeatId::from_raw(seat.0 as _), effective_mods | sym), ());
}
ServerMessage::NewInputDevice { .. } => {}
ServerMessage::DelInputDevice { .. } => {}
ServerMessage::ConnectorConnect { .. } => {}

View file

@ -285,6 +285,7 @@ pub struct Shortcut {
pub mask: Modifiers,
pub keysym: ModifiedKeySym,
pub action: Action,
pub latch: Option<Action>,
}
#[derive(Debug, Clone)]

View file

@ -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<String>, Spanned<Value>>,
) -> ParseResult<Self> {
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,
})
}
}

View file

@ -37,51 +37,75 @@ fn default_seat() -> Seat {
get_seat("default")
}
trait FnBuilder: Sized {
fn new<F: Fn() + 'static>(f: F) -> Self;
}
impl FnBuilder for Box<dyn Fn()> {
fn new<F: Fn() + 'static>(f: F) -> Self {
Box::new(f)
}
}
impl FnBuilder for Rc<dyn Fn()> {
fn new<F: Fn() + 'static>(f: F) -> Self {
Rc::new(f)
}
}
impl Action {
fn into_fn(self, state: &Rc<State>) -> Box<dyn FnMut()> {
fn into_fn(self, state: &Rc<State>) -> Box<dyn Fn()> {
self.into_fn_impl(state)
}
fn into_rc_fn(self, state: &Rc<State>) -> Rc<dyn Fn()> {
self.into_fn_impl(state)
}
fn into_fn_impl<B: FnBuilder>(self, state: &Rc<State>) -> 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);
}
}

View file

@ -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": []

View file

@ -639,6 +639,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).
<a name="types-Config"></a>
### `Config`

View file

@ -2131,3 +2131,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.