Home
A trackpad-first infinite canvas Wayland compositor.
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
| Input | Action | Context |
|---|---|---|
| 3-finger swipe | Pan viewport | anywhere |
| Trackpad scroll | Pan viewport | on-canvas |
Mod + LMB drag | Pan viewport | anywhere |
Mod+Ctrl + arrow | Pan viewport | — |
| 2-finger pinch | Zoom | on-canvas |
| 3-finger pinch | Zoom | anywhere |
Mod + scroll | Zoom at cursor | anywhere |
Mod+= / Mod+- | Zoom in / out | — |
Mod+0 / Mod+Z | Reset 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
| Input | Action |
|---|---|
| 4-finger swipe | Jump to nearest window (natural direction) |
Mod+Ctrl + LMB drag | Jump to nearest window (natural direction) |
Mod + arrow | Jump to nearest window in direction |
Alt-Tab / Alt-Shift-Tab | Cycle windows (MRU) |
4-finger pinch in / Mod+W | Zoom-to-fit (overview) |
4-finger pinch out / Mod+A | Home toggle (origin and back) |
4-finger hold / Mod+C | Center focused window |
Mod+1-4 | Jump 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-4to jump to a bookmark orMod+Ato 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
| Input | Action |
|---|---|
| 3-finger doubletap-swipe | Move window |
Alt + LMB drag | Move window |
Alt+Shift + LMB drag | Move snapped windows |
Alt + 3-finger swipe | Resize window |
Alt+Shift + 3-finger swipe | Resize snapped window |
Alt + RMB drag | Resize window |
Alt + MMB click / Mod+M | Fit window (maximize/restore) |
Alt+Shift + MMB click / Mod+Shift+M | Fit snapped window |
Mod + 4-finger pinch in / Mod+Shift+W | Zoom-to-fit snapped windows |
Alt + 2-finger pinch in/out | Fit window |
Alt + 3-finger pinch in/out | Toggle fullscreen |
Mod + MMB click / Mod+F | Toggle fullscreen |
Mod+Shift + arrow | Nudge 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
| Input | Action | Context |
|---|---|---|
| 1-finger swipe | Pan viewport | on-canvas |
| 3-finger swipe | Pan viewport | anywhere |
| 2-finger pinch | Zoom | on-canvas |
| 3-finger pinch | Zoom | anywhere |
| 4-finger swipe | Jump to nearest window | anywhere |
| 4-finger pinch in / out | Zoom-to-fit / home toggle | anywhere |
| 3-finger tap | Center window | anywhere |
| 3-finger double-tap | Fit window | on-window |
| 3-finger doubletap-swipe | Move window (hold: cluster) | on-window |
| 3-finger hold-swipe | Resize window | on-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 viatexture. Default is a dot grid. See docs/shaders.md to write your own. Bundled shaders live inextras/wallpapers/{static,animated,textured}/.tile— PNG/JPG (single texture, tiled infinitely), or a tiled pyramidal TIFF for gigapixel wallpapers. Setmirror_tile = trueto 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 externalwlr-layer-shellwallpaper daemon (swaybg,swww,mpvpaperfor 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); readingu_camera/u_zoomredraws on pan/zoom; readingu_timeredraws every frame. Tiles redraw on pan/zoom;wallpaperrenders 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_idortitle, rundriftwm 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.
| Input | Action |
|---|---|
Mod+Alt + arrow | Send 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 enabledriftwm(defaults tofalse).package: The package containing thedriftwmcompositor 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) orxdg-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 inAdwaita Sansto 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-fto 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.
| Shortcut | Action |
|---|---|
mod+return | Open terminal |
mod+d | Open launcher |
mod+q | Close window |
mod+l | Lock screen |
mod+ctrl+shift+q | Quit |
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
- driftwm-settings — GTK4 GUI config editor
- driftwm-noctalia — noctalia shell fork adapted for driftwm
- Just Enough Shell — minimal QuickShell desktop shell, driftwm-focused
- Gallery — community shaders & rices, share your own
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)
[navigation]
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]]
[navigation.edge_pan]
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 windownudge-window <dir>— move focused window by nudge_step pxpan-viewport <dir>— pan camera by pan_step pxcenter-window— center viewport on focused window + reset zoomfocus-center— focus + center on the window under the pointer + reset zoomcenter-nearest <dir>— navigate to nearest window in directioncycle-windows forward— Alt-Tab style window cyclingcycle-windows backward— reverse cyclehome-toggle— toggle between current position and originzoom-in— step zoom inzoom-out— step zoom outzoom-reset— zoom to 1.0go-to <x> <y>— jump camera to canvas position (bookmarks, Y-up)zoom-to-fit— fit all windows in viewportzoom-to-fit-snapped— fit only the focused window’s snap clustertoggle-fullscreen— toggle focused window fullscreenfit-window— toggle maximize: centers + resets zoom + fills viewport; restore only resizes backfit-window-snapped— fit-window for the focused window’s whole snap clusterfill-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 restoretoggle-pin-to-screen— pin/unpin the focused window to the screen (ignores pan/zoom, floats above)reload-config— hot-reload config filetoggle-cursor-pan— toggle cursor edge-pan (see [navigation.edge_pan])quit— exit the compositorsend-to-output <dir>— move focused window to adjacent outputsend-cursor-to-output <dir>— move the cursor to adjacent outputswitch-layout <target>— cycle keyboard layout: next, prev (or previous), or a 0-based indexnone— unbind this key combo
Directions: up, down, left, right, up-left, up-right, down-left, down-right
| Binding | Action | Notes |
|---|---|---|
"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-pan | toggle 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 1750 | top-left bookmark |
"mod+2" | go-to 1750 1750 | top-right bookmark |
"mod+3" | go-to 1750 -1750 | bottom-right bookmark |
"mod+4" | go-to -1750 -1750 | bottom-left bookmark |
"mod+alt+up" | send-to-output up | move 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 windowmove-snapped-windows— drag the window plus every window connected via snap adjacencyresize-window— drag-resize the focused windowresize-window-snapped— drag-resize, propagating to the snap clusterpan-viewport— drag or scroll to pan the camerazoom— drag or scroll to zoomcenter-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]
| Binding | Action | Notes |
|---|---|---|
"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]
| Binding | Action | Notes |
|---|---|---|
"left" | pan-viewport | unmodified left-click on empty canvas → pan |
"trackpad-scroll" | pan-viewport | trackpad scroll on empty canvas → pan |
"wheel-scroll" | zoom | mouse wheel on empty canvas → zoom |
[mouse.anywhere]
| Binding | Action | Notes |
|---|---|---|
"mod+left" | pan-viewport | |
"mod+ctrl+left" | center-nearest | direction 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 fallback3-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 onlyN-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]
| Binding | Action | Notes |
|---|---|---|
"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]
| Binding | Action | Notes |
|---|---|---|
"2-finger-pinch" | zoom |
[gestures.anywhere]
| Binding | Action | Notes |
|---|---|---|
"3-finger-swipe" | pan-viewport | continuous (per-frame dx/dy) |
"4-finger-swipe" | center-nearest | threshold (accumulate, detect direction, fire once) |
"mod+3-finger-swipe" | center-nearest | mod 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%-"
| Binding | Action | Notes |
|---|---|---|
"mod+2-finger-pinch" | zoom | mod overrides app forwarding |
"3-finger-pinch" | zoom | continuous |
"4-finger-pinch-in" | zoom-to-fit | threshold |
"mod+4-finger-pinch-in" | zoom-to-fit-snapped | |
"4-finger-pinch-out" | home-toggle | threshold |
"mod+3-finger-pinch-in" | zoom-to-fit | |
"mod+3-finger-pinch-out" | home-toggle | |
"4-finger-hold" | center-window | fires 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 fallbackN-finger-pinch— continuous only (use pinch-in/out for discrete)N-finger-pinch-in/out— threshold onlyN-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).
| Binding | Action | Notes |
|---|---|---|
"3-finger-doubletap" | fit-window | |
"3-finger-doubletap-swipe" | move-window | continuous (hold to move the cluster) |
"3-finger-hold-swipe" | resize-window | continuous (dwell then drag) |
[touch.on-canvas]
| Binding | Action | Notes |
|---|---|---|
"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.
| Binding | Action | Notes |
|---|---|---|
"3-finger-swipe" | pan-viewport | continuous |
"3-finger-pinch" | zoom | continuous |
"4-finger-swipe" | center-nearest | threshold (direction from drag) |
"4-finger-pinch-in" | zoom-to-fit | threshold |
"4-finger-pinch-out" | home-toggle | threshold |
"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 modewidget— 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).positionbecomes output-relative; movable unless widget = true. Toggle live withtoggle-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
positionresolved against it; dragging or send-to-output reassigns it afterward. Find names underoutputs.*indriftwm msg state. (default: unset)
- active output — with
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:Allis sticky-on;Onlylists 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.
| Field | Matches |
|---|---|
app_id | Wayland app_id (X11 apps via xwayland-satellite arrive with app_id set from WM_CLASS instance, typically lowercase) |
title | Window 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:
| Syntax | Example | Meaning |
|---|---|---|
| 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,
positionis measured from the output center (still center-anchored and Y-up):[0, 0]centers the window on the monitor,+Yis up. Droppositionto 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 withwidget = trueto 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-screenaction (bound toMod+Tby 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:
- Open the window (e.g. start Picture-in-Picture), click it to focus, and press
Mod+Tto pin it. It’s now in screen space — drag it anywhere with the mouse and resize to taste. - Run
driftwm msg stateand read thepinnedsection: each entry lists its output,app_id,title,position, andsize. Thoseposition/sizevalues are already output-relative rule coordinates. - Write the rule with those
position/sizevalues pluspinned_to_screen = true(anddecoration = "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
outputwins; otherwise the output the client itself requested; otherwise the active output (where the pointer is). - Screen-pinned — which monitor a
pinned_to_screenwindow initially pins to. Precedence: the rule’soutputwins; otherwise the active output. The rule’spositionis then resolved against that monitor. Afterwards, dragging the window across monitors — orsend-to-output— reassigns it, sooutputonly 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:
| Value | Behaviour |
|---|---|
false (or omit) | Compositor handles all keybindings normally (default) |
true | All 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+F1–F12) 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:
trueis sticky-on: if any rule setspass_keys = true, the result istrue.["combo", …]lists are unioned across all matching rules.trueoverrides a list: if one rule saystrueand another says["mod+q"], the result istrue.
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:
actioncan runexec/spawn(launch programs),quit, andreload-config. It’s safe only because the socket is0600(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
Moverequest and theWindowscreenshot 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). TheFocusedreply also grew from a bare app_id string to{"id":…,"app_id":…}.
Requests
| Request | JSON 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/state — key=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
subscribeevents 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 (fromstate)
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 (fromstate)
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.0–1.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 (fromstate)
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 (fromstate)
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 (fromstate)
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 intsX Y W H, or slurp’sX,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)
| Name | Type | Description |
|---|---|---|
v_coords | vec2 | Normalized position within the output, 0.0–1.0 |
size | vec2 | Output dimensions in pixels (e.g. 1920.0, 1080.0) |
Custom (provided by driftwm)
| Name | Type | Description |
|---|---|---|
u_camera | vec2 | Canvas→screen offset in canvas pixels (viewport’s top-left) |
u_zoom | float | Canvas→screen scale (1.0 = unzoomed, >1 zoomed in, <1 zoomed out) |
u_time | float | Seconds 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. Useprecision mediump float;orhighpfor noise. - Canvas coords: The standard pattern is
vec2 canvas = (v_coords * size + u_camera) * scale;wherescalecontrols the feature size (smaller = larger features). - Float precision:
u_cameracan be large (thousands of pixels from origin). If your shader usesmod()orfract()on canvas coords, reduce first:mod(u_camera, period)instead ofmod(canvas, period). Seedot_grid.glslfor an example. Noise-based shaders usingfloor()/fract()internally are naturally resilient since the hash functions wrap. - Animated shaders:
u_timegives seconds since compositor start, enabling time-driven animations. driftwm re-renders every frame when a shader usesu_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 byu_zoomto 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
constvalues 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:
| Name | Type | Provided by | Description |
|---|---|---|---|
tex | sampler2D | smithay | The configured image. Sample with texture2D |
v_coords | vec2 | smithay | Normalized position within the output, 0.0–1.0 |
u_texture_size | vec2 | driftwm | Image dimensions in pixels |
u_output_size | vec2 | driftwm | Viewport dimensions in pixels (= output / zoom) |
u_camera | vec2 | driftwm | Canvas→screen offset in canvas pixels |
u_zoom | float | driftwm | Canvas→screen scale |
u_time | float | driftwm | Seconds since compositor start |
Notes that differ from procedural shaders:
- No built-in
size— texture shaders don’t get smithay’ssize. Useu_output_sizeinstead (same value: viewport pixels). - No
textureSize()— GLSL ES 1.0 lacks it, so the image’s resolution arrives asu_texture_size. You need it to turn canvas pixels into texel UVs. alphais optional — backgrounds are opaque by default, so you don’t need to declare or multiply by analphauniform unless you settransparent_shader = true(see Transparent backgrounds).cache_shaderis 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 (asdot_griddoes) stays valid; a rawvec4(rgb, 0.5)would fringe too bright. cache_shaderis disabled whiletransparent_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 imagesswww/wpaperd— animated wallpapers and transitionsmpvpaper— live 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:
pathis 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 Commons — Large 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.
