Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Counters & Triggers

Shortcuts, window rules, and client rules each react to one thing at a time: a key press, a window, a client. Counters and triggers let you combine them. A counter is a named integer that any action can change; a trigger runs an action when a counter reaches a state you care about.

Together they answer questions no single rule can, such as “is any Alacritty window open?” or “is alt-x held down and at least one browser window visible?”.

Counters

A counter is just a name and an integer value. You never declare counters – the first action that mentions a name creates it, starting at 0.

Three actions change a counter:

inc-counter
Increase the counter named name by delta (default 1).
dec-counter
Decrease the counter named name by delta (default 1).
set-counter
Set the counter named name to value (required).
[shortcuts]
alt-equal = { type = "inc-counter", name = "my-counter" }
alt-minus = { type = "dec-counter", name = "my-counter" }
alt-0     = { type = "set-counter", name = "my-counter", value = 0 }

delta may be any integer, including a negative one, and can be larger than one:

[shortcuts]
alt-Prior = { type = "inc-counter", name = "my-counter", delta = 10 }

These are ordinary actions, so they work anywhere an action is accepted: shortcuts, startup hooks, named actions, window and client rules, and the action/latch of another trigger.

Triggers

Triggers live in the [[triggers]] array. Each one has a criterion and up to two actions:

match
The criterion that decides when the trigger is active. Required.
action
Runs when the criterion starts matching. Optional.
latch
Runs when the criterion stops matching. Optional.
[[triggers]]
match.counter.my-counter.gt = 0
action = { type = "exec", exec.shell = "notify-send 'above zero'" }
latch  = { type = "exec", exec.shell = "notify-send 'back to zero'" }

A trigger fires on the transition, not on every change. Raising my-counter from 1 to 2 above changes nothing, because the criterion was already matching. You can supply only latch if you only care about the falling edge.

Triggers have no window attached to them. If you use an action that normally applies to the matched window in a window rule – close, float, enter-fullscreen, and so on – it applies to the currently focused window instead.

Counter criteria

match.counter is a table keyed by counter name. Each entry accepts six comparisons, all optional:

eq
The counter equals this value.
ne
The counter does not equal this value.
gt
The counter is greater than this value.
ge
The counter is greater than or equal to this value.
lt
The counter is less than this value.
le
The counter is less than or equal to this value.

Listing several comparisons for one counter requires all of them to hold:

[[triggers]]
match.counter.my-counter = { ge = 2, ne = 5 }

This matches when the value is 2, 3, 4, 6, 7, … but not 5.

Naming several counters requires all of them to match:

[[triggers]]
match.counter.browsers.gt = 0
match.counter.terminals.gt = 0

Combining criteria

Criteria compose with the same combinators used by window rules:

all
Matches if every listed criterion matches.
any
Matches if at least one listed criterion matches.
not
Matches if the contained criterion does not match.
exactly
Matches if exactly num of the criteria in list match.
[[triggers]]
match.any = [
    { counter.browsers.gt = 0 },
    { counter.terminals.gt = 0 },
]

A bare array is shorthand for any:

[[triggers]]
match = [
    { counter.browsers.gt = 0 },
    { counter.terminals.gt = 0 },
]

exactly gives you an exclusive-or when num = 1:

[[triggers]]
match.exactly.num = 1
match.exactly.list = [
    { counter.browsers.gt = 0 },
    { counter.terminals.gt = 0 },
]

This matches when a browser is open or a terminal is open, but not when both are.

Warning

A match table with no fields matches unconditionally, so the trigger fires its action once as soon as the configuration is loaded. Make sure you have not misspelled a field name – unknown keys are reported as warnings in the log and otherwise ignored.

When triggers are evaluated

Every trigger is evaluated once when the configuration is loaded, and again whenever one of the counters it mentions changes value. Since all counters start at 0, a criterion such as lt = 1 or eq = 0 is already satisfied at load time and fires its action immediately.

Note

Counter values do not survive a config reload. Every counter starts at 0 again. Window and client rules are re-applied to existing windows and clients after a reload, so counts maintained by those rules rebuild themselves – but a trigger watching such a counter will fire its action again as the count is restored.

Loop protection

A trigger’s action can change a counter, which can activate another trigger. Jay caps how deeply this may nest with the top-level max-trigger-depth setting, which defaults to 16:

max-trigger-depth = 32

When the limit is reached, Jay logs an error and skips the action. Setting the value to 0 or less disables triggers completely.

This is the trigger counterpart to max-action-depth, which limits recursion between named actions.

Practical examples

React to the first and last window of an application

A window rule counts matching windows; the trigger reacts to the count crossing zero:

[[windows]]
match.app-id = "Alacritty"
action = { type = "inc-counter", name = "num-alacritty" }
latch  = { type = "dec-counter", name = "num-alacritty" }

[[triggers]]
match.counter.num-alacritty.gt = 0
action = { type = "exec", exec.shell = "notify-send 'first alacritty opened'" }
latch  = { type = "exec", exec.shell = "notify-send 'last alacritty closed'" }

The window rule’s latch runs when a window stops matching, which includes the window being closed, so the counter follows the number of open Alacritty windows.

React to a range

[[triggers]]
match.counter.num-alacritty.ge = 2
match.counter.num-alacritty.le = 4
action = { type = "exec", exec.shell = "notify-send 'between 2 and 4 open'" }

Combine a held key with a window state

complex-shortcuts can run an action on press and another on release, which turns a key into a counter that is non-zero exactly while it is held:

[complex-shortcuts.alt-x]
action = { type = "inc-counter", name = "alt-x-held" }
latch  = { type = "dec-counter", name = "alt-x-held" }

[[windows]]
match.app-id = "Alacritty"
action = { type = "inc-counter", name = "num-alacritty" }
latch  = { type = "dec-counter", name = "num-alacritty" }

[[triggers]]
match.exactly.num = 1
match.exactly.list = [
    { counter.num-alacritty.ne = 0 },
    { counter.alt-x-held.ne = 0 },
]
action = { type = "exec", exec.shell = "notify-send 'exactly one of the two'" }

Disable the idle timeout while a window is fullscreen

Keep the screen awake while anything is playing fullscreen, and restore the normal idle timeout once nothing is:

idle.minutes = 10

[[windows]]
match.fullscreen = true
action = { type = "inc-counter", name = "num-fullscreen" }
latch  = { type = "dec-counter", name = "num-fullscreen" }

[[triggers]]
match.counter.num-fullscreen.gt = 0
action = { type = "configure-idle", idle.minutes = 0 }
latch  = { type = "configure-idle", idle.minutes = 10 }

Setting idle.minutes = 0 disables the idle timeout; the latch puts the original value back, so keep it in sync with the top-level idle setting.

A window rule on its own cannot do this. Its latch fires whenever a window stops being fullscreen, so leaving fullscreen in one of two fullscreen windows would re-enable the idle timeout while the other is still playing. The counter tracks how many windows are fullscreen, and the trigger only reacts when that number moves away from or back to zero.

See spec.generated.md for the full specification of Trigger, TriggerMatch, TriggerMatchCounter, and the inc-counter, dec-counter, and set-counter actions.