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

Home

driftwm

A trackpad-first infinite canvas Wayland compositor.

License: GPL-3.0-or-later GitHub Release Packaging status

Primary repository: GitHub · Mirror: Codeberg

Traditional window managers arrange windows to fit your screen. Stacking compositors do so by piling windows on top of each other; tiling compositors do so by squeezing them to fit and utilizing workspaces.

driftwm is an infinite-canvas compositor: windows live at their native size on an infinite 2D canvas, and your display is a camera viewing it. When two windows come close, they snap together, forming implicit groups that can be moved, resized, and viewed together. No tiling, no workspaces, window overlaps happen only on purpose.

Designed with laptops in mind: navigation and window management are trackpad-first; the infinite canvas makes the most of a small screen.

Built on smithay. Inspired by vxwm; borrows implementation details from niri.

Warning

This is experimental software, primarily built with AI.

Features

Pan & zoom

Infinite 2D canvas with viewport panning, zoom, and scroll momentum. A quick flick carries the viewport smoothly until friction stops it.

Pan & zoom bindings
InputActionContext
3-finger swipePan viewportanywhere
Trackpad scrollPan viewporton-canvas
Mod + LMB dragPan viewportanywhere
Mod+Ctrl + arrowPan viewport
2-finger pinchZoomon-canvas
3-finger pinchZoomanywhere
Mod + scrollZoom at cursoranywhere
Mod+= / Mod+-Zoom in / out
Mod+0 / Mod+ZReset zoom to 1.0

Window navigation

Jump to the nearest window in any direction via cone search. MRU cycling (Alt-Tab) with hold-to-commit. Zoom-to-fit shows all windows at once. Configurable anchors act as navigation targets for directional jumps even with no window there — useful for areas with pinned widgets.

Navigation bindings
InputAction
4-finger swipeJump to nearest window (natural direction)
Mod+Ctrl + LMB dragJump to nearest window (natural direction)
Mod + arrowJump to nearest window in direction
Alt-Tab / Alt-Shift-TabCycle windows (MRU)
4-finger pinch in / Mod+WZoom-to-fit (overview)
4-finger pinch out / Mod+AHome toggle (origin and back)
4-finger hold / Mod+CCenter focused window
Mod+1-4Jump to bookmarked canvas position

All 4-finger navigation gestures also work as Mod + 3-finger for smaller trackpads.

Snapping

Move window with 3-finger doubletap-swipe or Alt + drag. Resize with Alt + 3-finger swipe. Snapping kicks in as edges approach each other. Drag past the viewport edge and the canvas auto-pans.

Snapped windows form a cluster. Two benefits: neighbors stay visible at your view’s edge for spatial context, and Shift + any move/resize/fit action acts on the whole cluster. Shuffle a layout in one drag, resize a row of panes proportionally, or scope an overview to just the cluster (Mod+Shift+W). No explicit grouping to manage.

Tip

While dragging a window, keyboard shortcuts still work. Use Mod+1-4 to jump to a bookmark or Mod+A to go home — your held window comes with you.

Fit-window (Mod+M) is the maximize analogue — centers the viewport, resets zoom to 1.0, and resizes the window to fill the screen. Toggle again to restore. Fullscreen (Mod+F) is a viewport mode, not a window state — any canvas action (launching an app, navigating) naturally exits it.

Snapping & window bindings
InputAction
3-finger doubletap-swipeMove window
Alt + LMB dragMove window
Alt+Shift + LMB dragMove snapped windows
Alt + 3-finger swipeResize window
Alt+Shift + 3-finger swipeResize snapped window
Alt + RMB dragResize window
Alt + MMB click / Mod+MFit window (maximize/restore)
Alt+Shift + MMB click / Mod+Shift+MFit snapped window
Mod + 4-finger pinch in / Mod+Shift+WZoom-to-fit snapped windows
Alt + 2-finger pinch in/outFit window
Alt + 3-finger pinch in/outToggle fullscreen
Mod + MMB click / Mod+FToggle fullscreen
Mod+Shift + arrowNudge window 20px

Touchscreen

Everything works by touch too: pan and zoom the canvas, jump between windows, and move or resize windows — even whole window groups — exactly as you would on a trackpad.

Touch gestures
InputActionContext
1-finger swipePan viewporton-canvas
3-finger swipePan viewportanywhere
2-finger pinchZoomon-canvas
3-finger pinchZoomanywhere
4-finger swipeJump to nearest windowanywhere
4-finger pinch in / outZoom-to-fit / home toggleanywhere
3-finger tapCenter windowanywhere
3-finger double-tapFit windowon-window
3-finger doubletap-swipeMove window (hold: cluster)on-window
3-finger hold-swipeResize windowon-window

Infinite background

The background is part of the canvas — it scrolls and zooms with the viewport, not stuck to the screen. This gives spatial awareness when panning.

Four modes:

  • shader — procedural GLSL, animated or static, optionally sampling an image via texture. Default is a dot grid. See docs/shaders.md to write your own. Bundled shaders live in extras/wallpapers/{static,animated,textured}/.
  • tile — PNG/JPG (single texture, tiled infinitely), or a tiled pyramidal TIFF for gigapixel wallpapers. Set mirror_tile = true to mirror-fold a non-seamless image so it tiles without seams (kaleidoscope look).
  • wallpaper — single image scaled to cover the viewport, aspect-preserving (does not scroll/zoom) — a classic desktop wallpaper.
  • none — no built-in background, so an external wlr-layer-shell wallpaper daemon (swaybg, swww, mpvpaper for live video) becomes the wallpaper instead.

Note

GPU cost scales with what a shader reads: one that reads no viewport uniforms renders once (as cheap as wallpaper); reading u_camera/u_zoom redraws on pan/zoom; reading u_time redraws every frame. Tiles redraw on pan/zoom; wallpaper renders once.

[background]
type = "shader"
path = "~/.config/driftwm/bg.glsl"
# texture = "~/Pictures/img.jpg"  # if it's a texture-based shader

# Or: type = "tile",      path = "~/Pictures/tile.png"
# Or: type = "tile",      path = "~/Pictures/world.tif"   # pyramidal TIFF
# Or: type = "wallpaper", path = "~/Pictures/wallpaper.jpg"
# Or: type = "none"                                       # external wallpaper daemon (swaybg/mpvpaper/…)

Window rules

Match windows by app_id and/or title (glob patterns) and control position, size, decorations, blur, opacity, key pass-through, and placement — fields combine freely.

Two special placement modes: widget = true fixes a window to the canvas (immovable, below normal windows, out of Alt-Tab — clocks, trays, and layer-shell surfaces like waybar); pinned_to_screen = true fixes it to the screen instead, so it ignores pan/zoom and floats above normal windows (Picture-in-Picture, call toolbars) — toggleable live with Mod+T.

# Frosted-glass terminal
[[window_rules]]
app_id = "Alacritty"
opacity = 0.85
blur = true

# Desktop widget — pinned to the canvas, borderless
[[window_rules]]
app_id = "my-clock"
position = [50, 50]
widget = true
decoration = "none"

Tip

To find a window’s app_id or title, run driftwm msg state — it lists every open window with its app ID, title, position, and size.

See docs/window-rules.md for more details.

Multi-monitor

Multiple monitors are independent viewports on the same canvas. An outline on each monitor shows where the other monitors’ viewports are. Cursor crosses between monitors freely; dragged windows teleport to the target viewport’s canvas position.

InputAction
Mod+Alt + arrowSend window to adjacent output

Panels, docks & taskbars

Layer shell surfaces (waybar, fuzzel, mako) work as expected. Foreign toplevel management means your dock/taskbar shows all windows — click one and the viewport pans to it and centers it. See extras/ for a fuzzel window-search script that lets you search and jump to any open window.

Everything else

  • New window placement: in viewport center (default), under cursor, or snapped adjacent to the focused window’s cluster
  • Click-to-focus (default) or focus-follows-mouse (sloppy focus)
  • Session lock (swaylock), idle notify (swayidle/hypridle)
  • Screen capture: screencasting (OBS, Firefox, Discord) and screenshots, incl. built-in canvas/DPI capture
  • 40+ Wayland protocols
  • IPC control: script the compositor over a Unix socket with driftwm msg (full command/flag reference: docs/cli.md)

Install

Arch Linux (AUR)

yay -S driftwm

or for latest main:

yay -S driftwm-git

NixOS / Nix

A flake.nix is included. To build:

nix build

For development (provides native deps, uses your system Rust):

nix develop
cargo build
cargo run

To enable driftwm on NixOS, you can import and use the provided NixOS module in your configuration.

Using Flakes:

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    driftwm.url = "github:malbiruk/driftwm";
  };

  outputs = { self, nixpkgs, driftwm, ... }: {
    nixosConfigurations.myHost = nixpkgs.lib.nixosSystem {
      modules = [
        driftwm.nixosModules.default
        ./configuration.nix
      ];
    };
  };
}

Then, enable it in your configuration:

# configuration.nix
{
  programs.driftwm.enable = true;
}

Alternatively, without flakes (by importing the flake’s output directly):

let
  driftwm-flake = builtins.getFlake "github:malbiruk/driftwm";
in
{
  imports = [ driftwm-flake.nixosModules.default ];
  programs.driftwm.enable = true;
}

NixOS Module Options

The NixOS module provides the following options under programs.driftwm:

  • enable: Whether to enable driftwm (defaults to false).
  • package: The package containing the driftwm compositor binary.

By default, the module enables XWayland support via xwayland-satellite by defaulting programs.xwayland.enable to true. If you want to disable or explicitly enable it, configure:

programs.xwayland.enable = true; # or false to disable XWayland and xwayland-satellite

Build from source

Requires Rust 1.88+ (edition 2024).

Install build dependencies:

Fedora:

sudo dnf install libseat-devel libdisplay-info-devel libinput-devel mesa-libgbm-devel libxkbcommon-devel

Ubuntu/Debian:

sudo apt install libseat-dev libdisplay-info-dev libinput-dev libudev-dev libgbm-dev libxkbcommon-dev libwayland-dev

Arch Linux:

sudo pacman -S libdisplay-info libinput seatd mesa libxkbcommon

Note

Ubuntu 24.04 ships Rust 1.75 which is too old. Install via rustup instead of apt install rustc.

Then build and install:

git clone https://github.com/malbiruk/driftwm.git
cd driftwm
make build
sudo make install

To uninstall, run sudo make uninstall from the repository.

Optional runtime dependencies

driftwm runs standalone — none of these are required — but each enables or improves a feature:

  • xwayland-satellite (≥ 0.7) — X11 app support (see below).
  • xdg-desktop-portal + xdg-desktop-portal-wlr (≥ 0.8.0) or xdg-desktop-portal-cosmic — screencasting, and screenshot apps that go through the portal (e.g. Flameshot). wlr needs a dmenu-style picker in $PATH (wmenu/wofi/rofi/bemenu/mew/fuzzel) to choose what to share.
  • grim + slurp — screenshots (+ cropping to region). driftwm also has a built-in canvas/DPI capture: see IPC › Screenshots.
  • adwaita-fonts — renders SSD title bars in Adwaita Sans to match GTK apps; without it a generic sans-serif is substituted. Font, size, weight, and alignment are configurable under [decorations].
  • A cursor theme — most desktops set one up already; on a bare install driftwm falls back to a basic built-in arrow.

X11 apps run through xwayland-satellite, which driftwm spawns at startup, exporting DISPLAY=:N so X11 clients connect transparently — no extra config beyond having the binary in $PATH.

  • Arch: sudo pacman -S xwayland-satellite
  • Fedora: sudo dnf install xwayland-satellite
  • NixOS: pkgs.xwayland-satellite
  • Debian/Ubuntu: not yet packaged — cargo install --locked xwayland-satellite

If satellite isn’t found at startup, driftwm logs a warning and continues without X11 support. You can override the binary path or disable the integration in config.reference.toml under [xwayland].

Running

driftwm auto-detects whether it’s running nested (inside an existing Wayland session) or on real hardware (from a TTY). Just run driftwm. For display manager integration, select “driftwm” from the session menu.

Tip

When launched by a display manager, driftwm runs as a systemd user service — view logs with journalctl --user -u driftwm.service (add -f to follow). Run directly and logs go to stderr.

Quick start

mod is Super by default. Terminal and launcher are auto-detected (foot/alacritty/kitty, fuzzel/wofi/bemenu); override in config.

ShortcutAction
mod+returnOpen terminal
mod+dOpen launcher
mod+qClose window
mod+lLock screen
mod+ctrl+shift+qQuit

Feature-specific bindings (navigation, zoom, snap) are in their respective sections above.

Configuration

Config file: ~/.config/driftwm/config.toml (respects XDG_CONFIG_HOME).

mkdir -p ~/.config/driftwm
cp /etc/driftwm/config.reference.toml ~/.config/driftwm/config.toml

Missing file uses built-in defaults. Partial configs merge with defaults — only specify what you want to change. Use "none" to unbind a default binding. Validate without starting: driftwm --check-config.

# Launch programs at startup
autostart = ["waybar", "swaync", "swayosd-server"]

Every option is documented in docs/config.md (generated from config.reference.toml): input settings, scroll/momentum tuning, snap behavior, decorations, effects, per-output config, gesture bindings, mouse bindings, touch bindings, and window rules.

Example setup

driftwm is just a compositor — everything else is standard Wayland tooling. Here are some tools that work well with it:

  • waybar — Status bar / taskbar
  • crystal-dock — macOS-style dock
  • fuzzel / wofi — App launcher
  • mako / swaync — Notifications
  • swaylock — Lock screen
  • swayidle / hypridle — Idle timeout (lock, suspend)
  • swayosd — Volume/brightness OSD
  • grim + slurp — Screenshots (or the built-in canvas/DPI capture: IPC › Screenshots)
  • wlr-randr / wdisplays — Output configuration
  • COSMIC Settings — Wi-Fi, Bluetooth, sound (or nm-applet + blueman + pavucontrol)

Compositor-agnostic full Wayland shells like noctalia, wayle, and dank-material-shell should work too (driftwm supports wlr-layer-shell protocol) but without compositor-specific features.

The extras/ directory contains a complete setup — driftwm config, GLSL shader wallpapers, Python widgets (clock, calendar, system stats, power menu), waybar with taskbar/tray, fuzzel window-search script, and window rules tying it all together. Use it as a starting point or steal pieces.

Community

Contributing

See CONTRIBUTING.md.

TL;DR: open an issue before writing non-trivial code, keep PRs small and focused.

Merch

If you want to support the project (or just want a shirt), this is the way.

XL

100 GEL · 37 USD · 2800 RUB

Ships worldwide from Tbilisi.

Order via Telegram, Instagram, or email 2601074@gmail.com.

Revenue goes to me as driftwm’s primary maintainer. If you’ve contributed substantively and want a shirt, drop me a line.

License

GPL-3.0-or-later

Configuration

driftwm reads its configuration from ~/.config/driftwm/config.toml (respecting XDG_CONFIG_HOME). Every field is optional — anything you omit uses the built-in default shown below. Copy config.reference.toml to get started, then uncomment and edit only the lines you want to change. Validate a config with driftwm --check-config.

General

mod_key

Default: "super"

Window manager modifier key: “super” (default) or “alt”

focus_follows_mouse

Default: false

Sloppy focus: keyboard focus follows the pointer to windows. Moving to empty canvas keeps focus; click empty canvas to unfocus.

window_placement

Default: "center"

Where new windows spawn when no window rule positions them:

  • "center" — viewport center; camera animates to the new window.
  • "cursor" — centered on the cursor (clamped to the active output’s usable area); camera stays put unless zoomed out and zoom.reset_on_new_window is true.
  • "auto" — snap-place adjacent to the focused window’s cluster: try focused’s edges (clockwise from the one nearest the viewport center), then BFS to neighbors. Falls back to “center” when no focused window or no valid placement was found.

autostart

Default: []

Commands to run at startup (after WAYLAND_DISPLAY is set). Each entry is passed to sh -c, so full shell syntax works (pipes, &&, env vars).

Running driftwm as a systemd session (via driftwm-session / a display manager) also launches XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), in addition to the autostart list above. To opt out:

systemctl --user mask xdg-desktop-autostart.target

Example:

autostart = ["waybar", "swaync"]

[env]

Environment variables set before any clients launch. Child processes (autostart, exec bindings) inherit these. These override the compositor’s built-in toolkit defaults (MOZ_ENABLE_WAYLAND, QT_QPA_PLATFORM, SDL_VIDEODRIVER, GDK_BACKEND, ELECTRON_OZONE_PLATFORM_HINT).

Example:

QT_WAYLAND_DISABLE_WINDOWDECORATION = "1"
MOZ_ENABLE_WAYLAND = "1"

[input.keyboard]

layout

Default: "us"

XKB layout (e.g., “us,ru” for multi-layout)

remember_layout_per_window

Default: false

for multi-layout setups

variant

Default: ""

XKB variant (e.g., “dvorak”, or “,” for two defaults)

options

Default: ""

XKB options (e.g., “grp:win_space_toggle” for Super+Space layout switch) grp:alt_shift_toggle/grp:ctrl_shift_toggle break those chord bindings — use a tap binding (see [keybindings]) instead

model

Default: ""

XKB model (e.g., “pc105”)

repeat_rate

Default: 25

keys/sec

repeat_delay

Default: 200

ms before repeat starts

layout_independent

Default: true

match bindings by physical key position across layouts

num_lock

Default: true

num lock state on startup

caps_lock

Default: false

caps lock state on startup

[input.trackpad]

tap_to_click

Default: true

enable tap-to-click

natural_scroll

Default: true

reverse scroll direction (content follows fingers)

tap_and_drag

Default: true

double-tap-hold to drag

accel_speed

Default: 0.0

pointer acceleration (-1.0 to 1.0)

accel_profile

Default: "adaptive"

“flat” or “adaptive”

click_method

Default: "none"

none = device default; clickfinger = finger count (1=left, 2=right, 3=middle); button_areas = position on trackpad

disable_while_typing

Default: true

ignore trackpad input shortly after a key press (palm rejection)

[input.mouse]

accel_speed

Default: 0.0

pointer acceleration (-1.0 to 1.0)

accel_profile

Default: "flat"

“flat” or “adaptive”

natural_scroll

Default: false

reverse scroll direction

left_handed

Default: false

swap left/right mouse buttons (skipped on devices without left-handed support)

[input.touch]

enable

Default: true

enable touchscreen support

map_to_output

Default: "none"

pin touch to an output by connector name; “none” (default) auto-detects the touchscreen by physical size + connector. Only set this to tell apart two identically-sized touchscreens.

Example:

map_to_output = "DP-2"

[cursor]

theme

Default: "none"

XCURSOR_THEME; “none” = inherit from environment (e.g. “Adwaita”)

size

Default: 0

XCURSOR_SIZE; 0 = inherit from environment (e.g. 24)

inactive_opacity

Default: 0.5

cursor opacity on non-active outputs (0.0–1.0)

trackpad_speed

Default: 1.5

trackpad (scroll/gestures) pan multiplier

mouse_speed

Default: 1.0

mouse (drag) pan multiplier (1.0 = direct)

touch_speed

Default: 1.0

touchscreen gesture pan speed multiplier

drift

Default: 0.5

momentum coast: 0 = off, 0.5 = default, 1 = floatiest

animation_speed

Default: 0.3

camera lerp factor (higher = faster)

auto_navigate_on_close

Default: true

on close, pan to the newly focused window if off-screen false = camera stays put; focus only moves to a visible window

auto_navigate_on_click

Default: false

completed click on a partially off-screen window also pans it in fully visible → focus only

nudge_step

Default: 20

px per nudge-window action (mod-shift-arrow by default)

pan_step

Default: 100.0

px per pan-viewport action (mod-ctrl-arrow by default)

anchors

Default: [[0, 0]]

Anchors: canvas points discoverable by center-nearest (4-finger swipe / Mod+Arrow) even when no window is there. Uses Y-up coordinate system.

Example: 4 corners

anchors = [[0, 0], [-1750, 1750], [1750, 1750], [1750, -1750], [-1750, -1750]]

zone

Default: 100.0

activation zone width (px from viewport edge)

speed_min

Default: 4.0

px/frame at zone boundary

speed_max

Default: 10.0

px/frame at viewport edge

cursor_pan

Default: false

pan when the bare cursor touches a screen edge (not just while dragging). Toggle: “toggle-cursor-pan”.

cursor_zone

Default: 20.0

cursor edge-pan activation zone (px) — kept small so it doesn’t trigger by accident. Pans at a constant speed_max within the zone (steady, push-speed independent); speed_min is unused here.

latency_ms

Default: 120

delay before pan starts at an edge bordering another monitor (ms). Outer edges remain immediate. 0 disables.

[zoom]

step

Default: 1.1

multiplier per keypress (1.1 = 10% per press)

trackpad_speed

Default: 1.0

trackpad pinch-zoom speed multiplier

mouse_speed

Default: 1.0

mouse-wheel zoom speed multiplier

touch_speed

Default: 1.0

touchscreen gesture zoom speed multiplier

fit_padding

Default: 80.0

viewport px padding for zoom-to-fit (screen space)

reset_on_new_window

Default: true

animate zoom to 1.0 when a new window is mapped (false = keep current zoom, pan only)

reset_on_activation

Default: true

animate zoom to 1.0 when an off-screen window requests focus (false = keep current zoom, pan only)

[snap]

enabled

Default: true

magnetic edge snapping during window drag

gap

Default: 12.0

gap between snapped windows (canvas px)

distance

Default: 24.0

activation threshold (screen px from edge)

break_force

Default: 32.0

screen px past snap to break free

corners

Default: false

also align corners (parallel edges line up: top-to-top, left-to-left)

centers

Default: false

also align centers (midpoints line up along the moved axis)

[decorations]

bg_color

Default: "#303030"

title bar background (default: dark gray)

fg_color

Default: "#FFFFFF"

title text + close button × color (default: white)

corner_radius

Default: 10

clip window corners to this radius

shadow

Default: true

drop shadow under window chrome

title_bar_height

Default: 25

SSD title bar text. The font is resolved via fontconfig — install the adwaita-fonts package for the default look; otherwise a generic sans is substituted.

SSD title bar height in px (default: 25)

font

Default: "Adwaita Sans"

title text font family

font_size

Default: 11

title text size in points (default: 11)

font_weight

Default: "medium"

thin/extralight/light/regular/medium/ semibold/bold/extrabold/black (default: medium)

title_align

Default: "center"

“left” or “center” (default: center). “center” centers short titles and left-aligns + ellipsizes long ones

default_mode

Default: "client"

Decoration mode for windows without a rule:

  • "client" — CSD: client draws its own titlebar (default)
  • "minimal" — SSD: no titlebar; shadow, corners, and border still apply via [decorations] + per-window rules
  • "none" — bare client surface: compositor adds zero chrome; per-window border/corner/shadow rules are ignored

“server” (driftwm titlebar) is intentionally not allowed as a global default: many toolkits (GTK, Electron) ignore xdg-decoration and keep drawing CSD, producing a misaligned double titlebar. Use it per-app via [[window_rules]] instead.

border_width

Default: 0

Borders apply to “client”, “server”, and “minimal” modes. “none” mode has no border unless one is set per-app in [[window_rules]].

px; 0 disables the border

border_color

Default: "#303030"

unfocused border (default: dark gray)

border_color_focused

Default: "#303030"

focused border. Same default as above — set this to a different color to get a focus indicator.

[effects]

blur_radius

Default: 2

number of Kawase down+up passes (default: 2)

blur_strength

Default: 1.1

per-pass texel spread (default: 1.1)

animate_blur_fps

Default: 20

refresh rate of blur under an animated wallpaper (0-144, default 20; 0 = off, freezing the frost so it stops re-sampling the wallpaper). The background is blurred once into a shared full-output texture and each window slices its rect from it, so cost stays flat as windows are added; a window stacked over other windows falls back to an exact per-window blur at the same cadence. Animated wallpapers evolve slowly, so well below the output rate still looks continuous through frosted glass. Camera moves force a refresh.

[background]

type

Default: "default"

Five types: “default” (built-in dot-grid — the default), “shader”, “tile”, “wallpaper”, “none”. For shader/tile/wallpaper, path is the source.

Example:

type = "shader"  # procedural GLSL (scrolls with canvas)
path = "/usr/local/share/driftwm/wallpapers/animated/fast_smoke.glsl"

type = "tile"  # image tiled across the canvas
path = "~/Pictures/Wallpapers/tile.png"

type = "wallpaper"  # single image fixed to viewport (does not scroll/zoom)
path = "~/Pictures/Wallpapers/wallpaper.jpg"

type = "none"  # render no built-in background (path ignored), so a
               # wlr-layer-shell wallpaper daemon (swaybg, swww, mpvpaper for live video)
               # is the wallpaper. Launch it yourself; see docs/shaders.md.

A “shader” can also sample an image via texture (bound to the shader’s tex sampler) — a procedural effect on your image. See docs/shaders.md.

Example: shader sampling an image

type = "shader"
path = "/usr/local/share/driftwm/wallpapers/textured/ripple.glsl"
texture = "~/Pictures/Wallpapers/photo.jpg"

mirror_tile

Default: false

tile mode only: mirror-fold the image (2×2 reflected block) so a non-seamless image’s edges always meet a reflection — no visible tile seams. Also gives a kaleidoscope/symmetry look. No-op for other background types. Single images only; gigapixel pyramidal TIFFs aren’t mirrored (pre-mirror the source).

cache_shader

Default: false

Bake a heavy static shader to a texture once, then pan that — so it pans as cheaply as an image instead of recomputing every frame. ONLY correct for shaders that slide rigidly with the camera: u_camera used once, at full scale, as the only camera term. In GLSL:

vec2 canvas = v_coords * size + u_camera;   // pan shifts the image 1:1

Parallax (u_camera * factor) renders WRONG; animated (u_time) and zoom-dependent (u_zoom) shaders are never cached and render live.

transparent_shader

Default: false

Honor a shader’s output alpha so transparent pixels reveal whatever sits below the background — e.g. a wlr-layer-shell Background surface from an external wallpaper engine. Forces the shader onto the live path, so cache_shader has no effect while this is on. Image backgrounds ignore it: tile/wallpaper PNGs with an alpha channel become see-through automatically. See docs/shaders.md.

cache_budget_mb

Default: 128

Memory ceiling (MB) shared by cache_shader and gigapixel-TIFF wallpapers, with LRU eviction. Raise it for sharper revisits on large / HiDPI displays; lower it on memory-constrained machines (too low just keeps the background blurrier).

animate_fps

Default: 0

Frame-rate cap for animated (u_time) shader backgrounds. 0 = every output frame (default). Slow-moving shaders look identical well below the refresh rate; between ticks the compositor reuses the composited result instead of re-evaluating the shader, so this directly scales the background’s GPU cost.

[bindings]

disable_defaults

Default: []

Opt out of built-in default bindings by category, for a clean slate. Normally your [keybindings]/[mouse]/[gestures] entries merge with the built-ins (use = "none" to drop a single default). Listing a category here removes ALL of that category’s defaults, leaving only your own entries. Categories: “keys”, “mouse”, “gestures”.

Example: bring your own keyboard scheme, keep mouse + gesture defaults

disable_defaults = ["keys"]

[keybindings]

Keyboard bindings: “Modifier+…+Keysym” = “action [arg]” Merges with defaults. Use “none” to unbind a default binding. “mod” expands to mod_key. Literal modifiers: alt, super, ctrl, shift. Keysyms are XKB names (case-insensitive): return, tab, up, a, equal, etc. A bare modifier combo (e.g. “alt+shift”) is a tap binding (fires on chord release; see [input.keyboard] options).

Actions:

  • exec <cmd> — launch an app (shows loading cursor until window appears, exits fullscreen)
  • exec-terminal — launch the auto-detected terminal (see [keybindings] below; override with $TERMINAL)
  • exec-launcher — launch the auto-detected app launcher (see [keybindings] below; override with $LAUNCHER)
  • spawn <cmd> — run a command without loading cursor and exiting fullscreen (toggles, OSD, screenshots)
  • close-window — close the focused window
  • nudge-window <dir> — move focused window by nudge_step px
  • pan-viewport <dir> — pan camera by pan_step px
  • center-window — center viewport on focused window + reset zoom
  • focus-center — focus + center on the window under the pointer + reset zoom
  • center-nearest <dir> — navigate to nearest window in direction
  • cycle-windows forward — Alt-Tab style window cycling
  • cycle-windows backward — reverse cycle
  • home-toggle — toggle between current position and origin
  • zoom-in — step zoom in
  • zoom-out — step zoom out
  • zoom-reset — zoom to 1.0
  • go-to <x> <y> — jump camera to canvas position (bookmarks, Y-up)
  • zoom-to-fit — fit all windows in viewport
  • zoom-to-fit-snapped — fit only the focused window’s snap cluster
  • toggle-fullscreen — toggle focused window fullscreen
  • fit-window — toggle maximize: centers + resets zoom + fills viewport; restore only resizes back
  • fit-window-snapped — fit-window for the focused window’s whole snap cluster
  • fill-window — grow in place to fill free space; edges outside the usable area or overlapping another window pull back to a gap; press again to restore
  • toggle-pin-to-screen — pin/unpin the focused window to the screen (ignores pan/zoom, floats above)
  • reload-config — hot-reload config file
  • toggle-cursor-pan — toggle cursor edge-pan (see [navigation.edge_pan])
  • quit — exit the compositor
  • send-to-output <dir> — move focused window to adjacent output
  • send-cursor-to-output <dir> — move the cursor to adjacent output
  • switch-layout <target> — cycle keyboard layout: next, prev (or previous), or a 0-based index
  • none — unbind this key combo

Directions: up, down, left, right, up-left, up-right, down-left, down-right

BindingActionNotes
"mod+return"exec-terminal$TERMINAL, else first of: foot, alacritty, ptyxis, kitty, wezterm, gnome-terminal, konsole
"mod+d"exec-launcher$LAUNCHER, else first of: fuzzel, wofi, rofi, bemenu-run, wmenu-run, tofi-drun, mew-run
"mod+q"close-window
"mod+e"toggle-cursor-pantoggle cursor edge-pan (see [navigation.edge_pan])
"mod+f"toggle-fullscreen
"mod+m"fit-window
"mod+shift+m"fit-window-snapped
"mod+t"toggle-pin-to-screen
"mod+c"center-window
"mod+x"focus-center
"mod+a"home-toggle
"mod+up"center-nearest up
"mod+down"center-nearest down
"mod+left"center-nearest left
"mod+right"center-nearest right
"mod+shift+up"nudge-window up
"mod+shift+down"nudge-window down
"mod+shift+left"nudge-window left
"mod+shift+right"nudge-window right
"mod+ctrl+up"pan-viewport up
"mod+ctrl+down"pan-viewport down
"mod+ctrl+left"pan-viewport left
"mod+ctrl+right"pan-viewport right
"alt+tab"cycle-windows forward
"alt+shift+tab"cycle-windows backward
"mod+equal"zoom-in
"mod+minus"zoom-out
"mod+0"zoom-reset
"mod+z"zoom-reset
"mod+w"zoom-to-fit
"mod+shift+w"zoom-to-fit-snapped
"mod+1"go-to -1750 1750top-left bookmark
"mod+2"go-to 1750 1750top-right bookmark
"mod+3"go-to 1750 -1750bottom-right bookmark
"mod+4"go-to -1750 -1750bottom-left bookmark
"mod+alt+up"send-to-output upmove window to output above
"mod+alt+down"send-to-output down
"mod+alt+left"send-to-output left
"mod+alt+right"send-to-output right
"mod+l"spawn swaylock -f -c 000000 -kl
"mod+ctrl+shift+q"quit
"XF86AudioRaiseVolume"spawn wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
"XF86AudioLowerVolume"spawn wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
"XF86AudioMute"spawn wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
"XF86MonBrightnessUp"spawn brightnessctl set +5%
"XF86MonBrightnessDown"spawn brightnessctl set 5%-
"XF86AudioPlay"spawn playerctl play-pause
"XF86AudioPause"spawn playerctl play-pause
"XF86AudioNext"spawn playerctl next
"XF86AudioPrev"spawn playerctl previous
"XF86AudioStop"spawn playerctl stop
"Print"spawn grim - | wl-copy
"shift+Print"spawn grim -g \"$(slurp -d)\" - | wl-copy

Example: built-in window capture (isolated window + shadow) to clipboard

"ctrl+Print" = "spawn driftwm msg screenshot window -o - | wl-copy"

Example: tap binding — bare modifier chord, fires on release with no key on top

"alt+shift" = "switch-layout next"

Example: fill-window (unbound by default)

"mod+g" = "fill-window"

[mouse]

resize_on_border

Default: true

When true (default), dragging a window’s edge or corner resizes it via the invisible resize border (SSD frame or CSD margin). Set false to make that border inert — resize only through explicit bindings (e.g. alt+right) or gestures.

decoration_resize_snapped

Default: false

When true, resizing a window by dragging its edge (SSD or CSD border) propagates to every window connected to it via snap adjacency. Keybinding/gesture resize is unaffected — bind resize-window-snapped explicitly if you want cluster-aware resize there too.

decoration_fit_snapped

Default: false

When true, maximize/unmaximize initiated via window decoration (CSD maximize button, SSD title-bar double-click, or xdg/foreign-toplevel set_maximized) propagates to every window connected via snap adjacency. Keybinding/gesture fit is unaffected — bind fit-window-snapped explicitly if you want cluster-aware fit there too.

Mouse bindings: “Modifier+…+Trigger” = “action” Context-aware: on-window, on-canvas, anywhere. Specific context checked first, then “anywhere” as fallback. Click-to-focus and SSD decoration clicks are always hardcoded. Triggers: left, right, middle (buttons), trackpad-scroll, wheel-scroll, wheel-up, wheel-down. The wheel-up/wheel-down triggers fire once per discrete wheel notch and can run any action, e.g. volume on mod+shift+scroll. Merges with defaults. Use “none” to unbind.

Mouse actions:

  • move-window — drag the focused window
  • move-snapped-windows — drag the window plus every window connected via snap adjacency
  • resize-window — drag-resize the focused window
  • resize-window-snapped — drag-resize, propagating to the snap cluster
  • pan-viewport — drag or scroll to pan the camera
  • zoom — drag or scroll to zoom
  • center-nearest — navigate toward the drag direction

Any action from the [keybindings] Actions list also works on button and wheel-up/wheel-down triggers: exec <cmd>, close-window, toggle-fullscreen, etc.

[mouse.on-window]

BindingActionNotes
"alt+left"move-window
"alt+shift+left"move-snapped-windows
"alt+right"resize-window
"alt+shift+right"resize-window-snapped
"alt+middle"fit-window
"alt+shift+middle"fit-window-snapped
"mod+middle"toggle-fullscreen

[mouse.on-canvas]

BindingActionNotes
"left"pan-viewportunmodified left-click on empty canvas → pan
"trackpad-scroll"pan-viewporttrackpad scroll on empty canvas → pan
"wheel-scroll"zoommouse wheel on empty canvas → zoom

[mouse.anywhere]

BindingActionNotes
"mod+left"pan-viewport
"mod+ctrl+left"center-nearestdirection from drag delta
"mod+trackpad-scroll"pan-viewport
"mod+wheel-scroll"zoom

[gestures]

swipe_threshold

Default: 12.0

Gesture thresholds — tune for your touchpad size.

px cumulative distance before directional swipe fires

pinch_in_threshold

Default: 0.85

scale below which pinch-in fires (1.0 = no pinch)

pinch_out_threshold

Default: 1.15

scale above which pinch-out fires (1.0 = no pinch)

Gesture bindings: "Modifier+N-finger-<type>" = "action" Context-aware: on-window, on-canvas, anywhere. Unbound gestures are forwarded to the focused app. “none” unbinds (prevents anywhere fallback, still forwards).

Gesture types (2–5 fingers):

  • N-finger-swipe — continuous OR threshold (action determines behavior)
  • N-finger-swipe-up/down/left/right — threshold only, checked before swipe fallback
  • 3-finger-doubletap-swipe — continuous OR threshold (3-finger tap then swipe)
  • N-finger-pinch — continuous only (use pinch-in/out for discrete)
  • N-finger-pinch-in/out — threshold only
  • N-finger-hold — threshold only (fires on release)

Continuous actions: pan-viewport, zoom, move-window, move-snapped-windows, resize-window, resize-window-snapped Threshold actions: any action from the [keybindings] Actions list except nudge-window, go-to, cycle-windows, and pan-viewport <dir> — e.g. center-nearest, home-toggle, zoom-to-fit, zoom-to-fit-snapped, fit-window, fill-window, exec <cmd>. center-nearest works on swipe triggers only (direction comes from the swipe).

[gestures.on-window]

BindingActionNotes
"alt+3-finger-swipe"resize-window
"alt+shift+3-finger-swipe"resize-window-snapped
"3-finger-doubletap-swipe"move-window
"alt+2-finger-pinch-in"fit-window
"alt+2-finger-pinch-out"fit-window
"alt+shift+2-finger-pinch-in"fit-window-snapped
"alt+shift+2-finger-pinch-out"fit-window-snapped
"alt+3-finger-pinch-in"toggle-fullscreen
"alt+3-finger-pinch-out"toggle-fullscreen

[gestures.on-canvas]

BindingActionNotes
"2-finger-pinch"zoom

[gestures.anywhere]

BindingActionNotes
"3-finger-swipe"pan-viewportcontinuous (per-frame dx/dy)
"4-finger-swipe"center-nearestthreshold (accumulate, detect direction, fire once)
"mod+3-finger-swipe"center-nearestmod makes 3-finger swipe navigate too

Example: per-direction overrides (threshold only, checked before swipe fallback)

"4-finger-swipe-up" = "exec brightnessctl set +5%"
"4-finger-swipe-down" = "exec brightnessctl set 5%-"
BindingActionNotes
"mod+2-finger-pinch"zoommod overrides app forwarding
"3-finger-pinch"zoomcontinuous
"4-finger-pinch-in"zoom-to-fitthreshold
"mod+4-finger-pinch-in"zoom-to-fit-snapped
"4-finger-pinch-out"home-togglethreshold
"mod+3-finger-pinch-in"zoom-to-fit
"mod+3-finger-pinch-out"home-toggle
"4-finger-hold"center-windowfires on release
"mod+3-finger-hold"center-window

[touch]

Touchscreen gesture BINDINGS. Distinct from [input.touch], which holds the touch device settings (enable, map_to_output) — put those there, not here (same split as [gestures] bindings vs [input.trackpad] device settings).

Bindings: "N-finger-<type>" = "action" (touch has no keyboard modifiers) Context-aware: on-window, on-canvas, anywhere. Unbound gestures are forwarded to the focused app. “none” removes a binding in its context (under [touch.anywhere] it also drops the anywhere fallback). A fully unbound gesture forwards to the app.

Touch gesture types (1–5 fingers):

  • N-finger-swipe — continuous OR threshold (action determines behavior)
  • N-finger-swipe-up/down/left/right — threshold only, checked before swipe fallback
  • N-finger-pinch — continuous only (use pinch-in/out for discrete)
  • N-finger-pinch-in/out — threshold only
  • N-finger-tap — threshold only (quick touch, no movement)
  • N-finger-doubletap — threshold only (two quick taps)
  • N-finger-doubletap-swipe — continuous only (tap then drag)
  • N-finger-hold-swipe — continuous only (dwell then drag)

Continuous actions: pan-viewport (swipe), zoom (pinch), and the window grabs — move-window / move-snapped-windows / resize-window / resize-window-snapped (doubletap-swipe / hold-swipe). A held move-window also extends to the snap-cluster. Threshold actions: any action from the [keybindings] Actions list except nudge-window, go-to, cycle-windows, and pan-viewport <dir> — e.g. center-nearest, center-window, home-toggle, zoom-to-fit, fit-window, fill-window, exec <cmd>. center-nearest works on swipe triggers only (direction comes from the swipe).

Note: within one physical gesture, a continuous translation (pan) and a threshold pinch on the same finger count don’t combine — either bind both axes continuous (pan + zoom) or drive discrete actions from a threshold swipe/pinch.

[touch.on-window]

1–2 fingers starting on a window forward to the app, and the 3+ finger pan/zoom/navigate gestures are bound “anywhere” so they apply over windows too. The window-targeted gestures live here: they act on the window the fingers land on (a gesture starting on empty canvas just pans).

BindingActionNotes
"3-finger-doubletap"fit-window
"3-finger-doubletap-swipe"move-windowcontinuous (hold to move the cluster)
"3-finger-hold-swipe"resize-windowcontinuous (dwell then drag)

[touch.on-canvas]

BindingActionNotes
"1-finger-swipe"pan-viewport
"2-finger-swipe"pan-viewport
"2-finger-pinch"zoom

[touch.anywhere]

5-finger navigation mirrors 4-finger by default, so a stray 5th contact doesn’t abort a navigation gesture.

BindingActionNotes
"3-finger-swipe"pan-viewportcontinuous
"3-finger-pinch"zoomcontinuous
"4-finger-swipe"center-nearestthreshold (direction from drag)
"4-finger-pinch-in"zoom-to-fitthreshold
"4-finger-pinch-out"home-togglethreshold
"3-finger-tap"center-window

[xwayland]

enabled

Default: true

X11 support via xwayland-satellite. driftwm spawns satellite eagerly at startup, exports DISPLAY=:N, and X11 apps connect transparently. If the binary isn’t found, X11 support is disabled with a warning; everything else still runs.

default: true

path

Default: "xwayland-satellite"

path to xwayland-satellite binary ($PATH lookup works)

[backend]

wait_for_frame_completion

Default: false

Hardware stability quirks. All default to false (opt-in). Enable these if you experience flickering, crashes, or rendering issues. Particularly useful on NVIDIA GPUs with proprietary drivers. Note: These flags must be set before launching driftwm. Changing them requires a restart. For additional NVIDIA-specific settings, set these environment variables in your session wrapper script or shell profile before starting driftwm:

export SMITHAY_USE_LEGACY=1          # Use legacy DRM API instead of atomic modesetting
export __GL_GSYNC_ALLOWED=0
export __GL_VRR_ALLOWED=0
export __GL_MaxFramesAllowed=1
export NVD_BACKEND=direct

Force GPU-fence wait before every page flip (already done automatically when smithay reports needs_sync — typical case on NVIDIA. Set true only if you still see flicker after defaults.)

disable_direct_scanout

Default: false

Force EGL composition (disable direct scanout)

disable_hardware_cursor

Default: false

Composite the cursor into the frame instead of using the KMS cursor plane, while keeping direct scanout for fullscreen apps. Fixes a stuttering / tearing hardware cursor on discrete NVIDIA GPUs.

max_capture_fps

Default: 0

Cap FPS delivered to continuous screen-capture clients (recorders / casts). 0 = unlimited. Each captured frame forces a full-scene re-composite that competes with a fullscreen app behind it; a cap (e.g. 30 or 60) trims that overhead while gaming + streaming. One-shot screenshots (grim) are never throttled.

[output.outline]

color

Default: "#ffffff"

outline color for other monitors’ viewports

thickness

Default: 1

pixels (0 to disable)

opacity

Default: 0.5

0.0–1.0

Outputs

Per-output configuration. Each [[outputs]] entry matches by connector name. Find connector names with wlr-randr or check driftwm logs at startup. Outputs without a matching entry default to scale 1.0. Winit backend ignores [[outputs]] entries.

name = "*" is a wildcard entry: it applies to any connected output that has no exact-name entry (exact entries always win). A fixed position makes no sense on the wildcard — it’s ignored (falls back to “auto”).

mode accepts “preferred”, “max”, “WxH”, or “WxH@Hz”. “preferred” (the default, and the safe choice) uses the monitor’s advertised preferred mode. “max” picks the highest resolution, then highest refresh. A bare “WxH” only selects a mode the monitor already advertises — if none matches, it keeps the preferred mode (logged as a warning, not an error). “WxH@Hz” forces that exact mode, synthesizing a CVT modeline when the monitor doesn’t advertise it (intended for CRTs or forcing non-standard modes; may be rejected by some panels).

Supported fields:

  • name — connector name, or “*” for the fallback entry (see above); required.
  • scale — fractional scale factor (default: 1.0).
  • transform — normal, 90, 180, 270, flipped, flipped-90, flipped-180, or flipped-270 (default: normal).
  • position — “auto” (left-to-right placement) or [x, y] in layout coords.
  • mode — “preferred”, “max”, “WxH”, or “WxH@Hz” (see above; default: preferred).

Example:

[[outputs]]
name = "eDP-1"           # connector name, or "*" for a fallback entry (required)
scale = 1.5              # fractional scale (default: 1.0)
transform = "normal"     # normal, 90, 180, 270, flipped, flipped-90, flipped-180, flipped-270
position = "auto"        # "auto" (left-to-right) or [x, y] in layout coords
mode = "preferred"       # "preferred", "max", "1920x1080", or "2560x1440@144"

[[outputs]]
name = "HDMI-A-1"
scale = 1.0
mode = "1920x1080@60"

Window rules

Window rules: match windows and apply per-window overrides. ALL matching rules are merged in config order (later rules override earlier ones for scalar fields; boolean flags are sticky-on). This lets you compose rules — e.g. one rule sets blur=true, a later one adds opacity=0.85.

This section is the field reference. The full recipe collection, along with matching/merge semantics and pattern-syntax details, lives in docs/window-rules.md.

Supported fields:

  • app_id — match: Wayland app_id. X11 apps proxied via xwayland-satellite arrive with app_id set from WM_CLASS instance (typically lowercase). At least one of app_id/title is required; all specified criteria must match.
  • title — match: window title.
  • position — [x, y] coordinates (window center, Y-up). Canvas coords, or output-relative (origin = output center) when pinned_to_screen.
  • size — [width, height] initial window dimensions (one-shot; user/app can resize afterwards, so pair with widget = true to lock it)
  • fullscreen — true: force this window to open in fullscreen mode
  • widget — true: pinned (immovable), below normal windows, excluded from navigation and alt-tab (default: false)
  • pinned_to_screen — true: lock the window to the output’s screen space — ignores pan/zoom, floats above normal windows (PiP, toolbars). position becomes output-relative; movable unless widget = true. Toggle live with toggle-pin-to-screen (Mod+T). (default: false)
  • decoration — overrides [decorations] default_mode for matched windows. Omit to inherit default_mode. Values:
    • “client”: CSD — client’s own titlebar
    • “server”: SSD — driftwm’s titlebar
    • “minimal”: SSD — no titlebar, just shadow + corners + border (this is the mode for chrome-on-borderless widgets; border_width / corner_radius / shadow rules apply)
    • “none”: bare client surface — compositor adds zero chrome, and per-window border_width / corner_radius / shadow rules are ignored. Use “minimal” if you want chrome without a titlebar.
  • blur — true: blur background behind this window (default: false). Real GPU/VRAM cost that does NOT scale down with zoom (a blurred window is processed at full resolution however far you zoom out), so prefer blur on a handful of windows over globally. Results are cached and only recomputed when the content behind the window changes.
  • opacity — 0.0–1.0: window transparency (default: 1.0, fully opaque)
  • border_width — per-window border width override (px). Set to 0 to disable border on a window even when [decorations] border_width > 0. Ignored for decoration = “none”.
  • border_color — per-window unfocused border color, “#rrggbb” or “#rrggbbaa” (optional alpha byte), e.g. “#5c5c5c”.
  • border_color_focused — per-window focused border color; same “#rrggbb[aa]” form, with an optional alpha byte.
  • corner_radius — per-window corner radius override (px). Affects content clip, border shape, and shadow. Ignored for decoration = “none”.
  • shadow — per-window shadow toggle. Overrides [decorations] shadow. Ignored for decoration = “none”.
  • output — output name (e.g. “DP-1”) for this window’s fullscreen and initial screen-pin placement. Fullscreen: the rule wins; otherwise the output the client requested; otherwise the active output. pinned_to_screen: the rule; otherwise the
    • active output — with position resolved against it; dragging or send-to-output reassigns it afterward. Find names under outputs.* in driftwm msg state. (default: unset)
  • pass_keys — controls which compositor keybindings are forwarded to the app:
    • pass_keys = true — forward ALL keys (game-friendly)
    • pass_keys = [“mod+q”, “ctrl+q”] — forward ONLY these combos; all other compositor shortcuts stay active
    • pass_keys = false / omit — compositor handles everything (default)
    • VT switching (Ctrl+Alt+F1–F12) — always stays in the compositor
  • layer_order — stacking among layer surfaces sharing the same wlr-layer (higher = on top; ties stack by map order, newest on top). The protocol has no z-index within a layer, so two overlay clients (e.g. an on-screen keyboard and a touch visualizer) otherwise stack by launch order. Also orders canvas-positioned layers among themselves. Ignored for regular windows.

Pattern syntax (applies to all match fields):

  • Plain string "kitty" — exact match
  • Glob "steam_app_*"* wildcard
  • Regex "/^steam_app_\\d+$/" — wrap in /…/

To find a window’s identifiers, run while the window is open:

`driftwm msg state`

Layer-shell surfaces (panels, notifications, bars like waybar): matched by their namespace against app_id. decoration is ignored — layers have no decoration mode. Chrome (border_width, corner_radius, shadow) is field-by-field opt-in on the rule and does NOT inherit from [decorations]. Without explicit values on the rule, a layer surface has no border, no shadow, and no corner clip.

A few representative rules follow; docs/window-rules.md collects the rest.

Example: Picture-in-Picture, pinned to the screen, stays put while you pan/zoom

[[window_rules]]
title            = "Picture-in-Picture"
pinned_to_screen = true
position         = [0, -300]   # output-relative: 300px below center; movable, drop to center on the output

Example: Game, only let ctrl+q through, keep everything else (mod+q still closes)

[[window_rules]]
app_id    = "factorio"
pass_keys = ["ctrl+q"]

Example: Compose rules, blur from first rule, opacity from second (both apply)

[[window_rules]]
app_id = "Alacritty"
blur   = true

[[window_rules]]
app_id  = "Alacritty"
opacity = 0.9

Window Rules

Window rules let you apply per-window overrides based on a window’s identity. Rules are declared as [[window_rules]] sections in your config file.

Most rule effects — position, size, opacity, decoration, borders, widget, pinned, output, … — are resolved once, when a window maps: reloading your config only affects windows opened afterwards, and a window that changes its title after mapping is not re-checked against title rules. Two things re-resolve live against the current config instead: pass_keys is evaluated per keypress (so a config reload — and a title change — takes effect immediately), and layer-surface chrome is evaluated per frame (so a config reload takes effect immediately).

How matching works

All matching rules are applied, not just the first one. Rules are processed in config order and merged together:

  • Scalar fields (decoration, opacity, position, size, border_width, border_color, border_color_focused, corner_radius, shadow): last-wins — a later rule overrides an earlier one.
  • Boolean flags (widget, blur): sticky-on — once set by any matching rule, the flag stays set regardless of later rules.
  • pass_keys: All is sticky-on; Only lists are unioned across rules (see pass_keys details).

This lets you compose independent rules for the same window:

# Rule 1: make kitty blur its background
[[window_rules]]
app_id = "kitty"
blur   = true

# Rule 2: also make it semi-transparent (blur from Rule 1 is kept)
[[window_rules]]
app_id  = "kitty"
opacity = 0.85

Match criteria

At least one criterion is required. All specified criteria must match.

FieldMatches
app_idWayland app_id (X11 apps via xwayland-satellite arrive with app_id set from WM_CLASS instance, typically lowercase)
titleWindow title

Finding a window’s identifiers

driftwm msg state   # camera, zoom, and the window inventory

To get the app ids and titles of all current non-widget windows:

driftwm msg --json state | \
jq '.Ok.State.windows[] | select(.is_widget == false) | {app_id, title}'

Pattern syntax

All match fields support three syntaxes:

SyntaxExampleMeaning
Exact string"kitty"Exact match (case-sensitive)
Glob"steam_app_*"* matches any sequence of chars
Regex"/^steam_app_\\d+$/"Full regular expression (wrap in /…/)

Multiple * wildcards are allowed in glob patterns: "*terminal*".

Regex patterns use the regex crate (RE2-compatible, no backreferences).

Field reference

Every rule field — its type, default, accepted values, and per-field caveats (which fields decoration = "none" ignores, the blur GPU/VRAM cost, the one-shot size, how layer-shell surfaces opt into chrome) — is documented in the generated config reference, whose canonical source is config.reference.toml. This page is the recipe and semantics guide; the reference is the field dictionary.

Layer-shell surfaces interpret chrome fields differently — see Layer-shell surfaces below.

Screen-pinned windows

pinned_to_screen = true lifts a window out of the infinite canvas and fixes it to one output’s screen space: it does not pan or zoom with the camera, and it renders above normal windows (but below panels / Top & Overlay layer-shell surfaces). Use it for Picture-in-Picture, video-call toolbars, or any always-on floating overlay.

  • Coordinates are output-relative. When pinned, position is measured from the output center (still center-anchored and Y-up): [0, 0] centers the window on the monitor, +Y is up. Drop position to center it.
  • Movable and resizable like a normal window — drag the title bar (or Mod-drag) to move, drag a border to resize. Dragging across monitors reassigns it to that output. Combine with widget = true to make it immovable.
  • Fullscreen round-trips. A fullscreen request (or Mod+F) temporarily unpins the window to fill the screen; exiting fullscreen re-pins it in place. Any canvas pan/zoom exits fullscreen, just like a normal window.
  • Off the canvas. Pinned windows are excluded from navigation, alt-tab, snapping, fit/center actions, and canvas screenshots (driftwm msg screenshot). They remain focusable and closable; SSD windows show a small dot in the title bar.
  • Toggle at runtime with the toggle-pin-to-screen action (bound to Mod+T by default), which pins/unpins the focused window in place.

Finding a pinned window’s position and size

driftwm msg state already reports a pinned window’s position/size in rule coordinates, so the flow is: pin the window live, place it, and copy the numbers straight into a rule:

  1. Open the window (e.g. start Picture-in-Picture), click it to focus, and press Mod+T to pin it. It’s now in screen space — drag it anywhere with the mouse and resize to taste.
  2. Run driftwm msg state and read the pinned section: each entry lists its output, app_id, title, position, and size. Those position/size values are already output-relative rule coordinates.
  3. Write the rule with those position/size values plus pinned_to_screen = true (and decoration = "none" for a chrome-free PiP surface).
[[window_rules]]
title            = "Picture-in-Picture"
pinned_to_screen = true
position         = [540, -350]
size             = [570, 320]
decoration       = "none"

Pinned windows stay absent from the canvas windows= inventory and from canvas screenshots (driftwm msg screenshot) — like layer-shell panels, they live in screen space, not on the canvas. They appear in their own per-output pinned section of driftwm msg state instead, which is where the copy-ready numbers come from.

Output selection

On a multi-monitor setup, output names a monitor by its output name (e.g. "DP-1" — find names under outputs.* in driftwm msg state). It governs two placements:

[[window_rules]]
app_id = "steam_app_*"
output = "DP-1"
  • Fullscreen — which monitor a window fullscreens onto. Precedence: the rule’s output wins; otherwise the output the client itself requested; otherwise the active output (where the pointer is).
  • Screen-pinned — which monitor a pinned_to_screen window initially pins to. Precedence: the rule’s output wins; otherwise the active output. The rule’s position is then resolved against that monitor. Afterwards, dragging the window across monitors — or send-to-output — reassigns it, so output only seeds the starting display.

An unknown or disconnected output name falls through to the next choice. output does not move a plain windowed (non-fullscreen, non-pinned) window.

Layer-shell surfaces

Layer-shell surfaces (panels, notifications, bars like waybar) have no decoration mode — the decoration field on a rule matching a layer surface is ignored.

Chrome on layers is field-by-field opt-in: set border_width, corner_radius, and/or shadow directly on the rule. Layers do not inherit [decorations] defaults for those three fields — without an explicit value on the rule, a layer surface has no border, no shadow, and no corner clipping. border_color_focused is also ignored on layers (the focused / unfocused distinction is window-only); layers always use border_color.

[[window_rules]]
app_id        = "waybar"
widget        = true
corner_radius = 10
shadow        = true
border_width  = 2

pass_keys details

pass_keys controls which compositor keybindings are forwarded to the focused window instead of being handled by the compositor:

ValueBehaviour
false (or omit)Compositor handles all keybindings normally (default)
trueAll keys forwarded — no compositor shortcuts fire while this window has focus
["mod+q", "ctrl+q"]Only the listed combos are forwarded; all other shortcuts stay active

VT switching (Ctrl+Alt+F1F12) always stays in the compositor regardless of pass_keys.

Key combo syntax is the same as in [keybindings]: mod+key, ctrl+shift+key, etc.

When multiple rules match the same window:

  • true is sticky-on: if any rule sets pass_keys = true, the result is true.
  • ["combo", …] lists are unioned across all matching rules.
  • true overrides a list: if one rule says true and another says ["mod+q"], the result is true.

Examples

Desktop widget (pinned clock/info panel)

[[window_rules]]
app_id     = "my-widget"
position   = [0, 0]
widget     = true
decoration = "none"

Pictures and text on the canvas (decals)

To pin arbitrary images to canvas spots — hand-drawn shortcut sheets, logos, region labels — render a transparent PNG/SVG as a borderless window with extras/scripts/driftwm-decal (deps: python-gobject + gtk4), then pin each one with a widget rule. The transparent parts show the dot grid (or your shader wallpaper) through; decals sit below normal windows and stay off alt-tab. Each invocation is one decal window, matched by --title:

autostart = [
    "driftwm-decal ~/decals/shortcuts.svg --title shortcuts",
    "driftwm-decal ~/decals/logo.png      --title logo",
]

[[window_rules]]
title      = "shortcuts"
widget     = true          # pin to canvas, below windows, off alt-tab
decoration = "none"
position   = [1200, -400]  # canvas coords, Y-up, image center
size       = [420, 130]

[[window_rules]]
title      = "logo"
widget     = true
decoration = "none"
position   = [-800, 600]
size       = [256, 256]

Transparent blurred terminal

[[window_rules]]
app_id  = "kitty"
opacity = 0.85
blur    = true

Game: pass all keys through (Wayland-native)

[[window_rules]]
app_id    = "steam_app_*"
pass_keys = true

Game: only let specific keys through

Keep mod+q and other compositor shortcuts active, but pass ctrl+q to the game:

[[window_rules]]
app_id    = "factorio"
pass_keys = ["ctrl+q", "ctrl+s"]

Match any Steam game by regex

[[window_rules]]
app_id    = "/^steam_app_\\d+$/"
pass_keys = true

Initial size and position for a floating panel

[[window_rules]]
app_id   = "myapp-panel"
size     = [400, 800]
position = [960, 0]
widget   = true

Composing rules (multi-rule merge)

# All three rules below apply to the same kitty window and are merged:

[[window_rules]]
app_id = "kitty"
blur   = true        # sticky-on: cannot be unset by later rules

[[window_rules]]
app_id  = "kitty"
opacity = 0.85       # blur from above is preserved

[[window_rules]]
title   = "*nvim*"   # title match narrows to nvim windows only
opacity = 1.0        # override opacity for nvim (blur still applies)

Widget with a custom border and shadow

decoration = "minimal" gives you a titlebar-less window that still participates in compositor chrome — borders, corner clipping, and shadow all apply. Use it when you want a widget that isn’t fully bare. decoration = "none" is the opposite: a bare client surface where the compositor adds (and ignores) all chrome overrides.

[[window_rules]]
app_id               = "my-clock"
widget               = true
decoration           = "minimal"
border_width         = 2
border_color         = "#5c5c5c"
border_color_focused = "#7aa2f7"
corner_radius        = 8
shadow               = true

Disable shadow on a specific app

[[window_rules]]
app_id = "firefox"
shadow = false

Suppress iced/libcosmic utility popups

Some apps (cosmic-term, etc.) open small utility windows that share the main app_id but have a generic title:

[[window_rules]]
title  = "winit window"
widget = true

On-screen keyboard above other overlays

Overlay layer-shell clients that share a wlr-layer (an on-screen keyboard, a touch visualizer) otherwise stack by launch order; a higher layer_order keeps this one on top (see Layer-shell surfaces):

[[window_rules]]
app_id      = "wvkbd"
layer_order = 10

Debugging

Enable debug logging to see which rules matched a window at map time:

RUST_LOG=debug driftwm 2>&1 | grep -i "window rule\|app_id"

IPC

driftwm exposes a small IPC over a Unix domain socket so external tools and scripts can query and control the running compositor. The driftwm msg subcommand is the built-in client; the wire protocol is plain line-delimited JSON, so any language can speak it directly.

driftwm msg

Run driftwm msg <command> from inside a driftwm session. Each command reads when given no arguments and writes when given arguments. Add --json to print the raw JSON reply instead of the human-readable form. A command that fails (bad value, no focused window, no match) prints an error to stderr and exits non-zero, so scripts can branch on it.

The commands — camera, zoom, focus, move, opacity, close, layout, action, screenshot, state, subscribe, and debug-counters — with their arguments, flags, and JSON reply shapes are documented in the generated CLI reference; driftwm msg <command> --help prints the same for one command. The conventions they share follow below.

Coordinates

Window and camera positions use the same convention as window rules and the state file: a center point, with Y pointing up. move 0 0 centers the focused window on the origin, camera 0 0 centers the viewport there, and positive y is above. Pinned and fullscreen windows live in screen space, not on the canvas, so move refuses to reposition them.

Screenshots

screenshot is a canvas capture, not a screen grab: it re-renders the canvas at an arbitrary resolution rather than copying the framebuffer, so it reaches off-screen content but omits panels/layer-shells and blur — use grim for a literal screen grab. Targets, flags, examples, and caveats live in the CLI reference (driftwm msg screenshot --help).

Subscribing to changes

subscribe turns the connection into a live feed instead of polling: one event per change (per rendered frame while something animates), each a whole-state snapshot. Nothing is pushed while nothing changes — render from the latest received snapshot rather than in lockstep with events. Mechanics are in the CLI reference; the wire-level event shape is under Events below.

A one-liner that prints the focused window’s app_id whenever anything changes:

driftwm msg --json subscribe | jq -r '.State.windows[0].app_id'

A small daemon that dims whatever loses focus and restores full opacity to whatever gains it (a snapshot arrives per rendered frame during a pan, so the focused id is deduped against the last one seen):

prev=
driftwm msg --json subscribe \
  | jq --unbuffered -r '.State.windows[] | select(.is_focused) | .id' \
  | while read -r id; do
      [ "$id" = "$prev" ] && continue                      # same focus, skip repeats
      [ -n "$prev" ] && driftwm msg opacity 0.7 --id "$prev" # dim the window we left
      driftwm msg opacity 1 --id "$id"                     # full opacity on the new one
      prev=$id
  done

Debug counters

debug-counters reports the sizes of the compositor’s internal per-window, per-surface, and per-client collections — a leak-diagnosis endpoint with unstable keys (internal field names that can change between releases). See the CLI reference.

Wire protocol

The socket path is $XDG_RUNTIME_DIR/driftwm/ipc-<WAYLAND_DISPLAY>.sock (permissions 0600). The name is derived from the compositor’s WAYLAND_DISPLAY, so each instance owns a distinct socket and a client launched inside a session automatically targets that session. Set DRIFTWM_SOCKET to point a client at an explicit path.

Warning

The IPC socket is a full control surface, not a read-only one: action can run exec/spawn (launch programs), quit, and reload-config. It’s safe only because the socket is 0600 (your user only) — anything that can open it could already run programs as you. So don’t loosen the permissions or bridge it over a network for “just reading state”: that hands arbitrary code execution to whoever reaches it.

The protocol is one JSON request per line, answered by one JSON reply per line. A single connection may carry several requests; the connection stays open until the client closes it.

A reply is {"Ok": <response>} on success or {"Err": "message"} on failure.

A window can be targeted by a selector: a JSON number is its stable id (from state), a JSON string is a case-insensitive app_id substring.

Note

The Move request and the Window screenshot target changed shape in this release: the old {"Move":[x,y]} tuple and bare "Window" string forms are gone (replaced by the object forms below). The Focused reply also grew from a bare app_id string to {"id":…,"app_id":…}.

Requests

RequestJSON to send
get / set camera{"Camera":null} / {"Camera":[500,300]}
get / set zoom{"Zoom":null} / {"Zoom":0.5}
get / set focus{"Focus":null} / {"Focus":"alacritty"} / {"Focus":5}
get / set move{"Move":{}} / {"Move":{"window":5,"to":[100,200]}} (both optional)
get / set opacity{"Opacity":{}} / {"Opacity":{"window":5,"value":0.5}} (both optional)
close{"Close":null} / {"Close":5} / {"Close":"alacritty"}
layout{"Layout":{"short":false}}
run action{"Action":"switch-layout next"}
screenshot{"Screenshot":{"target":"Viewport","scale":1.0,"path":"/abs/shot.png"}}
screenshot window{"Window":{}} / {"Window":{"window":5}} (as the target)
state"State"
subscribe"Subscribe"
debug counters"DebugCounters" (reply keys are unstable — see Debug counters)

Responses

{"Ok":{"Camera":{"x":500.0,"y":300.0}}}
{"Ok":{"Zoom":0.5}}
{"Ok":{"Layout":"English (US)"}}    // or "us" for {"Layout":{"short":true}}
{"Ok":{"Focused":{"id":5,"app_id":"alacritty"}}}   // or {"Ok":{"Focused":null}}
{"Ok":{"Position":{"x":100,"y":200}}}
{"Ok":{"Opacity":0.85}}
{"Ok":"Ok"}                          // action / close
{"Ok":{"DebugCounters":{"decorations":2,"stage_entries":2}}}   // abridged
{"Ok":{"State":{"camera":[-960.0,-600.0],"zoom":1.0,"layout":"English (US)",
  "layout_short":"us","windows":[
  {"id":3,"app_id":"foot","title":"~","position":[0,0],"size":[800,480],
   "is_focused":true,"is_widget":false}
]}}}
{"Err":"no focused window"}

The windows array is the same shape driftwm writes to its state file, focused window first. Each entry’s id is a stable per-session window handle — pass it back as a selector to focus, move, close, or screenshot window. The reply also carries layout (full XKB name) and layout_short (the configured code for the active group); fullscreen and pinned (screen-space windows, each carrying an id too — a pinned entry’s position/size are in rule coordinates, output-relative, so they paste straight into a pinned_to_screen rule); layers (namespaces of screen-space layer-shell surfaces); canvas_layers (canvas-positioned layers with rule-coordinate position and size); and outputs (per-output name, viewport camera (center, Y-up), zoom, logical size, and active flag).

Events

A subscribe connection doesn’t get Ok/Err replies after the initial ack; it gets one-way event lines:

{"State":{"camera":[-960.0,-600.0],"zoom":1.0,"layout":"English (US)","layout_short":"us","windows":[...],"outputs":[...]}}

The State payload is identical to the state reply’s, so anything that reads one reads the other.

Talking to the socket directly

SOCK="$XDG_RUNTIME_DIR/driftwm/ipc-$WAYLAND_DISPLAY.sock"

echo '"State"'            | socat -t1 - UNIX-CONNECT:"$SOCK"
echo '{"Camera":[500,300]}' | socat -t1 - UNIX-CONNECT:"$SOCK"

State file

For read-only polling (status bars, scripts), driftwm also writes a throttled (~10 Hz) snapshot to $XDG_RUNTIME_DIR/driftwm/statekey=value lines plus a windows= JSON array using the same window shape as state. Reading that file avoids a socket round-trip when you only need to observe; when you’d rather be pushed than poll, use subscribe instead.

Layer-shell clients appear too: layers= lists the namespaces of screen-space layer surfaces (bars, OSKs, overlays — useful for finding the app_id a window rule should match), and canvas_layers= is a JSON array of canvas-positioned layers with their namespace, rule-coordinate position, and size (the position reflects the current size, so it can drift from the placing rule if the surface resized after mapping).

Limitations

  • subscribe events are whole-state snapshots, not granular event types (window-opened, focus-changed, …) — diff consecutive snapshots if you need the delta.

CLI reference

driftwm’s command-line interface: the root command that starts the compositor, and every driftwm msg subcommand for controlling a running one. For the raw JSON wire protocol behind msg, see ipc.md.

driftwm

driftwm [OPTIONS] [COMMAND]

A trackpad-first infinite canvas Wayland compositor.

With no subcommand, starts the compositor, auto-detecting the backend (udev on a TTY, winit when nested). The msg subcommand instead talks to an already-running instance over its IPC socket; see docs/ipc.md for the raw wire protocol.

  • --backend <udev|winit> — Backend to use [default: udev on a TTY, winit if nested]
  • --config <PATH> — Use an alternate config file
  • --check-config — Validate the config and exit

driftwm msg

driftwm msg [OPTIONS] <COMMAND>

Send a command to the running compositor over its IPC socket.

Auto-targets the instance named by WAYLAND_DISPLAY (override with DRIFTWM_SOCKET). A subcommand with no arguments reads state; with arguments it writes. Add --json for the raw JSON reply. A command that fails (bad value, no match, no focused window) prints an error to stderr and exits non-zero, so scripts can branch on it.

  • --json — Print the raw JSON reply

driftwm msg camera

driftwm msg camera [X] [Y]

Get the camera position, or set it (animated) with <x> <y> (viewport center, Y-up).

With no arguments, prints the current camera position. With <x> <y>, pans the viewport (animated) to center that canvas point; positive y is up.

Reply: {"Ok":{"Camera":{"x":500.0,"y":300.0}}}.

driftwm msg zoom

driftwm msg zoom [LEVEL]

Get the zoom level, or set it with <level> (clamped to the supported range).

Setting is animated and clamped to the supported range (out to fit-all, in to native resolution — no magnification).

Reply: {"Ok":{"Zoom":0.5}}.

driftwm msg layout

driftwm msg layout [OPTIONS]

Print the active keyboard layout (full XKB name, e.g. English (US)).

With --short, prints the configured layout code for the active group (e.g. us, ru) instead — what most status bars want.

Reply: {"Ok":{"Layout":"English (US)"}} (or "us" with --short).

  • --short — Print the configured layout code instead (e.g. us, ru)

driftwm msg state

driftwm msg state

Dump camera, zoom, and the window inventory.

Prints camera, zoom, keyboard layout, and every window — each with a stable id usable as a selector for focus/move/close/screenshot window — plus fullscreen, pinned, layer-shell, and per-output details.

Reply: {"Ok":{"State":{"camera":[..],"zoom":1.0,"windows":[..],"outputs":[..]}}}.

driftwm msg debug-counters

driftwm msg debug-counters

Print internal collection sizes for leak diagnosis (unstable keys).

An introspection endpoint, not a stable interface: the keys are internal field names that can change between releases. Meant for leak diagnosis — a window/surface/client-keyed count should return to its idle baseline once the windows and clients that raised it are gone (output-keyed counters follow output lifetimes instead and can persist across hotplug).

Reply: {"Ok":{"DebugCounters":{"decorations":2,"stage_entries":2}}}.

driftwm msg subscribe

driftwm msg subscribe

Stream state snapshots as they change (one JSON line per event with –json).

Turns the connection into a live feed: the server acks, then pushes one event with the current state immediately and again on every change — including camera/zoom, the window list, focus, window titles, keyboard layout, a per-output viewport, the screen-space inventory (pinned and fullscreen windows — dragging a pinned window pushes events), and layer/canvas-layer changes. While something animates an event is pushed per rendered frame (not throttled like the state file), so a pan or drag streams at the compositor’s frame rate. Runs until interrupted.

Each event is {"State":{..}} — the whole snapshot, same shape as the state reply, and not wrapped in Ok/Err. A slow subscriber never blocks the compositor: it drops snapshots and catches up in full on the next change.

driftwm msg focus

driftwm msg focus [OPTIONS] [APP_ID]

Print the focused window, or focus a window by app_id substring or --id (the stable id shown in state).

With no argument, prints the focused window’s id and app_id. Given an app_id substring (case-insensitive) or --id <n>, focuses that window, navigating to it only if it is off-screen.

Reply: {"Ok":{"Focused":{"id":5,"app_id":"alacritty"}}} (or {"Ok":{"Focused":null}}).

  • --id <ID> — Focus the window with this stable id (from state)

driftwm msg move

driftwm msg move [OPTIONS] [X] [Y]

Get a window’s position, or move it (center, Y-up) with <x> <y>. Targets the focused window, or --id (the stable id shown in state).

Positions are a center point with y pointing up. Pinned and fullscreen windows live in screen space, not on the canvas, so move refuses to reposition them.

Reply: {"Ok":{"Position":{"x":100,"y":200}}}.

  • --id <ID> — Target the window with this stable id (from state)

driftwm msg opacity

driftwm msg opacity [OPTIONS] [VALUE]

Get a window’s opacity, or set it with <value> (0.0–1.0). Targets the focused window, or --id (the stable id shown in state).

0.0 is transparent, 1.0 opaque. Applies to any rendered window, pinned and fullscreen included; the change takes effect next frame. The value is runtime-only — seeded from an opacity window rule at map time, held for the session, and never persisted (it resets when the window or compositor restarts). Values outside 0.01.0 are rejected, not clamped; a window no rule touched reads 1.

Reply: {"Ok":{"Opacity":0.85}}.

  • --id <ID> — Target the window with this stable id (from state)

driftwm msg close

driftwm msg close [OPTIONS] [APP_ID]

Close the focused window, or a window by app_id substring or --id.

Targets the focused window by default, or a window by app_id substring (case-insensitive) or --id <n> (from state). Errors when nothing matches.

Reply: {"Ok":"Ok"}.

  • --id <ID> — Close the window with this stable id (from state)

driftwm msg action

driftwm msg action <SPEC>...

Run a config action, e.g. action close-window, action quit, action switch-layout next.

Runs any compositor action by the same string you would write in a config keybinding, parsed with the exact config parser, so every keybindable action is reachable. Replies Ok whenever the spec parses — even if it had no effect (e.g. close-window with nothing focused); only an unparseable spec errors.

The dedicated msg commands are the state you can read or set; every one-shot operation (close a window, quit, zoom a step) lives here under action. Window actions target the focused window, so to act on a specific one, focus it first — or use the --id selector on focus/move/close/screenshot window to target any window without the focus-first dance.

The socket is a full control surface: action can exec/spawn, quit, and reload-config. It is safe only because the socket is 0600.

Reply: {"Ok":"Ok"}.

  • <SPEC>... — Action and arguments, exactly as written in config (e.g. nudge-window up)

driftwm msg screenshot

driftwm msg screenshot [OPTIONS] [COMMAND]

Capture a canvas PNG (custom DPI). With no subcommand, captures the active output’s current view of the canvas.

A canvas capture, not a screen grab: it re-renders a virtual viewport onto the canvas, reaching off-screen content at any resolution. Windows get full chrome (title bar, border, shadow); panels/layer-shells and blur are not drawn (use grim for a literal grab). -o - streams the PNG to stdout (e.g. screenshot window -o - | wl-copy).

Blur caveat: a scene capture (viewport/all/region) shows a translucent window over a sharp backdrop, never a blurred one; a window capture keeps the translucency over transparent pixels. A gigapixel TIFF wallpaper uses a coarse pyramid level, softening at extreme --scale. Captures tile internally but cap at 16384 px/side.

Reply: {"Ok":{"Screenshot":{"path":"/abs/shot.png","width":1920,"height":1080}}}.

  • --scale <SCALE> — Pixels per canvas unit — higher captures more detail than the screen shows, independent of zoom (default: 1)
  • -o, --output <OUTPUT> — Output PNG path, or - for stdout [default: ./driftwm-screenshot-<time>.png]
driftwm msg screenshot window
driftwm msg screenshot window [OPTIONS] [APP_ID]

The focused window, or a window by app_id substring or --id.

Composed alone on transparency, so overlapping windows never appear; pinned and fullscreen windows capture like any other (a fullscreen window has no chrome). Reply shape is the shared Screenshot reply above.

  • --id <ID> — Capture the window with this stable id (from state)
driftwm msg screenshot all
driftwm msg screenshot all

The bounding box of all non-widget windows.

A scene with the canvas background plus every window’s chrome, framed with a [zoom] fit_padding margin. Reply shape is the shared Screenshot reply above.

driftwm msg screenshot region
driftwm msg screenshot region [OPTIONS] <COORDS>...

A rectangle — X Y W H (canvas coords, center/Y-up) or slurp’s native X,Y WxH. Commas and the x separator are tolerated, so $(slurp) drops in directly. Treated as output-screen pixels with --from-screen.

Captures a scene (canvas background plus window chrome) over the rectangle. Reply shape is the shared Screenshot reply above.

  • <COORDS>... — Four ints X Y W H, or slurp’s X,Y WxH (quoted or not)
  • --from-screen — Treat the rectangle as output-screen pixels mapped via the active viewport

Writing background shaders

driftwm renders the canvas background using a GLSL fragment shader. You can write your own to replace the default dot grid.

Tip

Looking for ready-made shaders, or want to share your own? Browse the Gallery.

How it works

The shader runs once per pixel every frame the viewport changes. It receives the pixel’s position and the viewport’s camera offset, and outputs a color. The result covers the entire output behind all windows.

Inputs

Built-in (provided by smithay)

NameTypeDescription
v_coordsvec2Normalized position within the output, 0.0–1.0
sizevec2Output dimensions in pixels (e.g. 1920.0, 1080.0)

Custom (provided by driftwm)

NameTypeDescription
u_cameravec2Canvas→screen offset in canvas pixels (viewport’s top-left)
u_zoomfloatCanvas→screen scale (1.0 = unzoomed, >1 zoomed in, <1 zoomed out)
u_timefloatSeconds since compositor start

All three are optional — declare only the ones your shader uses. driftwm detects each at compile time and skips pushing uniforms the shader doesn’t consume, so an unreferenced uniform costs nothing per frame.

v_coords * size gives screen-local pixel coordinates (top-left = 0,0). Adding u_camera converts to canvas coordinates — this is how the background scrolls with the viewport. Without u_camera, the shader is fixed to the screen and doesn’t scroll. By default features defined in canvas pixels grow/shrink with zoom, same as windows; u_zoom lets you change that relationship if you want (e.g. divide a feature’s size by u_zoom to keep it screen-sized regardless of zoom level).

Output

Set gl_FragColor to an RGBA vec4:

gl_FragColor = vec4(color, 1.0);

The alpha component (the 1.0 above) is ignored by default — backgrounds are composited opaque. To make a shader output its own transparency, set transparent_shader = true (see Transparent backgrounds).

Examples

Solid color (cheapest)

No camera, no zoom, no time — uniforms are pushed once at init and never again. Equivalent in cost to type = "wallpaper" with a 1×1 image:

precision mediump float;

const vec3 BG = vec3(0.07, 0.07, 0.09);

void main() {
    gl_FragColor = vec4(BG, 1.0);
}

Hue shift across the canvas

Uses u_camera so the gradient scrolls with the viewport:

precision mediump float;

varying vec2 v_coords;
uniform vec2 size;
uniform vec2 u_camera;

void main() {
    vec2 canvas = (v_coords * size + u_camera) * 0.001;
    vec3 col = vec3(
        sin(canvas.x) * 0.5 + 0.5,
        sin(canvas.y) * 0.5 + 0.5,
        0.5
    );
    gl_FragColor = vec4(col, 1.0);
}

Tips

  • GLSL ES 1.0: smithay auto-prepends #version 100. Don’t add your own version directive. Use precision mediump float; or highp for noise.
  • Canvas coords: The standard pattern is vec2 canvas = (v_coords * size + u_camera) * scale; where scale controls the feature size (smaller = larger features).
  • Float precision: u_camera can be large (thousands of pixels from origin). If your shader uses mod() or fract() on canvas coords, reduce first: mod(u_camera, period) instead of mod(canvas, period). See dot_grid.glsl for an example. Noise-based shaders using floor()/fract() internally are naturally resilient since the hash functions wrap.
  • Animated shaders: u_time gives seconds since compositor start, enabling time-driven animations. driftwm re-renders every frame when a shader uses u_time — declare it in your shader and it will animate continuously.
  • Zoom-aware shaders: declare uniform float u_zoom; to react to viewport zoom. Common pattern: divide canvas-pixel sizes by u_zoom to keep features the same screen size at any zoom level (e.g. DOT_RADIUS / u_zoom).
  • Colors as constants: Define colors, spacing, and other tunables as GLSL const values at the top of your shader. This keeps everything in one file — no config round-trip needed.
  • Shipped examples: See extras/wallpapers/ for dot grid, compass grid, noise clouds, dark sea, blue drift, and animated squares.

Sampling an image (textured shaders)

A type = "shader" background can sample a single image by adding a texture path. driftwm loads the image and binds it to the shader’s tex sampler:

[background]
type = "shader"
path = "~/shaders/scroll_image.glsl"
texture = "~/Pictures/tile.png"

Textured shaders are a slightly different contract from the procedural shaders above — they’re compiled as texture shaders, so the input set differs:

NameTypeProvided byDescription
texsampler2DsmithayThe configured image. Sample with texture2D
v_coordsvec2smithayNormalized position within the output, 0.0–1.0
u_texture_sizevec2driftwmImage dimensions in pixels
u_output_sizevec2driftwmViewport dimensions in pixels (= output / zoom)
u_cameravec2driftwmCanvas→screen offset in canvas pixels
u_zoomfloatdriftwmCanvas→screen scale
u_timefloatdriftwmSeconds since compositor start

Notes that differ from procedural shaders:

  • No built-in size — texture shaders don’t get smithay’s size. Use u_output_size instead (same value: viewport pixels).
  • No textureSize() — GLSL ES 1.0 lacks it, so the image’s resolution arrives as u_texture_size. You need it to turn canvas pixels into texel UVs.
  • alpha is optional — backgrounds are opaque by default, so you don’t need to declare or multiply by an alpha uniform unless you set transparent_shader = true (see Transparent backgrounds).
  • cache_shader is ignored — the shader-bake cache can’t sample a runtime texture, so textured shaders always render live.

The headline pattern — sample the image at the canvas position so it scrolls with the viewport:

precision highp float;
varying vec2 v_coords;
uniform sampler2D tex;
uniform vec2 u_camera;
uniform vec2 u_output_size;
uniform vec2 u_texture_size;

void main() {
    vec2 canvas = v_coords * u_output_size + mod(u_camera, u_texture_size);
    vec2 uv = fract(canvas / u_texture_size);  // fract() tiles it infinitely
    gl_FragColor = texture2D(tex, uv);
}

For a showcase of what textured mode uniquely enables — a procedural effect on your image, not just sampling it — see extras/wallpapers/textured/ripple.glsl, which animates a watery distortion over the tiled image.

Configuring the background

[background] accepts a type and a path. Four types are supported:

# Procedural GLSL shader — scrolls with the canvas
[background]
type = "shader"
path = "~/shaders/my_bg.glsl"
# Optional: bind an image the shader can sample via `tex`
# (see "Sampling an image" above)
# texture = "~/Pictures/tile.png"

# Image tiled across the canvas (scrolls with the camera)
[background]
type = "tile"
path = "~/Pictures/tile.png"

# Single image fixed to the viewport (does not scroll or zoom).
# Cheapest mode: zero per-frame uniform updates, so blur and overlays
# above stay cached across pans.
[background]
type = "wallpaper"
path = "~/Pictures/wallpaper.png"

# No built-in background (no path).
[background]
type = "none"

The wallpaper mode scales the image to cover the output while preserving its aspect ratio, centering and cropping any overflow. For a crop-free result, match the image’s aspect ratio to your monitor.

Transparent backgrounds

By default driftwm composites the background as fully opaque — a fast path that lets it skip blending and skip redrawing anything beneath it. But the background sits above any wlr-layer-shell Background-layer surface, so making it see-through lets an external wallpaper engine (e.g. a QuickShell or swaybg setup) show through while keeping the built-in background on top — for a full external wallpaper with no built-in background, use type = "none" below. Two ways to opt in, depending on background type:

Images (tile / wallpaper) — automatic. If the PNG carries an alpha channel with any transparent pixels, driftwm honors it: transparent areas blend to whatever’s below. A fully opaque image keeps the fast path. No config needed.

# Dots-with-transparent-gaps PNG tiled as a spatial reference over a live
# wallpaper engine running on the Background layer — gaps show the engine.
[background]
type = "tile"
path = "~/Pictures/dots.png"

Shaders (type = "shader") — opt in with transparent_shader = true. A shader is un-inspectable, so driftwm can’t autodetect transparency the way it does for images; the flag tells it to honor the shader’s output alpha:

[background]
type = "shader"
path = "~/shaders/dot_grid.glsl"
transparent_shader = true

Then output a low (or zero) alpha where you want the layer below to show:

// Opaque dots over a transparent field — the gaps reveal what's underneath.
const vec4 BG_COLOR  = vec4(0.0, 0.0, 0.0, 0.0);  // transparent
const vec4 DOT_COLOR = vec4(1.0, 1.0, 1.0, 1.0);  // opaque

Notes:

  • Premultiplied alpha — compositing is premultiplied, so output vec4(rgb * a, a). Mixing two valid premultiplied colors (as dot_grid does) stays valid; a raw vec4(rgb, 0.5) would fringe too bright.
  • cache_shader is disabled while transparent_shader = true — the shader-bake cache stores opaque canvas tiles and can’t carry transparency, so the shader is forced onto the live render path (re-evaluated each frame).
  • Cost — a transparent background gives up the opaque fast path: it blends every frame and redraws whatever sits below it. Free when the whole scene is static (no damage = no repaint), but a live engine underneath keeps repainting through it, and it removes the pan-cache optimization — so only turn it on when you actually have something to show through.

External wallpaper engines (type = "none")

type = "none" renders no built-in background at all, so whatever sits on the wlr-layer-shell Background layer becomes the wallpaper — letting you use a standard Wayland wallpaper daemon instead of driftwm’s shader/image modes:

  • swaybg — static images
  • swww / wpaperd — animated wallpapers and transitions
  • mpvpaperlive video wallpapers (mpv on a layer surface)

Launch the daemon yourself (e.g. from autostart); driftwm just gets out of the way. With nothing on the Background layer, you’ll see the clear color (black).

Notes:

  • path is ignored for this type.
  • Not feh. feh is X11 (it paints the X root window), which has no equivalent under a Wayland compositor — use a layer-shell daemon like the ones above.
  • A live video wallpaper damages the whole screen every frame, so it repaints continuously (the same cost profile as an animated shader).

Reloading after edits

The config is automatically reloaded when the file changes. The shader is re-read from disk on every reload, so touch the config to pick up shader edits:

touch ~/.config/driftwm/config.toml

To bind this to a key, add to your config:

[keybindings]
"mod+shift+c" = "spawn touch ~/.config/driftwm/config.toml"

Gigapixel wallpapers

driftwm’s canvas is infinite: windows float on an unbounded plane that you pan across and zoom out from. That leaves room for a background far larger than one screen — a gigapixel image can act as the canvas itself, something you pan over and zoom out to take in, rather than a fixed screen-sized wallpaper.

Why a tiled pyramidal TIFF

An ordinary PNG/JPG background is uploaded to the GPU as a single texture, which maxes out around 8K–16K pixels per side. Anything larger won’t fit — and that doesn’t take a literal gigapixel; a whole-world map or a large panorama is already over the line.

Instead it needs to be tiled (cut into small squares) and pyramidal (stored at several progressively smaller copies). driftwm loads only the tiles in view, and as you zoom out it switches to a smaller copy — so it never has to hold the whole image at full resolution. A tiled pyramidal TIFF packs all of that into one file.

Converting an image

The simplest route is libvips:

vips tiffsave input.jpg output.tif --tile --pyramid --bigtiff --compression=deflate

Then point your config at the result:

[background]
type = "tile"
path = "~/Pictures/output.tif"

--compression=deflate is lossless — preferable for a wallpaper you’ll zoom right into. --compression=jpeg is smaller but lossy.

Alternative: GDAL

If your source is already a GeoTIFF or you work with GIS tools, GDAL produces an equivalent tiled pyramid (a Cloud-Optimized GeoTIFF is a tiled pyramidal TIFF):

gdal_translate -of GTiff -co TILED=YES -co COMPRESS=DEFLATE input.tif output.tif
gdaladdo -r average output.tif 2 4 8 16 32

Where to find images

You just need a real downloadable file — a large JPEG, PNG, or TIFF past ~16K on a side. Skip zoom viewers that only stream tiles: Google Arts & Culture, the Rijksmuseum Night Watch viewer, and most of GigaPan don’t hand you the whole image. Beyond that it’s down to taste — the canvas tiles infinitely, but on something this large the repeat is far off-screen, so a non-seamless edge rarely matters.

Some sources:

  • World & satellite maps — NASA’s Blue Marble is a public-domain whole-Earth image up to 43200 × 21600 px (TIFF/JPG). Maps suit the canvas nicely — panning around one feels like exploring.
  • Wikimedia CommonsLarge images and Gigapixel images: a big pool of maps, panoramas, and scans in the 16K–40K range, each with its license on its own page (many public domain or CC). If a download only gives you a thumbnail, see downloading very large files. Skip the Google Art Project subcategory — tile sets, not single files.
  • Astronomy — ESA/Hubble’s Andromeda mosaic is 42208 × 9870 px under CC BY 4.0 (credit “ESA/Hubble”); NASA imagery is public domain. Many sky panoramas are only published small or viewer-only, so downloadable giant ones are fewer.

Then run your pick through the conversion step above.