Window Rules
Match windows and set how they open or how they look
sweets.window_rule matches a window by its initial identity and sets how it
opens, how it looks, or both.
sweets.window_rule({ app_id = "org.wezfurlong.wezterm", workspace = 2 })
sweets.window_rule({ title = "Picture-in-Picture", floating = true, focus = false })
sweets.window_rule({ backend = "xwayland", app_id = "steam", monitor = "DP-1" })
sweets.window_rule({ app_id = "foot", opacity = 0.88, blur = true })Layer-shell surfaces — panels, launchers, wallpapers — use
sweets.layer_rule instead.
Matchers
Every rule needs at least one matcher or exclusion, and at least one property. All matchers in one rule must match the same window.
| Field | Type | Matches |
|---|---|---|
backend | string | "xdg" or "xwayland" |
app_id | string or pattern | XDG app ID, or X11 class for XWayland |
title | string or pattern | Initial window title |
dialog | boolean | The window is a dialog |
transient | boolean | The window has a parent |
at_startup | boolean | The window opened within 60 seconds of compositor start |
A bare string matches exactly and case-sensitively. Titles often change, so
prefer app_id.
at_startup is measured from compositor start, so reloading the configuration
does not reopen the window. Use it for placement you want when restoring a
session but not for every later launch:
sweets.window_rule({ app_id = "org.gnome.Fractal", at_startup = true, workspace = 4 })Patterns
app_id and title also accept a table naming one predicate:
sweets.window_rule({ app_id = { prefix = "org.mozilla." }, opacity = 0.95 })
sweets.window_rule({ title = { contains = "Picture-in-Picture" }, floating = true })
sweets.window_rule({ app_id = { suffix = ".Devtools", ignore_case = true }, workspace = 3 })| Predicate | Matches when |
|---|---|
exact | The value equals the pattern |
prefix | The value begins with the pattern |
suffix | The value ends with the pattern |
contains | The pattern occurs anywhere in the value |
Name exactly one predicate per table. Optional ignore_case defaults to false
and folds ASCII case only. An empty pattern is rejected. There are no globs,
regular expressions, or callbacks.
Patterns can overlap, and every matching rule applies in declaration order.
When two rules set the same property, the later one wins. Run
sweets msg window-rule WINDOW_ID to see which rules a window matched.
Exclusions
exclude takes the same matcher fields and skips the rule for any window it
accepts:
sweets.window_rule({
app_id = { suffix = "term" },
exclude = { title = { contains = "mpv" } },
blur = true,
})Write several exclusions as a list. Each clause is complete in itself, so a window is skipped only when it satisfies every field of one of them:
sweets.window_rule({
app_id = "chromium",
exclude = { { dialog = true }, { title = "Save File" } },
workspace = 2,
})A rule may declare only exclusions, which applies it to everything else:
sweets.window_rule({ exclude = { app_id = "mpv" }, blur = true })One rule accepts at most 16 exclusions. An empty exclude table, or a clause
naming no field, is rejected.
Reach for exclude when a later rule cannot help. A later rule can set a
property to a different value, but it can never clear one.
XWayland app_id matches the X11 class, not the instance. Add
backend = "xwayland" when a native application shares the identifier.
Finding a window's identity
sweets msg windows # list windows and IDs
sweets msg window-rule WINDOW_ID # identity and resolved policy for oneThe second command reports the initial backend, app ID, title, X11 class and
instance, dialog, transient and startup facts, the effective policy, every rule
that matched, every rule an exclusion skipped, and any conflicts. Add --json
for machine-readable output.
Opening properties
| Field | Type | Effect |
|---|---|---|
workspace | integer | Open on workspace 1 to 10 |
monitor | string | Open on that monitor's visible workspace |
floating | boolean | Force floating or force tiling |
pin | boolean | Keep the window on its monitor across every workspace |
focus | boolean | false suppresses automatic focus |
size | table | Floating size, { width, height } |
min_size, max_size | table | Size bounds, { width, height } |
position | string or table | Floating anchor or { x, y } |
maximized | boolean | Start maximized |
fullscreen | boolean | Start fullscreen |
adaptive_sync | boolean | Allow on-demand VRR while fullscreen |
stacking | string | "normal", "above", or "below" |
corner_radius | integer or table | Override sweets.window, 0 to 1024 per corner |
on_xdg_activate | string | "ignore", "set_urgent", or "focus" |
column_width | number | Scrolling column width, as scrolling.column_width |
animation | boolean | false stops this window animating |
shortcuts_inhibit | boolean | false refuses this window's shortcut inhibiting |
maximize_requests | boolean | false refuses client maximize and unmaximize requests |
minimize_requests | boolean | false refuses client minimize requests |
fullscreen_requests | boolean | false refuses client fullscreen and unfullscreen requests |
Opening properties resolve once, before the window first appears. A reload affects only new windows — relaunch the application to test a change.
Without an explicit floating, Sweets automatically floats a window with a
fixed-size axis, or one whose maximum size is smaller than its tile would be.
Use floating = false to keep it tiled anyway.
Pinning a window to its monitor
A pinned window stays visible on its monitor whichever workspace you switch to. Use it for a Picture-in-Picture video, a notes window, or a chat window.
sweets.window_rule({
title = "Picture-in-Picture",
pin = true,
size = { 480, 270 },
position = "bottom-right",
})pin implies floating, so you do not need to write both. Only an ordinary
floating window can be pinned: pin = true alongside floating = false,
maximized or fullscreen in one rule is a configuration error.
Bind toggle_pin to pin a window by hand.
sweets.bind("MOD+P", "toggle_pin")A pinned window is a full member of the workspace it is on, so sweets msg windows reports the workspace you are looking at. Its pinned field is what
says it is pinned.
| Action | Result |
|---|---|
| Switch workspace | The window comes with you |
| Move it to another monitor | It pins to that monitor instead |
| Maximize or fullscreen it | The pin is dropped |
| Tile it | The pin is dropped |
| Unplug its monitor | It moves to a remaining monitor, still pinned |
Pinning is not the special workspace. The special workspace is modal — showing it dims the desktop — and holds a set of windows. A pin is one window and changes nothing else.
Appearance properties
| Field | Type | Effect |
|---|---|---|
opacity | number or table | Content opacity, one value or per state (focused, unfocused, dragged) |
dim_inactive | number, colour, or false | Overlay while another window holds focus; a number is black at that alpha, and false clears an earlier rule |
blur | boolean | Force or suppress blur |
blur_mode | string | "optimized" or "scene" |
border | boolean or table | Override sweets.border for this window |
shadow | boolean | Force or suppress the drop shadow |
dim_around | string or false | Darken the rest of the output behind a floating window |
popups | table | opacity, blur, blur_mode, corner_radius for popups |
idle_inhibit | string | "none", "always", "focus", or "fullscreen" |
render_unfocused | boolean | Keep the window drawing while it is on screen nowhere |
block_out_from | string | "none", "screencast", or "screen_capture" |
These are live: a valid reload restyles open windows without moving, resizing,
restacking, or refocusing them. Blur still needs the global
sweets.blur switch, and shadow
the global sweets.shadow one.
sweets.window_rule({
app_id = "foot",
workspace = 2, -- opening: needs a relaunch
opacity = 0.88, -- live: applies on reload
blur = true,
})Per-window borders
border overrides sweets.border
for the windows a rule matches. false removes the border; a table sets any of
width, focused, unfocused, urgent, and unresponsive.
-- A colour you cannot miss on the window holding your passwords.
sweets.window_rule({
app_id = "org.keepassxc.KeePassXC",
border = { focused = "#F7768E", unfocused = "#7D0D2D" },
})
-- Nothing around the video.
sweets.window_rule({ app_id = "mpv", border = false })
-- A thinner border with a gradient on the focused one.
sweets.window_rule({
app_id = "foot",
border = { width = 2, focused = { "#C27AFF", "#7AA2F7", angle = 45 } },
})Each field is independent: a rule naming only focused leaves the other states
as the global policy set them. Colours take the same value as sweets.border —
one #RRGGBB or #RRGGBBAA string, or a list of up to eight stops with an
optional angle.
width wins over both sweets.border.width and any
layout_style width. A later rule replaces an
earlier one field by field, so border = true gives a window back the width an
earlier border = false removed.
Three things still remove the border whatever a rule says: fullscreen, an X11
window that draws its own decoration, and a sole tiled window under
sweets.border({ smart = true }).
Dimming behind a window
dim_around darkens the output behind a window with a #RRGGBB or
#RRGGBBAA colour. Use it to make a prompt or dialog stand out.
sweets.window_rule({ app_id = "gcr-prompter", floating = true, dim_around = "#000000B3" })
-- A later rule can turn it off for a narrower match.
sweets.window_rule({ app_id = "gcr-prompter", title = "Unlock", dim_around = false })- It applies only while the window floats and is not fullscreen or maximized.
- Everything below the window on its monitor is darkened. The window, its popups, and anything above it are not.
- Several dimmers on one monitor never stack. The monitor is darkened once, with the strongest colour asked for, including the special workspace's dim.
- It fades in and out with the window's open and close animations.
- It appears in screenshots and screen shares, but not on the lock screen.
While a dimmer is up, a fullscreen window on that monitor cannot use direct scanout.
Keeping the screen awake
idle_inhibit holds the idle timers off while its mode is satisfied. Use it for
X11 clients, which cannot inhibit at all, and for players that inhibit
unreliably.
sweets.window_rule({
backend = "xwayland",
app_id = "vlc",
idle_inhibit = "fullscreen",
})A well-behaved Wayland player already keeps the screen awake by itself and needs no rule. Check before adding one: play something, wait, and see whether the screen blanks.
| Mode | Holds the timers off |
|---|---|
"none" | Never. This is the default, and overrides an earlier rule |
"always" | While the window is on screen |
"focus" | While the window is on screen and focused |
"fullscreen" | While the window is on screen and fullscreen |
Every mode needs the window on screen. A minimized window, one on a hidden workspace, and one another window covers all release it.
This adds inhibition; it never removes a client's own. "none" does not stop an
application that already keeps the screen awake by itself. A locked session
never inhibits.
sweets msg window WINDOW_ID reports the current value as idle_inhibit.
With pointer.follow_focus enabled, "focus" follows the pointer.
Keeping a hidden window drawing
A window that is on screen nowhere gets no frame callbacks, so a client that draws only when told a frame is due freezes. Most GPU-backed applications do. Switch workspaces while sharing one to a call and the call sees a still image.
render_unfocused keeps such a window drawing anyway.
-- The window being captured, not the tool capturing it.
sweets.window_rule({
app_id = "org.mozilla.firefox",
render_unfocused = true,
})
sweets.general({ render_unfocused_fps = 15 })Set it on the window whose pixels are being captured — the browser, the game, the terminal in the scene. That is the one that freezes. A rule on OBS keeps OBS's own window drawing and does nothing for its sources.
The window is still drawn on no output — only its own rendering continues, at
render_unfocused_fps frames per second. That rate is a cap from 1 to 60,
and 15 is enough for a shared window.
Set it on the windows you share, not broadly: each one keeps a client rendering that would otherwise be idle. A minimized window is never paced, and pacing stops while the session is locked and while the window's monitor is off.
On more than one monitor, each shows its own workspace. Switching workspaces on a different monitor leaves the window on screen, so nothing is paced — switch the workspace on the monitor the window is on.
Hiding a window from captures
block_out_from replaces a window with a solid black rectangle in screen
captures. The screen itself is unchanged. Its popups, border and blur go with
it, so nothing of the window reaches a blocked capture.
-- Off screen shares and recordings, still screenshottable.
sweets.window_rule({
app_id = "org.keepassxc.KeePassXC",
block_out_from = "screencast",
})
-- Never captured at all.
sweets.window_rule({
app_id = "com.bank.App",
block_out_from = "screen_capture",
})| Value | Blocks |
|---|---|
"none" | Nothing. Releases an earlier matching rule |
"screencast" | Screen sharing, video calls, and recorders |
"screen_capture" | Every capture, screenshots included |
This reduces accidental exposure. It is not a confidentiality guarantee. It controls only what Sweets hands to a capture request, and cannot protect against a camera pointed at the screen or a compromised client.
Sweets cannot tell a screen share from a screenshot tool: both ask over the
same interfaces. What the two modes select is whether a request is part of a
continuing stream or a single copy. Screenshot tools that ask the newer way —
grim among them — count as a stream, so "screencast" blocks them too.
Choose "screen_capture" when the answer is "never captured". Reach for
"screencast" only when you want your own screenshots of the window to keep
working with an older screenshot tool.
Match on app_id, not on a live title. A rule always uses the identity the
window had when it opened, so a title that changes later cannot start or stop
blocking it.
Popups
The nested popups table styles every XDG popup owned by the window, nested
menus and tooltips included.
sweets.window_rule({
app_id = "org.gnome.Nautilus",
popups = { opacity = 0.9, blur = true, corner_radius = 18 },
})Popup opacity is absolute — it does not multiply the window's own opacity —
and affects client content only, never the backdrop behind it. A popup stays
interactive even at 0.0.
corner_radius clips the popup's backdrop effect to a shape you specify, since
Wayland does not expose the real radius. It does not clip client pixels or
change where clicks land. The default is 0.
This does not affect X11 override-redirect menus, input-method popups, or menus an application draws inside its own window.
Rule order
Rules apply in declaration order, and a later rule replaces only the properties it names:
sweets.window_rule({ dialog = true, floating = true, focus = false })
sweets.window_rule({ app_id = "org.example.Editor", dialog = true, focus = true, size = { 900, 700 } })An editor dialog gets floating from the first rule, then focus and size
from the second. Installed rules run first, then yours, in include order.
A later rule cannot clear an earlier property. Keep placement rules disjoint —
adding monitor does not remove an inherited workspace, and that
combination is invalid. Use exclude to keep the first rule off the window
instead.
Placement
workspace picks a numbered workspace, which may be hidden. monitor picks
whatever workspace that monitor currently shows. Find connector names with
sweets msg outputs. One rule cannot use both.
If the monitor is not connected, the window opens normally and
sweets msg window-rule reports monitor_unavailable.
A generic rule does not pull a transient window away from its parent. Add a
dialog or transient matcher to place those deliberately:
sweets.window_rule({ app_id = "org.example.Editor", transient = true, workspace = 3 })Rules cannot target the special workspace.
Floating size and position
size and position apply to floating windows and imply floating = true.
Each component is read the same way:
0to1— a fraction of the work area. Exactly1means the full width or height, not one pixel.- Above
1— logical pixels, up to16384.
So { 0.5, 600 } is half the work-area width and 600 pixels tall.
position takes { x, y } from the work area's top-left corner, or one of
nine anchors:
center
top-left top top-right
left right
bottom-left bottom bottom-rightsweets.window_rule({ app_id = "mpv", size = { 960, 540 }, position = { 40, 0.1 } })Client minimum and maximum hints are respected, and oversized geometry is
clamped into the work area. Combined with maximized or fullscreen, size
becomes the geometry the window restores to.
Size bounds
min_size and max_size bound the size Sweets asks a window to be. They read
their components exactly like size, and unlike size they do not imply
floating.
sweets.window_rule({ app_id = "pavucontrol", floating = true, max_size = { 900, 700 } })Each one replaces the client's own hint for that axis, so it is also how you correct an application that reports a bad minimum or none at all. The bound applies from the window's first frame and to every later move or resize, and it feeds the automatic floating decision above.
A min_size larger than the max_size in the same rule is rejected. When two
separate rules combine into that, the maximum wins.
Column width
column_width opens a tiled window as a scrolling column of that width instead
of scrolling.column_width. It has no effect on other layouts or on floating
windows.
sweets.window_rule({ app_id = "firefox", column_width = 0.75 })Opting a window out
animation = false stops one window animating when it opens, closes, or
minimizes.
-- A password prompt should just appear.
sweets.window_rule({ app_id = "gcr-prompter", animation = false })Moving and resizing are not covered. A relayout moves every affected window on one shared clock, so excluding one would leave it jumping while its neighbours travel. The rule removes only that window's own animation; its neighbours still reflow normally around it.
shortcuts_inhibit = false refuses that window's requests to take over the
keyboard, so your bindings keep working while it has focus.
sweets.window_rule({ app_id = "org.gnome.Boxes", shortcuts_inhibit = false })This only affects windows that ask. A VM viewer, a remote desktop client, or a browser page using the Keyboard Lock API will; most applications never do, and the rule does nothing for them. If your bindings already work while the window has focus, it was not asking in the first place.
The window is told its request was declined. toggle_shortcuts_inhibit cannot
grant it either, though it still switches an already-granted window off. To
allow the window again, remove the rule and relaunch it.
The three client-state request fields stop an application changing its own state after it opens:
sweets.window_rule({
app_id = "org.example.Game",
maximize_requests = false,
minimize_requests = false,
fullscreen_requests = false,
})Each omitted field, and true, allows that request. false covers both
directions where the protocol has them: maximize and unmaximize, fullscreen
and unfullscreen, and X11 minimize and unminimize. XDG has only a minimize
hint. Your bindings and IPC commands remain authoritative and can still change
all three states.
These fields do not replace the opening-state fields. Use maximized = false
or fullscreen = false to override what the application requested before its
first map. The request fields govern later client-driven transitions. A
refused XDG maximize or fullscreen transition is acknowledged with a configure
carrying the state Sweets kept; minimize is a one-way hint with no state to
acknowledge. X11 keeps the corresponding EWMH property unchanged.
Refusing fullscreen is a compatibility choice. Video players usually remain usable in a window, but some games assume fullscreen succeeded. Test the application before keeping this rule.
All five opt-outs in this section are opening properties. A reload affects only new windows, so relaunch the application after changing one.
Focus, state, and stacking
focus = false stops a window taking keyboard focus when it opens. focus = true allows the normal path but cannot override X11 focus intent, session
lock, an interactive layer surface, or a popup grab.
maximized uses the work area and fullscreen uses the whole output. A rule
cannot set both.
adaptive_sync = true opts a window into on-demand VRR. It does not make the
window fullscreen, and it only matters on a monitor set to
adaptive_sync = "on_demand", where VRR turns on while this window is
fullscreen and visible.
stacking keeps a window "above" or "below" normal windows, tiled or
floating.
on_xdg_activate decides what an activation request from that window does.
Without it, a request carrying a seat serial Sweets recognizes focuses the
window, and a request without one only marks it urgent.
sweets.window_rule({
title = "Picture-in-Picture",
floating = true,
on_xdg_activate = "set_urgent",
})| Value | Effect |
|---|---|
"ignore" | Drop the request; focus and urgency stay as they are |
"set_urgent" | Mark the window urgent, never focus it |
"focus" | Focus the window, even without a recognized serial |
The rule changes only that choice. A locked session and a stale or foreign
token still reject the request first, so "focus" cannot bypass them. The same
value applies to XWayland windows. Because it is an opening property, the rule
that applies is the one that matched when the window opened, not one matching
its current title.
corner_radius overrides every other radius setting. A number covers every corner; a
table names the corners that are round and leaves the rest square. A later rule
replaces the whole radius rather than merging corners. Fullscreen windows and windows made edge-to-edge by smart gaps
stay square until that changes back.
Invalid combinations
workspacewithmonitorfloating = falsewithsizeorpositionmaximized = truewithfullscreen = truemin_sizelarger thanmax_sizein the same rule
A conflict inside one rule is rejected at validation. A conflict formed by
several rules matching one window can only be found when that window opens:
Sweets falls back to normal opening policy, logs a warning, and reports it in
sweets msg window-rule WINDOW_ID.
One configuration holds up to 128 window rules, 256 bytes per string, and 32 KiB of matcher and opening text in total.
Reload
sweets --check-config
sweets msg reloadA rule decision is fixed before the window first appears and survives unmap and remap, so later title or app-ID changes do not re-run the rules. Appearance properties re-resolve on every valid reload; opening properties need a relaunch.
sweets.rule and sweets.appearance_rule are the older, split spellings.
They still work but warn once. Use sweets.window_rule.