Sweets
Configuration

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.

FieldTypeMatches
backendstring"xdg" or "xwayland"
app_idstring or patternXDG app ID, or X11 class for XWayland
titlestring or patternInitial window title
dialogbooleanThe window is a dialog
transientbooleanThe window has a parent
at_startupbooleanThe 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 })
PredicateMatches when
exactThe value equals the pattern
prefixThe value begins with the pattern
suffixThe value ends with the pattern
containsThe 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 one

The 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

FieldTypeEffect
workspaceintegerOpen on workspace 1 to 10
monitorstringOpen on that monitor's visible workspace
floatingbooleanForce floating or force tiling
pinbooleanKeep the window on its monitor across every workspace
focusbooleanfalse suppresses automatic focus
sizetableFloating size, { width, height }
min_size, max_sizetableSize bounds, { width, height }
positionstring or tableFloating anchor or { x, y }
maximizedbooleanStart maximized
fullscreenbooleanStart fullscreen
adaptive_syncbooleanAllow on-demand VRR while fullscreen
stackingstring"normal", "above", or "below"
corner_radiusinteger or tableOverride sweets.window, 0 to 1024 per corner
on_xdg_activatestring"ignore", "set_urgent", or "focus"
column_widthnumberScrolling column width, as scrolling.column_width
animationbooleanfalse stops this window animating
shortcuts_inhibitbooleanfalse refuses this window's shortcut inhibiting
maximize_requestsbooleanfalse refuses client maximize and unmaximize requests
minimize_requestsbooleanfalse refuses client minimize requests
fullscreen_requestsbooleanfalse 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.

ActionResult
Switch workspaceThe window comes with you
Move it to another monitorIt pins to that monitor instead
Maximize or fullscreen itThe pin is dropped
Tile itThe pin is dropped
Unplug its monitorIt 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

FieldTypeEffect
opacitynumber or tableContent opacity, one value or per state (focused, unfocused, dragged)
dim_inactivenumber, colour, or falseOverlay while another window holds focus; a number is black at that alpha, and false clears an earlier rule
blurbooleanForce or suppress blur
blur_modestring"optimized" or "scene"
borderboolean or tableOverride sweets.border for this window
shadowbooleanForce or suppress the drop shadow
dim_aroundstring or falseDarken the rest of the output behind a floating window
popupstableopacity, blur, blur_mode, corner_radius for popups
idle_inhibitstring"none", "always", "focus", or "fullscreen"
render_unfocusedbooleanKeep the window drawing while it is on screen nowhere
block_out_fromstring"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.

ModeHolds 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",
})
ValueBlocks
"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:

  • 0 to 1 — a fraction of the work area. Exactly 1 means the full width or height, not one pixel.
  • Above 1 — logical pixels, up to 16384.

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-right
sweets.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",
})
ValueEffect
"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

  • workspace with monitor
  • floating = false with size or position
  • maximized = true with fullscreen = true
  • min_size larger than max_size in 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 reload

A 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.

On this page