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
namebydelta(default1). dec-counter- Decrease the counter named
namebydelta(default1). set-counter- Set the counter named
nametovalue(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
numof the criteria inlistmatch.
[[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
matchtable 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
0again. 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.