Commit Diff


commit - /dev/null
commit + 24205f2bf2d214fb410a04d3e30287d625cf3864
blob - /dev/null
blob + 4093c1a5c5b53bcfb3e904236b93866d14940fc3 (mode 644)
--- /dev/null
+++ .cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+rustflags = ["-L", "/usr/X11R6/lib"]
blob - /dev/null
blob + ea8c4bf7f35f6f77f75d92ad8ce8349f6e81ddba (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1 @@
+/target
blob - /dev/null
blob + bf955640bde5a20b8c2b53bb4b40cac7e8adcfc9 (mode 644)
--- /dev/null
+++ .rustfmt.toml
@@ -0,0 +1,4 @@
+# group_imports = "StdExternalCrate"
+# imports_granularity = "Module"
+max_width = 80
+reorder_imports = true
blob - /dev/null
blob + d9f6367db80b65f86630768c0ae3bf6e66bacab8 (mode 644)
--- /dev/null
+++ Cargo.lock
@@ -0,0 +1,175 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "owm"
+version = "0.1.0"
+dependencies = [
+ "libc",
+ "serde",
+ "serde_json",
+ "slotmap",
+ "x11",
+ "xcb",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "xcb"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6"
+dependencies = [
+ "bitflags",
+ "libc",
+ "quick-xml",
+ "x11",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
blob - /dev/null
blob + 0e21966665d2f1a0bb7404504d242ce6cc08613a (mode 644)
--- /dev/null
+++ Cargo.toml
@@ -0,0 +1,38 @@
+[package]
+name = "owm"
+version = "0.1.0"
+edition = "2024"
+
+[lints.clippy]
+all = "warn"
+
+[dependencies]
+xcb = { version = "1", features = ["xkb", "randr", "xlib_xcb"] }
+x11 = { version = "2", features = ["xlib", "xft", "xrender"] }
+slotmap = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+libc = "0.2"
+
+[[bin]]
+name = "owm"
+path = "src/bin/owm.rs"
+
+[[bin]]
+name = "owm-msg"
+path = "src/bin/owm-msg.rs"
+
+[[bin]]
+name = "omenu"
+path = "src/bin/omenu.rs"
+
+[[bin]]
+name = "ostatus"
+path = "src/bin/ostatus.rs"
+
+[profile.release]
+opt-level = "s"
+lto = true
+codegen-units = 1
+strip = true
+panic = "abort"
blob - /dev/null
blob + 832fca8043e861f066ae3fb3655624160639b849 (mode 644)
--- /dev/null
+++ LICENSE
@@ -0,0 +1,14 @@
+Copyright (c) 2026 murilo ijanc' <murilo@ijanc.org>
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
blob - /dev/null
blob + 2db1edd5f7bf34e93bdd72ad4254cb88e7e6dbb3 (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,62 @@
+CARGO = cargo
+DISPLAY_NUM = :2
+XEPHYR_SIZE = 1280x800
+
+OWM = ./target/debug/owm
+OWM_RELEASE = ./target/release/owm
+
+all: build
+
+build:
+	$(CARGO) build
+
+release:
+	$(CARGO) build --release
+
+clean:
+	$(CARGO) clean
+
+# Launch Xephyr + owm for testing inside your current X session.
+# Ctrl+Shift kills Xephyr when done.
+run: build
+	doas cp ./target/debug/omenu /usr/local/bin/
+	doas cp ./target/debug/ostatus /usr/local/bin/
+	@echo "Starting Xephyr on $(DISPLAY_NUM) ($(XEPHYR_SIZE))..."
+	Xephyr $(DISPLAY_NUM) -screen $(XEPHYR_SIZE) -ac &
+	@sleep 1
+	DISPLAY=$(DISPLAY_NUM) RUST_LOG=debug $(OWM) &
+	# @sleep 0.5
+	# DISPLAY=$(DISPLAY_NUM) xterm &
+	# @echo "owm running in Xephyr. Close the Xephyr window to stop."
+
+# Same but with release build.
+run-release: release
+	doas cp ./target/release/omenu ./target/release/ostatus /usr/local/bin
+	Xephyr $(DISPLAY_NUM) -screen $(XEPHYR_SIZE) -ac &
+	@sleep 1
+	DISPLAY=$(DISPLAY_NUM) RUST_LOG=info $(OWM_RELEASE) &
+	# @sleep 0.5
+	# DISPLAY=$(DISPLAY_NUM) xterm &
+
+install: release
+	doas install -m 755 target/release/owm /usr/local/bin/owm
+	doas install -m 755 target/release/owm-msg /usr/local/bin/owm-msg
+	doas install -m 755 target/release/omenu /usr/local/bin/omenu
+	doas install -m 755 target/release/ostatus /usr/local/bin/ostatus
+
+# Copy freshly built debug binaries without restarting.
+update: build
+	cp ./target/debug/omenu $(HOME)/bin/
+	-pkill -x ostatus 2>/dev/null; sleep 0.2
+	cp ./target/debug/ostatus $(HOME)/bin/
+
+# Kill any running Xephyr/owm instances.
+stop:
+	-pkill -f "Xephyr $(DISPLAY_NUM)" 2>/dev/null
+	-pkill -f "target/debug/owm" 2>/dev/null
+	-pkill -f "target/release/owm" 2>/dev/null
+
+# Restart: kill, rebuild, relaunch.
+restart: stop build run
+
+.PHONY: all build release clean run run-release update stop restart
blob - /dev/null
blob + 660cf36de431e288fa47039f07e07df6ff287a47 (mode 644)
--- /dev/null
+++ owm.1
@@ -0,0 +1,395 @@
+.\"	$OpenBSD$
+.\"
+.Dd $Mdocdate: March 28 2026 $
+.Dt OWM 1
+.Os
+.Sh NAME
+.Nm owm
+.Nd a tiling window manager for X11
+.Sh SYNOPSIS
+.Nm owm
+.Sh DESCRIPTION
+.Nm
+is a reparenting tiling window manager for X11 using XCB.
+It arranges windows in a tree structure, supporting horizontal and vertical
+splits, stacked and tabbed layouts, and multiple workspaces.
+.Pp
+Windows are decorated with a title bar showing the window name,
+configurable via
+.Ic default_border
+in
+.Xr owmrc 5
+.Pq normal, pixel, or none .
+Per-window rules can override the decoration style using
+.Ic for_window .
+.Pp
+A built-in status bar displays workspace names, highlighting the active workspace.
+The bar can be configured or disabled via
+.Xr owmrc 5 .
+.Pp
+.Nm
+provides an IPC mechanism for external control via a
+.Ux
+domain socket.
+.Pp
+.Nm
+is inspired by
+.Xr cwm 1 ,
+spectrwm,
+and i3.
+.Sh TREE STRUCTURE
+.Nm
+organizes windows in a tree hierarchy:
+.Pp
+.Bl -bullet -compact
+.It
+.Sy Root
+\(en top-level container spanning all outputs.
+.It
+.Sy Output
+\(en a physical display or monitor.
+.It
+.Sy Workspace
+\(en a named virtual desktop (1\(en9).
+.It
+.Sy Container
+\(en a split node or a leaf holding a client window.
+.El
+.Pp
+Each container has a layout that determines how its children are arranged:
+.Bl -tag -width "StackedXX"
+.It Sy SplitH
+Children are arranged side by side horizontally.
+.It Sy SplitV
+Children are arranged on top of each other vertically.
+.It Sy Stacked
+Children occupy the same space; only the focused child is visible.
+.It Sy Tabbed
+Like stacked, but intended for tabbed presentation.
+.El
+.Pp
+New windows are inserted next to the focused container.
+The split direction for the next window can be set with
+.Ic Mod-v
+(vertical) or
+.Ic Mod-b
+(horizontal).
+.Sh KEY BINDINGS
+The default modifier key is
+.Sy Mod4
+(Super/Windows key), referred to as
+.Ic Mod
+below.
+.Pp
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic Mod-Return
+Spawn
+.Xr xterm 1 .
+.It Ic Mod-Shift-q
+Close the focused window.
+.It Ic Mod-Shift-e
+Exit
+.Nm .
+.El
+.Ss Window Focus
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic Mod-j
+Focus next sibling.
+.It Ic Mod-k
+Focus previous sibling.
+.It Ic Mod-h
+Focus parent container.
+.It Ic Mod-l
+Focus child container.
+.El
+.Ss Window Movement
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic Mod-Shift-j
+Swap focused window with next sibling.
+.It Ic Mod-Shift-k
+Swap focused window with previous sibling.
+.El
+.Ss Split Direction
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic Mod-v
+Next window opens in a vertical split.
+.It Ic Mod-b
+Next window opens in a horizontal split.
+.El
+.Ss Layout
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic Mod-s
+Set parent layout to stacked.
+.It Ic Mod-w
+Set parent layout to tabbed.
+.It Ic Mod-e
+Toggle parent layout between horizontal and vertical split.
+.El
+.Ss Fullscreen and Floating
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic Mod-f
+Toggle fullscreen on the focused window.
+.It Ic Mod-Shift-Space
+Toggle floating on the focused window.
+.El
+.Ss Resize
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic resize grow width
+Increase the width of the focused tiling container.
+.It Ic resize shrink width
+Decrease the width of the focused tiling container.
+.It Ic resize grow height
+Increase the height of the focused tiling container.
+.It Ic resize shrink height
+Decrease the height of the focused tiling container.
+.El
+.Pp
+Resize commands adjust the container's share by 5% per step.
+They are typically used inside a binding mode
+.Pq see Sx BINDING MODES .
+.Ss Sticky
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic sticky
+Toggle sticky on the focused floating window.
+A sticky window remains visible across all workspace switches.
+Only floating windows can be made sticky.
+.El
+.Ss Marks
+Marks are arbitrary labels that can be assigned to windows for
+quick navigation via IPC.
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic mark Ar name
+Set a mark on the focused window.
+If another window already has the mark, it is removed first.
+.It Ic unmark Op Ar name
+Remove a specific mark, or all marks if no name is given.
+.El
+.Ss Workspaces
+.Bl -tag -width "Mod-Shift-SpaceXX" -compact
+.It Ic Mod-1 No \(en Ic Mod-9
+Switch to workspace 1\(en9.
+.It Ic Mod-Shift-1 No \(en Ic Mod-Shift-9
+Move the focused window to workspace 1\(en9.
+.El
+.Sh BINDING MODES
+.Nm
+supports binding modes, similar to i3.
+A mode activates an alternate set of key bindings, typically used for
+operations like resizing.
+.Pp
+Modes are defined in
+.Xr owmrc 5
+using
+.Ic mode
+blocks and activated with the
+.Ic mode
+command.
+Switching back to the default key bindings is done with
+.Ic mode default .
+.Pp
+For example, a resize mode can be set up so that pressing
+.Ic Mod-r
+enters resize mode, and the arrow keys resize the focused window
+until
+.Ic Escape
+or
+.Ic Return
+switches back to the default mode.
+See
+.Xr owmrc 5
+for configuration syntax.
+.Sh IPC
+.Nm
+listens on a
+.Ux
+domain socket for commands from external tools.
+The socket path is set in the
+.Ev OWM_SOCKET
+environment variable and defaults to:
+.Pp
+.D1 Pa /tmp/owm- Ns Ao uid Ac Ns / Ns Pa ipc- Ns Ao pid Ac Ns .sock
+.Pp
+The IPC protocol uses a binary header followed by a JSON payload.
+The header consists of 6 magic bytes
+.Pq Dq owm-ip ,
+a 4-byte payload size, and a 4-byte message type, all in native byte order.
+.Ss Message Types
+.Bl -tag -width "GET_WORKSPACES (1)XX"
+.It Sy RUN_COMMAND Pq 0
+Execute a command string.
+The payload is the command text.
+.It Sy GET_WORKSPACES Pq 1
+Return a JSON array of workspace information.
+.It Sy GET_TREE Pq 3
+Return the full container tree as JSON.
+.It Sy GET_VERSION Pq 4
+Return version information.
+.It Sy SUBSCRIBE Pq 5
+Subscribe to events.
+The payload is a 4-byte bitmask in native byte order specifying
+which event types the client wants to receive:
+.Pp
+.Bl -tag -width "Bit 0XX" -offset indent -compact
+.It Sy Bit 0
+Workspace events (switch, create, remove).
+.It Sy Bit 1
+Window events (focus, close, move).
+.El
+.Pp
+Events are sent to subscribed clients with a message type of
+.Li 0x80000000
+for workspace events and
+.Li 0x80000001
+for window events.
+.It Sy GET_MARKS Pq 6
+Return a JSON array of all marks currently set on windows.
+.It Sy GET_CONFIG Pq 7
+Return the current configuration as a JSON object, including
+.Cm border_width ,
+.Cm gaps
+(an object with
+.Cm inner , top , right , bottom , left ) ,
+.Cm smart_gaps ,
+.Cm color_focused ,
+.Cm color_unfocused ,
+.Cm color_urgent ,
+.Cm color_background ,
+.Cm terminal ,
+and
+.Cm focus_follows_mouse .
+.El
+.Ss IPC Commands
+The following command strings are accepted via
+.Sy RUN_COMMAND :
+.Bl -tag -width "move workspace NXX"
+.It Ic focus Ar direction
+Focus a window.
+.Ar direction
+is one of
+.Cm next ,
+.Cm prev ,
+.Cm parent ,
+or
+.Cm child .
+.It Ic split Ar orientation
+Set the split direction for the next window.
+.Ar orientation
+is one of
+.Cm h ,
+.Cm horizontal ,
+.Cm v ,
+or
+.Cm vertical .
+.It Ic layout Ar type
+Set the layout of the focused container's parent.
+.Ar type
+is one of
+.Cm splith ,
+.Cm splitv ,
+.Cm stacking ,
+or
+.Cm tabbed .
+.It Ic kill
+Close the focused window.
+.It Ic fullscreen
+Toggle fullscreen on the focused window.
+.It Ic workspace Ar N
+Switch to workspace
+.Ar N .
+.It Ic move workspace Ar N
+Move the focused window to workspace
+.Ar N .
+.It Ic resize Ar direction Ar dimension
+Resize the focused tiling container.
+.Ar direction
+is one of
+.Cm grow
+or
+.Cm shrink .
+.Ar dimension
+is one of
+.Cm width
+or
+.Cm height .
+.It Ic mode Ar name
+Switch to binding mode
+.Ar name .
+Use
+.Cm default
+to return to the default key bindings.
+.It Ic mark Ar name
+Set a mark on the focused window.
+.It Ic unmark Op Ar name
+Remove a specific mark, or all marks if no
+.Ar name
+is given.
+.It Ic sticky
+Toggle sticky on the focused floating window.
+.It Ic gaps Ar type Ar scope Ar op Op Ar pixels
+Modify gaps at runtime.
+.Ar type
+is one of
+.Cm inner , outer , horizontal , vertical , top , right , bottom ,
+or
+.Cm left .
+.Ar scope
+is
+.Cm current
+(active workspace) or
+.Cm all
+(every workspace).
+.Ar op
+is one of
+.Cm set , plus , minus ,
+or
+.Cm toggle .
+Example:
+.Dl gaps inner current plus 5
+.It Ic exec Ar command
+Spawn
+.Ar command .
+.It Ic exit
+Terminate
+.Nm .
+.El
+.Sh ENVIRONMENT
+.Bl -tag -width "OWM_SOCKETXX"
+.It Ev DISPLAY
+The X11 display to connect to.
+.It Ev OWM_SOCKET
+Override the default IPC socket path.
+.It Ev RUST_LOG
+Set the log level
+such as
+.Dq debug ,
+.Dq info ,
+or
+.Dq warn .
+.El
+.Sh EXAMPLES
+Start
+.Nm
+on the current display:
+.Pp
+.Dl $ owm
+.Pp
+Test inside a nested X server using
+.Xr Xephyr 1 :
+.Bd -literal -offset indent
+$ Xephyr :2 -screen 1280x800 -ac &
+$ DISPLAY=:2 owm &
+$ DISPLAY=:2 xterm &
+.Ed
+.Pp
+Query the window tree via IPC (using a hypothetical client):
+.Pp
+.Dl $ owm-msg get_tree
+.Sh SEE ALSO
+.Xr cwm 1 ,
+.Xr Xephyr 1 ,
+.Xr X 7 ,
+.Xr owmrc 5
+.Sh AUTHORS
+.Nm
+was written by
+.An ijanc .
blob - /dev/null
blob + 90e627cab37c08104c5ce301834daffcc1629b1b (mode 644)
--- /dev/null
+++ owmrc.5
@@ -0,0 +1,556 @@
+.\"	$OpenBSD$
+.\"
+.Dd $Mdocdate: March 29 2026 $
+.Dt OWMRC 5
+.Os
+.Sh NAME
+.Nm owmrc
+.Nd owm window manager configuration file
+.Sh DESCRIPTION
+This manual page describes the
+.Xr owm 1
+configuration file.
+.Pp
+The default configuration is read from
+.Pa ~/.owmrc .
+If the file does not exist,
+.Xr owm 1
+uses built-in defaults.
+.Pp
+Comments begin with a hash mark
+.Pq Sq #
+and extend to the end of the line.
+Empty lines are ignored.
+.Pp
+The general syntax is a keyword followed by its arguments,
+separated by whitespace:
+.Pp
+.D1 Ar keyword Ar arguments
+.Pp
+The following options are accepted:
+.Bl -tag -width Ds
+.It Ic borderwidth Ar pixels
+Set the window border width to
+.Ar pixels .
+The default is 2.
+.It Ic color Ar name Ar value
+Set a color.
+.Ar value
+is a hexadecimal RGB color, optionally prefixed with
+.Sq # .
+.Pp
+The following color names are recognised:
+.Pp
+.Bl -tag -width "inactiveborderXX" -offset indent -compact
+.It Ic activeborder
+Color of the focused window border.
+The default is
+.Li 4c7899 .
+.It Ic inactiveborder
+Color of unfocused window borders.
+The default is
+.Li 333333 .
+.It Ic urgencyborder
+Color of the border of a window indicating urgency.
+The default is
+.Li 900000 .
+.It Ic background
+Root window background color.
+The default is
+.Li 1a1a2e .
+.It Ic titlefocused
+Title bar text color for focused windows.
+The default is
+.Li ffffff .
+.It Ic titleunfocused
+Title bar text color for unfocused windows.
+The default is
+.Li 888888 .
+.El
+.It Ic gap Ar pixels
+Set the inner gap (between tiled windows) to
+.Ar pixels .
+This is a shorthand for
+.Ic gaps inner .
+The default is 0.
+.It Ic gaps Ar type Ar pixels
+Set gaps by type.
+.Ar type
+is one of:
+.Bl -tag -width "horizontal" -compact
+.It Cm inner
+Gap between adjacent tiled windows.
+.It Cm outer
+Gap between windows and all workspace edges (sets top, right, bottom, left).
+.It Cm horizontal
+Gap between windows and left/right edges.
+.It Cm vertical
+Gap between windows and top/bottom edges.
+.It Cm top
+Gap between windows and the top edge.
+.It Cm right
+Gap between windows and the right edge.
+.It Cm bottom
+Gap between windows and the bottom edge.
+.It Cm left
+Gap between windows and the left edge.
+.El
+.Pp
+Outer gaps may be negative down to
+.Fl inner
+to compensate for inner gaps.
+The default for all values is 0.
+.It Ic smart_gaps Ar mode
+Control gap behaviour when a workspace has only one tiled window.
+.Ar mode
+is one of:
+.Bl -tag -width "inverse_outer" -compact
+.It Cm off
+Gaps are always shown (default).
+.It Cm on
+Hide all gaps with a single window.
+.It Cm inverse_outer
+Hide inner gaps but keep outer gaps with a single window.
+.El
+.It Ic workspace Ar name Ic gaps Ar type Ar pixels
+Set per-workspace gap overrides.
+Fields not specified inherit from the global
+.Ic gaps
+setting.
+Example:
+.Dl workspace 2 gaps inner 0
+.It Ic terminal Ar command
+Set the terminal program spawned by the
+.Ic spawn
+action when bound to a key.
+The default is
+.Xr xterm 1 .
+.It Ic focus-follows-mouse Ic yes Ns | Ns Ic no
+Toggle focus-follows-mouse mode.
+When set to
+.Ic yes
+(the default),
+moving the pointer into a window automatically focuses it.
+When set to
+.Ic no ,
+windows must be focused explicitly via key bindings or mouse click.
+.It Ic font Ar pattern
+Set the font used for title bar text.
+The
+.Ar pattern
+is a fontconfig pattern such as
+.Li monospace:size=11
+or
+.Li "JetBrainsMono Nerd Font:size=11:antialias=true" .
+The default is
+.Li monospace:size=11 .
+TrueType, OpenType, and bitmap fonts are supported via Xft.
+.It Ic default_border Ic normal Ns | Ns Ic pixel Op Ar N Ns | Ns Ic none
+Set the default border style for new tiling windows.
+.Bl -tag -width "pixel NXX" -offset indent -compact
+.It Ic normal
+Title bar and border (the default).
+.It Ic pixel Op Ar N
+Border of
+.Ar N
+pixels only, no title bar.
+If
+.Ar N
+is omitted, the
+.Ic borderwidth
+value is used.
+.It Ic none
+No decoration at all.
+.El
+.It Ic default_floating_border Ic normal Ns | Ns Ic pixel Op Ar N Ns | Ns Ic none
+Set the default border style for new floating windows.
+Same syntax as
+.Ic default_border .
+The default is
+.Ic normal .
+.It Ic for_window Oo Ar criteria Oc Ic border Ar style
+Apply a border style to windows matching
+.Ar criteria .
+.Pp
+Criteria are specified inside square brackets as
+.Ic key Ns = Ns Ar value
+pairs separated by whitespace.
+The following criteria are supported:
+.Pp
+.Bl -tag -width "instanceXX" -offset indent -compact
+.It Ic class
+Match the WM_CLASS class field (exact match).
+.It Ic instance
+Match the WM_CLASS instance field (exact match).
+.It Ic title
+Match a substring of the window title.
+.El
+.Pp
+The
+.Ar style
+argument follows the same syntax as
+.Ic default_border :
+.Ic normal ,
+.Ic pixel Op Ar N ,
+or
+.Ic none .
+.Pp
+Example:
+.Dl for_window [class=Firefox] border pixel 0
+.Dl for_window [instance=dropdown] border none
+.It Ic bar_enabled Ic yes Ns | Ns Ic no
+Enable or disable the built-in status bar.
+When set to
+.Ic yes
+(the default), a bar is displayed showing workspace names.
+When set to
+.Ic no ,
+no bar is shown.
+.It Ic bar_position Ic top Ns | Ns Ic bottom
+Set the position of the status bar on the screen.
+The default is
+.Ic top .
+.It Ic bar_color Ar name Ar value
+Set a bar color.
+.Ar value
+is a hexadecimal RGB color, optionally prefixed with
+.Sq # .
+.Pp
+The following color names are recognised:
+.Pp
+.Bl -tag -width "activeXX" -offset indent -compact
+.It Ic bg
+Bar background color.
+The default is
+.Li 1a1a2e .
+.It Ic fg
+Bar text color.
+The default is
+.Li ffffff .
+.It Ic active
+Background color of the active workspace indicator.
+The default is
+.Li 4c7899 .
+.El
+.It Ic bind-key Ar key Ar function
+Bind or rebind
+.Ar key
+to
+.Ar function .
+.Pp
+The
+.Ar key
+is specified as modifier letters followed by a
+.Sq - ,
+then a keysym name.
+.Pp
+The following modifiers are recognised:
+.Pp
+.Bl -tag -width Ds -offset indent -compact
+.It Ic C
+Control key.
+.It Ic M
+Meta (Alt) key.
+.It Ic S
+Shift key.
+.It Ic 4
+Mod4 (Super/Windows) key.
+.El
+.Pp
+Modifiers may be combined.
+For example,
+.Ic 4S-Return
+means Mod4+Shift+Return.
+.Pp
+Keysym names follow X11 naming conventions.
+Common keysyms include lowercase letters
+.Pq Ic a Ns \(en Ns Ic z ,
+digits
+.Pq Ic 0 Ns \(en Ns Ic 9 ,
+and special keys such as
+.Ic Return ,
+.Ic space ,
+.Ic Tab ,
+.Ic Escape ,
+.Ic BackSpace ,
+.Ic Delete ,
+.Ic Left ,
+.Ic Right ,
+.Ic Up ,
+.Ic Down ,
+.Ic Home ,
+.Ic End ,
+.Ic Page_Up ,
+.Ic Page_Down ,
+and
+.Ic F1
+through
+.Ic F12 .
+Punctuation keys use their X11 names:
+.Ic minus ,
+.Ic equal ,
+.Ic bracketleft ,
+.Ic bracketright ,
+.Ic semicolon ,
+.Ic apostrophe ,
+.Ic grave ,
+.Ic comma ,
+.Ic period ,
+.Ic slash ,
+and
+.Ic backslash .
+.Pp
+If any
+.Ic bind-key
+directive appears in the configuration file,
+all default key bindings are discarded and only the bindings
+defined in the file are used.
+.Pp
+The
+.Ar function
+may be one of the functions from the
+.Sx BIND FUNCTION LIST
+(see below).
+If
+.Ar function
+does not match any known name, it is treated as a command
+to be executed with
+.Xr execvp 3 .
+.El
+.Sh BIND FUNCTION LIST
+The following functions are available for use with
+.Ic bind-key :
+.Bl -tag -width "layout-toggle-splitXX" -compact
+.It Ic focus-next
+Focus the next sibling window.
+.It Ic focus-prev
+Focus the previous sibling window.
+.It Ic focus-parent
+Focus the parent container.
+.It Ic focus-child
+Focus the child container.
+.It Ic swap-next
+Swap focused window with the next sibling.
+.It Ic swap-prev
+Swap focused window with the previous sibling.
+.It Ic split-vertical
+Set the next window to open in a vertical split.
+.It Ic split-horizontal
+Set the next window to open in a horizontal split.
+.It Ic layout-stacked
+Set the parent container layout to stacked.
+.It Ic layout-tabbed
+Set the parent container layout to tabbed.
+.It Ic layout-toggle-split
+Toggle the parent container layout between horizontal and vertical split.
+.It Ic toggle-fullscreen
+Toggle fullscreen on the focused window.
+.It Ic toggle-floating
+Toggle floating on the focused window.
+.It Ic toggle-sticky
+Toggle sticky on the focused floating window.
+A sticky window remains visible across all workspace switches.
+Only floating windows can be made sticky.
+.It Ic resize Ar direction Ar dimension
+Resize the focused tiling container.
+.Ar direction
+is one of
+.Cm grow
+or
+.Cm shrink .
+.Ar dimension
+is one of
+.Cm width
+or
+.Cm height .
+Each step adjusts the container's share by 5%.
+.It Ic mode Ar name
+Switch to binding mode
+.Ar name .
+Use
+.Cm default
+to return to the default key bindings.
+See
+.Sx BINDING MODES
+below.
+.It Ic mark Ar name
+Set a mark on the focused window.
+If another window already has the mark, it is removed first.
+.It Ic unmark Op Ar name
+Remove a specific mark, or all marks if no
+.Ar name
+is given.
+.It Ic close-window
+Close the focused window.
+.It Ic spawn Ar command
+Execute
+.Ar command .
+.It Ic workspace Ar N
+Switch to workspace
+.Ar N .
+.It Ic move-to-workspace Ar N
+Move the focused window to workspace
+.Ar N .
+.It Ic exit
+Exit
+.Xr owm 1 .
+.El
+.Sh BINDING MODES
+Binding modes provide alternate sets of key bindings that can be
+activated dynamically.
+A mode is defined with the
+.Ic mode
+keyword, followed by
+.Ic bind-key
+directives, and terminated with
+.Ic end :
+.Bd -literal -offset indent
+mode resize
+    bind-key 4-h    resize shrink width
+    bind-key 4-l    resize grow width
+    bind-key 4-j    resize grow height
+    bind-key 4-k    resize shrink height
+    bind-key 4-Escape   mode default
+    bind-key 4-Return   mode default
+end
+.Ed
+.Pp
+Switching to a mode replaces the active key bindings with those
+defined in the mode block.
+Use
+.Ic mode default
+to return to the default bindings.
+.Sh DEFAULT KEY BINDINGS
+The default modifier is Mod4 (Super key), referred to as
+.Ic 4
+below.
+.Pp
+.Bl -tag -width "4S-spaceXXXXXX" -compact
+.It Ic 4-j
+focus-next
+.It Ic 4-k
+focus-prev
+.It Ic 4-h
+focus-parent
+.It Ic 4-l
+focus-child
+.It Ic 4S-j
+swap-next
+.It Ic 4S-k
+swap-prev
+.It Ic 4-v
+split-vertical
+.It Ic 4-b
+split-horizontal
+.It Ic 4-s
+layout-stacked
+.It Ic 4-w
+layout-tabbed
+.It Ic 4-e
+layout-toggle-split
+.It Ic 4-f
+toggle-fullscreen
+.It Ic 4S-space
+toggle-floating
+.It Ic 4S-q
+close-window
+.It Ic 4-Return
+spawn xterm
+.It Ic 4-1 No \(en Ic 4-9
+workspace 1\(en9
+.It Ic 4S-1 No \(en Ic 4S-9
+move-to-workspace 1\(en9
+.It Ic 4S-e
+exit
+.El
+.Sh FILES
+.Bl -tag -width "~/.owmrcXXX" -compact
+.It Pa ~/.owmrc
+Default
+.Xr owm 1
+configuration file.
+.El
+.Sh EXAMPLES
+.Bd -literal
+# Appearance
+borderwidth 3
+gaps inner 8
+gaps outer 4
+smart_gaps on
+font 9x15
+color activeborder #5f87af
+color inactiveborder #333333
+color urgencyborder #900000
+color background #1a1a2e
+color titlefocused #ffffff
+color titleunfocused #888888
+
+# Title bar and border style
+default_border normal
+default_floating_border normal
+
+# Status bar
+bar_enabled yes
+bar_position top
+bar_color bg #1a1a2e
+bar_color fg #ffffff
+bar_color active #5f87af
+
+# Per-window rules
+for_window [class=Firefox] border pixel 0
+for_window [instance=dropdown] border none
+
+# Use st as default terminal
+terminal st
+
+# Click to focus
+focus-follows-mouse no
+
+# Key bindings (overrides all defaults)
+bind-key 4-j		focus-next
+bind-key 4-k		focus-prev
+bind-key 4-h		focus-parent
+bind-key 4-l		focus-child
+bind-key 4S-j		swap-next
+bind-key 4S-k		swap-prev
+bind-key 4-v		split-vertical
+bind-key 4-b		split-horizontal
+bind-key 4-s		layout-stacked
+bind-key 4-w		layout-tabbed
+bind-key 4-e		layout-toggle-split
+bind-key 4-f		toggle-fullscreen
+bind-key 4S-space	toggle-floating
+bind-key 4S-q		close-window
+bind-key 4-Return	spawn st
+bind-key 4-1		workspace 1
+bind-key 4-2		workspace 2
+bind-key 4-3		workspace 3
+bind-key 4S-1		move-to-workspace 1
+bind-key 4S-2		move-to-workspace 2
+bind-key 4S-3		move-to-workspace 3
+bind-key 4S-e		exit
+
+# Spawn arbitrary commands
+bind-key 4-d		"dmenu_run"
+bind-key 4S-Return	"st -e top"
+
+# Enter resize mode with Mod+r
+bind-key 4-r		mode resize
+
+# Resize mode
+mode resize
+    bind-key 4-h	resize shrink width
+    bind-key 4-l	resize grow width
+    bind-key 4-j	resize grow height
+    bind-key 4-k	resize shrink height
+    bind-key 4-Escape	mode default
+    bind-key 4-Return	mode default
+end
+.Ed
+.Sh SEE ALSO
+.Xr owm 1
+.Sh HISTORY
+The
+.Nm
+file format first appeared with
+.Xr owm 1 .
blob - /dev/null
blob + 7c9b34736c27135167471e64458d5f99e446958b (mode 644)
--- /dev/null
+++ owmrc.example
@@ -0,0 +1,100 @@
+# owm example configuration file
+# Copy to ~/.owmrc and customize to taste.
+# See owmrc(5) for the full reference.
+
+# Appearance
+borderwidth 2
+gap 4
+font Berkeley Mono:size=11
+default_border normal
+default_floating_border normal
+
+# Colors
+color activeborder   #4c7899
+color inactiveborder #333333
+color urgencyborder  #900000
+color background     #1a1a2e
+color titlefocused   #ffffff
+color titleunfocused #888888
+
+# Status bar
+bar_enabled yes
+bar_position top
+bar_status_command ostatus
+bar_color bg #1a1a2e
+bar_color fg #ffffff
+bar_color active #4c7899
+
+# Behaviour
+terminal xterm
+focus-follows-mouse yes
+
+# Per-window rules
+# for_window [class=Firefox] border pixel 0
+# for_window [instance=dropdown] border none
+
+# Key bindings (overrides all defaults)
+# Mod4 = Super key, S = Shift, C = Control, M = Alt
+
+# Focus
+bind-key 4-j         focus-next
+bind-key 4-k         focus-prev
+bind-key 4-h         focus-parent
+bind-key 4-l         focus-child
+
+# Move windows
+bind-key 4S-j        swap-next
+bind-key 4S-k        swap-prev
+
+# Split direction
+bind-key 4-v         split-vertical
+bind-key 4-b         split-horizontal
+
+# Layout
+bind-key 4-s         layout-stacked
+bind-key 4-w         layout-tabbed
+bind-key 4-e         layout-toggle-split
+
+# Window actions
+bind-key 4-f         toggle-fullscreen
+bind-key 4S-space    toggle-floating
+bind-key 4S-q        close-window
+
+# Spawn
+bind-key M-Return    spawn xterm
+bind-key M-d         spawn omenu
+
+# Workspaces
+bind-key 4-1         workspace 1
+bind-key 4-2         workspace 2
+bind-key 4-3         workspace 3
+bind-key 4-4         workspace 4
+bind-key 4-5         workspace 5
+bind-key 4-6         workspace 6
+bind-key 4-7         workspace 7
+bind-key 4-8         workspace 8
+bind-key 4-9         workspace 9
+bind-key 4S-1        move-to-workspace 1
+bind-key 4S-2        move-to-workspace 2
+bind-key 4S-3        move-to-workspace 3
+bind-key 4S-4        move-to-workspace 4
+bind-key 4S-5        move-to-workspace 5
+bind-key 4S-6        move-to-workspace 6
+bind-key 4S-7        move-to-workspace 7
+bind-key 4S-8        move-to-workspace 8
+bind-key 4S-9        move-to-workspace 9
+
+# Resize mode
+bind-key 4-r         mode resize
+
+mode resize
+    bind-key 4-h     resize shrink width
+    bind-key 4-l     resize grow width
+    bind-key 4-j     resize grow height
+    bind-key 4-k     resize shrink height
+    bind-key 4-Escape mode default
+    bind-key 4-Return mode default
+end
+
+# Exit
+bind-key 4S-e        exit
blob - /dev/null
blob + 45cae8deccbef926d2c53be754ddb65975eb0bb5 (mode 644)
--- /dev/null
+++ src/bar.rs
@@ -0,0 +1,528 @@
+use std::os::raw::c_ulong;
+use std::os::unix::io::RawFd;
+
+use xcb::x;
+use xcb::Xid;
+
+use crate::bar_child::BarChild;
+use crate::bar_proto::{self, ClickEvent};
+use crate::config::{BarPosition, Config};
+use crate::container::{ConType, ContainerTree, NodeKey, Rect};
+use crate::xcb_conn::XConn;
+
+/// Horizontal padding around each workspace label and status block.
+const PAD: u32 = 8;
+/// Width of the separator gap between status blocks.
+const SEP_WIDTH: u32 = 9;
+
+/// Result of a click on the bar.
+pub enum BarAction {
+    SwitchWorkspace(NodeKey),
+}
+
+/// Built-in status bar with optional child process for status blocks.
+pub struct Bar {
+    window: x::Window,
+    height: u32,
+    position: BarPosition,
+    child: Option<BarChild>,
+    /// Hit-test rectangles for workspace buttons: (key, x, width).
+    ws_rects: Vec<(NodeKey, i32, u32)>,
+    /// Hit-test rectangles for status blocks: (block_index, x, width).
+    block_rects: Vec<(usize, i32, u32)>,
+}
+
+impl Bar {
+    /// Create and map the status bar window, optionally spawning a child.
+    pub fn new(xconn: &XConn, config: &Config) -> Self {
+        let height = xconn.deco_height;
+        let screen = xconn.screen_rect;
+        let y = match config.bar_position {
+            BarPosition::Top => screen.y,
+            BarPosition::Bottom => {
+                screen.y + screen.h as i32 - height as i32
+            }
+        };
+
+        let window: x::Window = xconn.conn.generate_id();
+        xconn.conn.send_request(&x::CreateWindow {
+            depth: 0,
+            wid: window,
+            parent: xconn.root,
+            x: screen.x as i16,
+            y: y as i16,
+            width: screen.w as u16,
+            height: height as u16,
+            border_width: 0,
+            class: x::WindowClass::InputOutput,
+            visual: xconn.root_visual,
+            value_list: &[
+                x::Cw::BackPixel(config.bar_color_bg),
+                x::Cw::OverrideRedirect(true),
+                x::Cw::EventMask(
+                    x::EventMask::EXPOSURE | x::EventMask::BUTTON_PRESS,
+                ),
+            ],
+        });
+
+        // Set EWMH properties so other apps know this is a dock.
+        xconn.set_window_type_dock(window);
+
+        // Reserve screen edge space via _NET_WM_STRUT_PARTIAL.
+        let mut strut = [0u32; 12];
+        match config.bar_position {
+            BarPosition::Top => {
+                strut[2] = height; // top
+                strut[8] = screen.x as u32; // top_start_x
+                strut[9] = screen.x as u32 + screen.w; // top_end_x
+            }
+            BarPosition::Bottom => {
+                strut[3] = height; // bottom
+                strut[10] = screen.x as u32; // bottom_start_x
+                strut[11] = screen.x as u32 + screen.w; // bottom_end_x
+            }
+        }
+        xconn.set_strut_partial(window, &strut);
+
+        xconn.conn.send_request(&x::MapWindow { window });
+        xconn.conn.send_request(&x::ConfigureWindow {
+            window,
+            value_list: &[x::ConfigWindow::StackMode(x::StackMode::Above)],
+        });
+
+        let child = config.bar_status_command.as_ref().and_then(|cmd| {
+            match BarChild::spawn(cmd) {
+                Ok(c) => Some(c),
+                Err(e) => {
+                    crate::log_warn!("bar status command: {e}");
+                    None
+                }
+            }
+        });
+
+        Bar {
+            window,
+            height,
+            position: config.bar_position,
+            child,
+            ws_rects: Vec::new(),
+            block_rects: Vec::new(),
+        }
+    }
+
+    /// File descriptor of the child's stdout for poll(), or None.
+    pub fn child_fd(&self) -> Option<RawFd> {
+        self.child.as_ref().map(|c| c.fd())
+    }
+
+    /// Read available output from the child. Returns true if a redraw is needed.
+    pub fn process_child_output(&mut self) -> bool {
+        match self.child.as_mut() {
+            Some(c) => c.read_available(),
+            None => false,
+        }
+    }
+
+    /// Redraw the bar: workspace buttons (left) + status blocks (right).
+    pub fn redraw(
+        &mut self,
+        xconn: &XConn,
+        config: &Config,
+        tree: &ContainerTree,
+        current_ws: NodeKey,
+    ) {
+        let screen = xconn.screen_rect;
+        let bar_w = screen.w;
+        let drawable = x::Drawable::Window(self.window);
+
+        // Create a temporary GC for drawing.
+        let gc: x::Gcontext = xconn.conn.generate_id();
+        xconn.conn.send_request(&x::CreateGc {
+            cid: gc,
+            drawable,
+            value_list: &[
+                x::Gc::Foreground(config.bar_color_bg),
+                x::Gc::Background(config.bar_color_bg),
+            ],
+        });
+
+        // Clear the bar.
+        xconn.conn.send_request(&x::PolyFillRectangle {
+            drawable,
+            gc,
+            rectangles: &[x::Rectangle {
+                x: 0,
+                y: 0,
+                width: bar_w as u16,
+                height: self.height as u16,
+            }],
+        });
+
+        let text_y =
+            (self.height / 2 + xconn.xft.ascent / 2) as i32;
+
+        // --- Left zone: workspace buttons ---
+        let ws_end = if config.bar_workspace_buttons {
+            self.draw_workspaces(xconn, config, tree, current_ws, gc, drawable, text_y)
+        } else {
+            self.ws_rects.clear();
+            0
+        };
+
+        // --- Right zone: status blocks ---
+        self.draw_status_blocks(xconn, config, gc, drawable, text_y, bar_w, ws_end);
+
+        xconn.conn.send_request(&x::FreeGc { gc });
+        xconn.conn.flush().ok();
+        xconn.xft.flush();
+    }
+
+    /// Draw workspace buttons. Returns the x position after the last button.
+    fn draw_workspaces(
+        &mut self,
+        xconn: &XConn,
+        config: &Config,
+        tree: &ContainerTree,
+        current_ws: NodeKey,
+        gc: x::Gcontext,
+        drawable: x::Drawable,
+        text_y: i32,
+    ) -> i32 {
+        let mut workspaces: Vec<(NodeKey, &str, i32)> = tree
+            .iter()
+            .filter(|(_, c)| c.con_type == ConType::Workspace)
+            .map(|(k, c)| (k, c.name.as_str(), c.num))
+            .collect();
+        workspaces.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.1.cmp(b.1)));
+
+        self.ws_rects.clear();
+        let mut x_pos: i32 = 0;
+
+        for (ws_key, name, _) in &workspaces {
+            let is_active = *ws_key == current_ws;
+            let label_w = xconn.xft.text_width(name) + 2 * PAD;
+
+            let bg = if is_active {
+                config.bar_color_active
+            } else {
+                config.bar_color_bg
+            };
+
+            // Draw background.
+            xconn.conn.send_request(&x::ChangeGc {
+                gc,
+                value_list: &[x::Gc::Foreground(bg)],
+            });
+            xconn.conn.send_request(&x::PolyFillRectangle {
+                drawable,
+                gc,
+                rectangles: &[x::Rectangle {
+                    x: x_pos as i16,
+                    y: 0,
+                    width: label_w as u16,
+                    height: self.height as u16,
+                }],
+            });
+
+            // Draw text (flush XCB before Xft).
+            let _ = xconn.conn.flush();
+            xconn.xft.draw_text(
+                self.window.resource_id() as c_ulong,
+                x_pos + PAD as i32,
+                text_y,
+                name,
+                config.bar_color_fg,
+            );
+
+            self.ws_rects.push((*ws_key, x_pos, label_w));
+            x_pos += label_w as i32;
+        }
+
+        x_pos
+    }
+
+    /// Draw status blocks right-aligned. Falls back to short text if space is tight.
+    fn draw_status_blocks(
+        &mut self,
+        xconn: &XConn,
+        config: &Config,
+        gc: x::Gcontext,
+        drawable: x::Drawable,
+        text_y: i32,
+        bar_w: u32,
+        ws_end: i32,
+    ) {
+        self.block_rects.clear();
+
+        let blocks = match self.child.as_ref() {
+            Some(c) if !c.blocks.is_empty() => &c.blocks,
+            _ => return,
+        };
+
+        let available = (bar_w as i32 - ws_end).max(0) as u32;
+
+        // Compute widths for each block (full text first).
+        let mut widths: Vec<(u32, bool)> = blocks
+            .iter()
+            .map(|b| {
+                let tw = xconn.xft.text_width(&b.text);
+                let w = tw + 2 * PAD;
+                let w = match b.min_width {
+                    Some(mw) if mw > w => mw,
+                    _ => w,
+                };
+                let sep = if b.separator { SEP_WIDTH } else { 0 };
+                (w + sep, false) // (total_width, using_short)
+            })
+            .collect();
+
+        // Total width with full text.
+        let total: u32 = widths.iter().map(|(w, _)| *w).sum();
+
+        // If too wide, progressively switch to short text.
+        if total > available {
+            let mut current_total = total;
+            for (i, block) in blocks.iter().enumerate() {
+                if current_total <= available {
+                    break;
+                }
+                if let Some(ref short) = block.short {
+                    let short_tw = xconn.xft.text_width(short);
+                    let short_w = short_tw + 2 * PAD;
+                    let short_w = match block.min_width {
+                        Some(mw) if mw > short_w => mw,
+                        _ => short_w,
+                    };
+                    let sep = if block.separator { SEP_WIDTH } else { 0 };
+                    let new_w = short_w + sep;
+                    current_total = current_total - widths[i].0 + new_w;
+                    widths[i] = (new_w, true);
+                }
+            }
+        }
+
+        // Draw from right to left.
+        let total_w: u32 = widths.iter().map(|(w, _)| *w).sum();
+        let mut x_pos = bar_w as i32 - total_w as i32;
+        // Don't overlap workspace buttons.
+        if x_pos < ws_end {
+            x_pos = ws_end;
+        }
+
+        for (i, block) in blocks.iter().enumerate() {
+            let (block_w, use_short) = widths[i];
+            let sep_w = if block.separator { SEP_WIDTH } else { 0 };
+            let content_w = block_w - sep_w;
+
+            // Draw background if specified.
+            if let Some(bg) = block.bg {
+                xconn.conn.send_request(&x::ChangeGc {
+                    gc,
+                    value_list: &[x::Gc::Foreground(bg)],
+                });
+                xconn.conn.send_request(&x::PolyFillRectangle {
+                    drawable,
+                    gc,
+                    rectangles: &[x::Rectangle {
+                        x: x_pos as i16,
+                        y: 0,
+                        width: content_w as u16,
+                        height: self.height as u16,
+                    }],
+                });
+            }
+
+            // Draw border if specified.
+            if let Some(border) = block.border {
+                xconn.conn.send_request(&x::ChangeGc {
+                    gc,
+                    value_list: &[x::Gc::Foreground(border)],
+                });
+                // Top border.
+                xconn.conn.send_request(&x::PolyFillRectangle {
+                    drawable,
+                    gc,
+                    rectangles: &[
+                        x::Rectangle {
+                            x: x_pos as i16,
+                            y: 0,
+                            width: content_w as u16,
+                            height: 1,
+                        },
+                        // Bottom border.
+                        x::Rectangle {
+                            x: x_pos as i16,
+                            y: (self.height - 1) as i16,
+                            width: content_w as u16,
+                            height: 1,
+                        },
+                        // Left border.
+                        x::Rectangle {
+                            x: x_pos as i16,
+                            y: 0,
+                            width: 1,
+                            height: self.height as u16,
+                        },
+                        // Right border.
+                        x::Rectangle {
+                            x: (x_pos + content_w as i32 - 1) as i16,
+                            y: 0,
+                            width: 1,
+                            height: self.height as u16,
+                        },
+                    ],
+                });
+            }
+
+            // Draw text.
+            let text = if use_short {
+                block.short.as_deref().unwrap_or(&block.text)
+            } else {
+                &block.text
+            };
+            let text_color = block.color.unwrap_or(config.bar_color_fg);
+
+            // Calculate text x based on alignment.
+            let tw = xconn.xft.text_width(text);
+            let text_x = match block.align {
+                bar_proto::Align::Left => x_pos + PAD as i32,
+                bar_proto::Align::Center => {
+                    x_pos + (content_w as i32 - tw as i32) / 2
+                }
+                bar_proto::Align::Right => {
+                    x_pos + content_w as i32 - tw as i32 - PAD as i32
+                }
+            };
+
+            let _ = xconn.conn.flush();
+            xconn.xft.draw_text(
+                self.window.resource_id() as c_ulong,
+                text_x,
+                text_y,
+                text,
+                text_color,
+            );
+
+            // Draw separator.
+            if block.separator && i + 1 < blocks.len() {
+                let sep_x = x_pos + content_w as i32 + SEP_WIDTH as i32 / 2;
+                match config.bar_separator_symbol.as_deref() {
+                    Some(sym) => {
+                        let _ = xconn.conn.flush();
+                        let sw = xconn.xft.text_width(sym);
+                        xconn.xft.draw_text(
+                            self.window.resource_id() as c_ulong,
+                            sep_x - sw as i32 / 2,
+                            text_y,
+                            sym,
+                            config.bar_color_separator,
+                        );
+                    }
+                    None => {
+                        // Draw a 1px vertical line.
+                        xconn.conn.send_request(&x::ChangeGc {
+                            gc,
+                            value_list: &[
+                                x::Gc::Foreground(config.bar_color_separator),
+                            ],
+                        });
+                        let line_top = (self.height / 4) as i16;
+                        let line_bot = (self.height * 3 / 4) as i16;
+                        xconn.conn.send_request(&x::PolyFillRectangle {
+                            drawable,
+                            gc,
+                            rectangles: &[x::Rectangle {
+                                x: sep_x as i16,
+                                y: line_top,
+                                width: 1,
+                                height: (line_bot - line_top) as u16,
+                            }],
+                        });
+                    }
+                }
+            }
+
+            self.block_rects.push((i, x_pos, content_w));
+            x_pos += block_w as i32;
+        }
+    }
+
+    /// Handle a click on the bar. Returns a BarAction if a workspace was clicked.
+    pub fn handle_click(&mut self, click_x: i32, click_y: i32, button: u32) -> Option<BarAction> {
+        // Check workspace buttons.
+        for &(ws_key, x, w) in &self.ws_rects {
+            if click_x >= x && click_x < x + w as i32 {
+                return Some(BarAction::SwitchWorkspace(ws_key));
+            }
+        }
+
+        // Check status blocks — send click event to child.
+        for &(idx, x, w) in &self.block_rects {
+            if click_x >= x && click_x < x + w as i32 {
+                if let Some(ref blocks) = self.child.as_ref().map(|c| &c.blocks) {
+                    if let Some(block) = blocks.get(idx) {
+                        let event = ClickEvent {
+                            name: block.name.clone(),
+                            instance: block.instance.clone(),
+                            button,
+                            x: click_x - x,
+                            y: click_y,
+                        };
+                        if let Some(ref mut child) = self.child {
+                            child.send_click(event);
+                        }
+                    }
+                }
+                return None;
+            }
+        }
+
+        None
+    }
+
+    /// Returns the X11 window ID for event matching.
+    pub fn window_id(&self) -> u32 {
+        self.window.resource_id()
+    }
+
+    /// Compute the usable workspace rect, accounting for the bar.
+    pub fn workarea(&self, screen: Rect) -> Rect {
+        match self.position {
+            BarPosition::Top => Rect {
+                x: screen.x,
+                y: screen.y + self.height as i32,
+                w: screen.w,
+                h: screen.h.saturating_sub(self.height),
+            },
+            BarPosition::Bottom => Rect {
+                x: screen.x,
+                y: screen.y,
+                w: screen.w,
+                h: screen.h.saturating_sub(self.height),
+            },
+        }
+    }
+
+    /// Stop the child process (SIGSTOP).
+    pub fn stop_child(&self) {
+        if let Some(ref c) = self.child {
+            c.stop();
+        }
+    }
+
+    /// Resume the child process (SIGCONT).
+    pub fn resume_child(&self) {
+        if let Some(ref c) = self.child {
+            c.resume();
+        }
+    }
+
+    /// Destroy the bar window and kill the child process.
+    pub fn destroy(self, xconn: &XConn) {
+        // child is dropped here, which sends SIGTERM
+        drop(self.child);
+        xconn
+            .conn
+            .send_request(&x::DestroyWindow { window: self.window });
+    }
+}
blob - /dev/null
blob + ba92beac67f2878e2bc3d9e226a2139f08600357 (mode 644)
--- /dev/null
+++ src/bar_child.rs
@@ -0,0 +1,178 @@
+//! Child process management for the owm-bar status command.
+//!
+//! Spawns a shell command, reads its stdout line-by-line using the owm-bar
+//! protocol, and optionally writes click events to its stdin.
+
+use std::io::{Read, Write};
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::process::{Child, Command, Stdio};
+
+use crate::bar_proto::{
+    self, BarHeader, ClickEvent, StatusBlock,
+};
+use crate::sys;
+
+/// Parse state for the child's output stream.
+enum ParseState {
+    AwaitingHeader,
+    Streaming,
+}
+
+/// A running status command child process.
+pub struct BarChild {
+    child: Child,
+    stdout_fd: RawFd,
+    buf: Vec<u8>,
+    state: ParseState,
+    header: Option<BarHeader>,
+    pub blocks: Vec<StatusBlock>,
+    click_events: bool,
+}
+
+impl BarChild {
+    /// Spawn a status command via the shell.
+    pub fn spawn(command: &str) -> Result<Self, String> {
+        let shell = if cfg!(target_os = "openbsd") {
+            "/bin/ksh"
+        } else {
+            "/bin/sh"
+        };
+
+        let child = Command::new(shell)
+            .args(["-c", command])
+            .stdout(Stdio::piped())
+            .stdin(Stdio::piped())
+            .stderr(Stdio::null())
+            .spawn()
+            .map_err(|e| format!("failed to spawn status command: {e}"))?;
+
+        let stdout = child.stdout.as_ref().ok_or("no stdout")?;
+        let stdout_fd = stdout.as_raw_fd();
+        sys::set_nonblocking(stdout_fd);
+
+        Ok(BarChild {
+            child,
+            stdout_fd,
+            buf: Vec::with_capacity(4096),
+            state: ParseState::AwaitingHeader,
+            header: None,
+            blocks: Vec::new(),
+            click_events: false,
+        })
+    }
+
+    /// File descriptor for the child's stdout (for poll).
+    pub fn fd(&self) -> RawFd {
+        self.stdout_fd
+    }
+
+    /// Read available data from the child. Returns true if blocks changed.
+    pub fn read_available(&mut self) -> bool {
+        let mut tmp = [0u8; 4096];
+        let n = match self.child.stdout.as_mut() {
+            Some(stdout) => match stdout.read(&mut tmp) {
+                Ok(0) => {
+                    // EOF — child exited
+                    crate::log_warn!("bar child exited");
+                    return false;
+                }
+                Ok(n) => n,
+                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+                    return false;
+                }
+                Err(e) => {
+                    crate::log_warn!("bar child read error: {e}");
+                    return false;
+                }
+            },
+            None => return false,
+        };
+
+        self.buf.extend_from_slice(&tmp[..n]);
+        let mut changed = false;
+
+        // Process complete lines.
+        while let Some(newline_pos) = self.buf.iter().position(|&b| b == b'\n') {
+            let line: Vec<u8> = self.buf.drain(..=newline_pos).collect();
+            let line = match std::str::from_utf8(&line) {
+                Ok(s) => s.trim(),
+                Err(_) => continue,
+            };
+            if line.is_empty() {
+                continue;
+            }
+
+            match self.state {
+                ParseState::AwaitingHeader => {
+                    match bar_proto::parse_header(line) {
+                        Ok(header) => {
+                            self.click_events = header.click_events;
+                            self.header = Some(header);
+                            self.state = ParseState::Streaming;
+                            crate::log_info!(
+                                "bar child header: click_events={}",
+                                self.click_events
+                            );
+                        }
+                        Err(e) => {
+                            crate::log_warn!("bar child: {e}");
+                        }
+                    }
+                }
+                ParseState::Streaming => {
+                    match bar_proto::parse_status_line(line) {
+                        Ok(blocks) => {
+                            self.blocks = blocks;
+                            changed = true;
+                        }
+                        Err(e) => {
+                            crate::log_warn!("bar child: {e}");
+                        }
+                    }
+                }
+            }
+        }
+
+        changed
+    }
+
+    /// Send a click event to the child's stdin.
+    pub fn send_click(&mut self, event: ClickEvent) {
+        if !self.click_events {
+            return;
+        }
+        let data = bar_proto::serialize_click(&event);
+        if let Some(ref mut stdin) = self.child.stdin {
+            // Non-blocking write; drop the event if it would block.
+            let _ = stdin.write_all(data.as_bytes());
+        }
+    }
+
+    /// Pause the child with SIGSTOP.
+    pub fn stop(&self) {
+        if let Some(pid) = self.pid() {
+            let _ = sys::kill_process(pid, sys::SIGSTOP);
+        }
+    }
+
+    /// Resume the child with SIGCONT.
+    pub fn resume(&self) {
+        if let Some(pid) = self.pid() {
+            let _ = sys::kill_process(pid, sys::SIGCONT);
+        }
+    }
+
+    fn pid(&self) -> Option<u32> {
+        Some(self.child.id())
+    }
+}
+
+impl Drop for BarChild {
+    fn drop(&mut self) {
+        if let Some(pid) = self.pid() {
+            let _ = sys::kill_process(pid, sys::SIGTERM);
+            // Reap to avoid zombie.
+            let _ = self.child.wait();
+        }
+    }
+}
blob - /dev/null
blob + f2a01b99e4e9a1bf1fd37ffa7a5820605b72279b (mode 644)
--- /dev/null
+++ src/bar_proto.rs
@@ -0,0 +1,193 @@
+//! owm-bar protocol: types and parsing for the status bar child process.
+//!
+//! The protocol is line-based JSON over stdout/stdin pipes:
+//!
+//! 1. Child sends a header line: `{"version": 1, "click_events": false}`
+//! 2. Child sends status lines, each a JSON array of blocks:
+//!    `[{"text": "CPU: 25%", "color": "#ff0000"}, {"text": "14:30"}]`
+//! 3. If click_events is true, owm writes click events to the child's stdin:
+//!    `{"name": "cpu", "button": 1, "x": 12, "y": 5}`
+
+use serde::{Deserialize, Serialize};
+
+/// Protocol header sent by the child on its first line.
+#[derive(Debug, Deserialize)]
+pub struct BarHeader {
+    pub version: u32,
+    #[serde(default)]
+    pub click_events: bool,
+}
+
+/// Alignment for text within a status block.
+#[derive(Debug, Clone, Copy, Default)]
+pub enum Align {
+    #[default]
+    Left,
+    Center,
+    Right,
+}
+
+/// A single status block from the child's output.
+#[derive(Debug)]
+pub struct StatusBlock {
+    pub text: String,
+    pub short: Option<String>,
+    pub color: Option<u32>,
+    pub bg: Option<u32>,
+    pub border: Option<u32>,
+    pub min_width: Option<u32>,
+    pub align: Align,
+    pub name: Option<String>,
+    pub instance: Option<String>,
+    pub urgent: bool,
+    pub separator: bool,
+}
+
+/// Click event sent to the child's stdin.
+#[derive(Debug, Serialize)]
+pub struct ClickEvent {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub name: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub instance: Option<String>,
+    pub button: u32,
+    pub x: i32,
+    pub y: i32,
+}
+
+/// Raw JSON shape for deserializing a status block.
+#[derive(Deserialize)]
+struct RawBlock {
+    text: String,
+    short: Option<String>,
+    color: Option<String>,
+    bg: Option<String>,
+    border: Option<String>,
+    min_width: Option<u32>,
+    #[serde(default)]
+    align: Option<String>,
+    name: Option<String>,
+    instance: Option<String>,
+    #[serde(default)]
+    urgent: bool,
+    #[serde(default = "default_true")]
+    separator: bool,
+}
+
+fn default_true() -> bool {
+    true
+}
+
+/// Parse a `#rrggbb` hex color string to a u32.
+pub fn parse_color(s: &str) -> Option<u32> {
+    let s = s.strip_prefix('#')?;
+    if s.len() != 6 {
+        return None;
+    }
+    u32::from_str_radix(s, 16).ok()
+}
+
+fn parse_align(s: Option<&str>) -> Align {
+    match s {
+        Some("center") => Align::Center,
+        Some("right") => Align::Right,
+        _ => Align::Left,
+    }
+}
+
+impl From<RawBlock> for StatusBlock {
+    fn from(raw: RawBlock) -> Self {
+        StatusBlock {
+            text: raw.text,
+            short: raw.short,
+            color: raw.color.as_deref().and_then(parse_color),
+            bg: raw.bg.as_deref().and_then(parse_color),
+            border: raw.border.as_deref().and_then(parse_color),
+            min_width: raw.min_width,
+            align: parse_align(raw.align.as_deref()),
+            name: raw.name,
+            instance: raw.instance,
+            urgent: raw.urgent,
+            separator: raw.separator,
+        }
+    }
+}
+
+/// Parse the protocol header. Returns an error if version != 1.
+pub fn parse_header(line: &str) -> Result<BarHeader, String> {
+    let header: BarHeader =
+        serde_json::from_str(line).map_err(|e| format!("bad header: {e}"))?;
+    if header.version != 1 {
+        return Err(format!("unsupported protocol version {}", header.version));
+    }
+    Ok(header)
+}
+
+/// Parse a status line (JSON array of blocks).
+pub fn parse_status_line(line: &str) -> Result<Vec<StatusBlock>, String> {
+    let raw: Vec<RawBlock> =
+        serde_json::from_str(line).map_err(|e| format!("bad status line: {e}"))?;
+    Ok(raw.into_iter().map(StatusBlock::from).collect())
+}
+
+/// Serialize a click event to a JSON line (with trailing newline).
+pub fn serialize_click(event: &ClickEvent) -> String {
+    let mut s = serde_json::to_string(event).unwrap_or_default();
+    s.push('\n');
+    s
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_color() {
+        assert_eq!(parse_color("#ff0000"), Some(0xff0000));
+        assert_eq!(parse_color("#00ff00"), Some(0x00ff00));
+        assert_eq!(parse_color("#0000ff"), Some(0x0000ff));
+        assert_eq!(parse_color("invalid"), None);
+        assert_eq!(parse_color("#fff"), None);
+    }
+
+    #[test]
+    fn test_parse_header() {
+        let h = parse_header(r##"{"version": 1, "click_events": true}"##).unwrap();
+        assert_eq!(h.version, 1);
+        assert!(h.click_events);
+
+        let h = parse_header(r##"{"version": 1}"##).unwrap();
+        assert!(!h.click_events);
+
+        assert!(parse_header(r##"{"version": 2}"##).is_err());
+    }
+
+    #[test]
+    fn test_parse_status_line() {
+        let blocks = parse_status_line(
+            r##"[{"text": "CPU: 25%", "color": "#ff0000"}, {"text": "14:30"}]"##,
+        )
+        .unwrap();
+        assert_eq!(blocks.len(), 2);
+        assert_eq!(blocks[0].text, "CPU: 25%");
+        assert_eq!(blocks[0].color, Some(0xff0000));
+        assert_eq!(blocks[1].text, "14:30");
+        assert!(blocks[1].separator);
+    }
+
+    #[test]
+    fn test_serialize_click() {
+        let event = ClickEvent {
+            name: Some("cpu".into()),
+            instance: None,
+            button: 1,
+            x: 10,
+            y: 5,
+        };
+        let s = serialize_click(&event);
+        assert!(s.contains("\"name\":\"cpu\""));
+        assert!(s.contains("\"button\":1"));
+        assert!(!s.contains("instance"));
+        assert!(s.ends_with('\n'));
+    }
+}
blob - /dev/null
blob + a7c08f41dab55e88d7974993248e8de3d254b016 (mode 644)
--- /dev/null
+++ src/bin/omenu.rs
@@ -0,0 +1,539 @@
+//! omenu — minimal application launcher for owm.
+//!
+//! Centered, vertical-list launcher inspired by rofi.  Reads executables
+//! from $PATH, filters as you type, highlights matches.  Pure xcb + Xft.
+
+use std::collections::BTreeSet;
+use std::env;
+use std::os::raw::c_ulong;
+use std::os::unix::fs::PermissionsExt;
+use std::process::Command;
+
+use xcb::x;
+use xcb::{Connection, Xid};
+
+use owm::xft_font::XftFont;
+
+// Layout
+const MENU_WIDTH: u16 = 600;
+const LINE_H: u16 = 22;
+const INPUT_H: u16 = 28;
+const BORDER_W: u16 = 2;
+const PAD_X: i32 = 8;
+const MAX_VISIBLE: usize = 20;
+
+// Colors — matched to owm light theme defaults
+const COL_BG: u32 = 0xe0e0e0;
+const COL_FG: u32 = 0x333333;
+const COL_INPUT_BG: u32 = 0xf5f5f5;
+const COL_INPUT_FG: u32 = 0x333333;
+const COL_SEL_BG: u32 = 0x5294e2;
+const COL_SEL_FG: u32 = 0xffffff;
+const COL_MATCH: u32 = 0x4c7899;
+const COL_BORDER: u32 = 0x4c7899;
+const COL_COUNT: u32 = 0x888888;
+const FONT_NAME: &str = "monospace:size=11";
+
+struct Menu {
+    xft: XftFont,
+    conn: Connection,
+    win: x::Window,
+    gc: x::Gcontext,
+    input: String,
+    entries: Vec<String>,
+    filtered: Vec<usize>, // indices into entries
+    selected: usize,
+    scroll: usize,
+    running: bool,
+    result: Option<String>,
+    menu_h: u16,
+}
+
+impl Menu {
+    fn new() -> Result<Self, Box<dyn std::error::Error>> {
+        // Pure XCB connection for window management (preserves OverrideRedirect).
+        let (conn, screen_num) = Connection::connect(None)?;
+
+        let setup = conn.get_setup();
+        let screen = setup.roots().nth(screen_num as usize).ok_or("no screen")?;
+        let root = screen.root();
+        let screen_w = screen.width_in_pixels();
+        let screen_h = screen.height_in_pixels();
+
+        // Open a separate Xlib Display for Xft rendering.
+        let dpy = unsafe { x11::xlib::XOpenDisplay(std::ptr::null()) };
+        if dpy.is_null() {
+            return Err("failed to open Xlib display for Xft".into());
+        }
+        let xft = unsafe { XftFont::open(dpy, screen_num, FONT_NAME) }
+            .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
+
+        // GC (for rectangles only)
+        let gc = conn.generate_id();
+        conn.send_request(&x::CreateGc {
+            cid: gc,
+            drawable: x::Drawable::Window(root),
+            value_list: &[
+                x::Gc::Foreground(COL_FG),
+                x::Gc::Background(COL_BG),
+            ],
+        });
+
+        let entries = collect_executables();
+        let filtered: Vec<usize> = (0..entries.len()).collect();
+        let visible = filtered.len().min(MAX_VISIBLE);
+        let menu_h =
+            INPUT_H + (visible as u16) * LINE_H + 2 * BORDER_W;
+        let menu_x = (screen_w - MENU_WIDTH) as i16 / 2;
+        let menu_y = (screen_h - menu_h) as i16 / 3; // upper third
+
+        let win: x::Window = conn.generate_id();
+        conn.send_request(&x::CreateWindow {
+            depth: x::COPY_FROM_PARENT as u8,
+            wid: win,
+            parent: root,
+            x: menu_x,
+            y: menu_y,
+            width: MENU_WIDTH,
+            height: menu_h,
+            border_width: BORDER_W,
+            class: x::WindowClass::InputOutput,
+            visual: screen.root_visual(),
+            value_list: &[
+                x::Cw::BackPixel(COL_BG),
+                x::Cw::BorderPixel(COL_BORDER),
+                x::Cw::OverrideRedirect(true),
+                x::Cw::EventMask(
+                    x::EventMask::EXPOSURE | x::EventMask::KEY_PRESS,
+                ),
+            ],
+        });
+
+        conn.send_request(&x::MapWindow { window: win });
+        conn.send_request(&x::ConfigureWindow {
+            window: win,
+            value_list: &[x::ConfigWindow::StackMode(x::StackMode::Above)],
+        });
+        conn.flush()?;
+
+        // Grab keyboard — retry until the grab succeeds.
+        // The WM may briefly hold a passive grab from the key binding
+        // that launched us, so we need to wait for it to release.
+        let mut grabbed = false;
+        for _ in 0..50 {
+            let cookie = conn.send_request(&x::GrabKeyboard {
+                owner_events: false,
+                grab_window: win,
+                time: x::CURRENT_TIME,
+                pointer_mode: x::GrabMode::Async,
+                keyboard_mode: x::GrabMode::Async,
+            });
+            if let Ok(reply) = conn.wait_for_reply(cookie) {
+                if reply.status() == x::GrabStatus::Success {
+                    grabbed = true;
+                    break;
+                }
+            }
+            std::thread::sleep(std::time::Duration::from_millis(10));
+        }
+        if !grabbed {
+            return Err("failed to grab keyboard".into());
+        }
+
+        conn.send_request(&x::SetInputFocus {
+            revert_to: x::InputFocus::PointerRoot,
+            focus: win,
+            time: x::CURRENT_TIME,
+        });
+        conn.flush()?;
+
+        Ok(Menu {
+            conn,
+            win,
+            gc,
+            xft,
+            input: String::new(),
+            entries,
+            filtered,
+            selected: 0,
+            scroll: 0,
+            running: true,
+            result: None,
+            menu_h,
+        })
+    }
+
+    fn run(&mut self) -> Result<Option<String>, Box<dyn std::error::Error>> {
+        self.draw();
+        while self.running {
+            let event = self.conn.wait_for_event()?;
+            match event {
+                xcb::Event::X(x::Event::Expose(_)) => self.draw(),
+                xcb::Event::X(x::Event::KeyPress(ev)) => self.on_key(ev),
+                _ => {}
+            }
+        }
+        Ok(self.result.take())
+    }
+
+    fn on_key(&mut self, ev: x::KeyPressEvent) {
+        let keycode = ev.detail();
+        let state = ev.state();
+        let shift = state.contains(x::KeyButMask::SHIFT);
+        let ctrl = state.contains(x::KeyButMask::CONTROL);
+
+        match keycode {
+            9 => self.running = false, // Escape
+            36 => {
+                // Return
+                if let Some(&idx) = self.filtered.get(self.selected) {
+                    self.result = Some(self.entries[idx].clone());
+                } else if !self.input.is_empty() {
+                    self.result = Some(self.input.clone());
+                }
+                self.running = false;
+            }
+            22 => {
+                // BackSpace
+                if ctrl {
+                    let trimmed = self.input.trim_end();
+                    if let Some(pos) = trimmed.rfind(' ') {
+                        self.input.truncate(pos + 1);
+                    } else {
+                        self.input.clear();
+                    }
+                } else {
+                    self.input.pop();
+                }
+                self.update_filter();
+                self.resize_and_draw();
+            }
+            23 => {
+                // Tab
+                if let Some(&idx) = self.filtered.get(self.selected) {
+                    self.input = self.entries[idx].clone();
+                    self.update_filter();
+                    self.resize_and_draw();
+                }
+            }
+            111 => {
+                // Up
+                self.move_sel(-1);
+            }
+            116 => {
+                // Down
+                self.move_sel(1);
+            }
+            _ if ctrl && keycode == 45 => self.move_sel(-1), // Ctrl+k
+            _ if ctrl && keycode == 44 => self.move_sel(1),  // Ctrl+j
+            _ if ctrl && keycode == 33 => {                  // Ctrl+p
+                self.move_sel(-1);
+            }
+            _ if ctrl && keycode == 57 => {                  // Ctrl+n
+                self.move_sel(1);
+            }
+            _ if ctrl && keycode == 30 => {
+                // Ctrl+u
+                self.input.clear();
+                self.update_filter();
+                self.resize_and_draw();
+            }
+            _ => {
+                if let Some(ch) = keycode_to_char(keycode, shift) {
+                    self.input.push(ch);
+                    self.update_filter();
+                    self.resize_and_draw();
+                }
+            }
+        }
+    }
+
+    fn move_sel(&mut self, delta: i32) {
+        if self.filtered.is_empty() {
+            return;
+        }
+        if delta < 0 && self.selected > 0 {
+            self.selected -= 1;
+        } else if delta > 0 && self.selected + 1 < self.filtered.len() {
+            self.selected += 1;
+        }
+        self.ensure_visible();
+        self.draw();
+    }
+
+    fn update_filter(&mut self) {
+        let query = self.input.to_lowercase();
+        self.filtered = if query.is_empty() {
+            (0..self.entries.len()).collect()
+        } else {
+            // Prefix matches first, then substring
+            let mut prefix: Vec<usize> = Vec::new();
+            let mut substr: Vec<usize> = Vec::new();
+            for (i, e) in self.entries.iter().enumerate() {
+                let lower = e.to_lowercase();
+                if lower.starts_with(&query) {
+                    prefix.push(i);
+                } else if lower.contains(&query) {
+                    substr.push(i);
+                }
+            }
+            prefix.extend(substr);
+            prefix
+        };
+        self.selected = 0;
+        self.scroll = 0;
+    }
+
+    fn ensure_visible(&mut self) {
+        if self.selected < self.scroll {
+            self.scroll = self.selected;
+        }
+        if self.selected >= self.scroll + MAX_VISIBLE {
+            self.scroll = self.selected - MAX_VISIBLE + 1;
+        }
+    }
+
+    fn resize_and_draw(&mut self) {
+        let visible = self.filtered.len().min(MAX_VISIBLE);
+        let new_h = INPUT_H + (visible as u16) * LINE_H + 2 * BORDER_W;
+        if new_h != self.menu_h {
+            self.menu_h = new_h;
+            // Keep top position fixed — only resize height.
+            self.conn.send_request(&x::ConfigureWindow {
+                window: self.win,
+                value_list: &[
+                    x::ConfigWindow::Height(new_h as u32),
+                ],
+            });
+        }
+        self.draw();
+    }
+
+    fn draw(&self) {
+        let d = x::Drawable::Window(self.win);
+        let drawable_id = self.win.resource_id() as c_ulong;
+        let w = MENU_WIDTH;
+
+        // Clear background
+        self.set_fg(COL_BG);
+        self.fill_rect(d, 0, 0, w, self.menu_h);
+
+        // Input area background
+        self.set_fg(COL_INPUT_BG);
+        self.fill_rect(d, 0, 0, w, INPUT_H);
+
+        // Flush XCB before Xft draws
+        let _ = self.conn.flush();
+
+        // Input text
+        let ty = self.xft.ascent as i32
+            + (INPUT_H as i32 - self.xft.ascent as i32) / 2;
+        self.xft.draw_text(
+            drawable_id,
+            PAD_X,
+            ty,
+            &self.input,
+            COL_INPUT_FG,
+        );
+
+        // Cursor
+        let cursor_x = PAD_X + self.xft.text_width(&self.input) as i32;
+        self.set_fg(COL_INPUT_FG);
+        self.fill_rect(d, cursor_x as u16, 4, 1, INPUT_H - 8);
+
+        // Counter "N/total" at top-right
+        let count_str = format!(
+            "{}/{}",
+            self.filtered.len(),
+            self.entries.len()
+        );
+        let count_w = self.xft.text_width(&count_str) as i32;
+        let count_x = w as i32 - PAD_X - count_w;
+        self.xft.draw_text(
+            drawable_id,
+            count_x,
+            ty,
+            &count_str,
+            COL_COUNT,
+        );
+
+        // Separator line below input
+        self.set_fg(COL_BORDER);
+        self.fill_rect(d, 0, INPUT_H, w, 1);
+
+        // List entries
+        let query = self.input.to_lowercase();
+        let list_y_start = INPUT_H + 1;
+
+        for (vi, &entry_idx) in self
+            .filtered
+            .iter()
+            .skip(self.scroll)
+            .take(MAX_VISIBLE)
+            .enumerate()
+        {
+            let abs_idx = self.scroll + vi;
+            let is_sel = abs_idx == self.selected;
+            let y = list_y_start + (vi as u16) * LINE_H;
+            let text = &self.entries[entry_idx];
+
+            // Row background
+            let (bg, fg) = if is_sel {
+                (COL_SEL_BG, COL_SEL_FG)
+            } else {
+                (COL_BG, COL_FG)
+            };
+            self.set_fg(bg);
+            self.fill_rect(d, 0, y, w, LINE_H);
+
+            // Flush XCB before Xft text
+            let _ = self.conn.flush();
+
+            // Draw text with match highlighting
+            let text_y = y as i32 + self.xft.ascent as i32
+                + (LINE_H as i32 - self.xft.ascent as i32) / 2;
+            if !query.is_empty() {
+                self.draw_highlighted(
+                    drawable_id, PAD_X, text_y, text, &query, fg, is_sel,
+                );
+            } else {
+                self.xft.draw_text(drawable_id, PAD_X, text_y, text, fg);
+            }
+        }
+
+        self.xft.flush();
+        let _ = self.conn.flush();
+    }
+
+    /// Draw text with matched characters in bright white.
+    fn draw_highlighted(
+        &self,
+        drawable: c_ulong,
+        x: i32,
+        y: i32,
+        text: &str,
+        query: &str,
+        fg: u32,
+        is_sel: bool,
+    ) {
+        let lower = text.to_lowercase();
+        let match_start = lower.find(query);
+
+        if let Some(start) = match_start {
+            let end = start + query.len();
+            let mut cx = x;
+
+            // Before match
+            if start > 0 {
+                let before = &text[..start];
+                self.xft.draw_text(drawable, cx, y, before, fg);
+                cx += self.xft.text_width(before) as i32;
+            }
+
+            // Match portion: bright white
+            let matched = &text[start..end];
+            let match_fg = if is_sel { COL_SEL_FG } else { COL_MATCH };
+            self.xft.draw_text(drawable, cx, y, matched, match_fg);
+            cx += self.xft.text_width(matched) as i32;
+
+            // After match
+            if end < text.len() {
+                let after = &text[end..];
+                self.xft.draw_text(drawable, cx, y, after, fg);
+            }
+        } else {
+            self.xft.draw_text(drawable, x, y, text, fg);
+        }
+    }
+
+    fn set_fg(&self, color: u32) {
+        self.conn.send_request(&x::ChangeGc {
+            gc: self.gc,
+            value_list: &[x::Gc::Foreground(color)],
+        });
+    }
+
+    fn fill_rect(&self, d: x::Drawable, x: u16, y: u16, w: u16, h: u16) {
+        self.conn.send_request(&x::PolyFillRectangle {
+            drawable: d,
+            gc: self.gc,
+            rectangles: &[x::Rectangle {
+                x: x as i16,
+                y: y as i16,
+                width: w,
+                height: h,
+            }],
+        });
+    }
+}
+
+fn collect_executables() -> Vec<String> {
+    let path = env::var("PATH").unwrap_or_default();
+    let mut names = BTreeSet::new();
+    for dir in path.split(':') {
+        if dir.is_empty() {
+            continue;
+        }
+        let entries = match std::fs::read_dir(dir) {
+            Ok(e) => e,
+            Err(_) => continue,
+        };
+        for entry in entries.flatten() {
+            // Follow symlinks: entry.metadata() uses lstat, but we
+            // want stat to resolve symlinks like /usr/local/bin/firefox.
+            let meta = match std::fs::metadata(entry.path()) {
+                Ok(m) => m,
+                Err(_) => continue,
+            };
+            if !meta.is_file() || meta.permissions().mode() & 0o111 == 0 {
+                continue;
+            }
+            if let Some(name) = entry.file_name().to_str() {
+                names.insert(name.to_string());
+            }
+        }
+    }
+    names.into_iter().collect()
+}
+
+fn keycode_to_char(keycode: u8, shift: bool) -> Option<char> {
+    let ch = match keycode {
+        38 => 'a', 56 => 'b', 54 => 'c', 40 => 'd', 26 => 'e',
+        41 => 'f', 42 => 'g', 43 => 'h', 31 => 'i', 44 => 'j',
+        45 => 'k', 46 => 'l', 58 => 'm', 57 => 'n', 32 => 'o',
+        33 => 'p', 24 => 'q', 27 => 'r', 39 => 's', 28 => 't',
+        30 => 'u', 55 => 'v', 25 => 'w', 53 => 'x', 29 => 'y',
+        52 => 'z',
+        19 => '0', 10 => '1', 11 => '2', 12 => '3', 13 => '4',
+        14 => '5', 15 => '6', 16 => '7', 17 => '8', 18 => '9',
+        65 => ' ', 20 => '-', 21 => '=', 61 => '/', 60 => '.',
+        59 => ',', 48 => '\'', 47 => ';',
+        _ => return None,
+    };
+    if shift {
+        Some(ch.to_uppercase().next().unwrap_or(ch))
+    } else {
+        Some(ch)
+    }
+}
+
+fn main() {
+    let mut menu = match Menu::new() {
+        Ok(m) => m,
+        Err(e) => {
+            eprintln!("omenu: {}", e);
+            std::process::exit(1);
+        }
+    };
+
+    match menu.run() {
+        Ok(Some(cmd)) => {
+            let _ = Command::new("sh").arg("-c").arg(&cmd).spawn();
+        }
+        Ok(None) => {}
+        Err(e) => {
+            eprintln!("omenu: {}", e);
+            std::process::exit(1);
+        }
+    }
+}
blob - /dev/null
blob + 38555e538370be37e2df9b4b3f655723fd3294cd (mode 644)
--- /dev/null
+++ src/bin/ostatus.rs
@@ -0,0 +1,476 @@
+//! ostatus — lightweight status bar feeder for owm-bar.
+//!
+//! Outputs the owm-bar JSON protocol on stdout:
+//!   1. Header: {"version": 1}
+//!   2. Repeated status lines: [{"text": "...", "name": "...", ...}, ...]
+//!
+//! Modules: cpu, memory, disk, network, battery, datetime.
+//! Designed for OpenBSD; uses sysctl(2), statvfs(2), ioctl(2), getifaddrs(3).
+
+use std::ffi::CString;
+use std::io::{self, Write};
+use std::mem::{self, MaybeUninit};
+use std::thread;
+use std::time::Duration;
+
+// ---------------------------------------------------------------------------
+// OpenBSD sysctl constants
+// ---------------------------------------------------------------------------
+
+const CTL_KERN: libc::c_int = 1;
+const CTL_HW: libc::c_int = 6;
+const CTL_VM: libc::c_int = 2;
+
+const KERN_CPTIME: libc::c_int = 40;
+const HW_PHYSMEM64: libc::c_int = 19;
+const VM_UVMEXP: libc::c_int = 4;
+
+// CPU states (from sys/sched.h)
+const _CP_USER: usize = 0;
+const _CP_NICE: usize = 1;
+const _CP_SYS: usize = 2;
+const _CP_SPIN: usize = 3;
+const _CP_INTR: usize = 4;
+const CP_IDLE: usize = 5;
+const CPUSTATES: usize = 6;
+
+// APM (battery) ioctl
+// APM_IOC_GETPOWER = _IOR('A', 3, struct apm_power_info)
+// sizeof(apm_power_info) = 32
+const APM_IOC_GETPOWER: libc::c_ulong = 0x40204103;
+
+const APM_AC_ON: u8 = 0x01;
+const APM_BATT_CHARGING: u8 = 0x03;
+const APM_BATT_ABSENT: u8 = 0x04;
+const APM_BATT_UNKNOWN: u8 = 0xff;
+
+// ---------------------------------------------------------------------------
+// Structs matching kernel definitions
+// ---------------------------------------------------------------------------
+
+/// Partial uvmexp — we only need the first few fields.
+#[repr(C)]
+struct UvmExp {
+    pagesize: i32,
+    pagemask: i32,
+    pageshift: i32,
+    npages: i32,
+    free: i32,
+    active: i32,
+    inactive: i32,
+    paging: i32,
+    wired: i32,
+    // rest unused
+}
+
+#[repr(C)]
+struct ApmPowerInfo {
+    battery_state: u8,
+    ac_state: u8,
+    battery_life: u8,
+    spare1: u8,
+    minutes_left: u32,
+    spare2: [u32; 6],
+}
+
+// ---------------------------------------------------------------------------
+// sysctl helper
+// ---------------------------------------------------------------------------
+
+unsafe fn sysctl_raw(mib: &[libc::c_int], buf: *mut u8, len: &mut usize) -> bool {
+    unsafe {
+        libc::sysctl(
+            mib.as_ptr(),
+            mib.len() as libc::c_uint,
+            buf as *mut libc::c_void,
+            len,
+            std::ptr::null_mut(),
+            0,
+        ) == 0
+    }
+}
+
+// ---------------------------------------------------------------------------
+// CPU
+// ---------------------------------------------------------------------------
+
+type CpTime = [i64; CPUSTATES];
+
+fn read_cp_time() -> CpTime {
+    let mib = [CTL_KERN, KERN_CPTIME];
+    let mut cp = [0i64; CPUSTATES];
+    let mut len = mem::size_of_val(&cp);
+    unsafe {
+        sysctl_raw(
+            &mib,
+            cp.as_mut_ptr() as *mut u8,
+            &mut len,
+        );
+    }
+    cp
+}
+
+fn cpu_usage(prev: &CpTime, cur: &CpTime) -> u32 {
+    let mut delta = [0i64; CPUSTATES];
+    let mut total: i64 = 0;
+    for i in 0..CPUSTATES {
+        delta[i] = cur[i] - prev[i];
+        total += delta[i];
+    }
+    if total == 0 {
+        return 0;
+    }
+    let idle = delta[CP_IDLE];
+    let busy = total - idle;
+    ((busy * 100) / total) as u32
+}
+
+// ---------------------------------------------------------------------------
+// Memory
+// ---------------------------------------------------------------------------
+
+struct MemInfo {
+    used_mb: u64,
+    total_mb: u64,
+    pct: u32,
+}
+
+fn read_memory() -> MemInfo {
+    // Total physical memory via hw.physmem64
+    let mib_phys = [CTL_HW, HW_PHYSMEM64];
+    let mut physmem: i64 = 0;
+    let mut len = mem::size_of_val(&physmem);
+    unsafe {
+        sysctl_raw(&mib_phys, &mut physmem as *mut i64 as *mut u8, &mut len);
+    }
+
+    // Active/wired pages via vm.uvmexp
+    let mib_uvm = [CTL_VM, VM_UVMEXP];
+    let mut uvm = MaybeUninit::<UvmExp>::zeroed();
+    let uvm_len = mem::size_of::<UvmExp>();
+    unsafe {
+        // uvmexp struct is larger than our partial definition, but sysctl
+        // will fill only up to uvm_len bytes, which is fine.
+        // We need to pass the full struct size the kernel expects.
+        let mut full_len: usize = 0;
+        // First query the size
+        libc::sysctl(
+            mib_uvm.as_ptr(),
+            mib_uvm.len() as libc::c_uint,
+            std::ptr::null_mut(),
+            &mut full_len,
+            std::ptr::null_mut(),
+            0,
+        );
+        // Allocate enough space
+        let mut buf = vec![0u8; full_len];
+        libc::sysctl(
+            mib_uvm.as_ptr(),
+            mib_uvm.len() as libc::c_uint,
+            buf.as_mut_ptr() as *mut libc::c_void,
+            &mut full_len,
+            std::ptr::null_mut(),
+            0,
+        );
+        // Copy the part we care about
+        let copy_len = uvm_len.min(full_len);
+        std::ptr::copy_nonoverlapping(
+            buf.as_ptr(),
+            uvm.as_mut_ptr() as *mut u8,
+            copy_len,
+        );
+    }
+
+    let uvm = unsafe { uvm.assume_init() };
+    let pagesize = uvm.pagesize as u64;
+    let total = physmem as u64;
+    let used = (uvm.active as u64 + uvm.wired as u64) * pagesize;
+
+    let total_mb = total / (1024 * 1024);
+    let used_mb = used / (1024 * 1024);
+    let pct = if total > 0 {
+        ((used * 100) / total) as u32
+    } else {
+        0
+    };
+
+    MemInfo {
+        used_mb,
+        total_mb,
+        pct,
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Disk
+// ---------------------------------------------------------------------------
+
+struct DiskInfo {
+    used_gb: u64,
+    total_gb: u64,
+    pct: u32,
+}
+
+fn read_disk(path: &str) -> DiskInfo {
+    let c_path = CString::new(path).unwrap();
+    let mut st = MaybeUninit::<libc::statvfs>::zeroed();
+    let ok = unsafe { libc::statvfs(c_path.as_ptr(), st.as_mut_ptr()) == 0 };
+    if !ok {
+        return DiskInfo {
+            used_gb: 0,
+            total_gb: 0,
+            pct: 0,
+        };
+    }
+    let st = unsafe { st.assume_init() };
+    let bsize = st.f_frsize;
+    let total = st.f_blocks * bsize;
+    let avail = st.f_bavail * bsize;
+    let used = total - avail;
+    let gb = 1024 * 1024 * 1024;
+
+    DiskInfo {
+        used_gb: used / gb,
+        total_gb: total / gb,
+        pct: if total > 0 {
+            ((used * 100) / total) as u32
+        } else {
+            0
+        },
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Network
+// ---------------------------------------------------------------------------
+
+fn read_network() -> String {
+    unsafe {
+        let mut addrs: *mut libc::ifaddrs = std::ptr::null_mut();
+        if libc::getifaddrs(&mut addrs) != 0 {
+            return "NET: down".into();
+        }
+
+        let mut result = String::from("NET: down");
+        let mut cur = addrs;
+        while !cur.is_null() {
+            let ifa = &*cur;
+            cur = ifa.ifa_next;
+
+            // Skip loopback
+            if (ifa.ifa_flags & libc::IFF_LOOPBACK as libc::c_uint) != 0 {
+                continue;
+            }
+            // Only UP interfaces
+            if (ifa.ifa_flags & libc::IFF_UP as libc::c_uint) == 0 {
+                continue;
+            }
+
+            let sa = ifa.ifa_addr;
+            if sa.is_null() {
+                continue;
+            }
+            // IPv4 only
+            if (*sa).sa_family as i32 != libc::AF_INET {
+                continue;
+            }
+
+            let sa_in = sa as *const libc::sockaddr_in;
+            let ip = (*sa_in).sin_addr.s_addr;
+            let a = ip & 0xff;
+            let b = (ip >> 8) & 0xff;
+            let c = (ip >> 16) & 0xff;
+            let d = (ip >> 24) & 0xff;
+
+            let name = std::ffi::CStr::from_ptr(ifa.ifa_name)
+                .to_str()
+                .unwrap_or("?");
+
+            result = format!("{name}: {a}.{b}.{c}.{d}");
+            break;
+        }
+
+        libc::freeifaddrs(addrs);
+        result
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Battery
+// ---------------------------------------------------------------------------
+
+fn read_battery() -> Option<ApmPowerInfo> {
+    let fd = unsafe {
+        let path = CString::new("/dev/apm").unwrap();
+        libc::open(path.as_ptr(), libc::O_RDONLY)
+    };
+    if fd < 0 {
+        return None;
+    }
+
+    let mut info = MaybeUninit::<ApmPowerInfo>::zeroed();
+    let ok = unsafe { libc::ioctl(fd, APM_IOC_GETPOWER, info.as_mut_ptr()) == 0 };
+    unsafe {
+        libc::close(fd);
+    }
+    if !ok {
+        return None;
+    }
+
+    let info = unsafe { info.assume_init() };
+    if info.battery_state == APM_BATT_ABSENT || info.battery_state == APM_BATT_UNKNOWN {
+        return None;
+    }
+    Some(info)
+}
+
+fn format_battery(info: &ApmPowerInfo) -> String {
+    let pct = info.battery_life;
+    let charging = info.battery_state == APM_BATT_CHARGING || info.ac_state == APM_AC_ON;
+    let icon = if charging { "CHR" } else { "BAT" };
+
+    let mut s = format!("{icon}: {pct}%");
+    // minutes_left is only meaningful when discharging and within sane range
+    if !charging && info.minutes_left > 0 && info.minutes_left < 6000 {
+        let h = info.minutes_left / 60;
+        let m = info.minutes_left % 60;
+        s.push_str(&format!(" ({h}:{m:02})"));
+    }
+    s
+}
+
+// ---------------------------------------------------------------------------
+// Date / Time
+// ---------------------------------------------------------------------------
+
+fn read_datetime() -> String {
+    unsafe {
+        let mut now: libc::time_t = 0;
+        libc::time(&mut now);
+        let tm = libc::localtime(&now);
+        if tm.is_null() {
+            return String::new();
+        }
+        let tm = &*tm;
+        format!(
+            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
+            tm.tm_year + 1900,
+            tm.tm_mon + 1,
+            tm.tm_mday,
+            tm.tm_hour,
+            tm.tm_min,
+            tm.tm_sec,
+        )
+    }
+}
+
+// ---------------------------------------------------------------------------
+// JSON block formatting
+// ---------------------------------------------------------------------------
+
+fn block(name: &str, text: &str, color: Option<&str>) -> String {
+    // Escape text for JSON (handle quotes and backslashes)
+    let text = text.replace('\\', "\\\\").replace('"', "\\\"");
+    match color {
+        Some(c) => {
+            format!(r#"{{"text":"{text}","name":"{name}","color":"{c}"}}"#)
+        }
+        None => {
+            format!(r#"{{"text":"{text}","name":"{name}"}}"#)
+        }
+    }
+}
+
+fn threshold_color(pct: u32) -> Option<&'static str> {
+    if pct >= 90 {
+        Some("#ff0000")
+    } else if pct >= 70 {
+        Some("#ffaa00")
+    } else {
+        None
+    }
+}
+
+fn bat_color(pct: u8, charging: bool) -> Option<&'static str> {
+    if charging {
+        return Some("#00ff00");
+    }
+    if pct <= 10 {
+        Some("#ff0000")
+    } else if pct <= 25 {
+        Some("#ffaa00")
+    } else {
+        None
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Main loop
+// ---------------------------------------------------------------------------
+
+fn main() {
+    // Protocol header
+    println!(r#"{{"version":1}}"#);
+    let mut stdout = io::stdout().lock();
+
+    let mut prev_cp = read_cp_time();
+
+    loop {
+        thread::sleep(Duration::from_secs(1));
+
+        // CPU
+        let cur_cp = read_cp_time();
+        let cpu_pct = cpu_usage(&prev_cp, &cur_cp);
+        prev_cp = cur_cp;
+
+        // Memory
+        let mem = read_memory();
+
+        // Disk
+        let disk = read_disk("/");
+
+        // Network
+        let net = read_network();
+
+        // Battery
+        let bat = read_battery();
+
+        // Date/time
+        let dt = read_datetime();
+
+        // Build blocks
+        let mut blocks = Vec::with_capacity(6);
+        blocks.push(block(
+            "cpu",
+            &format!("CPU: {cpu_pct}%"),
+            threshold_color(cpu_pct),
+        ));
+        blocks.push(block(
+            "mem",
+            &format!("MEM: {}% ({}/{}M)", mem.pct, mem.used_mb, mem.total_mb),
+            threshold_color(mem.pct),
+        ));
+        blocks.push(block(
+            "disk",
+            &format!("DISK: {}% ({}/{}G)", disk.pct, disk.used_gb, disk.total_gb),
+            threshold_color(disk.pct),
+        ));
+        blocks.push(block("net", &net, None));
+        if let Some(ref info) = bat {
+            let charging =
+                info.battery_state == APM_BATT_CHARGING || info.ac_state == APM_AC_ON;
+            let text = format_battery(info);
+            blocks.push(block("bat", &text, bat_color(info.battery_life, charging)));
+        }
+        blocks.push(block("time", &dt, None));
+
+        // Output JSON array
+        let line = format!("[{}]", blocks.join(","));
+        if writeln!(stdout, "{line}").is_err() {
+            break;
+        }
+        if stdout.flush().is_err() {
+            break;
+        }
+    }
+}
blob - /dev/null
blob + 1fa1374a85ac6d7e11b27c5fa5a41f448c1c9dd0 (mode 644)
--- /dev/null
+++ src/bin/owm-msg.rs
@@ -0,0 +1,166 @@
+use std::env;
+use std::io::{self, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::process;
+
+use owm::ipc::IPC_MAGIC;
+use owm::sys;
+
+/// IPC message types.
+const MSG_RUN_COMMAND: u32 = 0;
+const MSG_GET_WORKSPACES: u32 = 1;
+const MSG_GET_TREE: u32 = 3;
+const MSG_GET_VERSION: u32 = 4;
+const MSG_GET_MARKS: u32 = 6;
+const MSG_GET_CONFIG: u32 = 7;
+
+fn usage() -> ! {
+    eprintln!("usage: owm-msg [-s socket] <command|query>");
+    eprintln!();
+    eprintln!("queries:");
+    eprintln!("  get_workspaces   get_tree   get_version");
+    eprintln!("  get_marks        get_config");
+    eprintln!();
+    eprintln!("commands:");
+    eprintln!("  Any RUN_COMMAND string, e.g.:");
+    eprintln!("  owm-msg reload");
+    eprintln!("  owm-msg 'gaps inner current plus 5'");
+    process::exit(1);
+}
+
+fn find_socket() -> Option<PathBuf> {
+    if let Ok(p) = env::var("OWM_SOCKET") {
+        return Some(PathBuf::from(p));
+    }
+
+    let uid = unsafe { sys::getuid() };
+    let dir = PathBuf::from(format!("/tmp/owm-{uid}"));
+    let mut newest: Option<(PathBuf, std::time::SystemTime)> = None;
+    if let Ok(entries) = std::fs::read_dir(&dir) {
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if path.extension().is_some_and(|e| e == "sock") {
+                let mtime = entry.metadata().ok()
+                    .and_then(|m| m.modified().ok())
+                    .unwrap_or(std::time::UNIX_EPOCH);
+                if newest.as_ref().is_none_or(|(_, t)| mtime > *t) {
+                    newest = Some((path, mtime));
+                }
+            }
+        }
+    }
+
+    newest.map(|(p, _)| p)
+}
+
+fn send_message(
+    stream: &mut UnixStream,
+    msg_type: u32,
+    payload: &[u8],
+) -> io::Result<()> {
+    let mut header = [0u8; 14];
+    header[..6].copy_from_slice(IPC_MAGIC);
+    header[6..10].copy_from_slice(&(payload.len() as u32).to_ne_bytes());
+    header[10..14].copy_from_slice(&msg_type.to_ne_bytes());
+    stream.write_all(&header)?;
+    if !payload.is_empty() {
+        stream.write_all(payload)?;
+    }
+    stream.flush()
+}
+
+fn recv_response(stream: &mut UnixStream) -> io::Result<(u32, Vec<u8>)> {
+    let mut header = [0u8; 14];
+    stream.read_exact(&mut header)?;
+
+    if &header[..6] != IPC_MAGIC {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            "invalid magic bytes in response",
+        ));
+    }
+
+    let size = u32::from_ne_bytes(header[6..10].try_into().unwrap()) as usize;
+    let msg_type = u32::from_ne_bytes(header[10..14].try_into().unwrap());
+
+    let mut payload = vec![0u8; size];
+    if size > 0 {
+        stream.read_exact(&mut payload)?;
+    }
+
+    Ok((msg_type, payload))
+}
+
+fn main() {
+    let args: Vec<String> = env::args().skip(1).collect();
+    if args.is_empty() {
+        usage();
+    }
+
+    let mut socket_override: Option<PathBuf> = None;
+    let mut cmd_args: &[String] = &args;
+
+    if args[0] == "-s" {
+        if args.len() < 3 {
+            usage();
+        }
+        socket_override = Some(PathBuf::from(&args[1]));
+        cmd_args = &args[2..];
+    }
+
+    if cmd_args.is_empty() {
+        usage();
+    }
+
+    let socket_path = socket_override.or_else(find_socket).unwrap_or_else(|| {
+        eprintln!("owm-msg: cannot find owm socket");
+        eprintln!("hint: is owm running? set OWM_SOCKET if needed");
+        process::exit(1);
+    });
+
+    let mut stream = match UnixStream::connect(&socket_path) {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("owm-msg: connect {}: {}", socket_path.display(), e);
+            process::exit(1);
+        }
+    };
+
+    let command = cmd_args.join(" ");
+    let msg_type = match command.as_str() {
+        "get_workspaces" => MSG_GET_WORKSPACES,
+        "get_tree" => MSG_GET_TREE,
+        "get_version" => MSG_GET_VERSION,
+        "get_marks" => MSG_GET_MARKS,
+        "get_config" => MSG_GET_CONFIG,
+        _ => MSG_RUN_COMMAND,
+    };
+
+    let payload = if msg_type == MSG_RUN_COMMAND {
+        command.as_bytes()
+    } else {
+        b""
+    };
+
+    if let Err(e) = send_message(&mut stream, msg_type, payload) {
+        eprintln!("owm-msg: send: {}", e);
+        process::exit(1);
+    }
+
+    match recv_response(&mut stream) {
+        Ok((_reply_type, data)) => {
+            if let Ok(text) = String::from_utf8(data) {
+                if !text.is_empty() {
+                    println!("{text}");
+                }
+            }
+        }
+        Err(e) => {
+            if e.kind() != io::ErrorKind::UnexpectedEof {
+                eprintln!("owm-msg: recv: {}", e);
+                process::exit(1);
+            }
+        }
+    }
+}
blob - /dev/null
blob + ee8c74b278baf710c4f632a0f489758197def6e1 (mode 644)
--- /dev/null
+++ src/bin/owm.rs
@@ -0,0 +1,2804 @@
+use std::process::Command;
+use std::time::Instant;
+
+use xcb::x;
+use xcb::{Xid, XidNew};
+
+use owm::{log_debug, log_error, log_info, log_warn};
+use owm::{bar, config, container, ipc, keybind, layout, log, nagbar, sys, xcb_conn};
+use config::Config;
+use container::{
+    BorderStyle, ConType, ContainerTree, Layout, NodeKey, Rect, SizeHints,
+};
+use ipc::{EventType, IpcServer};
+use keybind::{Action, KeybindManager, ResizeDir};
+use xcb_conn::{DecoColors, Decoration, WindowType, XConn};
+
+/// Mouse drag operation in progress.
+#[derive(Debug, Clone, Copy)]
+enum DragMode {
+    Move,
+    Resize,
+}
+
+/// Snapshot of a visible container's layout state for relayout.
+struct FrameSnapshot {
+    key: NodeKey,
+    frame_id: u32,
+    win_id: u32,
+    rect: Rect,
+    window_rect: Rect,
+    is_floating: bool,
+    focused: bool,
+    hidden: bool,
+    border_style: BorderStyle,
+    border_width: u32,
+    title: String,
+}
+
+/// State for an active mouse drag.
+#[derive(Debug, Clone, Copy)]
+struct DragState {
+    mode: DragMode,
+    /// Container being dragged.
+    target: NodeKey,
+    /// Pointer position at drag start.
+    start_x: i32,
+    start_y: i32,
+    /// Window geometry at drag start.
+    start_rect: Rect,
+}
+
+/// Window manager state.
+struct Owm {
+    xconn: XConn,
+    tree: ContainerTree,
+    keybinds: KeybindManager,
+    ipc: IpcServer,
+    config: Config,
+    running: bool,
+    /// Key of the output container in the tree.
+    output_idx: NodeKey,
+    /// Current workspace key in the tree.
+    current_ws: NodeKey,
+    /// Next split direction for new windows.
+    next_split: Layout,
+    /// Active mouse drag operation.
+    drag: Option<DragState>,
+    /// Client window IDs in mapping order (_NET_CLIENT_LIST).
+    client_list: Vec<u32>,
+    /// Windows pending graceful close via WM_DELETE_WINDOW.
+    pending_closes: Vec<(x::Window, Instant)>,
+    /// Scratchpad: hidden windows that can be toggled on/off.
+    scratchpad: Vec<NodeKey>,
+    /// Self-pipe for receiving SIGHUP/SIGTERM in the event loop.
+    signal_pipe: sys::SignalPipe,
+    /// Watches ~/.owmrc for modifications (kqueue/inotify).
+    config_watcher: Option<sys::ConfigWatcher>,
+    /// Notification bar for config errors.
+    nagbar: Option<nagbar::Nagbar>,
+    /// Built-in status bar.
+    bar: Option<bar::Bar>,
+    /// Suppress EnterNotify events until the next real pointer motion.
+    /// Set after relayout to prevent focus-follows-mouse from stealing
+    /// focus from a newly mapped window.
+    suppress_enter: bool,
+}
+
+impl Owm {
+    fn new() -> Result<(Self, Vec<String>), Box<dyn std::error::Error>> {
+        let (config, config_errors) = Config::load();
+
+        let xconn = XConn::connect(&config.font)?;
+        let screen_rect = xconn.screen_rect;
+
+        let mut tree = ContainerTree::new(screen_rect);
+
+        // Build initial tree: Root -> Output -> Workspace 1
+        let output_idx =
+            tree.create_child(tree.root, ConType::Output, "default");
+        if let Some(output) = tree.get_mut(output_idx) {
+            output.rect = screen_rect;
+        }
+
+        let ws_idx = tree.create_child(output_idx, ConType::Workspace, "1");
+        if let Some(ws) = tree.get_mut(ws_idx) {
+            ws.rect = screen_rect;
+            ws.layout = Layout::SplitH;
+        }
+
+        tree.focused = Some(ws_idx);
+
+        xconn.set_background(config.color_background);
+
+        let mut keybinds = KeybindManager::new();
+        keybinds.setup(&xconn.conn, xconn.root, &config.keybindings);
+        for (mode_name, mode_bindings) in &config.modes {
+            keybinds.setup_mode(&xconn.conn, mode_name, mode_bindings);
+        }
+        xconn.flush();
+
+        let ipc = IpcServer::new()?;
+        let signal_pipe = sys::SignalPipe::new()?;
+
+        let config_watcher = std::env::var("HOME")
+            .ok()
+            .and_then(|home| {
+                let path = format!("{}/.owmrc", home);
+                match sys::ConfigWatcher::new(&path) {
+                    Ok(w) => {
+                        log_info!("watching {} for changes", path);
+                        Some(w)
+                    }
+                    Err(e) => {
+                        log_warn!("config watcher: {}", e);
+                        None
+                    }
+                }
+            });
+
+        let bar = if config.bar_enabled {
+            Some(bar::Bar::new(&xconn, &config))
+        } else {
+            None
+        };
+
+        // Grab Mod+Button1 (move) and Mod+Button3 (resize) for floating windows
+        xconn.grab_buttons(x::ModMask::N4);
+        xconn.flush();
+
+        log_info!("owm initialized");
+
+        Ok((Owm {
+            xconn,
+            tree,
+            keybinds,
+            ipc,
+            config,
+            running: true,
+            output_idx,
+            current_ws: ws_idx,
+            next_split: Layout::SplitH,
+            drag: None,
+            client_list: Vec::new(),
+            pending_closes: Vec::new(),
+            scratchpad: Vec::new(),
+            signal_pipe,
+            config_watcher,
+            nagbar: None,
+            bar,
+            suppress_enter: false,
+        }, config_errors))
+    }
+
+    /// Main event loop using poll(2).
+    fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
+        while self.running {
+            let xcb_fd = self.xconn.fd();
+            let ipc_fd = self.ipc.fd();
+            let sig_fd = self.signal_pipe.fd();
+
+            // fd < 0 is ignored by poll(2), so absent watcher is safe.
+            let cw_fd = self
+                .config_watcher
+                .as_ref()
+                .map_or(-1, |w| w.fd());
+
+            // fd < 0 is ignored by poll(2), so absent child is safe.
+            let bar_fd = self
+                .bar
+                .as_ref()
+                .and_then(|b| b.child_fd())
+                .unwrap_or(-1);
+
+            let mut fds = [
+                sys::pollfd {
+                    fd: xcb_fd,
+                    events: sys::POLLIN,
+                    revents: 0,
+                },
+                sys::pollfd {
+                    fd: ipc_fd,
+                    events: sys::POLLIN,
+                    revents: 0,
+                },
+                sys::pollfd {
+                    fd: sig_fd,
+                    events: sys::POLLIN,
+                    revents: 0,
+                },
+                sys::pollfd {
+                    fd: cw_fd,
+                    events: sys::POLLIN,
+                    revents: 0,
+                },
+                sys::pollfd {
+                    fd: bar_fd,
+                    events: sys::POLLIN,
+                    revents: 0,
+                },
+            ];
+
+            // SAFETY: fds array is valid for the duration of the call
+            let ret =
+                unsafe { sys::poll(fds.as_mut_ptr(), fds.len() as _, 1000) };
+            if ret < 0 {
+                let errno = std::io::Error::last_os_error();
+                if errno.raw_os_error() == Some(sys::EINTR) {
+                    continue;
+                }
+                return Err(format!("poll error: {}", errno).into());
+            }
+
+            // Process signals from self-pipe
+            if fds[2].revents & sys::POLLIN != 0 {
+                self.process_signals();
+            }
+
+            // Process config file watcher
+            if let Some(ref mut watcher) = self.config_watcher {
+                let readable = fds[3].revents & sys::POLLIN != 0;
+                if (readable || watcher.needs_rewatch())
+                    && watcher.check()
+                {
+                    log_info!("config file changed, reloading");
+                    let errors = self.reload_config();
+                    self.update_nagbar(errors);
+                }
+            }
+
+            // Process bar child output
+            if fds[4].revents & sys::POLLIN != 0 {
+                if let Some(ref mut b) = self.bar {
+                    if b.process_child_output() {
+                        self.redraw_bar();
+                    }
+                }
+            }
+
+            // Process X11 events
+            self.process_x11_events();
+
+            // Process IPC
+            let commands = self.ipc.poll(
+                &self.tree,
+                &self.config,
+                self.keybinds.current_mode(),
+            );
+            for cmd in commands {
+                self.handle_ipc_command(&cmd);
+            }
+
+            // Check pending close timeouts
+            self.check_close_timeouts();
+
+            self.xconn.flush();
+        }
+
+        Ok(())
+    }
+
+    /// Process pending signals from the self-pipe.
+    fn process_signals(&mut self) {
+        for sig in self.signal_pipe.drain() {
+            match sig as i32 {
+                sys::SIGHUP => {
+                    log_info!("received SIGHUP, reloading configuration");
+                    let errors = self.reload_config();
+                    self.update_nagbar(errors);
+                }
+                sys::SIGTERM => {
+                    log_info!("received SIGTERM, shutting down");
+                    self.running = false;
+                }
+                _ => {
+                    log_debug!("received unexpected signal {}", sig);
+                }
+            }
+        }
+    }
+
+    /// Drain and handle all pending X11 events.
+    ///
+    /// Collects all queued events first, coalesces redundant MotionNotify
+    /// events (keeping only the last one), then processes the batch.
+    /// This avoids wasted work from intermediate mouse positions and
+    /// reduces relayouts when multiple events arrive between polls.
+    fn process_x11_events(&mut self) {
+        let mut events = Vec::new();
+
+        loop {
+            match self.xconn.conn.poll_for_event() {
+                Err(xcb::Error::Connection(_)) => {
+                    log_error!("X connection lost");
+                    self.running = false;
+                    return;
+                }
+                Err(e) => {
+                    log_warn!("X event error: {:?}", e);
+                    break;
+                }
+                Ok(None) => break,
+                Ok(Some(event)) => events.push(event),
+            }
+        }
+
+        // Coalesce MotionNotify: keep only the last one (like i3)
+        let last_motion = events.iter().rposition(|e| {
+            matches!(e, xcb::Event::X(x::Event::MotionNotify(_)))
+        });
+        if let Some(last_idx) = last_motion {
+            let mut i = 0;
+            events.retain(|e| {
+                let cur = i;
+                i += 1;
+                !matches!(e, xcb::Event::X(x::Event::MotionNotify(_)))
+                    || cur == last_idx
+            });
+        }
+
+        for event in events {
+            self.handle_event(event);
+        }
+    }
+
+    /// Dispatch an X11 event.
+    fn handle_event(&mut self, event: xcb::Event) {
+        match event {
+            xcb::Event::X(x::Event::MapRequest(ev)) => self.on_map_request(ev),
+            xcb::Event::X(x::Event::UnmapNotify(ev)) => {
+                self.on_unmap_notify(ev)
+            }
+            xcb::Event::X(x::Event::DestroyNotify(ev)) => {
+                self.on_destroy_notify(ev)
+            }
+            xcb::Event::X(x::Event::ConfigureRequest(ev)) => {
+                self.on_configure_request(ev)
+            }
+            xcb::Event::X(x::Event::KeyPress(ev)) => {
+                self.suppress_enter = false;
+                // Dismiss nagbar on any keypress if it's focused
+                if self.nagbar.as_ref().is_some_and(|n| {
+                    ev.event().resource_id() == n.window_id()
+                }) {
+                    self.dismiss_nagbar();
+                } else {
+                    self.on_key_press(ev);
+                }
+            }
+            xcb::Event::X(x::Event::EnterNotify(ev)) => {
+                self.on_enter_notify(ev)
+            }
+            xcb::Event::X(x::Event::PropertyNotify(ev)) => {
+                self.on_property_notify(ev)
+            }
+            xcb::Event::X(x::Event::ButtonPress(ev)) => {
+                self.suppress_enter = false;
+                // Dismiss nagbar on click
+                if self.nagbar.as_ref().is_some_and(|n| {
+                    ev.event().resource_id() == n.window_id()
+                }) {
+                    self.dismiss_nagbar();
+                } else if self.bar.as_ref().is_some_and(|b| {
+                    ev.event().resource_id() == b.window_id()
+                }) {
+                    self.handle_bar_click(&ev);
+                } else {
+                    self.on_button_press(ev);
+                }
+            }
+            xcb::Event::X(x::Event::MotionNotify(ev)) => {
+                self.on_motion_notify(ev)
+            }
+            xcb::Event::X(x::Event::ButtonRelease(ev)) => {
+                self.on_button_release(ev)
+            }
+            xcb::Event::X(x::Event::ClientMessage(ev)) => {
+                self.on_client_message(ev)
+            }
+            xcb::Event::X(x::Event::Expose(ev)) => {
+                let wid = ev.window().resource_id();
+                if self.nagbar.as_ref().is_some_and(|n| wid == n.window_id()) {
+                    if let Some(ref nag) = self.nagbar {
+                        nag.redraw(&self.xconn);
+                    }
+                } else if self.bar.as_ref().is_some_and(|b| wid == b.window_id()) {
+                    self.redraw_bar();
+                }
+            }
+            _ => {}
+        }
+    }
+
+    /// A window wants to be mapped (shown).
+    fn on_map_request(&mut self, ev: x::MapRequestEvent) {
+        let win = ev.window();
+        log_debug!("map request: {:?}", win);
+
+        // Never manage override-redirect windows (popups, menus, etc.)
+        let is_or = self.xconn.is_override_redirect(win);
+        log_debug!("map request: win={:?} override_redirect={}", win, is_or);
+        if is_or {
+            self.xconn.map_window(win);
+            return;
+        }
+
+        // Already managed: skip duplicate MapRequest
+        if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+            if self.tree.workspace_for(idx) != Some(self.current_ws)
+                && let Some(frame) =
+                    self.tree.get(idx).and_then(|c| c.frame)
+            {
+                self.xconn.map_window(x::Window::new(frame));
+            }
+            return;
+        }
+
+        // Read window title and class for rules
+        let title = self.xconn.get_wm_name(win);
+        let (wm_instance, wm_class) = self.xconn.get_wm_class(win);
+
+        // Determine border style: check for_window rules first, then defaults
+        let win_type = self.xconn.get_window_type(win);
+        let should_float = matches!(
+            win_type,
+            WindowType::Dialog | WindowType::Splash | WindowType::Utility
+        );
+
+        let (mut border_style, mut bw) = if should_float {
+            (self.config.default_floating_border, self.config.border_width)
+        } else {
+            (self.config.default_border, self.config.border_width)
+        };
+
+        // Apply for_window rules
+        for rule in &self.config.window_rules {
+            let class_match = rule
+                .class
+                .as_ref()
+                .map(|c| c == &wm_class)
+                .unwrap_or(true);
+            let inst_match = rule
+                .instance
+                .as_ref()
+                .map(|i| i == &wm_instance)
+                .unwrap_or(true);
+            let title_match = rule
+                .title
+                .as_ref()
+                .map(|t| title.contains(t.as_str()))
+                .unwrap_or(true);
+            if class_match && inst_match && title_match {
+                border_style = rule.border_style;
+                if let Some(w) = rule.border_width {
+                    bw = w;
+                }
+            }
+        }
+
+        // Determine where to insert.
+        // With autotiling, the split direction is chosen based on the
+        // focused container's dimensions: wider → SplitV (new window
+        // appears to the right), taller → SplitH (new window below).
+        let split_dir = if self.config.autotiling {
+            match self.tree.focused.and_then(|f| self.tree.get(f)) {
+                Some(c) if c.rect.w > c.rect.h => Layout::SplitH,
+                _ => Layout::SplitV,
+            }
+        } else {
+            self.next_split
+        };
+
+        let parent_idx = match self.tree.focused {
+            Some(focused) => {
+                let con = self.tree.get(focused);
+                match con {
+                    Some(c) if c.con_type == ConType::Workspace => focused,
+                    Some(c) if c.window.is_some() => {
+                        if let Some(p) = c.parent {
+                            let parent_layout =
+                                self.tree.get(p).map(|pc| pc.layout);
+                            if parent_layout == Some(split_dir) {
+                                p
+                            } else {
+                                self.tree.split(focused, split_dir)
+                            }
+                        } else {
+                            self.current_ws
+                        }
+                    }
+                    _ => self.current_ws,
+                }
+            }
+            None => self.current_ws,
+        };
+
+        if should_float {
+            let con_idx = self.tree.create_child(parent_idx, ConType::Con, "");
+            let hints = self.xconn.get_size_hints(win);
+
+            // Create frame window
+            let screen = self.xconn.screen_rect;
+            let rect = Rect {
+                x: screen.w as i32 / 4,
+                y: screen.h as i32 / 4,
+                w: screen.w / 2,
+                h: screen.h / 2,
+            };
+            let rect = self.clamp_floating_rect(rect, &hints);
+            let frame = self.xconn.create_frame(&rect);
+            let deco_h = match border_style {
+                BorderStyle::Normal => self.xconn.deco_height,
+                _ => 0,
+            };
+
+            if let Some(con) = self.tree.get_mut(con_idx) {
+                con.window = Some(win.resource_id());
+                con.frame = Some(frame.resource_id());
+                con.title = title;
+                con.border_style = border_style;
+                con.border_width = bw;
+                con.size_hints = hints;
+                con.rect = rect;
+                con.window_rect = match border_style {
+                    BorderStyle::Normal => Rect {
+                        x: bw as i32,
+                        y: deco_h as i32,
+                        w: rect.w.saturating_sub(2 * bw),
+                        h: rect.h.saturating_sub(deco_h + bw),
+                    },
+                    BorderStyle::Pixel => Rect {
+                        x: bw as i32,
+                        y: bw as i32,
+                        w: rect.w.saturating_sub(2 * bw),
+                        h: rect.h.saturating_sub(2 * bw),
+                    },
+                    BorderStyle::None => Rect {
+                        x: 0, y: 0, w: rect.w, h: rect.h,
+                    },
+                };
+            }
+
+            // Reparent client into frame
+            self.xconn
+                .reparent_window(win, frame, bw as i32, deco_h as i32);
+
+            // Detach from tiling and reattach as floating
+            self.tree.detach(con_idx);
+            self.tree.attach_floating(con_idx, self.current_ws);
+            if let Some(con) = self.tree.get_mut(con_idx) {
+                con.rect = rect;
+            }
+
+            self.relayout();
+            self.xconn.map_window(win);
+            self.xconn.map_window(frame);
+            if let Some(con) = self.tree.get_mut(con_idx) {
+                con.frame_mapped = true;
+            }
+            self.apply_window_geometry(con_idx);
+            self.xconn.raise_window(frame);
+            self.set_focus(con_idx);
+        } else {
+            let con_idx = self.tree.create_child(parent_idx, ConType::Con, "");
+
+            // Create frame (positioned later by relayout)
+            let initial_rect = Rect { x: 0, y: 0, w: 1, h: 1 };
+            let frame = self.xconn.create_frame(&initial_rect);
+            let deco_h = match border_style {
+                BorderStyle::Normal => self.xconn.deco_height,
+                _ => 0,
+            };
+
+            if let Some(con) = self.tree.get_mut(con_idx) {
+                con.window = Some(win.resource_id());
+                con.frame = Some(frame.resource_id());
+                con.title = title;
+                con.border_style = border_style;
+                con.border_width = bw;
+            }
+
+            // Reparent client into frame
+            self.xconn
+                .reparent_window(win, frame, bw as i32, deco_h as i32);
+
+            self.relayout();
+            self.xconn.map_window(win);
+            self.xconn.map_window(frame);
+            if let Some(con) = self.tree.get_mut(con_idx) {
+                con.frame_mapped = true;
+            }
+            self.set_focus(con_idx);
+        }
+
+        // Suppress EnterNotify events caused by relayout moving windows
+        // under the pointer — the new window should keep focus.
+        self.suppress_enter = true;
+
+        // Track in client list and update EWMH
+        self.client_list.push(win.resource_id());
+        self.update_client_lists();
+
+        self.ipc.broadcast_event(
+            EventType::Window,
+            &format!(r#"{{"change":"new","window":{}}}"#, win.resource_id()),
+        );
+    }
+
+    /// A window was unmapped (hidden).
+    fn on_unmap_notify(&mut self, ev: x::UnmapNotifyEvent) {
+        let win = ev.window();
+        self.pending_closes
+            .retain(|&(w, _)| w.resource_id() != win.resource_id());
+        if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+            // WM-initiated unmaps (workspace switch, minimize, scratchpad,
+            // move-to-workspace) increment ignore_unmap.  Decrement and
+            // skip removal — like i3's ignore_unmap counter.
+            let ignore = self
+                .tree
+                .get(idx)
+                .map(|c| c.ignore_unmap)
+                .unwrap_or(0);
+            if ignore > 0 {
+                if let Some(con) = self.tree.get_mut(idx) {
+                    con.ignore_unmap -= 1;
+                }
+                return;
+            }
+            self.remove_client(idx);
+        }
+    }
+
+    /// A window was destroyed.
+    fn on_destroy_notify(&mut self, ev: x::DestroyNotifyEvent) {
+        let win = ev.window();
+        self.pending_closes
+            .retain(|&(w, _)| w.resource_id() != win.resource_id());
+        if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+            self.remove_client(idx);
+        }
+    }
+
+    /// A window wants to configure itself.
+    fn on_configure_request(&mut self, ev: x::ConfigureRequestEvent) {
+        let win = ev.window();
+
+        // If we manage this window, apply our layout geometry
+        if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+            if let Some(con) = self.tree.get(idx) {
+                self.xconn
+                    .configure_window(win, &con.rect, con.border_width);
+            }
+            return;
+        }
+
+        // For unmanaged windows, honor their request
+        let mut values = Vec::new();
+        let mask = ev.value_mask();
+
+        if mask.contains(x::ConfigWindowMask::X) {
+            values.push(x::ConfigWindow::X(ev.x() as i32));
+        }
+        if mask.contains(x::ConfigWindowMask::Y) {
+            values.push(x::ConfigWindow::Y(ev.y() as i32));
+        }
+        if mask.contains(x::ConfigWindowMask::WIDTH) {
+            values.push(x::ConfigWindow::Width(ev.width() as u32));
+        }
+        if mask.contains(x::ConfigWindowMask::HEIGHT) {
+            values.push(x::ConfigWindow::Height(ev.height() as u32));
+        }
+        if mask.contains(x::ConfigWindowMask::BORDER_WIDTH) {
+            values.push(x::ConfigWindow::BorderWidth(ev.border_width() as u32));
+        }
+
+        self.xconn.conn.send_request(&x::ConfigureWindow {
+            window: win,
+            value_list: &values,
+        });
+    }
+
+    /// A key was pressed.
+    fn on_key_press(&mut self, ev: x::KeyPressEvent) {
+        let action = self
+            .keybinds
+            .lookup(ev.state().bits(), ev.detail())
+            .cloned();
+
+        if let Some(action) = action {
+            self.handle_action(action);
+        }
+    }
+
+    /// Pointer entered a window.
+    fn on_enter_notify(&mut self, ev: x::EnterNotifyEvent) {
+        if !self.config.focus_follows_mouse {
+            return;
+        }
+        // Ignore synthetic EnterNotify events generated by relayout
+        // (window geometry changes cause the pointer to "enter" a window
+        // even though the user didn't move the mouse).
+        if self.suppress_enter {
+            return;
+        }
+        let win = ev.event();
+        if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+            self.set_focus(idx);
+        }
+    }
+
+    /// A window property changed (urgency hints, title, etc).
+    fn on_property_notify(&mut self, ev: x::PropertyNotifyEvent) {
+        let win = ev.window();
+        if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+            let is_urgent = self.xconn.window_is_urgent(win);
+
+            // Update size hints if WM_NORMAL_HINTS changed on a floating window
+            if ev.atom() == self.xconn.atoms.wm_normal_hints {
+                let hints = self.xconn.get_size_hints(win);
+                if let Some(con) = self.tree.get_mut(idx) {
+                    con.size_hints = hints;
+                }
+            }
+
+            // Update title if _NET_WM_NAME or WM_NAME changed
+            if ev.atom() == self.xconn.atoms.net_wm_name
+                || ev.atom() == self.xconn.atoms.wm_name
+            {
+                let new_title = self.xconn.get_wm_name(win);
+                if let Some(con) = self.tree.get_mut(idx) {
+                    con.title = new_title;
+                }
+                self.redraw_decoration(idx);
+            }
+
+            if let Some(con) = self.tree.get_mut(idx) {
+                con.urgent = is_urgent;
+            }
+
+            // Redraw decoration based on urgency
+            self.redraw_decoration(idx);
+        }
+    }
+
+    /// Handle a keybinding action.
+    fn handle_action(&mut self, action: Action) {
+        match action {
+            Action::FocusNext => self.focus_adjacent(1),
+            Action::FocusPrev => self.focus_adjacent(-1),
+            Action::FocusParent => {
+                if let Some(focused) = self.tree.focused
+                    && let Some(parent) =
+                        self.tree.get(focused).and_then(|c| c.parent)
+                    && self.tree.get(parent).map(|c| c.con_type)
+                        != Some(ConType::Workspace)
+                {
+                    self.set_focus(parent);
+                }
+            }
+            Action::FocusChild => {
+                if let Some(focused) = self.tree.focused
+                    && let Some(child) = self.tree.focused_child(focused)
+                {
+                    self.set_focus(child);
+                }
+            }
+            Action::SwapNext => self.swap_adjacent(1),
+            Action::SwapPrev => self.swap_adjacent(-1),
+            Action::SplitVertical => {
+                self.next_split = Layout::SplitV;
+            }
+            Action::SplitHorizontal => {
+                self.next_split = Layout::SplitH;
+            }
+            Action::LayoutStacked => self.set_layout(Layout::Stacked),
+            Action::LayoutTabbed => self.set_layout(Layout::Tabbed),
+            Action::LayoutToggleSplit => {
+                if let Some(focused) = self.tree.focused
+                    && let Some(parent) =
+                        self.tree.get(focused).and_then(|c| c.parent)
+                {
+                    let current = self.tree.get(parent).map(|c| c.layout);
+                    let new_layout = match current {
+                        Some(Layout::SplitH) => Layout::SplitV,
+                        _ => Layout::SplitH,
+                    };
+                    if let Some(p) = self.tree.get_mut(parent) {
+                        p.layout = new_layout;
+                    }
+                    self.relayout();
+                }
+            }
+            Action::ToggleFullscreen => self.toggle_fullscreen(),
+            Action::ToggleFloating => self.toggle_floating(),
+            Action::ToggleSticky => self.toggle_sticky(),
+            Action::Minimize => self.minimize_focused(),
+            Action::MoveScratchpad => self.move_to_scratchpad(),
+            Action::ScratchpadShow => self.scratchpad_show(),
+            Action::CloseWindow => {
+                if let Some(focused) = self.tree.focused
+                    && let Some(win) =
+                        self.tree.get(focused).and_then(|c| c.window)
+                {
+                    let xwin = x::Window::new(win);
+                    if self.xconn.send_delete_window(xwin) {
+                        // Window supports WM_DELETE_WINDOW; track timeout
+                        self.pending_closes.push((xwin, Instant::now()));
+                    } else {
+                        // No protocol support; force kill immediately
+                        self.xconn.kill_window(xwin);
+                    }
+                }
+            }
+            Action::Spawn(cmd) => {
+                self.spawn(&cmd);
+            }
+            Action::Workspace(ref name) => {
+                self.switch_workspace(name);
+            }
+            Action::MoveToWorkspace(ref name) => {
+                self.move_to_workspace(name);
+            }
+            Action::Resize(dir) => {
+                self.resize_focused(dir);
+            }
+            Action::Mode(ref name) => {
+                self.keybinds.switch_mode(
+                    name,
+                    &self.xconn.conn,
+                    self.xconn.root,
+                );
+                self.xconn.flush();
+            }
+            Action::Restart => self.restart(),
+            Action::Exit => {
+                log_info!("exiting");
+                self.running = false;
+            }
+        }
+    }
+
+    /// Focus the next or previous sibling.
+    fn focus_adjacent(&mut self, direction: i32) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        let parent_idx = match self.tree.get(focused).and_then(|c| c.parent) {
+            Some(p) => p,
+            None => return,
+        };
+
+        let target_key = match self.tree.get(parent_idx) {
+            Some(p) => {
+                let children = &p.children;
+                match children.iter().position(|&c| c == focused) {
+                    Some(pos) => {
+                        let new_pos = if direction > 0 {
+                            (pos + 1) % children.len()
+                        } else {
+                            (pos + children.len() - 1) % children.len()
+                        };
+                        children[new_pos]
+                    }
+                    None => return,
+                }
+            }
+            None => return,
+        };
+        let target = self.tree.focused_leaf(target_key);
+
+        // In tabbed/stacked, relayout first to map/unmap the correct
+        // frames before setting X11 focus (which requires a mapped window).
+        let is_tab_stack = self.tree.get(parent_idx)
+            .is_some_and(|p| matches!(p.layout, Layout::Tabbed | Layout::Stacked));
+        if is_tab_stack {
+            // Update focus_stack before relayout so is_hidden() sees
+            // the new focused child.
+            if let Some(con) = self.tree.get_mut(target) {
+                con.focused = true;
+            }
+            if let Some(prev) = self.tree.focused {
+                if let Some(con) = self.tree.get_mut(prev) {
+                    con.focused = false;
+                }
+            }
+            // Update focus_stack up the tree for target
+            let mut child = target;
+            let mut cur = self.tree.get(target).and_then(|c| c.parent);
+            while let Some(pidx) = cur {
+                if let Some(parent) = self.tree.get_mut(pidx) {
+                    parent.focus_stack.retain(|&c| c != child);
+                    parent.focus_stack.push_front(child);
+                }
+                child = pidx;
+                cur = self.tree.get(pidx).and_then(|c| c.parent);
+            }
+            self.tree.focused = Some(target);
+            self.relayout();
+            // Now set X11 focus (frame is mapped by relayout).
+            if let Some(win) = self.tree.get(target).and_then(|c| c.window) {
+                let xwin = x::Window::new(win);
+                self.xconn.set_focus(xwin);
+                self.xconn.set_active_window(xwin);
+            }
+        } else {
+            self.set_focus(target);
+        }
+    }
+
+    /// Swap the focused container with an adjacent sibling.
+    fn swap_adjacent(&mut self, direction: i32) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        let parent_idx = match self.tree.get(focused).and_then(|c| c.parent) {
+            Some(p) => p,
+            None => return,
+        };
+
+        let swap = match self.tree.get(parent_idx) {
+            Some(p) => {
+                match p.children.iter().position(|&c| c == focused) {
+                    Some(pos) => {
+                        let new_pos = if direction > 0 {
+                            if pos + 1 >= p.children.len() {
+                                return;
+                            }
+                            pos + 1
+                        } else {
+                            if pos == 0 {
+                                return;
+                            }
+                            pos - 1
+                        };
+                        (pos, new_pos)
+                    }
+                    None => return,
+                }
+            }
+            None => return,
+        };
+
+        if let Some(parent) = self.tree.get_mut(parent_idx) {
+            parent.children.swap(swap.0, swap.1);
+        }
+
+        self.relayout();
+    }
+
+    /// Set the layout of the focused container's parent.
+    fn set_layout(&mut self, layout: Layout) {
+        if let Some(focused) = self.tree.focused
+            && let Some(parent) = self.tree.get(focused).and_then(|c| c.parent)
+        {
+            if let Some(p) = self.tree.get_mut(parent) {
+                p.layout = layout;
+            }
+            self.relayout();
+        }
+    }
+
+    /// Toggle fullscreen on the focused container.
+    fn toggle_fullscreen(&mut self) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        let is_fullscreen = self
+            .tree
+            .get(focused)
+            .map(|c| c.fullscreen)
+            .unwrap_or(false);
+
+        if let Some(con) = self.tree.get_mut(focused) {
+            con.fullscreen = !is_fullscreen;
+            if con.fullscreen {
+                // Fullscreen: frame fills screen, no decoration
+                let screen = self.xconn.screen_rect;
+                con.rect = screen;
+                con.window_rect = Rect {
+                    x: 0,
+                    y: 0,
+                    w: screen.w,
+                    h: screen.h,
+                };
+            } else {
+                con.border_width = self.config.border_width;
+            }
+        }
+
+        // Update _NET_WM_STATE on the window
+        if let Some(win) = self.tree.get(focused).and_then(|c| c.window) {
+            let xwin = x::Window::new(win);
+            if !is_fullscreen {
+                self.xconn.set_wm_state(
+                    xwin,
+                    &[self.xconn.atoms.net_wm_state_fullscreen.resource_id()],
+                );
+            } else {
+                self.xconn.set_wm_state(xwin, &[]);
+            }
+        }
+
+        if is_fullscreen {
+            self.relayout();
+        } else {
+            self.apply_window_geometry(focused);
+            // Raise fullscreen frame above everything
+            if let Some(frame) =
+                self.tree.get(focused).and_then(|c| c.frame)
+            {
+                self.xconn.raise_window(x::Window::new(frame));
+            }
+        }
+    }
+
+    /// Toggle floating state on the focused window.
+    fn toggle_floating(&mut self) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        // Only toggle leaf containers with windows
+        let (has_window, is_floating) = match self.tree.get(focused) {
+            Some(c) => (c.window.is_some(), c.is_floating),
+            None => return,
+        };
+        if !has_window {
+            return;
+        }
+
+        let ws = match self.tree.workspace_for(focused) {
+            Some(ws) => ws,
+            None => return,
+        };
+
+        if is_floating {
+            // Float -> Tiling: detach from floating, reattach to workspace as tiling
+            self.tree.detach(focused);
+            self.tree.attach_tiling(focused, ws);
+        } else {
+            // Tiling -> Float: save current geometry, detach, reattach as floating
+            let saved_rect = self.tree.get(focused).map(|c| c.rect);
+            let win_id = self.tree.get(focused).and_then(|c| c.window);
+            let hints = match win_id {
+                Some(w) => self.xconn.get_size_hints(x::Window::new(w)),
+                None => SizeHints::default(),
+            };
+            self.tree.detach(focused);
+            self.tree.attach_floating(focused, ws);
+            // Center floating window with reasonable size, respecting hints
+            let screen = self.xconn.screen_rect;
+            let rect = match saved_rect {
+                Some(r) if r.w > 0 && r.h > 0 => Rect {
+                    x: (screen.w as i32 - r.w as i32) / 2,
+                    y: (screen.h as i32 - r.h as i32) / 2,
+                    w: r.w,
+                    h: r.h,
+                },
+                _ => Rect {
+                    x: screen.w as i32 / 4,
+                    y: screen.h as i32 / 4,
+                    w: screen.w / 2,
+                    h: screen.h / 2,
+                },
+            };
+            let rect = self.clamp_floating_rect(rect, &hints);
+            let bw = self.config.border_width;
+            let deco_h = match self.config.default_floating_border {
+                BorderStyle::Normal => self.xconn.deco_height,
+                _ => 0,
+            };
+            if let Some(con) = self.tree.get_mut(focused) {
+                con.rect = rect;
+                con.border_style = self.config.default_floating_border;
+                con.border_width = bw;
+                con.size_hints = hints;
+                // Compute window_rect for the floating frame.
+                con.window_rect = match con.border_style {
+                    BorderStyle::Normal => Rect {
+                        x: bw as i32,
+                        y: deco_h as i32,
+                        w: rect.w.saturating_sub(2 * bw),
+                        h: rect.h.saturating_sub(deco_h + bw),
+                    },
+                    BorderStyle::Pixel => Rect {
+                        x: bw as i32,
+                        y: bw as i32,
+                        w: rect.w.saturating_sub(2 * bw),
+                        h: rect.h.saturating_sub(2 * bw),
+                    },
+                    BorderStyle::None => Rect {
+                        x: 0, y: 0, w: rect.w, h: rect.h,
+                    },
+                };
+            }
+        }
+
+        // Clean up empty parents from the tiling tree
+        if is_floating {
+            // Was floating, now tiling — relayout
+            self.relayout();
+        } else {
+            // Now floating — relayout tiling, then position floating window
+            self.relayout();
+            self.apply_window_geometry(focused);
+            if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame)
+            {
+                self.xconn.raise_window(x::Window::new(frame));
+            }
+        }
+    }
+
+    /// Toggle sticky state on the focused floating window.
+    /// Sticky windows follow workspace switches (like i3).
+    fn toggle_sticky(&mut self) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        // Only floating windows can be sticky (like i3)
+        match self.tree.get(focused) {
+            Some(c) if c.is_floating && c.window.is_some() => {}
+            _ => return,
+        }
+
+        if let Some(con) = self.tree.get_mut(focused) {
+            con.sticky = !con.sticky;
+            log_debug!(
+                "sticky toggled: {} for window {:?}",
+                con.sticky,
+                con.window
+            );
+        }
+    }
+
+    /// Minimize the focused window: unmap and set _NET_WM_STATE_HIDDEN.
+    fn minimize_focused(&mut self) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        let (has_window, already_minimized) = match self.tree.get(focused) {
+            Some(c) => (c.window.is_some(), c.minimized),
+            None => return,
+        };
+        if !has_window || already_minimized {
+            return;
+        }
+
+        // Mark as minimized and unmap
+        if let Some(con) = self.tree.get_mut(focused) {
+            con.minimized = true;
+        }
+
+        // Unmap the frame (hides both frame and client)
+        if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame) {
+            self.xconn.unmap_window(x::Window::new(frame));
+            if let Some(con) = self.tree.get_mut(focused) {
+                con.ignore_unmap += 1;
+            }
+        }
+
+        if let Some(win) = self.tree.get(focused).and_then(|c| c.window) {
+            let xwin = x::Window::new(win);
+            // Build state list: hidden + any existing states (e.g. fullscreen)
+            let mut states =
+                vec![self.xconn.atoms.net_wm_state_hidden.resource_id()];
+            if self
+                .tree
+                .get(focused)
+                .map(|c| c.fullscreen)
+                .unwrap_or(false)
+            {
+                states.push(
+                    self.xconn.atoms.net_wm_state_fullscreen.resource_id(),
+                );
+            }
+            self.xconn.set_wm_state(xwin, &states);
+        }
+
+        // Focus next window in current workspace
+        let next = self.tree.focused_leaf(self.current_ws);
+        if next != self.current_ws {
+            self.set_focus(next);
+        } else {
+            self.tree.focused = Some(self.current_ws);
+            self.xconn.clear_active_window();
+        }
+    }
+
+    /// Restore a minimized window by key: re-map and clear HIDDEN state.
+    fn restore_window(&mut self, idx: NodeKey) {
+        let (is_minimized, has_window) = match self.tree.get(idx) {
+            Some(c) => (c.minimized, c.window.is_some()),
+            None => return,
+        };
+        if !is_minimized || !has_window {
+            return;
+        }
+
+        if let Some(con) = self.tree.get_mut(idx) {
+            con.minimized = false;
+        }
+
+        // Map the frame (makes both frame and client visible)
+        if let Some(frame) = self.tree.get(idx).and_then(|c| c.frame) {
+            self.xconn.map_window(x::Window::new(frame));
+        }
+
+        if let Some(win) = self.tree.get(idx).and_then(|c| c.window) {
+            let xwin = x::Window::new(win);
+            // Rebuild _NET_WM_STATE without HIDDEN
+            let mut states = Vec::new();
+            if self.tree.get(idx).map(|c| c.fullscreen).unwrap_or(false) {
+                states.push(
+                    self.xconn.atoms.net_wm_state_fullscreen.resource_id(),
+                );
+            }
+            self.xconn.set_wm_state(xwin, &states);
+        }
+
+        self.set_focus(idx);
+    }
+
+    /// Move the focused window to the scratchpad (hide it).
+    fn move_to_scratchpad(&mut self) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        // Only move leaf containers with windows
+        match self.tree.get(focused) {
+            Some(c) if c.window.is_some() => {}
+            _ => return,
+        }
+
+        // Already in scratchpad
+        if self.scratchpad.contains(&focused) {
+            return;
+        }
+
+        // Detach from the tree and make it floating
+        let was_floating = self
+            .tree
+            .get(focused)
+            .map(|c| c.is_floating)
+            .unwrap_or(false);
+        self.tree.detach(focused);
+
+        // Mark as floating (scratchpad windows appear as floating when shown)
+        if let Some(con) = self.tree.get_mut(focused) {
+            con.is_floating = true;
+        }
+
+        // Unmap the frame (hides both frame and client)
+        if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame) {
+            self.xconn.unmap_window(x::Window::new(frame));
+            if let Some(con) = self.tree.get_mut(focused) {
+                con.ignore_unmap += 1;
+            }
+        }
+
+        self.scratchpad.push(focused);
+
+        // Focus next window in current workspace
+        let next = self.tree.focused_leaf(self.current_ws);
+        if next != self.current_ws {
+            self.set_focus(next);
+        } else {
+            self.tree.focused = Some(self.current_ws);
+            self.xconn.clear_active_window();
+        }
+
+        if !was_floating {
+            self.relayout();
+        }
+    }
+
+    /// Toggle the most recent scratchpad window: show if hidden, hide if visible.
+    fn scratchpad_show(&mut self) {
+        if self.scratchpad.is_empty() {
+            return;
+        }
+
+        // Check if the last scratchpad window is currently visible
+        // (attached to a workspace). If so, hide it again.
+        let last = *self.scratchpad.last().unwrap();
+        let is_attached = self.tree.get(last).and_then(|c| c.parent).is_some();
+
+        if is_attached {
+            // Currently shown: detach and hide
+            self.tree.detach(last);
+            if let Some(frame) = self.tree.get(last).and_then(|c| c.frame) {
+                self.xconn.unmap_window(x::Window::new(frame));
+                if let Some(con) = self.tree.get_mut(last) {
+                    con.ignore_unmap += 1;
+                }
+            }
+
+            // Focus next window in current workspace
+            let next = self.tree.focused_leaf(self.current_ws);
+            if next != self.current_ws {
+                self.set_focus(next);
+            } else {
+                self.tree.focused = Some(self.current_ws);
+                self.xconn.clear_active_window();
+            }
+        } else {
+            // Hidden: attach as floating to current workspace and show
+            self.tree.attach_floating(last, self.current_ws);
+
+            // Center on screen
+            let screen = self.xconn.screen_rect;
+            let hints = self
+                .tree
+                .get(last)
+                .map(|c| c.size_hints)
+                .unwrap_or_default();
+            let rect = Rect {
+                x: screen.w as i32 / 6,
+                y: screen.h as i32 / 6,
+                w: screen.w * 2 / 3,
+                h: screen.h * 2 / 3,
+            };
+            let rect = self.clamp_floating_rect(rect, &hints);
+            if let Some(con) = self.tree.get_mut(last) {
+                con.rect = rect;
+                con.border_width = self.config.border_width;
+            }
+
+            if let Some(frame) = self.tree.get(last).and_then(|c| c.frame) {
+                let xframe = x::Window::new(frame);
+                self.xconn.map_window(xframe);
+                self.xconn.configure_frame(xframe, &rect);
+                self.xconn.raise_window(xframe);
+            }
+            // Position client inside frame
+            self.apply_window_geometry(last);
+            self.set_focus(last);
+
+            // Rotate: move last to front so next show cycles
+            let popped = self.scratchpad.pop().unwrap();
+            self.scratchpad.insert(0, popped);
+        }
+    }
+
+    /// Handle a mouse button press (for floating move/resize).
+    fn on_button_press(&mut self, ev: x::ButtonPressEvent) {
+        let win = ev.child();
+        if win == x::WINDOW_NONE {
+            return;
+        }
+
+        let idx = match self.tree.find_by_window(win.resource_id()) {
+            Some(i) => i,
+            None => return,
+        };
+
+        let is_floating =
+            self.tree.get(idx).map(|c| c.is_floating).unwrap_or(false);
+        if !is_floating {
+            return;
+        }
+
+        self.set_focus(idx);
+
+        let mode = match ev.detail() {
+            1 => DragMode::Move,
+            3 => DragMode::Resize,
+            _ => return,
+        };
+
+        let start_rect = match self.tree.get(idx) {
+            Some(c) => c.rect,
+            None => return,
+        };
+
+        self.drag = Some(DragState {
+            mode,
+            target: idx,
+            start_x: ev.root_x() as i32,
+            start_y: ev.root_y() as i32,
+            start_rect,
+        });
+    }
+
+    /// Handle mouse motion (drag floating windows).
+    fn on_motion_notify(&mut self, ev: x::MotionNotifyEvent) {
+        // Real pointer motion: allow EnterNotify events again.
+        self.suppress_enter = false;
+
+        let drag = match self.drag {
+            Some(d) => d,
+            None => return,
+        };
+
+        let dx = ev.root_x() as i32 - drag.start_x;
+        let dy = ev.root_y() as i32 - drag.start_y;
+
+        let hints = self
+            .tree
+            .get(drag.target)
+            .map(|c| c.size_hints)
+            .unwrap_or_default();
+
+        let raw_rect = match drag.mode {
+            DragMode::Move => Rect {
+                x: drag.start_rect.x + dx,
+                y: drag.start_rect.y + dy,
+                w: drag.start_rect.w,
+                h: drag.start_rect.h,
+            },
+            DragMode::Resize => Rect {
+                x: drag.start_rect.x,
+                y: drag.start_rect.y,
+                w: (drag.start_rect.w as i32 + dx).max(1) as u32,
+                h: (drag.start_rect.h as i32 + dy).max(1) as u32,
+            },
+        };
+
+        let new_rect = self.clamp_floating_rect(raw_rect, &hints);
+
+        if let Some(con) = self.tree.get_mut(drag.target) {
+            con.rect = new_rect;
+        }
+        self.apply_window_geometry(drag.target);
+    }
+
+    /// Handle mouse button release (end drag).
+    fn on_button_release(&mut self, _ev: x::ButtonReleaseEvent) {
+        self.drag = None;
+    }
+
+    /// Switch to workspace by name, creating it if needed.
+    fn switch_workspace(&mut self, name: &str) {
+        // Find existing workspace or create new one
+        let ws_idx = self.find_workspace(name).unwrap_or_else(|| {
+            let idx = self.tree.create_child(
+                self.output_idx,
+                ConType::Workspace,
+                name,
+            );
+            if let Some(ws) = self.tree.get_mut(idx) {
+                ws.rect = self.xconn.screen_rect;
+                ws.layout = Layout::SplitH;
+            }
+            idx
+        });
+
+        if ws_idx == self.current_ws {
+            return;
+        }
+
+        let old_ws = self.current_ws;
+
+        // Move sticky floating windows to the target workspace (like i3)
+        let sticky_windows: Vec<NodeKey> = self
+            .tree
+            .get(old_ws)
+            .map(|ws| {
+                ws.floating_children
+                    .iter()
+                    .copied()
+                    .filter(|&k| {
+                        self.tree.get(k).map(|c| c.sticky).unwrap_or(false)
+                    })
+                    .collect()
+            })
+            .unwrap_or_default();
+        for key in sticky_windows {
+            self.tree.detach(key);
+            self.tree.attach_floating(key, ws_idx);
+        }
+
+        // Hide current workspace windows
+        self.set_workspace_visible(old_ws, false);
+
+        // Show target workspace windows
+        self.set_workspace_visible(ws_idx, true);
+
+        self.current_ws = ws_idx;
+
+        // Focus the most recently focused window in the new workspace,
+        // but avoid focusing the workspace container itself if it's empty.
+        let focus_target = self.tree.focused_leaf(ws_idx);
+        if focus_target != ws_idx {
+            self.set_focus(focus_target);
+        } else {
+            // Empty workspace: clear focus, no window to focus
+            if let Some(prev) = self.tree.focused {
+                if let Some(con) = self.tree.get_mut(prev) {
+                    con.focused = false;
+                }
+                if let Some(win) = self.tree.get(prev).and_then(|c| c.window) {
+                    self.xconn.set_border_color(
+                        x::Window::new(win),
+                        self.config.color_unfocused,
+                    );
+                }
+            }
+            self.tree.focused = Some(ws_idx);
+            self.xconn.clear_active_window();
+        }
+        self.relayout();
+
+        // Update EWMH desktop properties
+        let desktop_index = self.workspace_index(ws_idx);
+        self.xconn.set_current_desktop(desktop_index);
+        let ws_count = self.workspace_count();
+        self.xconn.set_number_of_desktops(ws_count);
+        self.xconn.set_workarea(ws_count, &self.workarea());
+
+        self.ipc.broadcast_event(
+            EventType::Workspace,
+            &format!(r#"{{"change":"focus","current":"{name}"}}"#),
+        );
+
+        // Clean up empty old workspace (like i3's workspace_show)
+        self.cleanup_empty_workspace(old_ws);
+    }
+
+    /// Remove a workspace if it has no tiling or floating children.
+    fn cleanup_empty_workspace(&mut self, ws_key: NodeKey) {
+        // Don't remove the current workspace
+        if ws_key == self.current_ws {
+            return;
+        }
+        let is_empty = self
+            .tree
+            .get(ws_key)
+            .map(|ws| ws.children.is_empty() && ws.floating_children.is_empty())
+            .unwrap_or(false);
+        if is_empty {
+            log_debug!("removing empty workspace");
+            self.tree.remove(ws_key);
+
+            let ws_count = self.workspace_count();
+            self.xconn.set_number_of_desktops(ws_count);
+            self.xconn.set_workarea(ws_count, &self.workarea());
+        }
+    }
+
+    /// Move the focused window to another workspace.
+    fn move_to_workspace(&mut self, name: &str) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        // Don't move workspace containers
+        if self.tree.get(focused).map(|c| c.con_type) != Some(ConType::Con) {
+            return;
+        }
+
+        let target_ws = self.find_workspace(name).unwrap_or_else(|| {
+            let idx = self.tree.create_child(
+                self.output_idx,
+                ConType::Workspace,
+                name,
+            );
+            if let Some(ws) = self.tree.get_mut(idx) {
+                ws.rect = self.xconn.screen_rect;
+                ws.layout = Layout::SplitH;
+            }
+            idx
+        });
+
+        if target_ws == self.current_ws {
+            return;
+        }
+
+        let is_floating = self
+            .tree
+            .get(focused)
+            .map(|c| c.is_floating)
+            .unwrap_or(false);
+
+        // Detach from current parent
+        self.tree.detach(focused);
+
+        // Attach to target workspace (preserving floating state)
+        if is_floating {
+            self.tree.attach_floating(focused, target_ws);
+        } else {
+            self.tree.attach_tiling(focused, target_ws);
+        }
+
+        // Hide the moved window (it's on a hidden workspace)
+        if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame) {
+            self.xconn.unmap_window(x::Window::new(frame));
+            if let Some(con) = self.tree.get_mut(focused) {
+                con.ignore_unmap += 1;
+            }
+        }
+
+        // Focus next window in current workspace
+        let next = self.tree.focused_leaf(self.current_ws);
+        if next != self.current_ws {
+            self.set_focus(next);
+        } else {
+            self.tree.focused = Some(self.current_ws);
+            self.xconn.clear_active_window();
+        }
+        self.relayout();
+    }
+
+    /// Find a workspace by name (linear scan, like i3).
+    fn find_workspace(&self, name: &str) -> Option<NodeKey> {
+        self.tree
+            .iter()
+            .find(|(_, c)| c.con_type == ConType::Workspace && c.name == name)
+            .map(|(i, _)| i)
+    }
+
+    /// Get the index of a workspace among all workspaces (for EWMH).
+    fn workspace_index(&self, ws_key: NodeKey) -> u32 {
+        match self.tree.get(self.output_idx) {
+            Some(output) => output
+                .children
+                .iter()
+                .filter(|&&k| {
+                    self.tree
+                        .get(k)
+                        .map(|c| c.con_type == ConType::Workspace)
+                        .unwrap_or(false)
+                })
+                .position(|&k| k == ws_key)
+                .unwrap_or(0) as u32,
+            None => 0,
+        }
+    }
+
+    /// Show or hide all windows in a workspace.
+    fn set_workspace_visible(&mut self, ws_idx: NodeKey, visible: bool) {
+        let Some(ws) = self.tree.get(ws_idx) else {
+            return;
+        };
+        let mut stack = Vec::with_capacity(
+            ws.children.len() + ws.floating_children.len(),
+        );
+        stack.extend_from_slice(&ws.children);
+        stack.extend_from_slice(&ws.floating_children);
+        self.set_subtree_visible_iter(&mut stack, visible);
+    }
+
+    fn set_subtree_visible_iter(
+        &mut self,
+        stack: &mut Vec<NodeKey>,
+        visible: bool,
+    ) {
+        while let Some(idx) = stack.pop() {
+            if let Some(con) = self.tree.get(idx) {
+                let frame = con.frame;
+                let children = con.children.clone();
+                if let Some(frame) = frame {
+                    let xframe = x::Window::new(frame);
+                    if visible {
+                        self.xconn.map_window(xframe);
+                    } else {
+                        self.xconn.unmap_window(xframe);
+                        if let Some(con) = self.tree.get_mut(idx) {
+                            con.ignore_unmap += 1;
+                        }
+                    }
+                }
+                stack.extend_from_slice(&children);
+            }
+        }
+    }
+
+    /// Restart owm in-place (like i3's restart).
+    ///
+    /// Reparents all client windows back to the root so they survive the
+    /// exec, unregisters as the WM, flushes X, then replaces the process.
+    fn restart(&mut self) {
+        log_info!("restarting owm");
+
+        // Reparent every managed client back to the root window so the
+        // windows survive across the exec boundary.
+        for (_, con) in self.tree.iter() {
+            if let (Some(win), Some(_frame)) = (con.window, con.frame) {
+                let xwin = x::Window::new(win);
+                self.xconn.conn.send_request(&x::ReparentWindow {
+                    window: xwin,
+                    parent: self.xconn.root,
+                    x: con.rect.x as i16,
+                    y: con.rect.y as i16,
+                });
+            }
+        }
+
+        // Unregister as the window manager so the new instance can claim
+        // SubstructureRedirect on the root.
+        self.xconn.conn.send_request(&x::ChangeWindowAttributes {
+            window: self.xconn.root,
+            value_list: &[x::Cw::EventMask(x::EventMask::NO_EVENT)],
+        });
+
+        self.xconn.flush();
+
+        // Remove IPC socket before exec (Drop won't run after execvp).
+        let _ = std::fs::remove_file(IpcServer::socket_path());
+
+        // Replace this process with a fresh owm.
+        let exe = std::env::current_exe().unwrap_or_else(|_| "owm".into());
+        let args: Vec<String> = std::env::args().collect();
+        let cargs: Vec<std::ffi::CString> = args
+            .iter()
+            .map(|a| std::ffi::CString::new(a.as_str()).unwrap())
+            .collect();
+        let cexe = std::ffi::CString::new(
+            exe.to_string_lossy().as_ref(),
+        )
+        .unwrap();
+
+        // execvp replaces the process; if it returns, something went wrong.
+        sys::exec_replace(&cexe, &cargs);
+
+        // Fallback: if exec failed, just exit.
+        log_warn!("exec failed, exiting");
+        self.running = false;
+    }
+
+    /// Remove a client container from the tree.
+    fn remove_client(&mut self, idx: NodeKey) {
+        let parent_idx = self.tree.get(idx).and_then(|c| c.parent);
+        let was_focused = self.tree.focused == Some(idx);
+        let win_id = self.tree.get(idx).and_then(|c| c.window);
+        let frame_id = self.tree.get(idx).and_then(|c| c.frame);
+
+        // Destroy the frame window
+        if let Some(fid) = frame_id {
+            self.xconn.destroy_frame(x::Window::new(fid));
+        }
+
+        // Clean up scratchpad references
+        self.scratchpad.retain(|&k| k != idx);
+
+        self.tree.remove(idx);
+
+        // Clean up empty split containers
+        if let Some(pidx) = parent_idx {
+            self.cleanup_empty(pidx);
+        }
+
+        if was_focused {
+            let next = self.tree.focused_leaf(self.current_ws);
+            if next != self.current_ws {
+                self.set_focus(next);
+            } else {
+                // No windows left: clear X11 focus
+                self.tree.focused = Some(self.current_ws);
+                self.xconn.clear_active_window();
+            }
+        }
+
+        self.relayout();
+
+        // Remove from client list and update EWMH
+        if let Some(wid) = win_id {
+            self.client_list.retain(|&w| w != wid);
+            self.update_client_lists();
+
+            self.ipc.broadcast_event(
+                EventType::Window,
+                &format!(r#"{{"change":"close","window":{wid}}}"#),
+            );
+        }
+    }
+
+    /// Remove empty non-workspace containers.
+    fn cleanup_empty(&mut self, idx: NodeKey) {
+        let (con_type, children_len, parent) = match self.tree.get(idx) {
+            Some(c) => (c.con_type, c.children.len(), c.parent),
+            None => return,
+        };
+
+        if con_type == ConType::Con && children_len == 0 {
+            self.tree.remove(idx);
+            if let Some(pidx) = parent {
+                self.cleanup_empty(pidx);
+            }
+        }
+    }
+
+    /// Set focus on a container.
+    ///
+    /// Only containers with a window (leaf nodes) should receive X11 focus.
+    /// If called with a non-leaf (workspace, output, root), we skip the
+    /// xconn calls and just update tree state.
+    fn set_focus(&mut self, idx: NodeKey) {
+        // Validate: only focus Con/FloatingCon containers with a window.
+        match self.tree.get(idx).map(|c| c.con_type) {
+            Some(ConType::Con) | Some(ConType::FloatingCon) => {}
+            _ => {
+                log_debug!("set_focus: ignoring non-leaf container");
+                return;
+            }
+        }
+
+        // Unfocus previous — redraw decoration with unfocused colors
+        if let Some(prev) = self.tree.focused {
+            if let Some(con) = self.tree.get_mut(prev) {
+                con.focused = false;
+            }
+            self.redraw_decoration(prev);
+        }
+
+        // Focus new — clear urgency on focus
+        if let Some(con) = self.tree.get_mut(idx) {
+            con.focused = true;
+            con.urgent = false;
+        }
+        self.tree.focused = Some(idx);
+
+        // Update focus stack up the tree
+        let mut child = idx;
+        let mut cur = self.tree.get(idx).and_then(|c| c.parent);
+        while let Some(pidx) = cur {
+            if let Some(parent) = self.tree.get_mut(pidx) {
+                parent.focus_stack.retain(|&c| c != child);
+                parent.focus_stack.push_front(child);
+            }
+            child = pidx;
+            cur = self.tree.get(pidx).and_then(|c| c.parent);
+        }
+
+        // Set X11 focus and redraw decoration with focused colors.
+        // Skip SetInputFocus if the window is hidden (frame unmapped)
+        // inside a tabbed/stacked parent — it would cause a BadMatch.
+        let is_hidden = self.tree.is_hidden(idx);
+        if !is_hidden
+            && let Some(win) = self.tree.get(idx).and_then(|c| c.window)
+        {
+            let xwin = x::Window::new(win);
+            self.xconn.set_focus(xwin);
+            self.xconn.set_active_window(xwin);
+            self.redraw_decoration(idx);
+
+            self.ipc.broadcast_event(
+                EventType::Window,
+                &format!(r#"{{"change":"focus","window":{win}}}"#),
+            );
+        } else {
+            self.xconn.clear_active_window();
+        }
+    }
+
+    /// Redraw decoration on a single container's frame.
+    ///
+    /// Skips containers inside tabbed/stacked parents — their
+    /// decorations are drawn collectively by `relayout()`.
+    fn redraw_decoration(&self, idx: NodeKey) {
+        // In tabbed/stacked, the parent draws all tabs during relayout.
+        if self.tree.tab_siblings(idx).is_some() {
+            return;
+        }
+
+        if let Some(con) = self.tree.get(idx)
+            && let Some(frame_id) = con.frame
+        {
+            let frame = x::Window::new(frame_id);
+            let deco_h = match con.border_style {
+                BorderStyle::Normal => self.xconn.deco_height,
+                _ => 0,
+            };
+            let colors = self.deco_colors(con.focused, con.urgent);
+            self.xconn.draw_decoration(&Decoration {
+                frame,
+                frame_w: con.rect.w,
+                frame_h: con.rect.h,
+                title: &con.title,
+                colors: &colors,
+                border_width: con.border_width,
+                deco_height: deco_h,
+            });
+        }
+    }
+
+    /// Recompute layout and apply to all windows.
+    /// Compute the usable workspace rect (screen minus bar).
+    fn workarea(&self) -> Rect {
+        match &self.bar {
+            Some(b) => b.workarea(self.xconn.screen_rect),
+            None => self.xconn.screen_rect,
+        }
+    }
+
+    /// Resolve the effective gaps for a workspace, merging per-workspace
+    /// overrides from config and from the container itself, then clamping.
+    fn resolve_gaps(&self, ws_key: NodeKey) -> config::Gaps {
+        let global = &self.config.gaps;
+
+        // Start with container-level override (set via IPC).
+        let con_ovr = self.tree.get(ws_key)
+            .map(|c| c.gaps_override)
+            .unwrap_or_default();
+
+        // Then layer config-level per-workspace override.
+        let cfg_ovr = self.tree.get(ws_key)
+            .and_then(|c| self.config.workspace_gaps.get(&c.name))
+            .copied()
+            .unwrap_or_default();
+
+        // Config overrides global, container overrides config.
+        let resolved = config::Gaps {
+            inner: con_ovr.inner
+                .or(cfg_ovr.inner)
+                .unwrap_or(global.inner),
+            top: con_ovr.top
+                .or(cfg_ovr.top)
+                .unwrap_or(global.top),
+            right: con_ovr.right
+                .or(cfg_ovr.right)
+                .unwrap_or(global.right),
+            bottom: con_ovr.bottom
+                .or(cfg_ovr.bottom)
+                .unwrap_or(global.bottom),
+            left: con_ovr.left
+                .or(cfg_ovr.left)
+                .unwrap_or(global.left),
+        };
+
+        resolved.clamped()
+    }
+
+    fn relayout(&mut self) {
+        // Set workspace rect (accounting for bar)
+        let wa = self.workarea();
+        if let Some(ws) = self.tree.get_mut(self.current_ws) {
+            ws.rect = wa;
+        }
+
+        // Resolve per-workspace gap overrides against global gaps.
+        let gaps = self.resolve_gaps(self.current_ws);
+
+        layout::apply_layout(
+            &mut self.tree,
+            self.current_ws,
+            &gaps,
+            self.config.smart_gaps,
+            self.xconn.deco_height,
+        );
+
+        // Collect all containers with frames in the current workspace
+        let containers: Vec<FrameSnapshot> = self
+            .tree
+            .iter()
+            .filter(|(_, c)| c.window.is_some() && c.frame.is_some())
+            .filter(|(idx, _)| {
+                self.tree.workspace_for(*idx) == Some(self.current_ws)
+            })
+            .map(|(idx, c)| FrameSnapshot {
+                key: idx,
+                frame_id: c.frame.unwrap(),
+                win_id: c.window.unwrap(),
+                rect: c.rect,
+                window_rect: c.window_rect,
+                is_floating: c.is_floating,
+                focused: c.focused,
+                hidden: self.tree.is_hidden(idx),
+                border_style: c.border_style,
+                border_width: c.border_width,
+                title: c.title.clone(),
+            })
+            .collect();
+
+        // Pass 1: geometry + map/unmap
+        for snap in &containers {
+            let frame = x::Window::new(snap.frame_id);
+            let client = x::Window::new(snap.win_id);
+
+            if snap.hidden {
+                if let Some(con) = self.tree.get_mut(snap.key) {
+                    if con.frame_mapped {
+                        con.frame_mapped = false;
+                        con.ignore_unmap += 1;
+                        self.xconn.unmap_window(frame);
+                    }
+                }
+                continue;
+            }
+
+            self.xconn.configure_frame(frame, &snap.rect);
+            self.xconn.configure_client_in_frame(
+                client,
+                snap.window_rect.x,
+                snap.window_rect.y,
+                snap.window_rect.w,
+                snap.window_rect.h,
+            );
+
+            if let Some(con) = self.tree.get_mut(snap.key) {
+                if !con.frame_mapped {
+                    con.frame_mapped = true;
+                    self.xconn.map_window(frame);
+                }
+            }
+
+            if snap.is_floating {
+                self.xconn.raise_window(frame);
+            }
+        }
+
+        // Flush geometry/map/unmap before drawing decorations.
+        self.xconn.flush();
+
+        // Pass 2: decorations (only on visible frames)
+        for snap in &containers {
+            if snap.hidden {
+                continue;
+            }
+
+            let frame = x::Window::new(snap.frame_id);
+            let deco_h = match snap.border_style {
+                BorderStyle::Normal => self.xconn.deco_height,
+                _ => 0,
+            };
+
+            let tab_info = self.tree.tab_siblings(snap.key);
+
+            if let Some((_, ref tabs)) = tab_info {
+                // Fill frame background (border color).
+                let colors_focused = self.deco_colors(true, false);
+                self.xconn.draw_decoration(&Decoration {
+                    frame,
+                    frame_w: snap.rect.w,
+                    frame_h: snap.rect.h,
+                    title: "",
+                    colors: &colors_focused,
+                    border_width: snap.border_width,
+                    deco_height: 0,
+                });
+
+                // Draw each tab
+                for &(sib_key, deco_rect) in tabs {
+                    let sib_title = self.tree.get(sib_key)
+                        .map(|c| c.title.as_str())
+                        .unwrap_or("");
+                    let sib_focused = sib_key == snap.key;
+                    let colors = self.deco_colors(sib_focused, false);
+                    self.xconn.draw_tab(
+                        frame,
+                        &deco_rect,
+                        sib_title,
+                        &colors,
+                        sib_focused,
+                    );
+                }
+            } else {
+                let colors = self.deco_colors(snap.focused, false);
+                self.xconn.draw_decoration(&Decoration {
+                    frame,
+                    frame_w: snap.rect.w,
+                    frame_h: snap.rect.h,
+                    title: &snap.title,
+                    colors: &colors,
+                    border_width: snap.border_width,
+                    deco_height: deco_h,
+                });
+
+                // Draw split direction indicator on the focused window.
+                if snap.focused && !snap.is_floating {
+                    let split_v = if self.config.autotiling {
+                        snap.rect.h >= snap.rect.w
+                    } else {
+                        self.next_split == Layout::SplitV
+                    };
+                    self.xconn.draw_indicator(
+                        frame,
+                        snap.rect.w,
+                        snap.rect.h,
+                        split_v,
+                        self.config.color_indicator,
+                    );
+                }
+            }
+        }
+
+        self.xconn.flush();
+
+        // Redraw the status bar
+        self.redraw_bar();
+    }
+
+    /// Redraw the status bar if enabled.
+    fn redraw_bar(&mut self) {
+        if let Some(ref mut b) = self.bar {
+            b.redraw(&self.xconn, &self.config, &self.tree, self.current_ws);
+        }
+    }
+
+    /// Handle a click on the status bar.
+    fn handle_bar_click(&mut self, ev: &x::ButtonPressEvent) {
+        let click_x = ev.event_x() as i32;
+        let click_y = ev.event_y() as i32;
+        let button = ev.detail() as u32;
+
+        // Extract the action without holding a borrow on self.bar.
+        let action = self.bar.as_mut().and_then(|b| {
+            b.handle_click(click_x, click_y, button)
+        });
+
+        if let Some(bar::BarAction::SwitchWorkspace(ws_key)) = action {
+            if let Some(name) = self.tree.get(ws_key).map(|c| c.name.clone()) {
+                self.switch_workspace(&name);
+            }
+        }
+    }
+
+    /// Get decoration colors for a container state.
+    ///
+    /// Matches i3 defaults: border is the indicator/edge color,
+    /// background is the fill behind title text.
+    fn deco_colors(&self, focused: bool, urgent: bool) -> DecoColors {
+        if urgent {
+            DecoColors {
+                border: self.config.color_urgent,
+                background: self.config.color_urgent_bg,
+                text: self.config.color_title_focused,
+            }
+        } else if focused {
+            DecoColors {
+                border: self.config.color_focused,
+                background: self.config.color_focused_bg,
+                text: self.config.color_title_focused,
+            }
+        } else {
+            DecoColors {
+                border: self.config.color_unfocused,
+                background: self.config.color_unfocused_bg,
+                text: self.config.color_title_unfocused,
+            }
+        }
+    }
+
+    /// Minimum pixels of a floating window that must remain visible on screen.
+    const MIN_VISIBLE: i32 = 50;
+
+    /// Clamp a floating window rect: apply size hints and keep on screen.
+    fn clamp_floating_rect(&self, mut rect: Rect, hints: &SizeHints) -> Rect {
+        let screen = self.xconn.screen_rect;
+
+        // Apply min size from hints (fallback to 50px hard minimum)
+        let min_w = hints.min_w.unwrap_or(50).max(1);
+        let min_h = hints.min_h.unwrap_or(50).max(1);
+        if rect.w < min_w {
+            rect.w = min_w;
+        }
+        if rect.h < min_h {
+            rect.h = min_h;
+        }
+
+        // Apply max size from hints
+        if let Some(max_w) = hints.max_w
+            && max_w > 0 && rect.w > max_w
+        {
+            rect.w = max_w;
+        }
+        if let Some(max_h) = hints.max_h
+            && max_h > 0 && rect.h > max_h
+        {
+            rect.h = max_h;
+        }
+
+        // Apply resize increments
+        if let Some(inc_w) = hints.inc_w
+            && inc_w > 1
+        {
+            let base = hints.base_w.unwrap_or(min_w);
+            let over = rect.w.saturating_sub(base);
+            rect.w = base + (over / inc_w) * inc_w;
+        }
+        if let Some(inc_h) = hints.inc_h
+            && inc_h > 1
+        {
+            let base = hints.base_h.unwrap_or(min_h);
+            let over = rect.h.saturating_sub(base);
+            rect.h = base + (over / inc_h) * inc_h;
+        }
+
+        // Keep at least MIN_VISIBLE pixels on screen in each axis
+        let max_x = screen.x + screen.w as i32 - Self::MIN_VISIBLE;
+        let min_x = screen.x - rect.w as i32 + Self::MIN_VISIBLE;
+        let max_y = screen.y + screen.h as i32 - Self::MIN_VISIBLE;
+        let min_y = screen.y - rect.h as i32 + Self::MIN_VISIBLE;
+
+        rect.x = rect.x.clamp(min_x, max_x);
+        rect.y = rect.y.clamp(min_y, max_y);
+
+        rect
+    }
+
+    /// Apply geometry for a single window.
+    fn apply_window_geometry(&self, idx: NodeKey) {
+        if let Some(con) = self.tree.get(idx)
+            && let Some(frame_id) = con.frame
+            && let Some(win_id) = con.window
+        {
+            let frame = x::Window::new(frame_id);
+            let client = x::Window::new(win_id);
+            self.xconn.configure_frame(frame, &con.rect);
+            self.xconn.configure_client_in_frame(
+                client,
+                con.window_rect.x,
+                con.window_rect.y,
+                con.window_rect.w,
+                con.window_rect.h,
+            );
+
+            let deco_h = match con.border_style {
+                BorderStyle::Normal => self.xconn.deco_height,
+                _ => 0,
+            };
+            let colors = self.deco_colors(con.focused, con.urgent);
+            self.xconn.draw_decoration(&Decoration {
+                frame,
+                frame_w: con.rect.w,
+                frame_h: con.rect.h,
+                title: &con.title,
+                colors: &colors,
+                border_width: con.border_width,
+                deco_height: deco_h,
+            });
+        }
+    }
+
+    /// Spawn a command.
+    fn spawn(&self, cmd: &str) {
+        let parts: Vec<&str> = cmd.split_whitespace().collect();
+        if parts.is_empty() {
+            return;
+        }
+
+        match Command::new(parts[0])
+            .args(&parts[1..])
+            .env("OWM_SOCKET", self.ipc.socket_path_str())
+            .spawn()
+        {
+            Ok(_) => log_debug!("spawned: {}", cmd),
+            Err(e) => log_error!("failed to spawn '{}': {}", cmd, e),
+        }
+    }
+
+    /// Handle a ClientMessage event (EWMH requests from clients).
+    fn on_client_message(&mut self, ev: x::ClientMessageEvent) {
+        let win = ev.window();
+        let msg_type = ev.r#type();
+
+        if msg_type == self.xconn.atoms.net_wm_state {
+            // _NET_WM_STATE change request (e.g. fullscreen toggle)
+            let x::ClientMessageData::Data32(data) = ev.data() else {
+                return;
+            };
+            let action = data[0];
+            let fs_id = self.xconn.atoms.net_wm_state_fullscreen.resource_id();
+
+            if (data[1] == fs_id || data[2] == fs_id)
+                && let Some(idx) = self.tree.find_by_window(win.resource_id())
+            {
+                let is_fs =
+                    self.tree.get(idx).map(|c| c.fullscreen).unwrap_or(false);
+                let want_fs = match action {
+                    0 => false,  // _NET_WM_STATE_REMOVE
+                    1 => true,   // _NET_WM_STATE_ADD
+                    2 => !is_fs, // _NET_WM_STATE_TOGGLE
+                    _ => return,
+                };
+                if want_fs != is_fs {
+                    self.set_focus(idx);
+                    self.toggle_fullscreen();
+                }
+            }
+        } else if msg_type == self.xconn.atoms.net_active_window {
+            // _NET_ACTIVE_WINDOW focus request.
+            // data[0] = source indication: 1 = normal app, 2 = pager/taskbar.
+            // Like i3: only pagers (source=2) may steal focus directly.
+            // Normal apps (source=1) just get urgency set.
+            let x::ClientMessageData::Data32(data) = ev.data() else {
+                return;
+            };
+            let source = data[0];
+
+            if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+                if source == 2 {
+                    // Pager/taskbar: allow focus change
+                    if let Some(ws) = self.tree.workspace_for(idx)
+                        && ws != self.current_ws
+                    {
+                        let ws_name = self
+                            .tree
+                            .get(ws)
+                            .map(|c| c.name.clone())
+                            .unwrap_or_default();
+                        self.switch_workspace(&ws_name);
+                    }
+                    self.set_focus(idx);
+                } else {
+                    // Normal app: set urgency instead of stealing focus
+                    let is_focused = self.tree.focused == Some(idx);
+                    if !is_focused {
+                        if let Some(con) = self.tree.get_mut(idx) {
+                            con.urgent = true;
+                        }
+                        self.xconn
+                            .set_border_color(win, self.config.color_urgent);
+                        log_debug!(
+                            "focus steal blocked for 0x{:x}, set urgent",
+                            win.resource_id()
+                        );
+                    }
+                }
+            }
+        }
+    }
+
+    /// Update _NET_CLIENT_LIST and _NET_CLIENT_LIST_STACKING on the root window.
+    fn update_client_lists(&self) {
+        // _NET_CLIENT_LIST: mapping order
+        self.xconn.set_client_list(&self.client_list);
+
+        // _NET_CLIENT_LIST_STACKING: bottom-to-top (tiling first, then floating)
+        let mut stacking: Vec<u32> = Vec::new();
+        for (_, c) in self.tree.iter() {
+            if let Some(win) = c.window
+                && !c.is_floating
+            {
+                stacking.push(win);
+            }
+        }
+        for (_, c) in self.tree.iter() {
+            if let Some(win) = c.window
+                && c.is_floating
+            {
+                stacking.push(win);
+            }
+        }
+        self.xconn.set_client_list_stacking(&stacking);
+    }
+
+    /// Force-kill windows that haven't closed within the timeout.
+    fn check_close_timeouts(&mut self) {
+        let timeout =
+            std::time::Duration::from_secs(xcb_conn::CLOSE_TIMEOUT_SECS);
+        let mut to_kill = Vec::new();
+        self.pending_closes.retain(|&(win, started)| {
+            if started.elapsed() >= timeout {
+                to_kill.push(win);
+                false
+            } else {
+                true
+            }
+        });
+        for win in to_kill {
+            log_warn!(
+                "window 0x{:x} did not respond to WM_DELETE_WINDOW, killing",
+                win.resource_id()
+            );
+            self.xconn.kill_window(win);
+        }
+    }
+
+    /// Resize the focused tiling container by adjusting its percent.
+    fn resize_focused(&mut self, dir: ResizeDir) {
+        let focused = match self.tree.focused {
+            Some(f) => f,
+            None => return,
+        };
+
+        let parent_key = match self.tree.get(focused).and_then(|c| c.parent) {
+            Some(p) => p,
+            None => return,
+        };
+
+        let parent_layout = match self.tree.get(parent_key) {
+            Some(p) => p.layout,
+            None => return,
+        };
+
+        // Only resize along the parent's split axis
+        let is_width =
+            matches!(dir, ResizeDir::GrowWidth | ResizeDir::ShrinkWidth);
+        let axis_match = match parent_layout {
+            Layout::SplitH => is_width,
+            Layout::SplitV => !is_width,
+            _ => return, // stacked/tabbed don't split space
+        };
+        if !axis_match {
+            return;
+        }
+
+        let siblings: Vec<NodeKey> = match self.tree.get(parent_key) {
+            Some(p) => p.children.clone(),
+            None => return,
+        };
+        if siblings.len() < 2 {
+            return;
+        }
+
+        let grow = matches!(dir, ResizeDir::GrowWidth | ResizeDir::GrowHeight);
+        let delta = 0.05_f64;
+
+        let cur_pct = self.tree.get(focused).map(|c| c.percent).unwrap_or(0.0);
+        if grow && cur_pct + delta > 0.95 {
+            return;
+        }
+        if !grow && cur_pct - delta < 0.05 {
+            return;
+        }
+
+        // Apply delta to focused, distribute inverse among siblings
+        let other_count = (siblings.len() - 1) as f64;
+        let per_other = delta / other_count;
+
+        if let Some(con) = self.tree.get_mut(focused) {
+            if grow {
+                con.percent += delta;
+            } else {
+                con.percent -= delta;
+            }
+        }
+
+        for &sib in &siblings {
+            if sib == focused {
+                continue;
+            }
+            if let Some(con) = self.tree.get_mut(sib) {
+                if grow {
+                    con.percent = (con.percent - per_other).max(0.05);
+                } else {
+                    con.percent += per_other;
+                }
+            }
+        }
+
+        self.relayout();
+    }
+
+    /// Count the number of workspaces in the tree.
+    fn workspace_count(&self) -> u32 {
+        self.tree
+            .iter()
+            .filter(|(_, c)| c.con_type == ConType::Workspace)
+            .count() as u32
+    }
+
+    /// Handle an IPC command string.
+    /// Reload configuration from ~/.owmrc and re-register keybindings.
+    fn reload_config(&mut self) -> Vec<String> {
+        let (new_config, errors) = Config::load();
+
+        self.keybinds.clear(&self.xconn.conn, self.xconn.root);
+        self.keybinds.setup(
+            &self.xconn.conn,
+            self.xconn.root,
+            &new_config.keybindings,
+        );
+        for (mode_name, mode_bindings) in &new_config.modes {
+            self.keybinds.setup_mode(
+                &self.xconn.conn,
+                mode_name,
+                mode_bindings,
+            );
+        }
+        self.xconn.flush();
+
+        self.config = new_config;
+
+        // Recreate or destroy bar based on new config
+        if let Some(old_bar) = self.bar.take() {
+            old_bar.destroy(&self.xconn);
+        }
+        if self.config.bar_enabled {
+            self.bar = Some(bar::Bar::new(&self.xconn, &self.config));
+        }
+
+        self.ipc.broadcast_event(
+            EventType::Config,
+            r#"{"change":"reload"}"#,
+        );
+
+        self.relayout();
+        log_info!("configuration reloaded");
+        errors
+    }
+
+    /// Show or dismiss the nagbar based on config errors.
+    fn update_nagbar(&mut self, errors: Vec<String>) {
+        // Dismiss existing nagbar
+        if let Some(nag) = self.nagbar.take() {
+            nag.dismiss(&self.xconn);
+        }
+        // Show new one if there are errors
+        if !errors.is_empty() {
+            self.nagbar = Some(nagbar::Nagbar::show(&self.xconn, &errors));
+        }
+    }
+
+    /// Dismiss the nagbar if present.
+    fn dismiss_nagbar(&mut self) {
+        if let Some(nag) = self.nagbar.take() {
+            nag.dismiss(&self.xconn);
+        }
+    }
+
+    fn handle_ipc_command(&mut self, cmd: &str) {
+        let parts: Vec<&str> = cmd.split_whitespace().collect();
+        match parts.first().copied() {
+            Some("focus") => match parts.get(1).copied() {
+                Some("next") => self.focus_adjacent(1),
+                Some("prev") => self.focus_adjacent(-1),
+                Some("parent") => self.handle_action(Action::FocusParent),
+                Some("child") => self.handle_action(Action::FocusChild),
+                _ => {}
+            },
+            Some("split") => match parts.get(1).copied() {
+                Some("v") | Some("vertical") => {
+                    self.next_split = Layout::SplitV
+                }
+                Some("h") | Some("horizontal") => {
+                    self.next_split = Layout::SplitH
+                }
+                _ => {}
+            },
+            Some("layout") => match parts.get(1).copied() {
+                Some("stacking") => self.set_layout(Layout::Stacked),
+                Some("tabbed") => self.set_layout(Layout::Tabbed),
+                Some("splith") => self.set_layout(Layout::SplitH),
+                Some("splitv") => self.set_layout(Layout::SplitV),
+                _ => {}
+            },
+            Some("kill") => self.handle_action(Action::CloseWindow),
+            Some("fullscreen") => self.toggle_fullscreen(),
+            Some("workspace") => {
+                if let Some(name) = parts.get(1) {
+                    self.switch_workspace(name);
+                }
+            }
+            Some("move") => {
+                if parts.get(1).copied() == Some("workspace")
+                    && let Some(name) = parts.get(2)
+                {
+                    self.move_to_workspace(name);
+                }
+            }
+            Some("exec") => {
+                let cmd = parts[1..].join(" ");
+                self.spawn(&cmd);
+            }
+            Some("mode") => {
+                if let Some(name) = parts.get(1) {
+                    self.keybinds.switch_mode(
+                        name,
+                        &self.xconn.conn,
+                        self.xconn.root,
+                    );
+                    self.xconn.flush();
+                }
+            }
+            Some("resize") => {
+                // resize grow|shrink width|height
+                let dir = match (parts.get(1).copied(), parts.get(2).copied()) {
+                    (Some("grow"), Some("width")) => Some(ResizeDir::GrowWidth),
+                    (Some("shrink"), Some("width")) => {
+                        Some(ResizeDir::ShrinkWidth)
+                    }
+                    (Some("grow"), Some("height")) => {
+                        Some(ResizeDir::GrowHeight)
+                    }
+                    (Some("shrink"), Some("height")) => {
+                        Some(ResizeDir::ShrinkHeight)
+                    }
+                    _ => None,
+                };
+                if let Some(d) = dir {
+                    self.resize_focused(d);
+                }
+            }
+            Some("mark") => {
+                if let Some(name) = parts.get(1)
+                    && let Some(focused) = self.tree.focused
+                {
+                    self.tree.set_mark(focused, name);
+                }
+            }
+            Some("unmark") => match parts.get(1) {
+                Some(name) => self.tree.unmark(name),
+                None => self.tree.unmark_all(),
+            },
+            Some("sticky") => self.toggle_sticky(),
+            Some("minimize") => self.minimize_focused(),
+            Some("restore") => {
+                // "restore" without args: restore the last minimized window
+                // "restore <window_id>": restore a specific window
+                if let Some(wid_str) = parts.get(1) {
+                    if let Ok(wid) = wid_str.parse::<u32>()
+                        && let Some(idx) = self.tree.find_by_window(wid)
+                    {
+                        self.restore_window(idx);
+                    }
+                } else {
+                    // Find last minimized window on any workspace
+                    let minimized: Option<NodeKey> = self
+                        .tree
+                        .iter()
+                        .find(|(_, c)| c.minimized && c.window.is_some())
+                        .map(|(k, _)| k);
+                    if let Some(idx) = minimized {
+                        self.restore_window(idx);
+                    }
+                }
+            }
+            Some("scratchpad") => match parts.get(1).copied() {
+                Some("show") => self.scratchpad_show(),
+                _ => self.move_to_scratchpad(),
+            },
+            Some("gaps") => {
+                // gaps <type> current|all set|plus|minus|toggle <px>
+                self.handle_gaps_command(&parts[1..]);
+            }
+            Some("reload") => {
+                let errors = self.reload_config();
+                self.update_nagbar(errors);
+            }
+            Some("restart") => self.restart(),
+            Some("exit") => self.running = false,
+            _ => log_warn!("unknown IPC command: {}", cmd),
+        }
+    }
+
+    /// Handle `gaps <type> current|all set|plus|minus|toggle <px>`.
+    fn handle_gaps_command(&mut self, args: &[&str]) {
+        if args.len() < 3 {
+            log_warn!("gaps: expected <type> <scope> <op> [px]");
+            return;
+        }
+
+        let gap_type = args[0];
+        let scope = args[1];
+        let op = args[2];
+        let px = args.get(3).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
+
+        // Collect workspace keys to modify.
+        let ws_keys: Vec<NodeKey> = match scope {
+            "current" => {
+                vec![self.current_ws]
+            }
+            "all" => {
+                self.tree.iter()
+                    .filter(|(_, c)| c.con_type == ConType::Workspace)
+                    .map(|(k, _)| k)
+                    .collect()
+            }
+            _ => {
+                log_warn!("gaps: scope must be 'current' or 'all'");
+                return;
+            }
+        };
+
+        for ws_key in &ws_keys {
+            // Verify workspace exists.
+            if self.tree.get(*ws_key).is_none() {
+                continue;
+            }
+
+            // Which fields to modify.
+            let fields: Vec<fn(&mut config::GapsOverride) -> &mut Option<i32>> = match gap_type {
+                "inner" => vec![|o: &mut config::GapsOverride| &mut o.inner],
+                "outer" => vec![
+                    |o: &mut config::GapsOverride| &mut o.top,
+                    |o: &mut config::GapsOverride| &mut o.right,
+                    |o: &mut config::GapsOverride| &mut o.bottom,
+                    |o: &mut config::GapsOverride| &mut o.left,
+                ],
+                "horizontal" => vec![
+                    |o: &mut config::GapsOverride| &mut o.left,
+                    |o: &mut config::GapsOverride| &mut o.right,
+                ],
+                "vertical" => vec![
+                    |o: &mut config::GapsOverride| &mut o.top,
+                    |o: &mut config::GapsOverride| &mut o.bottom,
+                ],
+                "top" => vec![|o: &mut config::GapsOverride| &mut o.top],
+                "right" => vec![|o: &mut config::GapsOverride| &mut o.right],
+                "bottom" => vec![|o: &mut config::GapsOverride| &mut o.bottom],
+                "left" => vec![|o: &mut config::GapsOverride| &mut o.left],
+                _ => {
+                    log_warn!("gaps: unknown type '{}'", gap_type);
+                    return;
+                }
+            };
+
+            // Resolve current global value for fallback.
+            let global = &self.config.gaps;
+
+            // For the "inner" field, get the global inner; for directional
+            // fields we need the specific global value, but since we apply
+            // the accessor to ovr, the current value is ovr.field or global.
+            // We'll read the current effective value, apply the op, then store.
+            for accessor in &fields {
+                let ovr = match self.tree.get_mut(*ws_key) {
+                    Some(ws) => &mut ws.gaps_override,
+                    None => continue,
+                };
+                let field = accessor(ovr);
+                let global_val = {
+                    // Determine which global field this corresponds to by
+                    // examining which field the accessor modifies.
+                    let mut probe = config::GapsOverride::default();
+                    *accessor(&mut probe) = Some(9999);
+                    if probe.inner == Some(9999) { global.inner }
+                    else if probe.top == Some(9999) { global.top }
+                    else if probe.right == Some(9999) { global.right }
+                    else if probe.bottom == Some(9999) { global.bottom }
+                    else { global.left }
+                };
+                let current = field.unwrap_or(global_val);
+
+                match op {
+                    "set" => *field = Some(px),
+                    "plus" => *field = Some(current + px),
+                    "minus" => *field = Some(current - px),
+                    "toggle" => {
+                        if current != 0 {
+                            *field = Some(0);
+                        } else {
+                            *field = Some(px);
+                        }
+                    }
+                    _ => {
+                        log_warn!("gaps: unknown op '{}' (set|plus|minus|toggle)", op);
+                        return;
+                    }
+                }
+            }
+        }
+
+        self.relayout();
+    }
+}
+
+fn main() {
+    log::init();
+
+    log_info!("starting owm v{}", env!("CARGO_PKG_VERSION"));
+
+    let mut wm = match Owm::new() {
+        Ok((mut wm, errors)) => {
+            if !errors.is_empty() {
+                wm.nagbar = Some(nagbar::Nagbar::show(&wm.xconn, &errors));
+            }
+            wm
+        }
+        Err(e) => {
+            eprintln!("owm: failed to initialize: {}", e);
+            std::process::exit(1);
+        }
+    };
+
+    // TODO: re-enable sandbox after Xft/fontconfig paths are sorted out
+    // let socket_dir = IpcServer::socket_path()
+    //     .parent()
+    //     .unwrap_or(std::path::Path::new("/tmp"))
+    //     .to_string_lossy()
+    //     .into_owned();
+    // if let Err(e) = sys::sandbox(&socket_dir) {
+    //     eprintln!("owm: sandbox: {}", e);
+    //     std::process::exit(1);
+    // }
+
+    if let Err(e) = wm.run() {
+        eprintln!("owm: {}", e);
+        std::process::exit(1);
+    }
+}
blob - /dev/null
blob + dcb52b150ff54d03b28b305688e7126e5c4fab8c (mode 644)
--- /dev/null
+++ src/config.rs
@@ -0,0 +1,1416 @@
+use std::collections::HashMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use crate::container::BorderStyle;
+use crate::keybind::{Action, ResizeDir};
+
+/// A per-window rule applied via `for_window` in owmrc.
+#[derive(Debug, Clone)]
+pub struct WindowRule {
+    /// WM_CLASS class to match (exact string match).
+    pub class: Option<String>,
+    /// WM_CLASS instance to match (exact string match).
+    pub instance: Option<String>,
+    /// Window title substring to match.
+    pub title: Option<String>,
+    /// Border style to force on matching windows.
+    pub border_style: BorderStyle,
+    /// Border width override (only for Pixel style).
+    pub border_width: Option<u32>,
+}
+
+/// Gap configuration with per-direction control (i3-gaps style).
+///
+/// `inner` is the gap between adjacent tiled windows.
+/// `top`, `right`, `bottom`, `left` are outer gaps between windows and
+/// the workspace edge.  Outer values may be negative to compensate for
+/// inner gaps (minimum: -inner).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Gaps {
+    pub inner: i32,
+    pub top: i32,
+    pub right: i32,
+    pub bottom: i32,
+    pub left: i32,
+}
+
+impl Default for Gaps {
+    fn default() -> Self {
+        Gaps { inner: 0, top: 0, right: 0, bottom: 0, left: 0 }
+    }
+}
+
+impl Gaps {
+    /// Clamp outer values so they never go below -inner.
+    pub fn clamped(&self) -> Gaps {
+        let min = -self.inner.max(0);
+        Gaps {
+            inner: self.inner.max(0),
+            top: self.top.max(min),
+            right: self.right.max(min),
+            bottom: self.bottom.max(min),
+            left: self.left.max(min),
+        }
+    }
+}
+
+/// Smart gaps behaviour.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum SmartGaps {
+    /// Gaps are always shown (default).
+    #[default]
+    Off,
+    /// Hide all gaps when a workspace has only one window.
+    On,
+    /// Hide inner gaps but keep (inverted) outer gaps with one window.
+    InverseOuter,
+}
+
+/// Per-workspace gap overrides.  Each field is `Some` only when
+/// explicitly set in the config; `None` means "inherit global".
+#[derive(Debug, Clone, Copy, Default)]
+pub struct GapsOverride {
+    pub inner: Option<i32>,
+    pub top: Option<i32>,
+    pub right: Option<i32>,
+    pub bottom: Option<i32>,
+    pub left: Option<i32>,
+}
+
+/// Runtime configuration loaded from ~/.owmrc.
+pub struct Config {
+    pub border_width: u32,
+    pub gaps: Gaps,
+    pub smart_gaps: SmartGaps,
+    /// Per-workspace gap overrides keyed by workspace name.
+    pub workspace_gaps: HashMap<String, GapsOverride>,
+    /// Focused window: border color (i3 default: #4c7899).
+    pub color_focused: u32,
+    /// Focused window: title bar background (i3 default: #285577).
+    pub color_focused_bg: u32,
+    /// Unfocused window: border color (i3 default: #333333).
+    pub color_unfocused: u32,
+    /// Unfocused window: title bar background (i3 default: #222222).
+    pub color_unfocused_bg: u32,
+    /// Urgent window: border color (i3 default: #2f343a).
+    pub color_urgent: u32,
+    /// Urgent window: title bar background (i3 default: #900000).
+    pub color_urgent_bg: u32,
+    /// Split direction indicator on focused window (i3 default: #2e9ef4).
+    pub color_indicator: u32,
+    /// Root window background (i3 default: #000000).
+    pub color_background: u32,
+    /// Title bar text color for focused windows.
+    pub color_title_focused: u32,
+    /// Title bar text color for unfocused windows.
+    pub color_title_unfocused: u32,
+    /// Default border style for new windows.
+    pub default_border: BorderStyle,
+    /// Default border style for new floating windows.
+    pub default_floating_border: BorderStyle,
+    /// Font name (fontconfig pattern, e.g. "monospace:size=11").
+    pub font: String,
+    pub terminal: String,
+    pub focus_follows_mouse: bool,
+    pub keybindings: Vec<(u32, u32, Action)>,
+    /// Named binding modes (e.g. "resize") with their own keybindings.
+    pub modes: HashMap<String, Vec<(u32, u32, Action)>>,
+    /// Per-window rules (for_window).
+    pub window_rules: Vec<WindowRule>,
+    /// Whether the built-in status bar is enabled.
+    pub bar_enabled: bool,
+    /// Bar position: top or bottom of the screen.
+    pub bar_position: BarPosition,
+    /// Bar background color.
+    pub bar_color_bg: u32,
+    /// Bar foreground (text) color.
+    pub bar_color_fg: u32,
+    /// Bar active workspace background color.
+    pub bar_color_active: u32,
+    /// Bar separator color.
+    pub bar_color_separator: u32,
+    /// Bar urgent workspace background color.
+    pub bar_color_urgent_ws: u32,
+    /// Command to run for status information (owm-bar protocol).
+    pub bar_status_command: Option<String>,
+    /// Custom separator symbol between status blocks.
+    pub bar_separator_symbol: Option<String>,
+    /// Whether to show workspace buttons in the bar.
+    pub bar_workspace_buttons: bool,
+    /// Automatically choose split direction based on container dimensions.
+    /// When enabled, new windows split along the longer axis of the
+    /// focused container (wider → vertical split, taller → horizontal).
+    pub autotiling: bool,
+}
+
+/// Bar position on screen.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum BarPosition {
+    #[default]
+    Top,
+    Bottom,
+}
+
+impl Default for Config {
+    fn default() -> Self {
+        let terminal = "xterm".to_string();
+        Config {
+            border_width: 2,
+            gaps: Gaps::default(),
+            smart_gaps: SmartGaps::Off,
+            workspace_gaps: HashMap::new(),
+            color_focused: 0x4c7899,
+            color_focused_bg: 0x5294e2,
+            color_unfocused: 0xc0c0c0,
+            color_unfocused_bg: 0xe0e0e0,
+            color_urgent: 0xd32f2f,
+            color_urgent_bg: 0xffcdd2,
+            color_indicator: 0x2e9ef4,
+            color_background: 0xf5f5f5,
+            color_title_focused: 0xffffff,
+            color_title_unfocused: 0x555555,
+            default_border: BorderStyle::Normal,
+            default_floating_border: BorderStyle::Normal,
+            font: "monospace:size=11".to_string(),
+            terminal: terminal.clone(),
+            focus_follows_mouse: true,
+            keybindings: default_keybindings(&terminal),
+            modes: default_modes(),
+            window_rules: Vec::new(),
+            bar_enabled: true,
+            bar_position: BarPosition::Bottom,
+            bar_color_bg: 0xe0e0e0,
+            bar_color_fg: 0x333333,
+            bar_color_active: 0x5294e2,
+            bar_color_separator: 0x666666,
+            bar_color_urgent_ws: 0xd32f2f,
+            bar_status_command: Some("ostatus".to_string()),
+            bar_separator_symbol: None,
+            bar_workspace_buttons: true,
+            autotiling: true,
+        }
+    }
+}
+
+impl Config {
+    /// Load configuration from ~/.owmrc, falling back to defaults.
+    /// Returns the config and a list of any errors encountered during parsing.
+    pub fn load() -> (Self, Vec<String>) {
+        let mut conf = Config::default();
+        let mut errors: Vec<String> = Vec::new();
+
+        let path = match std::env::var("HOME") {
+            Ok(home) => PathBuf::from(home).join(".owmrc"),
+            Err(_) => return (conf, errors),
+        };
+
+        let contents = match fs::read_to_string(&path) {
+            Ok(c) => c,
+            Err(e) => {
+                if e.kind() != std::io::ErrorKind::NotFound {
+                    let msg = format!(
+                        "failed to read {}: {}",
+                        path.display(),
+                        e
+                    );
+                    crate::log_error!("{}", msg);
+                    errors.push(msg);
+                }
+                return (conf, errors);
+            }
+        };
+
+        let keysym_map = build_keysym_map();
+        let mut has_binds = false;
+        // Track whether we're inside a "mode <name> ... end" block
+        let mut current_mode: Option<String> = None;
+
+        for (lineno, raw_line) in contents.lines().enumerate() {
+            let line = raw_line.trim();
+            if line.is_empty() || line.starts_with('#') {
+                continue;
+            }
+
+            let n = lineno + 1;
+
+            let mut parts = line.splitn(2, char::is_whitespace);
+            let keyword = match parts.next() {
+                Some(k) => k,
+                None => continue,
+            };
+            let rest = parts.next().map(|s| s.trim()).unwrap_or("");
+
+            // Inside a mode block: only accept bind-key and end
+            if let Some(ref mode_name) = current_mode {
+                match keyword {
+                    "bind-key" => {
+                        let bindings = conf
+                            .modes
+                            .entry(mode_name.clone())
+                            .or_insert_with(Vec::new);
+                        parse_bind_key_into(
+                            &path, n, rest, &keysym_map, bindings,
+                            &mut errors,
+                        );
+                    }
+                    "end" => {
+                        crate::log_debug!("mode '{}' loaded", mode_name);
+                        current_mode = None;
+                    }
+                    _ => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: unexpected '{}' inside mode block",
+                                path.display(), n, keyword));
+                    }
+                }
+                continue;
+            }
+
+            match keyword {
+                "borderwidth" => match rest.parse::<u32>() {
+                    Ok(v) => conf.border_width = v,
+                    Err(_) => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: invalid borderwidth",
+                                path.display(), n));
+                    }
+                },
+                "gap" => match rest.parse::<i32>() {
+                    Ok(v) => conf.gaps.inner = v.max(0),
+                    Err(_) => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: invalid gap",
+                                path.display(), n));
+                    }
+                },
+                "gaps" => {
+                    parse_gaps(&path, n, rest, &mut conf, &mut errors);
+                }
+                "smart_gaps" | "smart-gaps" => {
+                    match rest {
+                        "on" | "yes" => conf.smart_gaps = SmartGaps::On,
+                        "off" | "no" => conf.smart_gaps = SmartGaps::Off,
+                        "inverse_outer" => conf.smart_gaps = SmartGaps::InverseOuter,
+                        _ => {
+                            config_warn(&mut errors,
+                                format!("{}:{}: expected off|on|inverse_outer",
+                                    path.display(), n));
+                        }
+                    }
+                },
+                "autotiling" => match rest {
+                    "yes" | "on" => conf.autotiling = true,
+                    "no" | "off" => conf.autotiling = false,
+                    _ => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: expected yes|no",
+                                path.display(), n));
+                    }
+                },
+                "color" => {
+                    parse_color(&path, n, rest, &mut conf, &mut errors);
+                }
+                "terminal" => {
+                    if rest.is_empty() {
+                        config_warn(&mut errors,
+                            format!("{}:{}: missing terminal value",
+                                path.display(), n));
+                    } else {
+                        conf.terminal = rest.to_string();
+                    }
+                }
+                "focus-follows-mouse" => match rest {
+                    "yes" => conf.focus_follows_mouse = true,
+                    "no" => conf.focus_follows_mouse = false,
+                    _ => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: expected yes|no",
+                                path.display(), n));
+                    }
+                },
+                "bind-key" => {
+                    if !has_binds {
+                        conf.keybindings.clear();
+                        has_binds = true;
+                    }
+                    parse_bind_key(
+                        &path, n, rest, &keysym_map, &mut conf,
+                        &mut errors,
+                    );
+                }
+                "font" => {
+                    if rest.is_empty() {
+                        config_warn(&mut errors,
+                            format!("{}:{}: missing font name",
+                                path.display(), n));
+                    } else {
+                        conf.font = rest.to_string();
+                    }
+                }
+                "default_border" | "default-border" => {
+                    parse_default_border(
+                        &path, n, rest, &mut conf.default_border,
+                        &mut conf.border_width, &mut errors,
+                    );
+                }
+                "default_floating_border" | "default-floating-border" => {
+                    parse_default_border(
+                        &path, n, rest, &mut conf.default_floating_border,
+                        &mut conf.border_width, &mut errors,
+                    );
+                }
+                "for_window" | "for-window" => {
+                    parse_for_window(&path, n, rest, &mut conf, &mut errors);
+                }
+                "bar_enabled" | "bar-enabled" => match rest {
+                    "yes" => conf.bar_enabled = true,
+                    "no" => conf.bar_enabled = false,
+                    _ => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: expected yes|no",
+                                path.display(), n));
+                    }
+                },
+                "bar_position" | "bar-position" => match rest {
+                    "top" => conf.bar_position = BarPosition::Top,
+                    "bottom" => conf.bar_position = BarPosition::Bottom,
+                    _ => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: expected top|bottom",
+                                path.display(), n));
+                    }
+                },
+                "bar_color" | "bar-color" => {
+                    parse_bar_color(&path, n, rest, &mut conf, &mut errors);
+                }
+                "bar_status_command" | "bar-status-command" => {
+                    if rest.is_empty() {
+                        conf.bar_status_command = None;
+                    } else {
+                        conf.bar_status_command = Some(rest.to_string());
+                    }
+                }
+                "bar_separator_symbol" | "bar-separator-symbol" => {
+                    if rest.is_empty() {
+                        conf.bar_separator_symbol = None;
+                    } else {
+                        conf.bar_separator_symbol = Some(rest.to_string());
+                    }
+                }
+                "bar_workspace_buttons" | "bar-workspace-buttons" => match rest {
+                    "yes" => conf.bar_workspace_buttons = true,
+                    "no" => conf.bar_workspace_buttons = false,
+                    _ => {
+                        config_warn(&mut errors,
+                            format!("{}:{}: expected yes|no",
+                                path.display(), n));
+                    }
+                },
+                "workspace" => {
+                    // workspace <name> gaps <type> <px>
+                    parse_workspace_gaps(&path, n, rest, &mut conf, &mut errors);
+                }
+                "mode" => {
+                    if rest.is_empty() {
+                        config_warn(&mut errors,
+                            format!("{}:{}: missing mode name",
+                                path.display(), n));
+                    } else {
+                        current_mode = Some(rest.to_string());
+                    }
+                }
+                _ => {
+                    config_warn(&mut errors,
+                        format!("{}:{}: unknown keyword '{}'",
+                            path.display(), n, keyword));
+                }
+            }
+        }
+
+        if current_mode.is_some() {
+            config_warn(&mut errors,
+                format!("{}: unterminated mode block", path.display()));
+        }
+
+        crate::log_info!("loaded config from {}", path.display());
+        (conf, errors)
+    }
+}
+
+/// Log a config warning and collect it for the nagbar.
+fn config_warn(errors: &mut Vec<String>, msg: String) {
+    crate::log_warn!("{}", msg);
+    errors.push(msg);
+}
+
+/// Parse "color <name> <hex>" directive.
+fn parse_color(
+    path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+    errors: &mut Vec<String>,
+) {
+    let mut parts = rest.splitn(2, char::is_whitespace);
+    let name = match parts.next() {
+        Some(n) => n,
+        None => {
+            config_warn(errors,
+                format!("{}:{}: missing color name", path.display(), lineno));
+            return;
+        }
+    };
+    let hex = match parts.next().map(|s| s.trim()) {
+        Some(h) if !h.is_empty() => h,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: missing color value", path.display(), lineno));
+            return;
+        }
+    };
+
+    let hex = hex.strip_prefix('#').unwrap_or(hex);
+    let value = match u32::from_str_radix(hex, 16) {
+        Ok(v) => v,
+        Err(_) => {
+            config_warn(errors,
+                format!("{}:{}: invalid hex color '{}'", path.display(), lineno, hex));
+            return;
+        }
+    };
+
+    match name {
+        "activeborder" => conf.color_focused = value,
+        "activebg" => conf.color_focused_bg = value,
+        "inactiveborder" => conf.color_unfocused = value,
+        "inactivebg" => conf.color_unfocused_bg = value,
+        "urgencyborder" => conf.color_urgent = value,
+        "urgencybg" => conf.color_urgent_bg = value,
+        "indicator" => conf.color_indicator = value,
+        "background" => conf.color_background = value,
+        "titlefocused" => conf.color_title_focused = value,
+        "titleunfocused" => conf.color_title_unfocused = value,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: unknown color name '{}'", path.display(), lineno, name));
+        }
+    }
+}
+
+/// Parse "bar_color <name> <hex>" directive.
+fn parse_bar_color(
+    path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+    errors: &mut Vec<String>,
+) {
+    let mut parts = rest.splitn(2, char::is_whitespace);
+    let name = match parts.next() {
+        Some(n) => n,
+        None => {
+            config_warn(errors,
+                format!("{}:{}: missing bar_color name", path.display(), lineno));
+            return;
+        }
+    };
+    let hex = match parts.next().map(|s| s.trim()) {
+        Some(h) if !h.is_empty() => h,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: missing bar_color value", path.display(), lineno));
+            return;
+        }
+    };
+    let hex = hex.strip_prefix('#').unwrap_or(hex);
+    let value = match u32::from_str_radix(hex, 16) {
+        Ok(v) => v,
+        Err(_) => {
+            config_warn(errors,
+                format!("{}:{}: invalid hex color '{}'", path.display(), lineno, hex));
+            return;
+        }
+    };
+    match name {
+        "bg" => conf.bar_color_bg = value,
+        "fg" => conf.bar_color_fg = value,
+        "active" => conf.bar_color_active = value,
+        "separator" => conf.bar_color_separator = value,
+        "urgent_ws" => conf.bar_color_urgent_ws = value,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: unknown bar_color name '{}' \
+                    (expected bg|fg|active|separator|urgent_ws)",
+                    path.display(), lineno, name));
+        }
+    }
+}
+
+/// Parse "gaps inner|outer|horizontal|vertical|top|right|bottom|left <px>" directive.
+/// Also handles "workspace <name> gaps <type> <px>" when called from the
+/// workspace-gaps path.
+fn parse_gaps(
+    path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+    errors: &mut Vec<String>,
+) {
+    let mut parts = rest.split_whitespace();
+    let gap_type = match parts.next() {
+        Some(t) => t,
+        None => {
+            config_warn(errors,
+                format!("{}:{}: gaps: missing type", path.display(), lineno));
+            return;
+        }
+    };
+    let value = match parts.next().and_then(|s| s.parse::<i32>().ok()) {
+        Some(v) => v,
+        None => {
+            config_warn(errors,
+                format!("{}:{}: gaps: missing or invalid pixel value",
+                    path.display(), lineno));
+            return;
+        }
+    };
+
+    apply_gap_type(&mut conf.gaps, gap_type, value, path, lineno, errors);
+}
+
+/// Parse per-workspace gaps: "workspace <name> gaps <type> <px>".
+fn parse_workspace_gaps(
+    path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+    errors: &mut Vec<String>,
+) {
+    // rest = "<name> gaps <type> <px>"
+    let mut parts = rest.split_whitespace();
+    let ws_name = match parts.next() {
+        Some(n) => n.to_string(),
+        None => {
+            config_warn(errors,
+                format!("{}:{}: workspace: missing name", path.display(), lineno));
+            return;
+        }
+    };
+    // expect "gaps"
+    match parts.next() {
+        Some("gaps") => {}
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: expected 'gaps' after workspace name",
+                    path.display(), lineno));
+            return;
+        }
+    }
+    let gap_type = match parts.next() {
+        Some(t) => t,
+        None => {
+            config_warn(errors,
+                format!("{}:{}: workspace gaps: missing type",
+                    path.display(), lineno));
+            return;
+        }
+    };
+    let value = match parts.next().and_then(|s| s.parse::<i32>().ok()) {
+        Some(v) => v,
+        None => {
+            config_warn(errors,
+                format!("{}:{}: workspace gaps: missing or invalid pixel value",
+                    path.display(), lineno));
+            return;
+        }
+    };
+
+    let ovr = conf.workspace_gaps.entry(ws_name).or_insert_with(GapsOverride::default);
+    apply_gap_type_override(ovr, gap_type, value, path, lineno, errors);
+}
+
+/// Set gap fields on a Gaps struct by type keyword.
+fn apply_gap_type(
+    gaps: &mut Gaps, gap_type: &str, value: i32,
+    path: &Path, lineno: usize, errors: &mut Vec<String>,
+) {
+    match gap_type {
+        "inner" => gaps.inner = value,
+        "outer" => {
+            gaps.top = value;
+            gaps.right = value;
+            gaps.bottom = value;
+            gaps.left = value;
+        }
+        "horizontal" => {
+            gaps.left = value;
+            gaps.right = value;
+        }
+        "vertical" => {
+            gaps.top = value;
+            gaps.bottom = value;
+        }
+        "top" => gaps.top = value,
+        "right" => gaps.right = value,
+        "bottom" => gaps.bottom = value,
+        "left" => gaps.left = value,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: unknown gap type '{}'", path.display(), lineno, gap_type));
+        }
+    }
+}
+
+/// Set gap override fields by type keyword.
+fn apply_gap_type_override(
+    ovr: &mut GapsOverride, gap_type: &str, value: i32,
+    path: &Path, lineno: usize, errors: &mut Vec<String>,
+) {
+    match gap_type {
+        "inner" => ovr.inner = Some(value),
+        "outer" => {
+            ovr.top = Some(value);
+            ovr.right = Some(value);
+            ovr.bottom = Some(value);
+            ovr.left = Some(value);
+        }
+        "horizontal" => {
+            ovr.left = Some(value);
+            ovr.right = Some(value);
+        }
+        "vertical" => {
+            ovr.top = Some(value);
+            ovr.bottom = Some(value);
+        }
+        "top" => ovr.top = Some(value),
+        "right" => ovr.right = Some(value),
+        "bottom" => ovr.bottom = Some(value),
+        "left" => ovr.left = Some(value),
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: unknown gap type '{}'", path.display(), lineno, gap_type));
+        }
+    }
+}
+
+/// Parse "default_border normal|pixel [N]|none" directive.
+fn parse_default_border(
+    path: &Path, lineno: usize, rest: &str,
+    border_style: &mut BorderStyle, border_width: &mut u32,
+    errors: &mut Vec<String>,
+) {
+    let mut parts = rest.splitn(2, char::is_whitespace);
+    match parts.next() {
+        Some("normal") => *border_style = BorderStyle::Normal,
+        Some("pixel") => {
+            *border_style = BorderStyle::Pixel;
+            if let Some(w) = parts.next().and_then(|s| s.trim().parse::<u32>().ok()) {
+                *border_width = w;
+            }
+        }
+        Some("none") => *border_style = BorderStyle::None,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: expected normal|pixel [N]|none",
+                    path.display(), lineno));
+        }
+    }
+}
+
+/// Parse "for_window [class=X] border pixel|normal|none" directive.
+fn parse_for_window(
+    path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+    errors: &mut Vec<String>,
+) {
+    let rest = rest.trim();
+    if !rest.starts_with('[') {
+        config_warn(errors,
+            format!("{}:{}: for_window requires [criteria]", path.display(), lineno));
+        return;
+    }
+    let bracket_end = match rest.find(']') {
+        Some(i) => i,
+        None => {
+            config_warn(errors,
+                format!("{}:{}: missing ']' in for_window", path.display(), lineno));
+            return;
+        }
+    };
+    let criteria_str = &rest[1..bracket_end];
+    let command_str = rest[bracket_end + 1..].trim();
+
+    let mut rule = WindowRule {
+        class: None,
+        instance: None,
+        title: None,
+        border_style: BorderStyle::Normal,
+        border_width: None,
+    };
+    for criterion in criteria_str.split_whitespace() {
+        if let Some((key, value)) = criterion.split_once('=') {
+            let value = value.trim_matches('"');
+            match key {
+                "class" => rule.class = Some(value.to_string()),
+                "instance" => rule.instance = Some(value.to_string()),
+                "title" => rule.title = Some(value.to_string()),
+                _ => {
+                    config_warn(errors,
+                        format!("{}:{}: unknown criterion '{}'",
+                            path.display(), lineno, key));
+                }
+            }
+        }
+    }
+
+    let mut cparts = command_str.splitn(3, char::is_whitespace);
+    match cparts.next() {
+        Some("border") => match cparts.next() {
+            Some("normal") => rule.border_style = BorderStyle::Normal,
+            Some("pixel") => {
+                rule.border_style = BorderStyle::Pixel;
+                if let Some(w) = cparts.next().and_then(|s| s.trim().parse::<u32>().ok()) {
+                    rule.border_width = Some(w);
+                }
+            }
+            Some("none") => rule.border_style = BorderStyle::None,
+            _ => {
+                config_warn(errors,
+                    format!("{}:{}: expected 'border normal|pixel [N]|none'",
+                        path.display(), lineno));
+                return;
+            }
+        },
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: for_window only supports 'border' command",
+                    path.display(), lineno));
+            return;
+        }
+    }
+
+    conf.window_rules.push(rule);
+}
+
+/// Parse a key combo string into (modifiers, keysym).
+///
+/// Modifier letters: 4 = Mod4 (Super), S = Shift, C = Control, M = Mod1 (Alt).
+/// Example: "4S-Return" -> (Mod4|Shift, 0xff0d)
+fn parse_key_combo(
+    path: &Path, lineno: usize, combo: &str,
+    keysym_map: &HashMap<&str, u32>, errors: &mut Vec<String>,
+) -> Option<(u32, u32)> {
+    let (modifiers, keysym_name) = match combo.rsplit_once('-') {
+        Some((mods, key)) => {
+            let mut mask = 0u32;
+            for ch in mods.chars() {
+                match ch {
+                    '4' => mask |= xcb::x::ModMask::N4.bits(),
+                    'S' => mask |= xcb::x::ModMask::SHIFT.bits(),
+                    'C' => mask |= xcb::x::ModMask::CONTROL.bits(),
+                    'M' => mask |= xcb::x::ModMask::N1.bits(),
+                    _ => {
+                        config_warn(errors,
+                            format!("{}:{}: unknown modifier '{}'",
+                                path.display(), lineno, ch));
+                        return None;
+                    }
+                }
+            }
+            (mask, key)
+        }
+        None => {
+            config_warn(errors,
+                format!("{}:{}: invalid key combo '{}'",
+                    path.display(), lineno, combo));
+            return None;
+        }
+    };
+
+    match keysym_map.get(keysym_name) {
+        Some(&ks) => Some((modifiers, ks)),
+        None => {
+            config_warn(errors,
+                format!("{}:{}: unknown keysym '{}'",
+                    path.display(), lineno, keysym_name));
+            None
+        }
+    }
+}
+
+/// Parse an action string into an Action.
+fn parse_action(
+    path: &Path, lineno: usize, action_str: &str,
+    errors: &mut Vec<String>,
+) -> Option<Action> {
+    let mut action_parts = action_str.splitn(2, char::is_whitespace);
+    let action_name = action_parts.next().unwrap();
+    let action_arg = action_parts.next().map(|s| s.trim());
+
+    let action = match action_name {
+        "focus-next" => Action::FocusNext,
+        "focus-prev" => Action::FocusPrev,
+        "focus-parent" => Action::FocusParent,
+        "focus-child" => Action::FocusChild,
+        "swap-next" => Action::SwapNext,
+        "swap-prev" => Action::SwapPrev,
+        "split-vertical" => Action::SplitVertical,
+        "split-horizontal" => Action::SplitHorizontal,
+        "layout-stacked" => Action::LayoutStacked,
+        "layout-tabbed" => Action::LayoutTabbed,
+        "layout-toggle-split" => Action::LayoutToggleSplit,
+        "toggle-fullscreen" => Action::ToggleFullscreen,
+        "toggle-floating" => Action::ToggleFloating,
+        "toggle-sticky" | "sticky" => Action::ToggleSticky,
+        "minimize" => Action::Minimize,
+        "close-window" => Action::CloseWindow,
+        "move-scratchpad" => Action::MoveScratchpad,
+        "scratchpad-show" => Action::ScratchpadShow,
+        "restart" => Action::Restart,
+        "exit" => Action::Exit,
+        "mode" => match action_arg {
+            Some(name) => Action::Mode(name.to_string()),
+            None => {
+                config_warn(errors,
+                    format!("{}:{}: mode requires a name",
+                        path.display(), lineno));
+                return None;
+            }
+        },
+        "resize" => {
+            let mut rparts =
+                action_arg.unwrap_or("").splitn(2, char::is_whitespace);
+            let dir = match (rparts.next(), rparts.next().map(|s| s.trim())) {
+                (Some("grow"), Some("width")) => ResizeDir::GrowWidth,
+                (Some("shrink"), Some("width")) => ResizeDir::ShrinkWidth,
+                (Some("grow"), Some("height")) => ResizeDir::GrowHeight,
+                (Some("shrink"), Some("height")) => ResizeDir::ShrinkHeight,
+                _ => {
+                    config_warn(errors,
+                        format!("{}:{}: resize requires grow|shrink width|height",
+                            path.display(), lineno));
+                    return None;
+                }
+            };
+            Action::Resize(dir)
+        }
+        "spawn" => match action_arg {
+            Some(cmd) => Action::Spawn(cmd.to_string()),
+            None => {
+                config_warn(errors,
+                    format!("{}:{}: spawn requires a command",
+                        path.display(), lineno));
+                return None;
+            }
+        },
+        "workspace" => match action_arg {
+            Some(name) if !name.is_empty() => {
+                Action::Workspace(name.to_string())
+            }
+            _ => {
+                config_warn(errors,
+                    format!("{}:{}: workspace requires a name",
+                        path.display(), lineno));
+                return None;
+            }
+        },
+        "move-to-workspace" => match action_arg {
+            Some(name) if !name.is_empty() => {
+                Action::MoveToWorkspace(name.to_string())
+            }
+            _ => {
+                config_warn(errors,
+                    format!("{}:{}: move-to-workspace requires a name",
+                        path.display(), lineno));
+                return None;
+            }
+        },
+        _ => {
+            // Unknown action name: treat as spawn command
+            Action::Spawn(action_str.to_string())
+        }
+    };
+
+    Some(action)
+}
+
+/// Parse a "bind-key" line and push the result into an arbitrary bindings vec.
+fn parse_bind_key_into(
+    path: &Path, lineno: usize, rest: &str,
+    keysym_map: &HashMap<&str, u32>,
+    bindings: &mut Vec<(u32, u32, Action)>,
+    errors: &mut Vec<String>,
+) {
+    let mut parts = rest.splitn(2, char::is_whitespace);
+    let combo_str = match parts.next() {
+        Some(c) if !c.is_empty() => c,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: missing key combo", path.display(), lineno));
+            return;
+        }
+    };
+    let action_str = match parts.next().map(|s| s.trim()) {
+        Some(a) if !a.is_empty() => a,
+        _ => {
+            config_warn(errors,
+                format!("{}:{}: missing action", path.display(), lineno));
+            return;
+        }
+    };
+
+    let Some((modifiers, keysym)) =
+        parse_key_combo(path, lineno, combo_str, keysym_map, errors)
+    else {
+        return;
+    };
+
+    let Some(action) = parse_action(path, lineno, action_str, errors) else {
+        return;
+    };
+
+    bindings.push((modifiers, keysym, action));
+}
+
+/// Parse "bind-key" directive into the default keybindings.
+fn parse_bind_key(
+    path: &Path, lineno: usize, rest: &str,
+    keysym_map: &HashMap<&str, u32>, conf: &mut Config,
+    errors: &mut Vec<String>,
+) {
+    parse_bind_key_into(path, lineno, rest, keysym_map, &mut conf.keybindings, errors);
+}
+
+/// Default keybindings when no bind-key directives are in the config.
+fn default_keybindings(terminal: &str) -> Vec<(u32, u32, Action)> {
+    let m = xcb::x::ModMask::N1.bits(); // Alt (Mod1)
+    let ms = m | xcb::x::ModMask::SHIFT.bits();
+
+    vec![
+        // Window focus
+        (m, keysym::XK_j, Action::FocusNext),
+        (m, keysym::XK_k, Action::FocusPrev),
+        (m, keysym::XK_h, Action::FocusParent),
+        (m, keysym::XK_l, Action::FocusChild),
+        // Window movement
+        (ms, keysym::XK_j, Action::SwapNext),
+        (ms, keysym::XK_k, Action::SwapPrev),
+        // Split direction
+        (m, keysym::XK_v, Action::SplitVertical),
+        (m, keysym::XK_b, Action::SplitHorizontal),
+        // Layout
+        (m, keysym::XK_s, Action::LayoutStacked),
+        (m, keysym::XK_w, Action::LayoutTabbed),
+        (m, keysym::XK_e, Action::LayoutToggleSplit),
+        // Fullscreen
+        (m, keysym::XK_f, Action::ToggleFullscreen),
+        // Floating
+        (ms, keysym::XK_space, Action::ToggleFloating),
+        // Kill window
+        (ms, keysym::XK_q, Action::CloseWindow),
+        // Spawn terminal / launcher
+        (m, keysym::XK_Return, Action::Spawn(terminal.to_string())),
+        (m, keysym::XK_d, Action::Spawn("omenu".to_string())),
+        // Workspaces 1-9
+        (m, keysym::XK_1, Action::Workspace("1".into())),
+        (m, keysym::XK_2, Action::Workspace("2".into())),
+        (m, keysym::XK_3, Action::Workspace("3".into())),
+        (m, keysym::XK_4, Action::Workspace("4".into())),
+        (m, keysym::XK_5, Action::Workspace("5".into())),
+        (m, keysym::XK_6, Action::Workspace("6".into())),
+        (m, keysym::XK_7, Action::Workspace("7".into())),
+        (m, keysym::XK_8, Action::Workspace("8".into())),
+        (m, keysym::XK_9, Action::Workspace("9".into())),
+        // Move to workspace
+        (ms, keysym::XK_1, Action::MoveToWorkspace("1".into())),
+        (ms, keysym::XK_2, Action::MoveToWorkspace("2".into())),
+        (ms, keysym::XK_3, Action::MoveToWorkspace("3".into())),
+        (ms, keysym::XK_4, Action::MoveToWorkspace("4".into())),
+        (ms, keysym::XK_5, Action::MoveToWorkspace("5".into())),
+        (ms, keysym::XK_6, Action::MoveToWorkspace("6".into())),
+        (ms, keysym::XK_7, Action::MoveToWorkspace("7".into())),
+        (ms, keysym::XK_8, Action::MoveToWorkspace("8".into())),
+        (ms, keysym::XK_9, Action::MoveToWorkspace("9".into())),
+        // Resize mode
+        (m, keysym::XK_r, Action::Mode("resize".into())),
+        // Restart
+        (ms, keysym::XK_r, Action::Restart),
+        // Exit
+        (ms, keysym::XK_e, Action::Exit),
+    ]
+}
+
+/// Default binding modes (resize).
+fn default_modes() -> HashMap<String, Vec<(u32, u32, Action)>> {
+    let m = xcb::x::ModMask::N1.bits(); // Alt
+    let mut modes = HashMap::new();
+    modes.insert(
+        "resize".to_string(),
+        vec![
+            (m, keysym::XK_h, Action::Resize(ResizeDir::ShrinkWidth)),
+            (m, keysym::XK_l, Action::Resize(ResizeDir::GrowWidth)),
+            (m, keysym::XK_j, Action::Resize(ResizeDir::GrowHeight)),
+            (m, keysym::XK_k, Action::Resize(ResizeDir::ShrinkHeight)),
+            (m, keysym::XK_Escape, Action::Mode("default".into())),
+            (m, keysym::XK_Return, Action::Mode("default".into())),
+        ],
+    );
+    modes
+}
+
+/// Build a map from keysym names (as used in .owmrc) to keysym values.
+fn build_keysym_map() -> HashMap<&'static str, u32> {
+    let mut m = HashMap::new();
+    // Letters
+    for (name, val) in [
+        ("a", 0x0061u32),
+        ("b", 0x0062),
+        ("c", 0x0063),
+        ("d", 0x0064),
+        ("e", 0x0065),
+        ("f", 0x0066),
+        ("g", 0x0067),
+        ("h", 0x0068),
+        ("i", 0x0069),
+        ("j", 0x006a),
+        ("k", 0x006b),
+        ("l", 0x006c),
+        ("m", 0x006d),
+        ("n", 0x006e),
+        ("o", 0x006f),
+        ("p", 0x0070),
+        ("q", 0x0071),
+        ("r", 0x0072),
+        ("s", 0x0073),
+        ("t", 0x0074),
+        ("u", 0x0075),
+        ("v", 0x0076),
+        ("w", 0x0077),
+        ("x", 0x0078),
+        ("y", 0x0079),
+        ("z", 0x007a),
+    ] {
+        m.insert(name, val);
+    }
+    // Numbers
+    for (name, val) in [
+        ("1", 0x0031u32),
+        ("2", 0x0032),
+        ("3", 0x0033),
+        ("4", 0x0034),
+        ("5", 0x0035),
+        ("6", 0x0036),
+        ("7", 0x0037),
+        ("8", 0x0038),
+        ("9", 0x0039),
+        ("0", 0x0030),
+    ] {
+        m.insert(name, val);
+    }
+    // Special keys
+    m.insert("Return", 0xff0d);
+    m.insert("space", 0x0020);
+    m.insert("Tab", 0xff09);
+    m.insert("Escape", 0xff1b);
+    m.insert("BackSpace", 0xff08);
+    m.insert("Delete", 0xffff);
+    m.insert("Left", 0xff51);
+    m.insert("Up", 0xff52);
+    m.insert("Right", 0xff53);
+    m.insert("Down", 0xff54);
+    m.insert("Home", 0xff50);
+    m.insert("End", 0xff57);
+    m.insert("Page_Up", 0xff55);
+    m.insert("Page_Down", 0xff56);
+    m.insert("F1", 0xffbe);
+    m.insert("F2", 0xffbf);
+    m.insert("F3", 0xffc0);
+    m.insert("F4", 0xffc1);
+    m.insert("F5", 0xffc2);
+    m.insert("F6", 0xffc3);
+    m.insert("F7", 0xffc4);
+    m.insert("F8", 0xffc5);
+    m.insert("F9", 0xffc6);
+    m.insert("F10", 0xffc7);
+    m.insert("F11", 0xffc8);
+    m.insert("F12", 0xffc9);
+    m.insert("minus", 0x002d);
+    m.insert("equal", 0x003d);
+    m.insert("bracketleft", 0x005b);
+    m.insert("bracketright", 0x005d);
+    m.insert("semicolon", 0x003b);
+    m.insert("apostrophe", 0x0027);
+    m.insert("grave", 0x0060);
+    m.insert("comma", 0x002c);
+    m.insert("period", 0x002e);
+    m.insert("slash", 0x002f);
+    m.insert("backslash", 0x005c);
+    m
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::io::Write;
+    use std::sync::Mutex;
+    use std::sync::atomic::{AtomicU64, Ordering};
+
+    static TEST_LOCK: Mutex<()> = Mutex::new(());
+    static TEST_SEQ: AtomicU64 = AtomicU64::new(0);
+
+    /// Helper: create a temp dir with a .owmrc, set HOME, call Config::load().
+    /// Serialized via mutex since we mutate the HOME env var.
+    fn load_config(content: &str) -> Config {
+        let _guard = TEST_LOCK.lock().unwrap();
+        let seq = TEST_SEQ.fetch_add(1, Ordering::Relaxed);
+        let dir = std::env::temp_dir().join(format!(
+            "owm-test-{}-{}",
+            std::process::id(),
+            seq
+        ));
+        let _ = fs::create_dir_all(&dir);
+        let rc = dir.join(".owmrc");
+        let mut f = fs::File::create(&rc).unwrap();
+        f.write_all(content.as_bytes()).unwrap();
+
+        // SAFETY: serialized by TEST_LOCK.
+        let old_home = std::env::var("HOME").ok();
+        unsafe { std::env::set_var("HOME", &dir) };
+        let (conf, _errors) = Config::load();
+        match old_home {
+            Some(h) => unsafe { std::env::set_var("HOME", h) },
+            None => unsafe { std::env::remove_var("HOME") },
+        }
+        let _ = fs::remove_file(&rc);
+        let _ = fs::remove_dir(&dir);
+        conf
+    }
+
+    // --- defaults ---
+
+    #[test]
+    fn default_values() {
+        let conf = Config::default();
+        assert_eq!(conf.border_width, 2);
+        assert_eq!(conf.gaps, Gaps::default());
+        assert_eq!(conf.terminal, "xterm");
+        assert!(conf.focus_follows_mouse);
+        assert!(!conf.keybindings.is_empty());
+        assert!(conf.modes.is_empty());
+    }
+
+    // --- valid config ---
+
+    #[test]
+    fn parse_borderwidth_and_gap() {
+        let conf = load_config("borderwidth 5\ngap 10\n");
+        assert_eq!(conf.border_width, 5);
+        assert_eq!(conf.gaps.inner, 10);
+    }
+
+    #[test]
+    fn parse_gaps_directives() {
+        let conf = load_config("gaps inner 8\ngaps outer 4\n");
+        assert_eq!(conf.gaps.inner, 8);
+        assert_eq!(conf.gaps.top, 4);
+        assert_eq!(conf.gaps.right, 4);
+        assert_eq!(conf.gaps.bottom, 4);
+        assert_eq!(conf.gaps.left, 4);
+    }
+
+    #[test]
+    fn parse_gaps_directional() {
+        let conf = load_config(
+            "gaps inner 10\ngaps top 2\ngaps horizontal 6\n",
+        );
+        assert_eq!(conf.gaps.inner, 10);
+        assert_eq!(conf.gaps.top, 2);
+        assert_eq!(conf.gaps.left, 6);
+        assert_eq!(conf.gaps.right, 6);
+        assert_eq!(conf.gaps.bottom, 0);
+    }
+
+    #[test]
+    fn parse_smart_gaps() {
+        let conf = load_config("smart_gaps on\n");
+        assert_eq!(conf.smart_gaps, SmartGaps::On);
+
+        let conf2 = load_config("smart_gaps inverse_outer\n");
+        assert_eq!(conf2.smart_gaps, SmartGaps::InverseOuter);
+    }
+
+    #[test]
+    fn parse_workspace_gaps() {
+        let conf = load_config("workspace 2 gaps inner 12\n");
+        let ovr = conf.workspace_gaps.get("2").unwrap();
+        assert_eq!(ovr.inner, Some(12));
+        assert_eq!(ovr.top, None);
+    }
+
+    #[test]
+    fn negative_outer_gaps_clamped() {
+        let g = Gaps { inner: 10, top: -20, right: -5, bottom: 0, left: 3 };
+        let c = g.clamped();
+        assert_eq!(c.top, -10); // clamped to -inner
+        assert_eq!(c.right, -5); // within range
+        assert_eq!(c.bottom, 0);
+        assert_eq!(c.left, 3);
+    }
+
+    #[test]
+    fn parse_terminal() {
+        let conf = load_config("terminal alacritty\n");
+        assert_eq!(conf.terminal, "alacritty");
+    }
+
+    #[test]
+    fn parse_focus_follows_mouse() {
+        let conf = load_config("focus-follows-mouse no\n");
+        assert!(!conf.focus_follows_mouse);
+
+        let conf2 = load_config("focus-follows-mouse yes\n");
+        assert!(conf2.focus_follows_mouse);
+    }
+
+    #[test]
+    fn parse_colors() {
+        let conf = load_config(
+            "color activeborder #ff0000\ncolor inactiveborder 00ff00\ncolor urgencyborder #0000ff\ncolor background 1a1a1a\n",
+        );
+        assert_eq!(conf.color_focused, 0xff0000);
+        assert_eq!(conf.color_unfocused, 0x00ff00);
+        assert_eq!(conf.color_urgent, 0x0000ff);
+        assert_eq!(conf.color_background, 0x1a1a1a);
+    }
+
+    #[test]
+    fn parse_bind_key_replaces_defaults() {
+        let conf = load_config("bind-key 4-Return spawn st\n");
+        // When bind-key is present, default bindings are cleared
+        assert_eq!(conf.keybindings.len(), 1);
+        let (mods, ks, ref action) = conf.keybindings[0];
+        assert_eq!(ks, 0xff0d); // Return
+        assert_eq!(mods, xcb::x::ModMask::N4.bits());
+        assert_eq!(*action, crate::keybind::Action::Spawn("st".to_string()));
+    }
+
+    #[test]
+    fn parse_mode_block() {
+        let conf =
+            load_config("mode resize\nbind-key 4-h resize shrink width\nend\n");
+        assert!(conf.modes.contains_key("resize"));
+        let binds = &conf.modes["resize"];
+        assert_eq!(binds.len(), 1);
+        assert_eq!(
+            binds[0].2,
+            crate::keybind::Action::Resize(
+                crate::keybind::ResizeDir::ShrinkWidth
+            )
+        );
+    }
+
+    #[test]
+    fn parse_action_variants() {
+        let path = PathBuf::from("test");
+        let cases = [
+            ("focus-next", Action::FocusNext),
+            ("focus-prev", Action::FocusPrev),
+            ("focus-parent", Action::FocusParent),
+            ("focus-child", Action::FocusChild),
+            ("swap-next", Action::SwapNext),
+            ("swap-prev", Action::SwapPrev),
+            ("split-vertical", Action::SplitVertical),
+            ("split-horizontal", Action::SplitHorizontal),
+            ("layout-stacked", Action::LayoutStacked),
+            ("layout-tabbed", Action::LayoutTabbed),
+            ("layout-toggle-split", Action::LayoutToggleSplit),
+            ("toggle-fullscreen", Action::ToggleFullscreen),
+            ("toggle-floating", Action::ToggleFloating),
+            ("toggle-sticky", Action::ToggleSticky),
+            ("close-window", Action::CloseWindow),
+            ("exit", Action::Exit),
+            ("workspace web", Action::Workspace("web".to_string())),
+            (
+                "move-to-workspace 3",
+                Action::MoveToWorkspace("3".to_string()),
+            ),
+            ("mode resize", Action::Mode("resize".to_string())),
+            ("resize grow width", Action::Resize(ResizeDir::GrowWidth)),
+            (
+                "resize shrink height",
+                Action::Resize(ResizeDir::ShrinkHeight),
+            ),
+            ("spawn dmenu_run", Action::Spawn("dmenu_run".to_string())),
+        ];
+        for (input, expected) in &cases {
+            let result = parse_action(&path, 1, input, &mut Vec::new());
+            assert_eq!(result, Some(expected.clone()), "failed for: {}", input);
+        }
+    }
+
+    // --- invalid config ---
+
+    #[test]
+    fn invalid_borderwidth_keeps_default() {
+        let conf = load_config("borderwidth abc\n");
+        assert_eq!(conf.border_width, 2); // default
+    }
+
+    #[test]
+    fn invalid_color_hex_keeps_default() {
+        let conf = load_config("color activeborder gggggg\n");
+        assert_eq!(conf.color_focused, 0x4c7899); // default
+    }
+
+    #[test]
+    fn unknown_keyword_ignored() {
+        let conf = load_config("foobar 123\nterminal st\n");
+        assert_eq!(conf.terminal, "st");
+    }
+
+    #[test]
+    fn comments_and_blank_lines_ignored() {
+        let conf = load_config("# comment\n\n  \nterminal foot\n");
+        assert_eq!(conf.terminal, "foot");
+    }
+
+    #[test]
+    fn missing_config_file_returns_defaults() {
+        let _guard = TEST_LOCK.lock().unwrap();
+        let seq = TEST_SEQ.fetch_add(1, Ordering::Relaxed);
+        let dir = std::env::temp_dir().join(format!(
+            "owm-norc-{}-{}",
+            std::process::id(),
+            seq
+        ));
+        let _ = fs::create_dir_all(&dir);
+        let old_home = std::env::var("HOME").ok();
+        unsafe { std::env::set_var("HOME", &dir) };
+        let (conf, _errors) = Config::load();
+        match old_home {
+            Some(h) => unsafe { std::env::set_var("HOME", h) },
+            None => unsafe { std::env::remove_var("HOME") },
+        }
+        let _ = fs::remove_dir(&dir);
+        assert_eq!(conf.border_width, 2);
+        assert_eq!(conf.terminal, "xterm");
+    }
+}
+
+/// X11 keysym constants (following X11 naming convention).
+#[allow(non_upper_case_globals)]
+pub mod keysym {
+    pub const XK_Return: u32 = 0xff0d;
+    pub const XK_space: u32 = 0x0020;
+    pub const XK_1: u32 = 0x0031;
+    pub const XK_2: u32 = 0x0032;
+    pub const XK_3: u32 = 0x0033;
+    pub const XK_4: u32 = 0x0034;
+    pub const XK_5: u32 = 0x0035;
+    pub const XK_6: u32 = 0x0036;
+    pub const XK_7: u32 = 0x0037;
+    pub const XK_8: u32 = 0x0038;
+    pub const XK_9: u32 = 0x0039;
+    pub const XK_b: u32 = 0x0062;
+    pub const XK_d: u32 = 0x0064;
+    pub const XK_e: u32 = 0x0065;
+    pub const XK_f: u32 = 0x0066;
+    pub const XK_h: u32 = 0x0068;
+    pub const XK_j: u32 = 0x006a;
+    pub const XK_k: u32 = 0x006b;
+    pub const XK_l: u32 = 0x006c;
+    pub const XK_q: u32 = 0x0071;
+    pub const XK_r: u32 = 0x0072;
+    pub const XK_s: u32 = 0x0073;
+    pub const XK_v: u32 = 0x0076;
+    pub const XK_w: u32 = 0x0077;
+    pub const XK_Escape: u32 = 0xff1b;
+}
blob - /dev/null
blob + 37f7fc46e38cb2c68ad365c1b4375ec78df95e00 (mode 644)
--- /dev/null
+++ src/container.rs
@@ -0,0 +1,937 @@
+use std::collections::VecDeque;
+
+use slotmap::{SlotMap, new_key_type};
+
+use crate::config::GapsOverride;
+
+new_key_type! {
+    /// Generational key for container nodes in the arena.
+    pub struct NodeKey;
+}
+
+/// Unique identifier for containers.
+pub type ConId = u64;
+
+/// Rectangle geometry.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub struct Rect {
+    pub x: i32,
+    pub y: i32,
+    pub w: u32,
+    pub h: u32,
+}
+
+/// Size hints from WM_NORMAL_HINTS (ICCCM §4.1.2.3).
+#[derive(Debug, Clone, Copy, Default)]
+pub struct SizeHints {
+    pub min_w: Option<u32>,
+    pub min_h: Option<u32>,
+    pub max_w: Option<u32>,
+    pub max_h: Option<u32>,
+    pub inc_w: Option<u32>,
+    pub inc_h: Option<u32>,
+    pub base_w: Option<u32>,
+    pub base_h: Option<u32>,
+}
+
+/// Container type in the tree hierarchy.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ConType {
+    Root,
+    Output,
+    Workspace,
+    Con,
+    FloatingCon,
+}
+
+/// Layout direction for split containers.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum Layout {
+    #[default]
+    SplitH,
+    SplitV,
+    Stacked,
+    Tabbed,
+}
+
+/// Border style for window decorations.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum BorderStyle {
+    /// Title bar + border (default).
+    #[default]
+    Normal,
+    /// Border only, no title bar.
+    Pixel,
+    /// No decoration at all.
+    None,
+}
+
+/// A container in the window tree.
+///
+/// The tree hierarchy:
+///   Root -> Output(s) -> Workspace(s) -> Con(s) -> Window leaf nodes
+///
+/// Inspired by i3's Con structure, using a SlotMap with generational
+/// keys instead of raw pointers or plain indices for memory safety.
+#[derive(Debug)]
+pub struct Container {
+    pub id: ConId,
+    pub con_type: ConType,
+    pub layout: Layout,
+    pub rect: Rect,
+    /// Decoration bar position/size, relative to parent's rect.
+    /// Used in tabbed/stacked layouts where the parent draws all
+    /// children's title bars.
+    pub deco_rect: Rect,
+    pub window_rect: Rect,
+    pub name: String,
+    /// Workspace number extracted from name, or -1 for named-only workspaces.
+    pub num: i32,
+    pub urgent: bool,
+    pub focused: bool,
+    pub fullscreen: bool,
+    pub is_floating: bool,
+    /// Sticky floating windows follow workspace switches (like i3).
+    pub sticky: bool,
+    /// Window is minimized (hidden via _NET_WM_STATE_HIDDEN).
+    pub minimized: bool,
+
+    /// Percentage of parent space this container occupies.
+    pub percent: f64,
+
+    /// X11 window ID, if this is a leaf container managing a window.
+    pub window: Option<u32>,
+
+    /// X11 frame window ID (reparenting WM: decoration window wrapping the client).
+    pub frame: Option<u32>,
+
+    /// Window title from _NET_WM_NAME or WM_NAME.
+    pub title: String,
+
+    /// Border style: Normal (title bar + border), Pixel (border only), None.
+    pub border_style: BorderStyle,
+
+    /// Border width in pixels.
+    pub border_width: u32,
+
+    /// Size hints from WM_NORMAL_HINTS.
+    pub size_hints: SizeHints,
+
+    /// Number of pending UnmapNotify events to ignore (WM-initiated unmaps).
+    /// Incremented when the WM unmaps a frame (workspace switch, minimize,
+    /// move-to-workspace, scratchpad); decremented in on_unmap_notify.
+    pub ignore_unmap: u32,
+
+    /// Whether the frame window is currently mapped (visible on screen).
+    /// Tracked to avoid redundant map/unmap calls and spurious events.
+    pub frame_mapped: bool,
+
+    /// Per-workspace gap overrides (only meaningful for Workspace containers).
+    pub gaps_override: GapsOverride,
+
+    /// User-assigned mark (label) for quick navigation.
+    pub mark: Option<String>,
+
+    /// Parent container key in the arena.
+    pub parent: Option<NodeKey>,
+
+    /// Child container keys (tiling order).
+    pub children: Vec<NodeKey>,
+
+    /// Child container keys (focus order, most recent first).
+    pub focus_stack: VecDeque<NodeKey>,
+
+    /// Floating children keys.
+    pub floating_children: Vec<NodeKey>,
+}
+
+/// Arena-based container tree using generational indices.
+///
+/// All containers live in a SlotMap, referenced by NodeKey.
+/// Generational keys prevent use-after-free bugs from stale indices.
+pub struct ContainerTree {
+    nodes: SlotMap<NodeKey, Container>,
+    next_id: ConId,
+    pub root: NodeKey,
+    pub focused: Option<NodeKey>,
+}
+
+impl ContainerTree {
+    pub fn new(screen_rect: Rect) -> Self {
+        let mut nodes = SlotMap::with_key();
+
+        let root = Container {
+            id: 0,
+            con_type: ConType::Root,
+            layout: Layout::SplitH,
+            rect: screen_rect,
+            deco_rect: Rect::default(),
+            window_rect: Rect::default(),
+            name: String::from("root"),
+            num: -1,
+            urgent: false,
+            focused: false,
+            fullscreen: false,
+            is_floating: false,
+            sticky: false,
+            minimized: false,
+            percent: 1.0,
+            window: None,
+            frame: None,
+            title: String::new(),
+            border_style: BorderStyle::Normal,
+            border_width: 0,
+            size_hints: SizeHints::default(),
+            ignore_unmap: 0,
+            frame_mapped: false,
+            gaps_override: GapsOverride::default(),
+            mark: None,
+            parent: None,
+            children: Vec::new(),
+            focus_stack: VecDeque::new(),
+            floating_children: Vec::new(),
+        };
+
+        let root_key = nodes.insert(root);
+
+        ContainerTree {
+            nodes,
+            next_id: 1,
+            root: root_key,
+            focused: None,
+        }
+    }
+
+    /// Allocate a new container in the arena, returning its key.
+    pub fn alloc(&mut self, mut con: Container) -> NodeKey {
+        con.id = self.next_id;
+        self.next_id += 1;
+        self.nodes.insert(con)
+    }
+
+    /// Get a reference to a container by key.
+    pub fn get(&self, key: NodeKey) -> Option<&Container> {
+        self.nodes.get(key)
+    }
+
+    /// Get a mutable reference to a container by key.
+    pub fn get_mut(&mut self, key: NodeKey) -> Option<&mut Container> {
+        self.nodes.get_mut(key)
+    }
+
+    /// Create a new container and attach it as a child of `parent_key`.
+    pub fn create_child(
+        &mut self,
+        parent_key: NodeKey,
+        con_type: ConType,
+        name: &str,
+    ) -> NodeKey {
+        let num = if con_type == ConType::Workspace {
+            name.parse::<i32>().unwrap_or(-1)
+        } else {
+            -1
+        };
+        let con = Container {
+            id: 0, // will be set by alloc
+            con_type,
+            layout: Layout::default(),
+            rect: Rect::default(),
+            deco_rect: Rect::default(),
+            window_rect: Rect::default(),
+            name: name.to_string(),
+            num,
+            urgent: false,
+            focused: false,
+            fullscreen: false,
+            is_floating: false,
+            sticky: false,
+            minimized: false,
+            percent: 0.0,
+            window: None,
+            frame: None,
+            title: String::new(),
+            border_style: BorderStyle::Normal,
+            border_width: 0,
+            size_hints: SizeHints::default(),
+            ignore_unmap: 0,
+            frame_mapped: false,
+            gaps_override: GapsOverride::default(),
+            mark: None,
+            parent: Some(parent_key),
+            children: Vec::new(),
+            focus_stack: VecDeque::new(),
+            floating_children: Vec::new(),
+        };
+
+        let key = self.alloc(con);
+        if let Some(parent) = self.get_mut(parent_key) {
+            parent.children.push(key);
+            parent.focus_stack.push_front(key);
+        }
+        self.fix_percent(parent_key);
+        key
+    }
+
+    /// Remove a container and all its descendants from the tree.
+    pub fn remove(&mut self, key: NodeKey) {
+        // Collect all descendants iteratively (avoids clone + recursion)
+        let mut to_remove = vec![key];
+        let mut i = 0;
+        while i < to_remove.len() {
+            if let Some(con) = self.nodes.get(to_remove[i]) {
+                to_remove.extend_from_slice(&con.children);
+            }
+            i += 1;
+        }
+
+        // Remove from parent's children/focus lists
+        if let Some(parent_key) = self.nodes.get(key).and_then(|c| c.parent) {
+            if let Some(parent) = self.nodes.get_mut(parent_key) {
+                parent.children.retain(|&c| c != key);
+                parent.focus_stack.retain(|&c| c != key);
+                parent.floating_children.retain(|&c| c != key);
+            }
+            self.fix_percent(parent_key);
+        }
+
+        // Free all collected nodes
+        for &node_key in &to_remove {
+            self.nodes.remove(node_key);
+            if self.focused == Some(node_key) {
+                self.focused = None;
+            }
+        }
+    }
+
+    /// Detach a container from its parent's children/focus lists without freeing it.
+    /// Returns the old parent key.
+    pub fn detach(&mut self, key: NodeKey) -> Option<NodeKey> {
+        let parent_key = self.get(key).and_then(|c| c.parent);
+        if let Some(pkey) = parent_key {
+            if let Some(parent) = self.get_mut(pkey) {
+                parent.children.retain(|&c| c != key);
+                parent.focus_stack.retain(|&c| c != key);
+                parent.floating_children.retain(|&c| c != key);
+            }
+            self.fix_percent(pkey);
+        }
+        parent_key
+    }
+
+    /// Attach a container as a floating child of a workspace.
+    pub fn attach_floating(&mut self, key: NodeKey, ws_key: NodeKey) {
+        if let Some(con) = self.get_mut(key) {
+            con.parent = Some(ws_key);
+            con.is_floating = true;
+            con.con_type = ConType::FloatingCon;
+        }
+        if let Some(ws) = self.get_mut(ws_key) {
+            ws.floating_children.push(key);
+            ws.focus_stack.push_front(key);
+        }
+    }
+
+    /// Attach a container as a tiling child of a parent.
+    pub fn attach_tiling(&mut self, key: NodeKey, parent_key: NodeKey) {
+        if let Some(con) = self.get_mut(key) {
+            con.parent = Some(parent_key);
+            con.is_floating = false;
+            con.con_type = ConType::Con;
+        }
+        if let Some(parent) = self.get_mut(parent_key) {
+            parent.children.push(key);
+            parent.focus_stack.push_front(key);
+        }
+        self.fix_percent(parent_key);
+    }
+
+    /// Recalculate percent values for children of a container.
+    pub fn fix_percent(&mut self, parent_key: NodeKey) {
+        let count = match self.nodes.get(parent_key) {
+            Some(p) => p.children.len(),
+            None => return,
+        };
+        if count == 0 {
+            return;
+        }
+        let each = 1.0 / count as f64;
+        for i in 0..count {
+            let child_key = self.nodes[parent_key].children[i];
+            if let Some(child) = self.nodes.get_mut(child_key) {
+                child.percent = each;
+            }
+        }
+    }
+
+    /// Split a container: insert a new split container between it and its parent,
+    /// then move the original container as a child of the new split.
+    pub fn split(&mut self, target_key: NodeKey, layout: Layout) -> NodeKey {
+        let parent_key = match self.get(target_key) {
+            Some(c) => c.parent,
+            None => return target_key,
+        };
+
+        let split = Container {
+            id: 0,
+            con_type: ConType::Con,
+            layout,
+            rect: Rect::default(),
+            deco_rect: Rect::default(),
+            window_rect: Rect::default(),
+            name: String::new(),
+            num: -1,
+            urgent: false,
+            focused: false,
+            fullscreen: false,
+            is_floating: false,
+            sticky: false,
+            minimized: false,
+            percent: 0.0,
+            window: None,
+            frame: None,
+            title: String::new(),
+            border_style: BorderStyle::Normal,
+            border_width: 0,
+            size_hints: SizeHints::default(),
+            ignore_unmap: 0,
+            frame_mapped: false,
+            gaps_override: GapsOverride::default(),
+            mark: None,
+            parent: parent_key,
+            children: vec![target_key],
+            focus_stack: {
+                let mut fs = VecDeque::with_capacity(1);
+                fs.push_back(target_key);
+                fs
+            },
+            floating_children: Vec::new(),
+        };
+
+        let split_key = self.alloc(split);
+
+        // Replace target with split in parent's children list
+        if let Some(pkey) = parent_key
+            && let Some(parent) = self.get_mut(pkey)
+        {
+            for c in &mut parent.children {
+                if *c == target_key {
+                    *c = split_key;
+                }
+            }
+            for c in parent.focus_stack.iter_mut() {
+                if *c == target_key {
+                    *c = split_key;
+                }
+            }
+        }
+
+        // Reparent target
+        if let Some(target) = self.get_mut(target_key) {
+            target.parent = Some(split_key);
+        }
+
+        split_key
+    }
+
+    /// Find the workspace containing the given container.
+    pub fn workspace_for(&self, key: NodeKey) -> Option<NodeKey> {
+        let mut cur = key;
+        loop {
+            match self.get(cur) {
+                Some(c) if c.con_type == ConType::Workspace => {
+                    return Some(cur);
+                }
+                Some(c) => match c.parent {
+                    Some(p) => cur = p,
+                    None => return None,
+                },
+                None => return None,
+            }
+        }
+    }
+
+    /// Find the focused child in a container's focus stack.
+    pub fn focused_child(&self, key: NodeKey) -> Option<NodeKey> {
+        self.get(key).and_then(|c| c.focus_stack.front().copied())
+    }
+
+    /// Find a leaf (window) container by descending the focus stack.
+    pub fn focused_leaf(&self, key: NodeKey) -> NodeKey {
+        let mut cur = key;
+        loop {
+            match self.focused_child(cur) {
+                Some(child) => cur = child,
+                None => return cur,
+            }
+        }
+    }
+
+    /// Iterate all containers.
+    pub fn iter(&self) -> impl Iterator<Item = (NodeKey, &Container)> {
+        self.nodes.iter()
+    }
+
+    /// Find a container managing the given X11 window (client or frame).
+    pub fn find_by_window(&self, window: u32) -> Option<NodeKey> {
+        self.iter()
+            .find(|(_, c)| {
+                c.window == Some(window) || c.frame == Some(window)
+            })
+            .map(|(key, _)| key)
+    }
+
+    /// Find a container by its mark.
+    pub fn find_by_mark(&self, mark: &str) -> Option<NodeKey> {
+        self.iter()
+            .find(|(_, c)| c.mark.as_deref() == Some(mark))
+            .map(|(key, _)| key)
+    }
+
+    /// Set a mark on a container, removing it from any other container first.
+    pub fn set_mark(&mut self, key: NodeKey, mark: &str) {
+        // Remove the mark from any existing container
+        if let Some(old_key) = self.find_by_mark(mark)
+            && let Some(old) = self.get_mut(old_key)
+        {
+            old.mark = None;
+        }
+        if let Some(con) = self.get_mut(key) {
+            con.mark = Some(mark.to_string());
+        }
+    }
+
+    /// Remove a mark from any container that has it.
+    pub fn unmark(&mut self, mark: &str) {
+        if let Some(key) = self.find_by_mark(mark)
+            && let Some(con) = self.get_mut(key)
+        {
+            con.mark = None;
+        }
+    }
+
+    /// Remove all marks from all containers.
+    pub fn unmark_all(&mut self) {
+        let keys: Vec<NodeKey> = self
+            .iter()
+            .filter(|(_, c)| c.mark.is_some())
+            .map(|(k, _)| k)
+            .collect();
+        for key in keys {
+            if let Some(con) = self.get_mut(key) {
+                con.mark = None;
+            }
+        }
+    }
+
+    /// Collect all marks in the tree.
+    pub fn marks(&self) -> Vec<&str> {
+        self.iter().filter_map(|(_, c)| c.mark.as_deref()).collect()
+    }
+
+    /// Check if a container is hidden inside a tabbed/stacked parent.
+    ///
+    /// Like i3's `con_is_hidden()`: walks up the tree; if any ancestor
+    /// has Tabbed or Stacked layout and this container is not the
+    /// focused child of that ancestor, the container is hidden.
+    pub fn is_hidden(&self, key: NodeKey) -> bool {
+        let mut cur = key;
+        loop {
+            let parent_key = match self.get(cur).and_then(|c| c.parent) {
+                Some(p) => p,
+                None => return false,
+            };
+            let parent = match self.get(parent_key) {
+                Some(p) => p,
+                None => return false,
+            };
+            // Check layout BEFORE stopping at workspace — a workspace
+            // itself can have tabbed/stacked layout.
+            if matches!(parent.layout, Layout::Tabbed | Layout::Stacked) {
+                if parent.focus_stack.front() != Some(&cur) {
+                    return true;
+                }
+            }
+            if matches!(
+                parent.con_type,
+                ConType::Workspace | ConType::Output | ConType::Root
+            ) {
+                return false;
+            }
+            cur = parent_key;
+        }
+    }
+
+    /// Get siblings info for tab drawing: returns vec of (NodeKey, title, is_focused)
+    /// for all children of the given container's parent, if parent is tabbed/stacked.
+    pub fn tab_siblings(&self, key: NodeKey) -> Option<(Layout, Vec<(NodeKey, Rect)>)> {
+        let parent_key = self.get(key)?.parent?;
+        let parent = self.get(parent_key)?;
+        if !matches!(parent.layout, Layout::Tabbed | Layout::Stacked) {
+            return None;
+        }
+        let tabs: Vec<(NodeKey, Rect)> = parent.children.iter()
+            .filter_map(|&k| {
+                let c = self.get(k)?;
+                Some((k, c.deco_rect))
+            })
+            .collect();
+        Some((parent.layout, tabs))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn screen() -> Rect {
+        Rect {
+            x: 0,
+            y: 0,
+            w: 1920,
+            h: 1080,
+        }
+    }
+
+    // --- create ---
+
+    #[test]
+    fn new_tree_has_root() {
+        let tree = ContainerTree::new(screen());
+        let root = tree.get(tree.root).unwrap();
+        assert_eq!(root.con_type, ConType::Root);
+        assert_eq!(root.name, "root");
+        assert_eq!(root.rect, screen());
+        assert!(root.children.is_empty());
+    }
+
+    #[test]
+    fn create_child_adds_to_parent() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "eDP-1");
+
+        let root = tree.get(tree.root).unwrap();
+        assert_eq!(root.children.len(), 1);
+        assert_eq!(root.children[0], out);
+        assert_eq!(root.focus_stack[0], out);
+
+        let output = tree.get(out).unwrap();
+        assert_eq!(output.con_type, ConType::Output);
+        assert_eq!(output.name, "eDP-1");
+        assert_eq!(output.parent, Some(tree.root));
+    }
+
+    #[test]
+    fn create_workspace_parses_num() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "eDP-1");
+        let ws1 = tree.create_child(out, ConType::Workspace, "1");
+        let wsn = tree.create_child(out, ConType::Workspace, "media");
+
+        assert_eq!(tree.get(ws1).unwrap().num, 1);
+        assert_eq!(tree.get(wsn).unwrap().num, -1);
+    }
+
+    #[test]
+    fn create_child_assigns_unique_ids() {
+        let mut tree = ContainerTree::new(screen());
+        let a = tree.create_child(tree.root, ConType::Output, "a");
+        let b = tree.create_child(tree.root, ConType::Output, "b");
+        assert_ne!(tree.get(a).unwrap().id, tree.get(b).unwrap().id);
+    }
+
+    #[test]
+    fn fix_percent_distributes_evenly() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+        let c2 = tree.create_child(ws, ConType::Con, "c2");
+        let c3 = tree.create_child(ws, ConType::Con, "c3");
+
+        let eps = 1e-9;
+        let third = 1.0 / 3.0;
+        assert!((tree.get(c1).unwrap().percent - third).abs() < eps);
+        assert!((tree.get(c2).unwrap().percent - third).abs() < eps);
+        assert!((tree.get(c3).unwrap().percent - third).abs() < eps);
+    }
+
+    // --- remove ---
+
+    #[test]
+    fn remove_detaches_from_parent() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c = tree.create_child(ws, ConType::Con, "c");
+
+        tree.remove(c);
+        let ws_node = tree.get(ws).unwrap();
+        assert!(ws_node.children.is_empty());
+        assert!(ws_node.focus_stack.is_empty());
+        assert!(tree.get(c).is_none());
+    }
+
+    #[test]
+    fn remove_cascades_to_descendants() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let parent = tree.create_child(ws, ConType::Con, "p");
+        let child = tree.create_child(parent, ConType::Con, "c");
+
+        tree.remove(parent);
+        assert!(tree.get(parent).is_none());
+        assert!(tree.get(child).is_none());
+    }
+
+    #[test]
+    fn remove_clears_focused() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c = tree.create_child(ws, ConType::Con, "c");
+        tree.focused = Some(c);
+
+        tree.remove(c);
+        assert_eq!(tree.focused, None);
+    }
+
+    // --- split ---
+
+    #[test]
+    fn split_inserts_intermediate_container() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let leaf = tree.create_child(ws, ConType::Con, "leaf");
+
+        let split = tree.split(leaf, Layout::SplitV);
+
+        // split container is now a child of ws
+        let ws_node = tree.get(ws).unwrap();
+        assert!(ws_node.children.contains(&split));
+        assert!(!ws_node.children.contains(&leaf));
+
+        // leaf is now a child of split
+        let split_node = tree.get(split).unwrap();
+        assert_eq!(split_node.layout, Layout::SplitV);
+        assert_eq!(split_node.children, vec![leaf]);
+        assert_eq!(tree.get(leaf).unwrap().parent, Some(split));
+    }
+
+    // --- detach ---
+
+    #[test]
+    fn detach_removes_from_parent_keeps_node() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c = tree.create_child(ws, ConType::Con, "c");
+
+        let old_parent = tree.detach(c);
+        assert_eq!(old_parent, Some(ws));
+        assert!(tree.get(c).is_some()); // node still exists
+        let ws_node = tree.get(ws).unwrap();
+        assert!(!ws_node.children.contains(&c));
+        assert!(!ws_node.focus_stack.contains(&c));
+    }
+
+    // --- attach ---
+
+    #[test]
+    fn attach_tiling_reparents() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws1 = tree.create_child(out, ConType::Workspace, "1");
+        let ws2 = tree.create_child(out, ConType::Workspace, "2");
+        let c = tree.create_child(ws1, ConType::Con, "c");
+
+        tree.detach(c);
+        tree.attach_tiling(c, ws2);
+
+        assert_eq!(tree.get(c).unwrap().parent, Some(ws2));
+        assert!(tree.get(ws2).unwrap().children.contains(&c));
+        assert!(!tree.get(ws1).unwrap().children.contains(&c));
+    }
+
+    #[test]
+    fn attach_floating_sets_type() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c = tree.create_child(ws, ConType::Con, "c");
+
+        tree.detach(c);
+        tree.attach_floating(c, ws);
+
+        let con = tree.get(c).unwrap();
+        assert!(con.is_floating);
+        assert_eq!(con.con_type, ConType::FloatingCon);
+        assert!(tree.get(ws).unwrap().floating_children.contains(&c));
+    }
+
+    // --- utility ---
+
+    #[test]
+    fn workspace_for_finds_ancestor() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c = tree.create_child(ws, ConType::Con, "c");
+
+        assert_eq!(tree.workspace_for(c), Some(ws));
+        assert_eq!(tree.workspace_for(ws), Some(ws));
+        assert_eq!(tree.workspace_for(out), None);
+    }
+
+    #[test]
+    fn find_by_window_and_mark() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c = tree.create_child(ws, ConType::Con, "c");
+
+        tree.get_mut(c).unwrap().window = Some(0x1234);
+        assert_eq!(tree.find_by_window(0x1234), Some(c));
+        assert_eq!(tree.find_by_window(0x9999), None);
+
+        tree.set_mark(c, "test");
+        assert_eq!(tree.find_by_mark("test"), Some(c));
+        assert_eq!(tree.marks(), vec!["test"]);
+
+        tree.unmark("test");
+        assert_eq!(tree.find_by_mark("test"), None);
+        assert!(tree.marks().is_empty());
+    }
+
+    #[test]
+    fn focused_leaf_descends_focus_stack() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+        let _c2 = tree.create_child(ws, ConType::Con, "c2");
+
+        // c2 was created last so it's at front of focus_stack
+        // add a child to c1
+        let leaf = tree.create_child(c1, ConType::Con, "leaf");
+
+        // focused_leaf from c1 should reach leaf
+        assert_eq!(tree.focused_leaf(c1), leaf);
+    }
+
+    // --- is_hidden (tabbed/stacked visibility) ---
+
+    #[test]
+    fn is_hidden_split_layout_never_hides() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+        let c2 = tree.create_child(ws, ConType::Con, "c2");
+
+        // Default layout is SplitH — nothing is hidden.
+        assert!(!tree.is_hidden(c1));
+        assert!(!tree.is_hidden(c2));
+    }
+
+    #[test]
+    fn is_hidden_tabbed_workspace() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+        let c2 = tree.create_child(ws, ConType::Con, "c2");
+        let c3 = tree.create_child(ws, ConType::Con, "c3");
+
+        // Set workspace to tabbed. focus_stack front = c3 (last created).
+        tree.get_mut(ws).unwrap().layout = Layout::Tabbed;
+
+        // c3 is focused (front of focus_stack), others are hidden.
+        assert!(tree.is_hidden(c1));
+        assert!(tree.is_hidden(c2));
+        assert!(!tree.is_hidden(c3));
+    }
+
+    #[test]
+    fn is_hidden_stacked_workspace() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+        let c2 = tree.create_child(ws, ConType::Con, "c2");
+
+        tree.get_mut(ws).unwrap().layout = Layout::Stacked;
+
+        // c2 is at front of focus_stack
+        assert!(tree.is_hidden(c1));
+        assert!(!tree.is_hidden(c2));
+
+        // Change focus to c1
+        tree.get_mut(ws).unwrap().focus_stack.retain(|&c| c != c1);
+        tree.get_mut(ws).unwrap().focus_stack.push_front(c1);
+
+        assert!(!tree.is_hidden(c1));
+        assert!(tree.is_hidden(c2));
+    }
+
+    #[test]
+    fn is_hidden_nested_tabbed() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        // Split container with tabbed layout
+        let split = tree.create_child(ws, ConType::Con, "split");
+        tree.get_mut(split).unwrap().layout = Layout::Tabbed;
+        let c1 = tree.create_child(split, ConType::Con, "c1");
+        let c2 = tree.create_child(split, ConType::Con, "c2");
+
+        // c2 at front of focus_stack
+        assert!(tree.is_hidden(c1));
+        assert!(!tree.is_hidden(c2));
+    }
+
+    #[test]
+    fn is_hidden_single_child_not_hidden() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+
+        tree.get_mut(ws).unwrap().layout = Layout::Tabbed;
+
+        // Single child is always the focused one.
+        assert!(!tree.is_hidden(c1));
+    }
+
+    // --- tab_siblings ---
+
+    #[test]
+    fn tab_siblings_returns_none_for_split() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+
+        assert!(tree.tab_siblings(c1).is_none());
+    }
+
+    #[test]
+    fn tab_siblings_returns_all_for_tabbed() {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        let c1 = tree.create_child(ws, ConType::Con, "c1");
+        let c2 = tree.create_child(ws, ConType::Con, "c2");
+        let c3 = tree.create_child(ws, ConType::Con, "c3");
+
+        tree.get_mut(ws).unwrap().layout = Layout::Tabbed;
+
+        let (layout, tabs) = tree.tab_siblings(c1).unwrap();
+        assert_eq!(layout, Layout::Tabbed);
+        assert_eq!(tabs.len(), 3);
+        assert_eq!(tabs[0].0, c1);
+        assert_eq!(tabs[1].0, c2);
+        assert_eq!(tabs[2].0, c3);
+    }
+}
blob - /dev/null
blob + 522b83a31ea2a76892af95f5f77aaac17bcee984 (mode 644)
--- /dev/null
+++ src/ipc.rs
@@ -0,0 +1,721 @@
+use std::fmt::Write as FmtWrite;
+use std::io::{self, Read, Write};
+use std::os::unix::fs::PermissionsExt;
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::PathBuf;
+use std::time::Instant;
+
+use crate::container::{ConType, ContainerTree, NodeKey};
+
+/// IPC magic bytes.
+pub const IPC_MAGIC: &[u8; 6] = b"owm-ip";
+
+/// IPC message types (client -> wm).
+#[allow(dead_code)]
+#[repr(u32)]
+pub enum MsgType {
+    RunCommand = 0,
+    GetWorkspaces = 1,
+    GetTree = 3,
+    GetVersion = 4,
+    Subscribe = 5,
+    GetMarks = 6,
+    GetConfig = 7,
+}
+
+/// IPC event types (wm -> client).
+#[derive(Clone, Copy)]
+#[repr(u32)]
+pub enum EventType {
+    Workspace = 0x8000_0000,
+    Window = 0x8000_0001,
+    Config = 0x8000_0002,
+}
+
+/// Subscription bitmask bits matching EventType.
+const SUB_WORKSPACE: u32 = 1 << 0;
+const SUB_WINDOW: u32 = 1 << 1;
+const SUB_CONFIG: u32 = 1 << 2;
+
+/// Maximum messages per second per client before disconnection.
+const RATE_LIMIT: u32 = 100;
+
+/// Maximum command payload size in bytes.
+const MAX_COMMAND_LEN: usize = 4096;
+
+/// State for a connected IPC client.
+struct IpcClient {
+    stream: UnixStream,
+    read_buf: Vec<u8>,
+    write_buf: Vec<u8>,
+    /// Bitmask of subscribed event types.
+    subscriptions: u32,
+    /// Message count in the current rate-limit window.
+    msg_count: u32,
+    /// Start of the current rate-limit window.
+    window_start: Instant,
+}
+
+impl IpcClient {
+    fn new(stream: UnixStream) -> io::Result<Self> {
+        stream.set_nonblocking(true)?;
+        Ok(IpcClient {
+            stream,
+            read_buf: Vec::new(),
+            write_buf: Vec::new(),
+            subscriptions: 0,
+            msg_count: 0,
+            window_start: Instant::now(),
+        })
+    }
+
+    /// Check rate limit. Returns false if the client exceeded the limit.
+    fn check_rate(&mut self) -> bool {
+        let now = Instant::now();
+        if now.duration_since(self.window_start).as_secs() >= 1 {
+            self.msg_count = 0;
+            self.window_start = now;
+        }
+        self.msg_count += 1;
+        self.msg_count <= RATE_LIMIT
+    }
+}
+
+/// IPC server listening on a Unix socket.
+pub struct IpcServer {
+    listener: UnixListener,
+    clients: Vec<IpcClient>,
+    socket_path: PathBuf,
+}
+
+impl IpcServer {
+    /// Create a new IPC server.
+    pub fn new() -> io::Result<Self> {
+        let socket_path = Self::socket_path();
+
+        // Remove stale socket
+        let _ = std::fs::remove_file(&socket_path);
+
+        // Ensure parent directory exists with restricted permissions
+        if let Some(parent) = socket_path.parent() {
+            std::fs::create_dir_all(parent)?;
+            std::fs::set_permissions(
+                parent,
+                std::fs::Permissions::from_mode(0o700),
+            )?;
+        }
+
+        let listener = UnixListener::bind(&socket_path)?;
+        listener.set_nonblocking(true)?;
+
+        crate::log_info!("IPC listening on {}", socket_path.display());
+
+        Ok(IpcServer {
+            listener,
+            clients: Vec::new(),
+            socket_path,
+        })
+    }
+
+    /// Get the IPC socket path.
+    pub fn socket_path() -> PathBuf {
+        if let Ok(path) = std::env::var("OWM_SOCKET") {
+            return PathBuf::from(path);
+        }
+
+        let uid = unsafe { crate::sys::getuid() };
+        let dir = PathBuf::from(format!("/tmp/owm-{uid}"));
+        dir.join(format!("ipc-{}.sock", std::process::id()))
+    }
+
+    /// Get the socket path as a string for child process env vars.
+    pub fn socket_path_str(&self) -> &str {
+        self.socket_path.to_str().unwrap_or("")
+    }
+
+    /// Get the listener file descriptor for polling.
+    pub fn fd(&self) -> RawFd {
+        self.listener.as_raw_fd()
+    }
+
+    /// Accept new connections and process messages.
+    /// Returns a list of commands to execute.
+    pub fn poll(
+        &mut self,
+        tree: &ContainerTree,
+        config: &crate::config::Config,
+        current_mode: &str,
+    ) -> Vec<String> {
+        let mut commands = Vec::new();
+
+        // Accept new connections
+        loop {
+            match self.listener.accept() {
+                Ok((stream, _)) => match IpcClient::new(stream) {
+                    Ok(client) => {
+                        crate::log_debug!("IPC client connected");
+                        self.clients.push(client);
+                    }
+                    Err(e) => {
+                        crate::log_warn!("failed to init IPC client: {}", e);
+                    }
+                },
+                Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
+                Err(e) => {
+                    crate::log_warn!("IPC accept error: {}", e);
+                    break;
+                }
+            }
+        }
+
+        // Process each client
+        let mut to_remove = Vec::new();
+        for (i, client) in self.clients.iter_mut().enumerate() {
+            let mut buf = [0u8; 4096];
+            match client.stream.read(&mut buf) {
+                Ok(0) => {
+                    to_remove.push(i);
+                }
+                Ok(n) => {
+                    client.read_buf.extend_from_slice(&buf[..n]);
+                    while let Some((msg_type, payload)) =
+                        parse_message(&mut client.read_buf)
+                    {
+                        // Rate limiting
+                        if !client.check_rate() {
+                            crate::log_warn!(
+                                "IPC client exceeded rate limit, disconnecting"
+                            );
+                            to_remove.push(i);
+                            break;
+                        }
+
+                        // Handle Subscribe (msg type 5)
+                        if msg_type == MsgType::Subscribe as u32 {
+                            if payload.len() >= 4 {
+                                let mask = u32::from_ne_bytes([
+                                    payload[0], payload[1], payload[2],
+                                    payload[3],
+                                ]);
+                                client.subscriptions |= mask;
+                                crate::log_debug!(
+                                    "IPC client subscribed: 0x{:08x}",
+                                    client.subscriptions
+                                );
+                            }
+                            let resp = r#"{"success":true}"#.as_bytes();
+                            let header =
+                                make_header(msg_type, resp.len() as u32);
+                            client.write_buf.extend_from_slice(&header);
+                            client.write_buf.extend_from_slice(resp);
+                            continue;
+                        }
+
+                        let response = handle_message(
+                            msg_type, &payload, tree, config, current_mode,
+                        );
+
+                        // Validate and collect RUN_COMMAND
+                        if msg_type == MsgType::RunCommand as u32
+                            && let Some(cmd) = validate_command(payload)
+                        {
+                            commands.push(cmd);
+                        }
+
+                        let response_bytes = response.into_bytes();
+                        let header =
+                            make_header(msg_type, response_bytes.len() as u32);
+                        client.write_buf.extend_from_slice(&header);
+                        client.write_buf.extend_from_slice(&response_bytes);
+                    }
+                    if !client.write_buf.is_empty() {
+                        let _ = client.stream.write_all(&client.write_buf);
+                        client.write_buf.clear();
+                    }
+                }
+                Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}
+                Err(_) => {
+                    to_remove.push(i);
+                }
+            }
+        }
+
+        // Deduplicate indices and remove in reverse order
+        to_remove.sort_unstable();
+        to_remove.dedup();
+        for i in to_remove.into_iter().rev() {
+            crate::log_debug!("IPC client disconnected");
+            self.clients.remove(i);
+        }
+
+        commands
+    }
+
+    /// Broadcast an event only to clients subscribed to this event type.
+    pub fn broadcast_event(&mut self, event_type: EventType, json: &str) {
+        let sub_bit = match event_type {
+            EventType::Workspace => SUB_WORKSPACE,
+            EventType::Window => SUB_WINDOW,
+            EventType::Config => SUB_CONFIG,
+        };
+
+        let payload = json.as_bytes();
+        let header = make_header(event_type as u32, payload.len() as u32);
+
+        let mut to_remove = Vec::new();
+        for (i, client) in self.clients.iter_mut().enumerate() {
+            if client.subscriptions & sub_bit == 0 {
+                continue;
+            }
+            if client.stream.write_all(&header).is_err()
+                || client.stream.write_all(payload).is_err()
+            {
+                to_remove.push(i);
+            }
+        }
+        for i in to_remove.into_iter().rev() {
+            self.clients.remove(i);
+        }
+    }
+}
+
+impl Drop for IpcServer {
+    fn drop(&mut self) {
+        let _ = std::fs::remove_file(&self.socket_path);
+    }
+}
+
+/// Parse a message from the read buffer.
+fn parse_message(buf: &mut Vec<u8>) -> Option<(u32, Vec<u8>)> {
+    const HEADER_SIZE: usize = 14;
+    if buf.len() < HEADER_SIZE {
+        return None;
+    }
+
+    if &buf[..6] != IPC_MAGIC {
+        crate::log_warn!(
+            "IPC: invalid magic bytes {:02x?}, dropping buffer",
+            &buf[..6]
+        );
+        buf.clear();
+        return None;
+    }
+
+    let size = u32::from_ne_bytes([buf[6], buf[7], buf[8], buf[9]]) as usize;
+    let msg_type = u32::from_ne_bytes([buf[10], buf[11], buf[12], buf[13]]);
+
+    if buf.len() < HEADER_SIZE + size {
+        return None;
+    }
+
+    let payload = buf[HEADER_SIZE..HEADER_SIZE + size].to_vec();
+    buf.drain(..HEADER_SIZE + size);
+
+    Some((msg_type, payload))
+}
+
+/// Create a response header (stack-allocated).
+fn make_header(msg_type: u32, size: u32) -> [u8; 14] {
+    let mut header = [0u8; 14];
+    header[..6].copy_from_slice(IPC_MAGIC);
+    header[6..10].copy_from_slice(&size.to_ne_bytes());
+    header[10..14].copy_from_slice(&msg_type.to_ne_bytes());
+    header
+}
+
+/// Allowed command prefixes for RUN_COMMAND.
+const ALLOWED_COMMANDS: &[&str] = &[
+    "focus",
+    "split",
+    "layout",
+    "kill",
+    "fullscreen",
+    "workspace",
+    "move",
+    "exec",
+    "reload",
+    "restart",
+    "exit",
+    "mark",
+    "unmark",
+    "mode",
+    "resize",
+    "sticky",
+    "minimize",
+    "restore",
+    "scratchpad",
+    "gaps",
+];
+
+/// Validate and sanitize a RUN_COMMAND payload.
+/// Returns the command string if valid, None otherwise.
+fn validate_command(payload: Vec<u8>) -> Option<String> {
+    if payload.len() > MAX_COMMAND_LEN {
+        crate::log_warn!(
+            "IPC: command too long ({} bytes), rejecting",
+            payload.len()
+        );
+        return None;
+    }
+
+    let cmd = match String::from_utf8(payload) {
+        Ok(s) => s,
+        Err(_) => {
+            crate::log_warn!("IPC: command is not valid UTF-8, rejecting");
+            return None;
+        }
+    };
+
+    // Reject control characters (except space/tab)
+    if cmd.bytes().any(|b| b < 0x20 && b != b' ' && b != b'\t') {
+        crate::log_warn!("IPC: command contains control characters, rejecting");
+        return None;
+    }
+
+    let first_word = cmd.split_whitespace().next().unwrap_or("");
+    if !ALLOWED_COMMANDS.contains(&first_word) {
+        crate::log_warn!("IPC: unknown command '{}', rejecting", first_word);
+        return None;
+    }
+
+    Some(cmd)
+}
+
+/// Handle an IPC message and return a JSON response string.
+fn handle_message(
+    msg_type: u32,
+    _payload: &[u8],
+    tree: &ContainerTree,
+    config: &crate::config::Config,
+    current_mode: &str,
+) -> String {
+    match msg_type {
+        // RUN_COMMAND
+        0 => r#"{"success":true}"#.to_string(),
+        // GET_WORKSPACES
+        1 => {
+            let data = dump_workspaces(tree);
+            format!(r#"{{"success":true,"data":{data}}}"#)
+        }
+        // GET_TREE
+        3 => {
+            let data = dump_tree(tree, tree.root);
+            format!(r#"{{"success":true,"data":{data}}}"#)
+        }
+        // GET_VERSION
+        4 => {
+            format!(
+                r#"{{"success":true,"data":{{"version":"{}","name":"owm"}}}}"#,
+                env!("CARGO_PKG_VERSION")
+            )
+        }
+        // SUBSCRIBE handled in poll(), shouldn't reach here
+        5 => r#"{"success":true}"#.to_string(),
+        // GET_MARKS
+        6 => {
+            let data = dump_marks(tree);
+            format!(r#"{{"success":true,"data":{data}}}"#)
+        }
+        // GET_CONFIG
+        7 => {
+            let data = dump_config(config, current_mode);
+            format!(r#"{{"success":true,"data":{data}}}"#)
+        }
+        _ => {
+            format!(
+                r#"{{"success":false,"error":"unknown message type: {msg_type}"}}"#
+            )
+        }
+    }
+}
+
+/// Escape a string for JSON output.
+fn json_escape(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '"' => out.push_str("\\\""),
+            '\\' => out.push_str("\\\\"),
+            '\n' => out.push_str("\\n"),
+            '\r' => out.push_str("\\r"),
+            '\t' => out.push_str("\\t"),
+            c if c < '\x20' => {
+                let _ = write!(out, "\\u{:04x}", c as u32);
+            }
+            c => out.push(c),
+        }
+    }
+    out
+}
+
+/// Recursively dump the container tree as a JSON string.
+fn dump_tree(tree: &ContainerTree, idx: NodeKey) -> String {
+    let con = match tree.get(idx) {
+        Some(c) => c,
+        None => return "null".to_string(),
+    };
+
+    let mut nodes = String::new();
+    for (i, &child) in con.children.iter().enumerate() {
+        if i > 0 {
+            nodes.push(',');
+        }
+        nodes.push_str(&dump_tree(tree, child));
+    }
+
+    let name = json_escape(&con.name);
+    let window = match con.window {
+        Some(w) => w.to_string(),
+        None => "null".to_string(),
+    };
+    let mark = match &con.mark {
+        Some(m) => format!("\"{}\"", json_escape(m)),
+        None => "null".to_string(),
+    };
+
+    format!(
+        concat!(
+            r#"{{"id":{},"type":"{:?}","name":"{}","layout":"{:?}","#,
+            r#""rect":{{"x":{},"y":{},"w":{},"h":{}}},"#,
+            r#""window":{},"focused":{},"urgent":{},"fullscreen":{},"#,
+            r#""percent":{},"mark":{},"nodes":[{}]}}"#,
+        ),
+        con.id,
+        con.con_type,
+        name,
+        con.layout,
+        con.rect.x,
+        con.rect.y,
+        con.rect.w,
+        con.rect.h,
+        window,
+        con.focused,
+        con.urgent,
+        con.fullscreen,
+        con.percent,
+        mark,
+        nodes,
+    )
+}
+
+/// Dump all marks as a JSON array string.
+fn dump_marks(tree: &ContainerTree) -> String {
+    let marks = tree.marks();
+    let mut out = String::from("[");
+    for (i, m) in marks.iter().enumerate() {
+        if i > 0 {
+            out.push(',');
+        }
+        out.push('"');
+        out.push_str(&json_escape(m));
+        out.push('"');
+    }
+    out.push(']');
+    out
+}
+
+/// Dump current configuration as a JSON object string.
+fn dump_config(config: &crate::config::Config, current_mode: &str) -> String {
+    let terminal = json_escape(&config.terminal);
+    let mode = json_escape(current_mode);
+    let g = &config.gaps;
+    let smart = match config.smart_gaps {
+        crate::config::SmartGaps::Off => "off",
+        crate::config::SmartGaps::On => "on",
+        crate::config::SmartGaps::InverseOuter => "inverse_outer",
+    };
+    let mut s = String::new();
+    let _ = write!(
+        s,
+        concat!(
+            "{{\"border_width\":{},",
+            "\"gaps\":{{\"inner\":{},\"top\":{},\"right\":{},\"bottom\":{},\"left\":{}}},",
+            "\"smart_gaps\":\"{}\",",
+            "\"color_focused\":\"#{:06x}\",\"color_unfocused\":\"#{:06x}\",",
+            "\"color_urgent\":\"#{:06x}\",\"color_background\":\"#{:06x}\",",
+            "\"terminal\":\"{}\",\"focus_follows_mouse\":{},",
+            "\"mode\":\"{}\"}}"
+        ),
+        config.border_width,
+        g.inner, g.top, g.right, g.bottom, g.left,
+        smart,
+        config.color_focused,
+        config.color_unfocused,
+        config.color_urgent,
+        config.color_background,
+        terminal,
+        config.focus_follows_mouse,
+        mode,
+    );
+    s
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Build a valid IPC message buffer.
+    fn make_message(msg_type: u32, payload: &[u8]) -> Vec<u8> {
+        let mut buf = Vec::new();
+        buf.extend_from_slice(IPC_MAGIC);
+        buf.extend_from_slice(&(payload.len() as u32).to_ne_bytes());
+        buf.extend_from_slice(&msg_type.to_ne_bytes());
+        buf.extend_from_slice(payload);
+        buf
+    }
+
+    // --- parse_message ---
+
+    #[test]
+    fn parse_valid_message() {
+        let payload = b"focus next";
+        let mut buf = make_message(0, payload);
+        let result = parse_message(&mut buf);
+        assert!(result.is_some());
+        let (msg_type, data) = result.unwrap();
+        assert_eq!(msg_type, 0);
+        assert_eq!(data, payload);
+        assert!(buf.is_empty());
+    }
+
+    #[test]
+    fn parse_empty_payload() {
+        let mut buf = make_message(4, b"");
+        let result = parse_message(&mut buf);
+        assert!(result.is_some());
+        let (msg_type, data) = result.unwrap();
+        assert_eq!(msg_type, 4);
+        assert!(data.is_empty());
+    }
+
+    #[test]
+    fn parse_incomplete_header() {
+        let mut buf = vec![b'o', b'w', b'm'];
+        let result = parse_message(&mut buf);
+        assert!(result.is_none());
+        assert_eq!(buf.len(), 3); // buffer unchanged
+    }
+
+    #[test]
+    fn parse_incomplete_payload() {
+        let mut buf = make_message(0, b"hello");
+        buf.truncate(buf.len() - 2); // remove last 2 bytes of payload
+        let result = parse_message(&mut buf);
+        assert!(result.is_none());
+    }
+
+    #[test]
+    fn parse_invalid_magic_clears_buffer() {
+        let mut buf = vec![
+            0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+            0x00, 0x00, 0x00,
+        ];
+        let result = parse_message(&mut buf);
+        assert!(result.is_none());
+        assert!(buf.is_empty());
+    }
+
+    #[test]
+    fn parse_two_messages() {
+        let mut buf = make_message(0, b"cmd1");
+        buf.extend(make_message(1, b"cmd2"));
+
+        let r1 = parse_message(&mut buf);
+        assert!(r1.is_some());
+        assert_eq!(r1.unwrap().1, b"cmd1");
+
+        let r2 = parse_message(&mut buf);
+        assert!(r2.is_some());
+        assert_eq!(r2.unwrap().1, b"cmd2");
+
+        assert!(buf.is_empty());
+    }
+
+    // --- validate_command ---
+
+    #[test]
+    fn validate_allowed_commands() {
+        for cmd in ALLOWED_COMMANDS {
+            let payload = cmd.as_bytes().to_vec();
+            assert!(
+                validate_command(payload).is_some(),
+                "should accept: {}",
+                cmd
+            );
+        }
+    }
+
+    #[test]
+    fn validate_command_with_args() {
+        let r = validate_command(b"workspace 3".to_vec());
+        assert_eq!(r, Some("workspace 3".to_string()));
+    }
+
+    #[test]
+    fn validate_rejects_unknown_command() {
+        let r = validate_command(b"shutdown".to_vec());
+        assert!(r.is_none());
+    }
+
+    #[test]
+    fn validate_rejects_empty() {
+        let r = validate_command(b"".to_vec());
+        assert!(r.is_none());
+    }
+
+    #[test]
+    fn validate_rejects_too_long() {
+        let long = vec![b'a'; MAX_COMMAND_LEN + 1];
+        let r = validate_command(long);
+        assert!(r.is_none());
+    }
+
+    #[test]
+    fn validate_rejects_invalid_utf8() {
+        let r = validate_command(vec![0xff, 0xfe]);
+        assert!(r.is_none());
+    }
+
+    #[test]
+    fn validate_rejects_control_chars() {
+        let r = validate_command(b"focus\x01next".to_vec());
+        assert!(r.is_none());
+    }
+
+    #[test]
+    fn validate_max_length_accepted() {
+        let mut cmd = String::from("exec ");
+        while cmd.len() < MAX_COMMAND_LEN {
+            cmd.push('a');
+        }
+        let cmd = cmd[..MAX_COMMAND_LEN].to_string();
+        let r = validate_command(cmd.into_bytes());
+        assert!(r.is_some());
+    }
+}
+
+/// Dump workspace list as a JSON array string.
+fn dump_workspaces(tree: &ContainerTree) -> String {
+    let mut out = String::from("[");
+    let mut first = true;
+    for (idx, con) in tree.iter() {
+        if con.con_type == ConType::Workspace {
+            if !first {
+                out.push(',');
+            }
+            first = false;
+            let focused = tree
+                .focused
+                .is_some_and(|f| tree.workspace_for(f) == Some(idx));
+            let name = json_escape(&con.name);
+            let _ = write!(
+                out,
+                r#"{{"id":{},"name":"{}","num":{},"focused":{},"rect":{{"x":{},"y":{},"w":{},"h":{}}}}}"#,
+                con.id, name, con.num, focused, con.rect.x, con.rect.y, con.rect.w, con.rect.h,
+            );
+        }
+    }
+    out.push(']');
+    out
+}
blob - /dev/null
blob + af9276213e1af669f20ac438a8c5beb9180b72bc (mode 644)
--- /dev/null
+++ src/keybind.rs
@@ -0,0 +1,228 @@
+use std::collections::HashMap;
+use xcb::x;
+
+/// Resize direction.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ResizeDir {
+    GrowWidth,
+    ShrinkWidth,
+    GrowHeight,
+    ShrinkHeight,
+}
+
+/// Actions that can be triggered by key bindings.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Action {
+    FocusNext,
+    FocusPrev,
+    FocusParent,
+    FocusChild,
+    SwapNext,
+    SwapPrev,
+    SplitVertical,
+    SplitHorizontal,
+    LayoutStacked,
+    LayoutTabbed,
+    LayoutToggleSplit,
+    ToggleFullscreen,
+    ToggleFloating,
+    ToggleSticky,
+    Minimize,
+    CloseWindow,
+    MoveScratchpad,
+    ScratchpadShow,
+    Spawn(String),
+    Workspace(String),
+    MoveToWorkspace(String),
+    Resize(ResizeDir),
+    Mode(String),
+    Restart,
+    Exit,
+}
+
+/// A key binding: modifier mask + keycode -> action.
+#[derive(Debug, Clone, Hash, Eq, PartialEq)]
+pub struct KeyCombo {
+    pub modifiers: u32,
+    pub keycode: x::Keycode,
+}
+
+/// Default mode name.
+pub const MODE_DEFAULT: &str = "default";
+
+/// Manages key bindings organized by mode, with mode switching.
+pub struct KeybindManager {
+    modes: HashMap<String, HashMap<KeyCombo, Action>>,
+    current_mode: String,
+}
+
+/// Ignored modifier bits (NumLock=0x10, CapsLock=0x02).
+const IGNORE_MASK: u32 = 0x02 | 0x10;
+
+impl KeybindManager {
+    pub fn new() -> Self {
+        KeybindManager {
+            modes: HashMap::new(),
+            current_mode: MODE_DEFAULT.to_string(),
+        }
+    }
+
+    /// Current active mode name.
+    pub fn current_mode(&self) -> &str {
+        &self.current_mode
+    }
+
+    /// Switch to a named mode. Returns false if mode doesn't exist.
+    pub fn switch_mode(
+        &mut self,
+        mode: &str,
+        conn: &xcb::Connection,
+        root: x::Window,
+    ) -> bool {
+        if !self.modes.contains_key(mode) {
+            crate::log_warn!("unknown binding mode: {}", mode);
+            return false;
+        }
+        self.current_mode = mode.to_string();
+        // Re-grab keys for the new mode
+        self.grab_current_mode(conn, root);
+        crate::log_info!("switched to binding mode: {}", mode);
+        true
+    }
+
+    /// Remove all keybindings and ungrab keys.
+    pub fn clear(&mut self, conn: &xcb::Connection, root: x::Window) {
+        conn.send_request(&x::UngrabKey {
+            key: x::GRAB_ANY,
+            grab_window: root,
+            modifiers: x::ModMask::ANY,
+        });
+        self.modes.clear();
+        self.current_mode = MODE_DEFAULT.to_string();
+    }
+
+    /// Register keybindings for a given mode, converting keysyms to keycodes.
+    pub fn setup_mode(
+        &mut self,
+        conn: &xcb::Connection,
+        mode: &str,
+        bindings: &[(u32, u32, Action)],
+    ) {
+        let setup = conn.get_setup();
+        let min_keycode = setup.min_keycode();
+        let max_keycode = setup.max_keycode();
+
+        let cookie = conn.send_request(&x::GetKeyboardMapping {
+            first_keycode: min_keycode,
+            count: max_keycode - min_keycode + 1,
+        });
+
+        let reply = match conn.wait_for_reply(cookie) {
+            Ok(r) => r,
+            Err(e) => {
+                crate::log_error!("failed to get keyboard mapping: {}", e);
+                return;
+            }
+        };
+
+        let keysyms_per_keycode = reply.keysyms_per_keycode() as usize;
+        let all_keysyms = reply.keysyms();
+
+        let mode_bindings = self.modes.entry(mode.to_string()).or_default();
+
+        for &(modifiers, keysym, ref action) in bindings {
+            if let Some(keycode) = keysym_to_keycode(
+                all_keysyms,
+                keysyms_per_keycode,
+                min_keycode,
+                keysym,
+            ) {
+                let combo = KeyCombo { modifiers, keycode };
+                mode_bindings.insert(combo, action.clone());
+                crate::log_debug!(
+                    "[{}] bound keysym {:#x} (keycode {}) -> {:?}",
+                    mode,
+                    keysym,
+                    keycode,
+                    action
+                );
+            } else {
+                crate::log_warn!("no keycode found for keysym {:#x}", keysym);
+            }
+        }
+    }
+
+    /// Register keybindings for the default mode (convenience wrapper).
+    pub fn setup(
+        &mut self,
+        conn: &xcb::Connection,
+        root: x::Window,
+        bindings: &[(u32, u32, Action)],
+    ) {
+        self.setup_mode(conn, MODE_DEFAULT, bindings);
+        self.grab_current_mode(conn, root);
+    }
+
+    /// Grab X keys for the current mode.
+    fn grab_current_mode(&self, conn: &xcb::Connection, root: x::Window) {
+        // Ungrab everything first
+        conn.send_request(&x::UngrabKey {
+            key: x::GRAB_ANY,
+            grab_window: root,
+            modifiers: x::ModMask::ANY,
+        });
+
+        let Some(bindings) = self.modes.get(&self.current_mode) else {
+            return;
+        };
+
+        for combo in bindings.keys() {
+            for extra in &[0u32, 0x02, 0x10, 0x12] {
+                conn.send_request(&x::GrabKey {
+                    owner_events: true,
+                    grab_window: root,
+                    modifiers: x::ModMask::from_bits_truncate(
+                        combo.modifiers | extra,
+                    ),
+                    key: combo.keycode,
+                    pointer_mode: x::GrabMode::Async,
+                    keyboard_mode: x::GrabMode::Async,
+                });
+            }
+        }
+    }
+
+    /// Look up the action for a key event in the current mode.
+    pub fn lookup(
+        &self,
+        modifiers: u32,
+        keycode: x::Keycode,
+    ) -> Option<&Action> {
+        let clean = modifiers & !IGNORE_MASK;
+        let combo = KeyCombo {
+            modifiers: clean,
+            keycode,
+        };
+        self.modes
+            .get(&self.current_mode)
+            .and_then(|b| b.get(&combo))
+    }
+}
+
+/// Convert a keysym to a keycode using the keyboard mapping.
+fn keysym_to_keycode(
+    keysyms: &[x::Keysym],
+    per_keycode: usize,
+    min_keycode: x::Keycode,
+    target: u32,
+) -> Option<x::Keycode> {
+    for (i, chunk) in keysyms.chunks(per_keycode).enumerate() {
+        for &ks in chunk {
+            if ks == target {
+                let offset = u8::try_from(i).ok()?;
+                return min_keycode.checked_add(offset);
+            }
+        }
+    }
+    None
+}
blob - /dev/null
blob + 529b030f3ccc45b98c3fcb45dd76124e7618dc6b (mode 644)
--- /dev/null
+++ src/layout.rs
@@ -0,0 +1,529 @@
+use crate::config::{Gaps, SmartGaps};
+use crate::container::{BorderStyle, ContainerTree, Layout, NodeKey, Rect};
+
+/// Recursively compute the geometry of all containers in the tree.
+///
+/// `gaps` is the effective (already resolved and clamped) gap configuration
+/// for the workspace being laid out.  `smart` controls whether gaps are
+/// suppressed when a workspace has only one tiled child.
+pub fn apply_layout(
+    tree: &mut ContainerTree,
+    ws_key: NodeKey,
+    gaps: &Gaps,
+    smart: SmartGaps,
+    deco_height: u32,
+) {
+    let tiled_count = tree
+        .get(ws_key)
+        .map(|c| c.children.len())
+        .unwrap_or(0);
+
+    let effective = effective_gaps(gaps, smart, tiled_count);
+
+    // Apply outer gaps as inset on the workspace rect.
+    if let Some(ws) = tree.get_mut(ws_key) {
+        let r = ws.rect;
+        ws.rect = Rect {
+            x: r.x + effective.left,
+            y: r.y + effective.top,
+            w: r.w.saturating_sub((effective.left + effective.right).max(0) as u32),
+            h: r.h.saturating_sub((effective.top + effective.bottom).max(0) as u32),
+        };
+    }
+
+    let inner = effective.inner.max(0) as u32;
+    layout_recurse(tree, ws_key, inner, deco_height);
+}
+
+/// Compute effective gaps given smart_gaps policy.
+fn effective_gaps(gaps: &Gaps, smart: SmartGaps, tiled_count: usize) -> Gaps {
+    if tiled_count <= 1 {
+        match smart {
+            SmartGaps::Off => *gaps,
+            SmartGaps::On => Gaps {
+                inner: 0, top: 0, right: 0, bottom: 0, left: 0,
+            },
+            SmartGaps::InverseOuter => Gaps {
+                inner: 0,
+                top: gaps.top,
+                right: gaps.right,
+                bottom: gaps.bottom,
+                left: gaps.left,
+            },
+        }
+    } else {
+        *gaps
+    }
+}
+
+/// Recursive layout pass (uses inner gap only; outer already applied).
+fn layout_recurse(
+    tree: &mut ContainerTree,
+    idx: NodeKey,
+    gap: u32,
+    deco_height: u32,
+) {
+    let (layout, rect, children) = match tree.get(idx) {
+        Some(c) => (c.layout, c.rect, c.children.clone()),
+        None => return,
+    };
+
+    if children.is_empty() {
+        return;
+    }
+
+    match layout {
+        Layout::SplitH => layout_split_h(tree, &children, rect, gap),
+        Layout::SplitV => layout_split_v(tree, &children, rect, gap),
+        Layout::Stacked => layout_stacked_impl(tree, idx, &children, rect, deco_height),
+        Layout::Tabbed => layout_tabbed_impl(tree, idx, &children, rect, deco_height),
+    }
+
+    // Compute window_rect (client position within frame) for each child.
+    // For stacked/tabbed children whose parent draws the tabs,
+    // compute_window_rect uses the child's (already-adjusted) rect.
+    for &child_idx in &children {
+        compute_window_rect(tree, child_idx, deco_height);
+    }
+
+    // Recurse into children
+    for child_idx in children {
+        layout_recurse(tree, child_idx, gap, deco_height);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Split layouts
+// ---------------------------------------------------------------------------
+
+/// Horizontal split: divide width among children.
+fn layout_split_h(
+    tree: &mut ContainerTree,
+    children: &[NodeKey],
+    parent_rect: Rect,
+    gap: u32,
+) {
+    let n = children.len() as u32;
+    if n == 0 {
+        return;
+    }
+
+    let total_gaps = gap * (n - 1);
+    let available_w = parent_rect.w.saturating_sub(total_gaps);
+    if available_w == 0 {
+        return;
+    }
+
+    let default_pct = 1.0 / n as f64;
+    let percents: Vec<f64> = children
+        .iter()
+        .map(|&k| tree.get(k).map(|c| c.percent).unwrap_or(default_pct))
+        .collect();
+    let all_equal = percents
+        .windows(2)
+        .all(|w| (w[0] - w[1]).abs() < f64::EPSILON);
+
+    let mut x = parent_rect.x;
+
+    for (i, &child_idx) in children.iter().enumerate() {
+        let percent = if all_equal { default_pct } else { percents[i] };
+        let w = if i == children.len() - 1 {
+            let remaining = parent_rect
+                .x
+                .saturating_add(parent_rect.w as i32)
+                .saturating_sub(x);
+            (remaining.max(0)) as u32
+        } else {
+            (available_w as f64 * percent) as u32
+        };
+
+        if let Some(child) = tree.get_mut(child_idx) {
+            child.rect = Rect { x, y: parent_rect.y, w, h: parent_rect.h };
+            child.deco_rect = Rect::default();
+        }
+
+        x = x.saturating_add(w as i32).saturating_add(gap as i32);
+    }
+}
+
+/// Vertical split: divide height among children.
+fn layout_split_v(
+    tree: &mut ContainerTree,
+    children: &[NodeKey],
+    parent_rect: Rect,
+    gap: u32,
+) {
+    let n = children.len() as u32;
+    if n == 0 {
+        return;
+    }
+
+    let total_gaps = gap * (n - 1);
+    let available_h = parent_rect.h.saturating_sub(total_gaps);
+    if available_h == 0 {
+        return;
+    }
+
+    let default_pct = 1.0 / n as f64;
+    let percents: Vec<f64> = children
+        .iter()
+        .map(|&k| tree.get(k).map(|c| c.percent).unwrap_or(default_pct))
+        .collect();
+    let all_equal = percents
+        .windows(2)
+        .all(|w| (w[0] - w[1]).abs() < f64::EPSILON);
+
+    let mut y = parent_rect.y;
+
+    for (i, &child_idx) in children.iter().enumerate() {
+        let percent = if all_equal { default_pct } else { percents[i] };
+        let h = if i == children.len() - 1 {
+            let remaining = parent_rect
+                .y
+                .saturating_add(parent_rect.h as i32)
+                .saturating_sub(y);
+            (remaining.max(0)) as u32
+        } else {
+            (available_h as f64 * percent) as u32
+        };
+
+        if let Some(child) = tree.get_mut(child_idx) {
+            child.rect = Rect { x: parent_rect.x, y, w: parent_rect.w, h };
+            child.deco_rect = Rect::default();
+        }
+
+        y = y.saturating_add(h as i32).saturating_add(gap as i32);
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Stacked layout  (like i3: vertical title bars, one per child)
+// ---------------------------------------------------------------------------
+
+/// Stacked: all children share the same full rect (including tab area).
+///
+/// Each child gets a `deco_rect` for its title bar (stacked vertically).
+/// The child's rect covers the full parent (tabs + content); the
+/// window_rect computation pushes the client below the tab area.
+fn layout_stacked_impl(
+    tree: &mut ContainerTree,
+    _parent_key: NodeKey,
+    children: &[NodeKey],
+    parent_rect: Rect,
+    deco_height: u32,
+) {
+    let n = children.len() as u32;
+    if n == 0 {
+        return;
+    }
+
+    // Each child's frame covers the entire parent area (tabs + content).
+    // The tabs are drawn in the top portion of each child's frame.
+    for (i, &child_idx) in children.iter().enumerate() {
+        if let Some(child) = tree.get_mut(child_idx) {
+            child.rect = parent_rect;
+
+            // deco_rect: this child's title bar within the frame
+            // (frame-local coordinates since rect == parent_rect).
+            child.deco_rect = Rect {
+                x: 0,
+                y: (i as u32 * deco_height) as i32,
+                w: parent_rect.w,
+                h: deco_height,
+            };
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Tabbed layout  (like i3: horizontal tabs, one row)
+// ---------------------------------------------------------------------------
+
+/// Tabbed: all children share the same full rect (including tab row).
+///
+/// Each child gets a `deco_rect` for its tab (horizontally distributed).
+/// The child's rect covers the full parent; window_rect pushes the
+/// client below the single tab row.
+fn layout_tabbed_impl(
+    tree: &mut ContainerTree,
+    _parent_key: NodeKey,
+    children: &[NodeKey],
+    parent_rect: Rect,
+    deco_height: u32,
+) {
+    let n = children.len() as u32;
+    if n == 0 {
+        return;
+    }
+
+    let tab_w = parent_rect.w / n;
+
+    for (i, &child_idx) in children.iter().enumerate() {
+        let this_w = if i as u32 == n - 1 {
+            parent_rect.w - tab_w * (n - 1)
+        } else {
+            tab_w
+        };
+
+        if let Some(child) = tree.get_mut(child_idx) {
+            child.rect = parent_rect;
+
+            child.deco_rect = Rect {
+                x: (i as u32 * tab_w) as i32,
+                y: 0,
+                w: this_w,
+                h: deco_height,
+            };
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Window rect (client position inside frame)
+// ---------------------------------------------------------------------------
+
+/// Compute window_rect for a container based on its border_style.
+/// window_rect defines where the client window sits inside the frame.
+fn compute_window_rect(
+    tree: &mut ContainerTree,
+    idx: NodeKey,
+    deco_height: u32,
+) {
+    let (border_style, border_width, rect, parent_info) = match tree.get(idx) {
+        Some(c) => {
+            let pi = c.parent.and_then(|p| tree.get(p)).map(|p| {
+                (p.layout, p.children.len() as u32)
+            });
+            (c.border_style, c.border_width, c.rect, pi)
+        }
+        None => return,
+    };
+
+    let (parent_layout, sibling_count) = parent_info.unwrap_or((Layout::SplitH, 1));
+
+    // Total height consumed by tab/title bars at the top of the frame.
+    let tab_area_h = match parent_layout {
+        Layout::Stacked => deco_height * sibling_count,
+        Layout::Tabbed => deco_height,
+        _ => 0,
+    };
+
+    let window_rect = if tab_area_h > 0 {
+        // In tabbed/stacked: tabs are drawn in the top portion of the frame;
+        // the client window sits below all tabs, with optional border.
+        let bw = match border_style {
+            BorderStyle::None => 0,
+            _ => border_width,
+        };
+        Rect {
+            x: bw as i32,
+            y: tab_area_h as i32 + bw as i32,
+            w: rect.w.saturating_sub(2 * bw),
+            h: rect.h.saturating_sub(tab_area_h + 2 * bw),
+        }
+    } else {
+        match border_style {
+            BorderStyle::Normal => {
+                let bw = border_width;
+                Rect {
+                    x: bw as i32,
+                    y: deco_height as i32,
+                    w: rect.w.saturating_sub(2 * bw),
+                    h: rect.h.saturating_sub(deco_height + bw),
+                }
+            }
+            BorderStyle::Pixel => {
+                let bw = border_width;
+                Rect {
+                    x: bw as i32,
+                    y: bw as i32,
+                    w: rect.w.saturating_sub(2 * bw),
+                    h: rect.h.saturating_sub(2 * bw),
+                }
+            }
+            BorderStyle::None => {
+                Rect { x: 0, y: 0, w: rect.w, h: rect.h }
+            }
+        }
+    };
+
+    if let Some(con) = tree.get_mut(idx) {
+        con.window_rect = window_rect;
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::Gaps;
+    use crate::container::{ConType, ContainerTree};
+
+    fn screen() -> Rect {
+        Rect { x: 0, y: 0, w: 1200, h: 800 }
+    }
+
+    const DECO_H: u32 = 20;
+
+    fn make_tree_with_children(n: usize, layout: Layout) -> (ContainerTree, NodeKey, Vec<NodeKey>) {
+        let mut tree = ContainerTree::new(screen());
+        let out = tree.create_child(tree.root, ConType::Output, "out");
+        let ws = tree.create_child(out, ConType::Workspace, "1");
+        if let Some(w) = tree.get_mut(ws) {
+            w.rect = screen();
+            w.layout = layout;
+        }
+        let mut children = Vec::new();
+        for i in 0..n {
+            let c = tree.create_child(ws, ConType::Con, &format!("c{i}"));
+            children.push(c);
+        }
+        (tree, ws, children)
+    }
+
+    // --- Tabbed layout geometry ---
+
+    #[test]
+    fn tabbed_all_children_same_rect() {
+        let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        // All children should have the same rect (full workspace area).
+        let r0 = tree.get(children[0]).unwrap().rect;
+        let r1 = tree.get(children[1]).unwrap().rect;
+        let r2 = tree.get(children[2]).unwrap().rect;
+        assert_eq!(r0, r1);
+        assert_eq!(r1, r2);
+        assert_eq!(r0, screen());
+    }
+
+    #[test]
+    fn tabbed_deco_rects_horizontal() {
+        let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        let d0 = tree.get(children[0]).unwrap().deco_rect;
+        let d1 = tree.get(children[1]).unwrap().deco_rect;
+        let d2 = tree.get(children[2]).unwrap().deco_rect;
+
+        // Tabs distributed horizontally, all at y=0.
+        assert_eq!(d0.y, 0);
+        assert_eq!(d1.y, 0);
+        assert_eq!(d2.y, 0);
+
+        assert_eq!(d0.h, DECO_H);
+        assert_eq!(d1.h, DECO_H);
+        assert_eq!(d2.h, DECO_H);
+
+        // Each tab ~400px wide (1200/3), last absorbs remainder.
+        assert_eq!(d0.x, 0);
+        assert_eq!(d0.w, 400);
+        assert_eq!(d1.x, 400);
+        assert_eq!(d1.w, 400);
+        assert_eq!(d2.x, 800);
+        assert_eq!(d2.w, 400);
+    }
+
+    #[test]
+    fn tabbed_window_rect_below_tabs() {
+        let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        let wr = tree.get(children[0]).unwrap().window_rect;
+        // Client starts below one row of tabs (DECO_H) + border.
+        let bw = tree.get(children[0]).unwrap().border_width;
+        assert_eq!(wr.y, DECO_H as i32 + bw as i32);
+    }
+
+    // --- Stacked layout geometry ---
+
+    #[test]
+    fn stacked_all_children_same_rect() {
+        let (mut tree, ws, children) = make_tree_with_children(3, Layout::Stacked);
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        let r0 = tree.get(children[0]).unwrap().rect;
+        let r1 = tree.get(children[1]).unwrap().rect;
+        let r2 = tree.get(children[2]).unwrap().rect;
+        assert_eq!(r0, r1);
+        assert_eq!(r1, r2);
+        assert_eq!(r0, screen());
+    }
+
+    #[test]
+    fn stacked_deco_rects_vertical() {
+        let (mut tree, ws, children) = make_tree_with_children(3, Layout::Stacked);
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        let d0 = tree.get(children[0]).unwrap().deco_rect;
+        let d1 = tree.get(children[1]).unwrap().deco_rect;
+        let d2 = tree.get(children[2]).unwrap().deco_rect;
+
+        // Tabs stacked vertically, each full width.
+        assert_eq!(d0.x, 0);
+        assert_eq!(d1.x, 0);
+        assert_eq!(d2.x, 0);
+
+        assert_eq!(d0.w, 1200);
+        assert_eq!(d1.w, 1200);
+        assert_eq!(d2.w, 1200);
+
+        assert_eq!(d0.y, 0);
+        assert_eq!(d0.h, DECO_H);
+        assert_eq!(d1.y, DECO_H as i32);
+        assert_eq!(d1.h, DECO_H);
+        assert_eq!(d2.y, 2 * DECO_H as i32);
+        assert_eq!(d2.h, DECO_H);
+    }
+
+    #[test]
+    fn stacked_window_rect_below_all_tabs() {
+        let (mut tree, ws, children) = make_tree_with_children(3, Layout::Stacked);
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        let wr = tree.get(children[0]).unwrap().window_rect;
+        let bw = tree.get(children[0]).unwrap().border_width;
+        // Client starts below 3 stacked title bars + border.
+        assert_eq!(wr.y, 3 * DECO_H as i32 + bw as i32);
+    }
+
+    // --- Single child ---
+
+    #[test]
+    fn tabbed_single_child() {
+        let (mut tree, ws, children) = make_tree_with_children(1, Layout::Tabbed);
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        let d = tree.get(children[0]).unwrap().deco_rect;
+        assert_eq!(d.x, 0);
+        assert_eq!(d.y, 0);
+        assert_eq!(d.w, 1200);
+        assert_eq!(d.h, DECO_H);
+    }
+
+    // --- Tab width rounding ---
+
+    #[test]
+    fn tabbed_last_tab_absorbs_remainder() {
+        let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+        // Use 1201 width to force uneven division.
+        if let Some(w) = tree.get_mut(ws) {
+            w.rect.w = 1201;
+        }
+        let gaps = Gaps::default();
+        apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+        let d0 = tree.get(children[0]).unwrap().deco_rect;
+        let d2 = tree.get(children[2]).unwrap().deco_rect;
+        // 1201 / 3 = 400, remainder 1 goes to last tab.
+        assert_eq!(d0.w, 400);
+        assert_eq!(d2.w, 401);
+        assert_eq!(d0.w + tree.get(children[1]).unwrap().deco_rect.w + d2.w, 1201);
+    }
+}
blob - /dev/null
blob + 608ee3fb44f1b2a76c0ff0301193dbf8dc87abc7 (mode 644)
--- /dev/null
+++ src/lib.rs
@@ -0,0 +1,13 @@
+pub mod bar;
+pub mod bar_child;
+pub mod bar_proto;
+pub mod config;
+pub mod container;
+pub mod ipc;
+pub mod keybind;
+pub mod layout;
+pub mod log;
+pub mod nagbar;
+pub mod sys;
+pub mod xcb_conn;
+pub mod xft_font;
blob - /dev/null
blob + 8739461ceadbe88807dfebb6ab73f402bcb26ff5 (mode 644)
--- /dev/null
+++ src/log.rs
@@ -0,0 +1,63 @@
+use std::sync::OnceLock;
+
+/// Log level, controlled by OWM_LOG env var.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Level {
+    Debug,
+    Info,
+    Warn,
+    Error,
+}
+
+static LOG_LEVEL: OnceLock<Level> = OnceLock::new();
+
+pub fn init() {
+    let level = match std::env::var("OWM_LOG").as_deref() {
+        Ok("debug") => Level::Debug,
+        Ok("warn") => Level::Warn,
+        Ok("error") => Level::Error,
+        _ => Level::Info,
+    };
+    let _ = LOG_LEVEL.set(level);
+}
+
+#[inline]
+pub fn level() -> Level {
+    LOG_LEVEL.get().copied().unwrap_or(Level::Info)
+}
+
+#[macro_export]
+macro_rules! log_debug {
+    ($($arg:tt)*) => {
+        if $crate::log::level() <= $crate::log::Level::Debug {
+            eprintln!("owm [DEBUG] {}", format_args!($($arg)*));
+        }
+    };
+}
+
+#[macro_export]
+macro_rules! log_info {
+    ($($arg:tt)*) => {
+        if $crate::log::level() <= $crate::log::Level::Info {
+            eprintln!("owm [INFO] {}", format_args!($($arg)*));
+        }
+    };
+}
+
+#[macro_export]
+macro_rules! log_warn {
+    ($($arg:tt)*) => {
+        if $crate::log::level() <= $crate::log::Level::Warn {
+            eprintln!("owm [WARN] {}", format_args!($($arg)*));
+        }
+    };
+}
+
+#[macro_export]
+macro_rules! log_error {
+    ($($arg:tt)*) => {
+        if $crate::log::level() <= $crate::log::Level::Error {
+            eprintln!("owm [ERROR] {}", format_args!($($arg)*));
+        }
+    };
+}
blob - /dev/null
blob + de977daff73cf9aba40e34d9806145983054c52c (mode 644)
--- /dev/null
+++ src/nagbar.rs
@@ -0,0 +1,131 @@
+use std::os::raw::c_ulong;
+
+use xcb::x;
+use xcb::Xid;
+
+use crate::xcb_conn::XConn;
+
+/// Height of the nagbar in pixels.
+const BAR_HEIGHT: u32 = 26;
+/// Background color (red).
+const BG_COLOR: u32 = 0x900000;
+/// Text color (white).
+const TEXT_COLOR: u32 = 0xffffff;
+/// Horizontal text padding.
+const TEXT_PAD: u32 = 8;
+
+/// A notification bar displayed at the top of the screen to report config errors.
+///
+/// Creates an override-redirect X11 window that sits above all other windows.
+/// Dismissed by clicking on it or pressing Escape.
+pub struct Nagbar {
+    window: x::Window,
+    message: String,
+}
+
+impl Nagbar {
+    /// Create and display a nagbar showing the given error messages.
+    pub fn show(xconn: &XConn, errors: &[String]) -> Self {
+        let message = if errors.len() == 1 {
+            format!("config error: {}", errors[0])
+        } else {
+            format!("config: {} errors (click to dismiss)", errors.len())
+        };
+
+        let screen = xconn.screen_rect;
+        let window: x::Window = xconn.conn.generate_id();
+
+        xconn.conn.send_request(&x::CreateWindow {
+            depth: 0,
+            wid: window,
+            parent: xconn.root,
+            x: screen.x as i16,
+            y: screen.y as i16,
+            width: screen.w as u16,
+            height: BAR_HEIGHT as u16,
+            border_width: 0,
+            class: x::WindowClass::InputOutput,
+            visual: xconn.root_visual,
+            value_list: &[
+                x::Cw::BackPixel(BG_COLOR),
+                x::Cw::OverrideRedirect(true),
+                x::Cw::EventMask(
+                    x::EventMask::BUTTON_PRESS
+                        | x::EventMask::EXPOSURE
+                        | x::EventMask::KEY_PRESS,
+                ),
+            ],
+        });
+
+        xconn.conn.send_request(&x::MapWindow { window });
+        // Keep it above everything.
+        xconn.conn.send_request(&x::ConfigureWindow {
+            window,
+            value_list: &[x::ConfigWindow::StackMode(x::StackMode::Above)],
+        });
+        xconn.conn.flush().ok();
+
+        let nag = Nagbar { window, message };
+        nag.redraw(xconn);
+        nag
+    }
+
+    /// Redraw the nagbar text (called on Expose events).
+    pub fn redraw(&self, xconn: &XConn) {
+        let drawable = x::Drawable::Window(self.window);
+        let screen = xconn.screen_rect;
+
+        // Fill background
+        let gc: x::Gcontext = xconn.conn.generate_id();
+        xconn.conn.send_request(&x::CreateGc {
+            cid: gc,
+            drawable,
+            value_list: &[
+                x::Gc::Foreground(BG_COLOR),
+                x::Gc::Background(BG_COLOR),
+            ],
+        });
+        xconn.conn.send_request(&x::PolyFillRectangle {
+            drawable,
+            gc,
+            rectangles: &[x::Rectangle {
+                x: 0,
+                y: 0,
+                width: screen.w as u16,
+                height: BAR_HEIGHT as u16,
+            }],
+        });
+
+        // Draw text (flush XCB before Xft)
+        let _ = xconn.conn.flush();
+        let avail = screen.w.saturating_sub(2 * TEXT_PAD);
+        let text = xconn.xft.truncate_to_width(&self.message, avail);
+        let text_y =
+            (BAR_HEIGHT / 2 + xconn.xft.ascent / 2) as i32;
+
+        xconn.xft.draw_text(
+            self.window.resource_id() as c_ulong,
+            TEXT_PAD as i32,
+            text_y,
+            text,
+            TEXT_COLOR,
+        );
+
+        xconn.xft.flush();
+        xconn.conn.send_request(&x::FreeGc { gc });
+        xconn.conn.flush().ok();
+    }
+
+    /// Returns the X11 window ID so the event loop can match events.
+    pub fn window_id(&self) -> u32 {
+        self.window.resource_id()
+    }
+
+    /// Destroy the nagbar window.
+    pub fn dismiss(self, xconn: &XConn) {
+        xconn
+            .conn
+            .send_request(&x::DestroyWindow { window: self.window });
+        xconn.conn.flush().ok();
+    }
+}
blob - /dev/null
blob + 288720471c38df1619997e0a2b160998fd81126f (mode 644)
--- /dev/null
+++ src/sys.rs
@@ -0,0 +1,605 @@
+use std::os::unix::io::RawFd;
+
+pub const POLLIN: i16 = 0x0001;
+pub const EINTR: i32 = 4;
+
+/// Signal numbers.
+pub const SIGHUP: std::ffi::c_int = 1;
+pub const SIGTERM: std::ffi::c_int = 15;
+
+#[cfg(target_os = "openbsd")]
+pub const SIGSTOP: std::ffi::c_int = 17;
+#[cfg(target_os = "openbsd")]
+pub const SIGCONT: std::ffi::c_int = 19;
+#[cfg(target_os = "linux")]
+pub const SIGSTOP: std::ffi::c_int = 19;
+#[cfg(target_os = "linux")]
+pub const SIGCONT: std::ffi::c_int = 18;
+
+#[repr(C)]
+pub struct pollfd {
+    pub fd: RawFd,
+    pub events: i16,
+    pub revents: i16,
+}
+
+#[cfg(target_os = "openbsd")]
+type NfdsT = std::ffi::c_uint;
+
+#[cfg(target_os = "linux")]
+type NfdsT = std::ffi::c_ulong;
+
+/// Minimal sigaction structure (POSIX).
+#[repr(C)]
+struct SigAction {
+    sa_handler: extern "C" fn(std::ffi::c_int),
+    sa_mask: u32,
+    sa_flags: std::ffi::c_int,
+}
+
+unsafe extern "C" {
+    pub fn poll(
+        fds: *mut pollfd,
+        nfds: NfdsT,
+        timeout: std::ffi::c_int,
+    ) -> std::ffi::c_int;
+    pub fn getuid() -> u32;
+    fn pipe(fds: *mut std::ffi::c_int) -> std::ffi::c_int;
+    fn write(fd: std::ffi::c_int, buf: *const u8, count: usize) -> isize;
+    fn read(fd: std::ffi::c_int, buf: *mut u8, count: usize) -> isize;
+    fn close(fd: std::ffi::c_int) -> std::ffi::c_int;
+    fn sigaction(
+        sig: std::ffi::c_int,
+        act: *const SigAction,
+        oact: *mut SigAction,
+    ) -> std::ffi::c_int;
+    fn fcntl(
+        fd: std::ffi::c_int,
+        cmd: std::ffi::c_int,
+        arg: std::ffi::c_int,
+    ) -> std::ffi::c_int;
+    fn execvp(
+        file: *const std::ffi::c_char,
+        argv: *const *const std::ffi::c_char,
+    ) -> std::ffi::c_int;
+    fn kill(pid: std::ffi::c_int, sig: std::ffi::c_int) -> std::ffi::c_int;
+    fn waitpid(
+        pid: std::ffi::c_int,
+        status: *mut std::ffi::c_int,
+        options: std::ffi::c_int,
+    ) -> std::ffi::c_int;
+}
+
+const WNOHANG: std::ffi::c_int = 1;
+
+/// Send a signal to a process.
+pub fn kill_process(pid: u32, sig: std::ffi::c_int) -> Result<(), std::io::Error> {
+    let ret = unsafe { kill(pid as std::ffi::c_int, sig) };
+    if ret == -1 {
+        Err(std::io::Error::last_os_error())
+    } else {
+        Ok(())
+    }
+}
+
+/// Non-blocking waitpid. Returns Some(status) if child exited, None otherwise.
+pub fn waitpid_nohang(pid: u32) -> Option<i32> {
+    let mut status: std::ffi::c_int = 0;
+    let ret = unsafe { waitpid(pid as std::ffi::c_int, &mut status, WNOHANG) };
+    if ret > 0 { Some(status) } else { None }
+}
+
+/// Set a file descriptor to non-blocking mode.
+pub fn set_nonblocking(fd: RawFd) {
+    unsafe { fcntl(fd, F_SETFL, O_NONBLOCK); }
+}
+
+/// Write end of the signal self-pipe (global, written from signal handler).
+static mut SIGNAL_WRITE_FD: std::ffi::c_int = -1;
+
+/// Signal handler: writes the signal number byte to the pipe.
+extern "C" fn signal_handler(sig: std::ffi::c_int) {
+    let b = sig as u8;
+    unsafe {
+        let _ = write(SIGNAL_WRITE_FD, &b as *const u8, 1);
+    }
+}
+
+/// Self-pipe for receiving signals in the poll loop.
+pub struct SignalPipe {
+    read_fd: RawFd,
+}
+
+/// F_SETFL and O_NONBLOCK for setting pipe to non-blocking.
+const F_SETFL: std::ffi::c_int = 4;
+#[cfg(target_os = "openbsd")]
+const O_NONBLOCK: std::ffi::c_int = 0x0004;
+#[cfg(target_os = "linux")]
+const O_NONBLOCK: std::ffi::c_int = 0x0800;
+
+impl SignalPipe {
+    /// Create a self-pipe and register handlers for SIGHUP and SIGTERM.
+    pub fn new() -> Result<Self, std::io::Error> {
+        let mut fds = [0i32; 2];
+        let ret = unsafe { pipe(fds.as_mut_ptr()) };
+        if ret == -1 {
+            return Err(std::io::Error::last_os_error());
+        }
+
+        // Set both ends non-blocking
+        unsafe {
+            fcntl(fds[0], F_SETFL, O_NONBLOCK);
+            fcntl(fds[1], F_SETFL, O_NONBLOCK);
+        }
+
+        // Store write fd globally for signal handler
+        unsafe {
+            SIGNAL_WRITE_FD = fds[1];
+        }
+
+        // Install signal handlers
+        let sa = SigAction {
+            sa_handler: signal_handler,
+            sa_mask: 0,
+            sa_flags: 0,
+        };
+        unsafe {
+            sigaction(SIGHUP, &sa, std::ptr::null_mut());
+            sigaction(SIGTERM, &sa, std::ptr::null_mut());
+        }
+
+        Ok(SignalPipe { read_fd: fds[0] })
+    }
+
+    /// File descriptor for polling.
+    pub fn fd(&self) -> RawFd {
+        self.read_fd
+    }
+
+    /// Read pending signal bytes from the pipe.
+    /// Returns a vec of signal numbers received.
+    pub fn drain(&self) -> Vec<u8> {
+        let mut signals = Vec::new();
+        let mut buf = [0u8; 16];
+        loop {
+            let n = unsafe { read(self.read_fd, buf.as_mut_ptr(), buf.len()) };
+            if n <= 0 {
+                break;
+            }
+            for &b in &buf[..n as usize] {
+                signals.push(b);
+            }
+        }
+        signals
+    }
+}
+
+impl Drop for SignalPipe {
+    fn drop(&mut self) {
+        unsafe {
+            close(self.read_fd);
+            if SIGNAL_WRITE_FD >= 0 {
+                close(SIGNAL_WRITE_FD);
+                SIGNAL_WRITE_FD = -1;
+            }
+        }
+    }
+}
+
+/// Restrict process syscalls with pledge(2).
+///
+/// On non-OpenBSD systems this is a no-op.
+#[cfg(target_os = "openbsd")]
+pub fn pledge(
+    promises: &str,
+    execpromises: Option<&str>,
+) -> Result<(), std::io::Error> {
+    use std::ffi::CString;
+
+    unsafe extern "C" {
+        fn pledge(
+            promises: *const std::ffi::c_char,
+            execpromises: *const std::ffi::c_char,
+        ) -> std::ffi::c_int;
+    }
+
+    let promises_c = CString::new(promises).unwrap();
+    let exec_c = execpromises.map(|e| CString::new(e).unwrap());
+
+    let exec_ptr = match &exec_c {
+        Some(c) => c.as_ptr(),
+        None => std::ptr::null(),
+    };
+
+    let ret = unsafe { pledge(promises_c.as_ptr(), exec_ptr) };
+    if ret == -1 {
+        Err(std::io::Error::last_os_error())
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(not(target_os = "openbsd"))]
+pub fn pledge(
+    _promises: &str,
+    _execpromises: Option<&str>,
+) -> Result<(), std::io::Error> {
+    Ok(())
+}
+
+/// Restrict filesystem visibility with unveil(2).
+///
+/// Call with `(path, permissions)` to reveal a path, or `(None, None)` to lock.
+/// On non-OpenBSD systems this is a no-op.
+#[cfg(target_os = "openbsd")]
+pub fn unveil(
+    path: Option<&str>,
+    permissions: Option<&str>,
+) -> Result<(), std::io::Error> {
+    use std::ffi::CString;
+
+    unsafe extern "C" {
+        fn unveil(
+            path: *const std::ffi::c_char,
+            permissions: *const std::ffi::c_char,
+        ) -> std::ffi::c_int;
+    }
+
+    let path_c = path.map(|p| CString::new(p).unwrap());
+    let perm_c = permissions.map(|p| CString::new(p).unwrap());
+
+    let path_ptr = match &path_c {
+        Some(c) => c.as_ptr(),
+        None => std::ptr::null(),
+    };
+    let perm_ptr = match &perm_c {
+        Some(c) => c.as_ptr(),
+        None => std::ptr::null(),
+    };
+
+    let ret = unsafe { unveil(path_ptr, perm_ptr) };
+    if ret == -1 {
+        Err(std::io::Error::last_os_error())
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(not(target_os = "openbsd"))]
+pub fn unveil(
+    _path: Option<&str>,
+    _permissions: Option<&str>,
+) -> Result<(), std::io::Error> {
+    Ok(())
+}
+
+// --- Config file watcher (kqueue on OpenBSD, inotify on Linux) ---
+
+#[cfg(target_os = "openbsd")]
+const EVFILT_VNODE: i16 = -4;
+#[cfg(target_os = "openbsd")]
+const EV_ADD: u16 = 0x0001;
+#[cfg(target_os = "openbsd")]
+const EV_CLEAR: u16 = 0x0020;
+#[cfg(target_os = "openbsd")]
+const NOTE_DELETE: u32 = 0x0001;
+#[cfg(target_os = "openbsd")]
+const NOTE_WRITE: u32 = 0x0002;
+#[cfg(target_os = "openbsd")]
+const NOTE_RENAME: u32 = 0x0020;
+
+#[cfg(target_os = "openbsd")]
+#[repr(C)]
+struct Kevent {
+    ident: usize,
+    filter: i16,
+    flags: u16,
+    fflags: u32,
+    data: i64,
+    udata: *mut std::ffi::c_void,
+}
+
+#[cfg(target_os = "openbsd")]
+#[repr(C)]
+struct Timespec {
+    tv_sec: i64,
+    tv_nsec: i64,
+}
+
+#[cfg(target_os = "openbsd")]
+unsafe extern "C" {
+    fn kqueue() -> std::ffi::c_int;
+    fn kevent(
+        kq: std::ffi::c_int,
+        changelist: *const Kevent,
+        nchanges: std::ffi::c_int,
+        eventlist: *mut Kevent,
+        nevents: std::ffi::c_int,
+        timeout: *const Timespec,
+    ) -> std::ffi::c_int;
+}
+
+#[cfg(target_os = "linux")]
+const IN_NONBLOCK: std::ffi::c_int = 0x800;
+#[cfg(target_os = "linux")]
+const IN_CLOSE_WRITE: u32 = 0x0000_0008;
+#[cfg(target_os = "linux")]
+const IN_DELETE_SELF: u32 = 0x0000_0400;
+#[cfg(target_os = "linux")]
+const IN_MOVE_SELF: u32 = 0x0000_0800;
+
+#[cfg(target_os = "linux")]
+unsafe extern "C" {
+    fn inotify_init1(flags: std::ffi::c_int) -> std::ffi::c_int;
+    fn inotify_add_watch(
+        fd: std::ffi::c_int,
+        pathname: *const std::ffi::c_char,
+        mask: u32,
+    ) -> std::ffi::c_int;
+}
+
+/// Watches a config file for modifications.
+///
+/// Uses kqueue `EVFILT_VNODE` on OpenBSD and inotify on Linux.
+/// Handles editors that delete-and-recreate the file by automatically
+/// re-establishing the watch when the file reappears.
+pub struct ConfigWatcher {
+    /// kqueue fd (OpenBSD) or inotify fd (Linux).
+    watch_fd: RawFd,
+    /// File kept open for kqueue EVFILT_VNODE (OpenBSD).
+    #[cfg(target_os = "openbsd")]
+    file: Option<std::fs::File>,
+    /// inotify watch descriptor (Linux).
+    #[cfg(target_os = "linux")]
+    wd: std::ffi::c_int,
+    /// Path to the watched file.
+    path: String,
+}
+
+#[cfg(target_os = "openbsd")]
+impl ConfigWatcher {
+    /// Create a new config watcher for the given path.
+    pub fn new(path: &str) -> Result<Self, std::io::Error> {
+        let kq = unsafe { kqueue() };
+        if kq < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        // Set kqueue fd non-blocking
+        unsafe {
+            fcntl(kq, F_SETFL, O_NONBLOCK);
+        }
+        let mut watcher = ConfigWatcher {
+            watch_fd: kq,
+            file: None,
+            path: path.to_string(),
+        };
+        watcher.try_watch();
+        Ok(watcher)
+    }
+
+    /// Try to open the file and register a kqueue vnode watch.
+    fn try_watch(&mut self) -> bool {
+        use std::os::unix::io::AsRawFd;
+
+        let file = match std::fs::File::open(&self.path) {
+            Ok(f) => f,
+            Err(_) => return false,
+        };
+
+        let fd = file.as_raw_fd();
+        let mut ev: Kevent = unsafe { std::mem::zeroed() };
+        ev.ident = fd as usize;
+        ev.filter = EVFILT_VNODE;
+        ev.flags = EV_ADD | EV_CLEAR;
+        ev.fflags = NOTE_WRITE | NOTE_DELETE | NOTE_RENAME;
+
+        let ts = Timespec {
+            tv_sec: 0,
+            tv_nsec: 0,
+        };
+        let ret = unsafe {
+            kevent(self.watch_fd, &ev, 1, std::ptr::null_mut(), 0, &ts)
+        };
+        if ret < 0 {
+            return false;
+        }
+
+        self.file = Some(file);
+        true
+    }
+
+    /// File descriptor for poll().
+    pub fn fd(&self) -> RawFd {
+        self.watch_fd
+    }
+
+    /// Returns true if the watch is broken and needs periodic retry.
+    pub fn needs_rewatch(&self) -> bool {
+        self.file.is_none()
+    }
+
+    /// Check for file changes. Returns true if the config was modified.
+    ///
+    /// Call when `fd()` is readable, or periodically if `needs_rewatch()`.
+    pub fn check(&mut self) -> bool {
+        if self.file.is_none() {
+            // File was deleted; try to re-establish watch.
+            return self.try_watch();
+        }
+
+        // Drain kqueue events.
+        let mut events: [Kevent; 4] = unsafe { std::mem::zeroed() };
+        let ts = Timespec {
+            tv_sec: 0,
+            tv_nsec: 0,
+        };
+        let n = unsafe {
+            kevent(
+                self.watch_fd,
+                std::ptr::null(),
+                0,
+                events.as_mut_ptr(),
+                4,
+                &ts,
+            )
+        };
+        if n <= 0 {
+            return false;
+        }
+
+        // Drop old file fd and re-establish watch for subsequent changes.
+        self.file = None;
+        self.try_watch();
+        true
+    }
+}
+
+#[cfg(target_os = "linux")]
+impl ConfigWatcher {
+    /// Create a new config watcher for the given path.
+    pub fn new(path: &str) -> Result<Self, std::io::Error> {
+        let fd = unsafe { inotify_init1(IN_NONBLOCK) };
+        if fd < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        let mut watcher = ConfigWatcher {
+            watch_fd: fd,
+            wd: -1,
+            path: path.to_string(),
+        };
+        watcher.try_watch();
+        Ok(watcher)
+    }
+
+    /// Try to add an inotify watch on the config file.
+    fn try_watch(&mut self) -> bool {
+        let cpath = match std::ffi::CString::new(self.path.as_str()) {
+            Ok(c) => c,
+            Err(_) => return false,
+        };
+        let wd = unsafe {
+            inotify_add_watch(
+                self.watch_fd,
+                cpath.as_ptr(),
+                IN_CLOSE_WRITE | IN_DELETE_SELF | IN_MOVE_SELF,
+            )
+        };
+        if wd < 0 {
+            return false;
+        }
+        self.wd = wd;
+        true
+    }
+
+    /// File descriptor for poll().
+    pub fn fd(&self) -> RawFd {
+        self.watch_fd
+    }
+
+    /// Returns true if the watch is broken and needs periodic retry.
+    pub fn needs_rewatch(&self) -> bool {
+        self.wd < 0
+    }
+
+    /// Check for file changes. Returns true if the config was modified.
+    ///
+    /// Call when `fd()` is readable, or periodically if `needs_rewatch()`.
+    pub fn check(&mut self) -> bool {
+        if self.wd < 0 {
+            return self.try_watch();
+        }
+
+        // Drain inotify events.
+        let mut buf = [0u8; 256];
+        let n = unsafe { read(self.watch_fd, buf.as_mut_ptr(), buf.len()) };
+        if n <= 0 {
+            return false;
+        }
+
+        // Check if file was deleted/moved (watch auto-removed by kernel).
+        let mut offset = 0;
+        while offset + 16 <= n as usize {
+            let mask = u32::from_ne_bytes([
+                buf[offset + 4],
+                buf[offset + 5],
+                buf[offset + 6],
+                buf[offset + 7],
+            ]);
+            let name_len = u32::from_ne_bytes([
+                buf[offset + 12],
+                buf[offset + 13],
+                buf[offset + 14],
+                buf[offset + 15],
+            ]) as usize;
+            if mask & (IN_DELETE_SELF | IN_MOVE_SELF) != 0 {
+                self.wd = -1;
+                self.try_watch();
+            }
+            offset += 16 + name_len;
+        }
+
+        true
+    }
+}
+
+impl Drop for ConfigWatcher {
+    fn drop(&mut self) {
+        unsafe {
+            close(self.watch_fd);
+        }
+    }
+}
+
+/// Apply OpenBSD security restrictions after initialization.
+///
+/// Must be called after X connection, IPC socket binding, and config loading
+/// are complete.
+pub fn sandbox(socket_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
+    // Reveal only the paths the WM needs at runtime.
+    if let Ok(home) = std::env::var("HOME") {
+        let owmrc = format!("{}/.owmrc", home);
+        unveil(Some(&owmrc), Some("r"))?;
+    }
+    unveil(Some(socket_dir), Some("rwc"))?;
+    // /usr/X11R6/lib is needed by libxcb for auth/transport.
+    unveil(Some("/usr/X11R6/lib"), Some("r"))?;
+    // /usr/local/lib may be needed for shared libraries.
+    unveil(Some("/usr/local/lib"), Some("r"))?;
+    // /usr/lib for libc and other base libraries.
+    unveil(Some("/usr/lib"), Some("r"))?;
+    // /dev is needed by status commands reading hardware sensors.
+    unveil(Some("/dev"), Some("r"))?;
+    // Allow executing programs (terminal, user commands).
+    unveil(Some("/usr/local/bin"), Some("rx"))?;
+    unveil(Some("/usr/X11R6/bin"), Some("rx"))?;
+    unveil(Some("/usr/bin"), Some("rx"))?;
+    unveil(Some("/bin"), Some("rx"))?;
+    // Lock unveil — no further unveil calls allowed.
+    unveil(None, None)?;
+
+    // Restrict syscalls.
+    // stdio: basic I/O (read/write/close/poll)
+    // rpath: read files (config reload)
+    // wpath: write files (IPC socket)
+    // cpath: create files (socket dir)
+    // unix: Unix domain sockets (X11 + IPC)
+    // proc: fork (spawning commands)
+    // exec: exec (spawning commands)
+    pledge("stdio rpath wpath cpath unix proc exec", None)?;
+
+    crate::log_info!("sandbox: pledge and unveil applied");
+    Ok(())
+}
+
+/// Replace the current process with the given executable and arguments.
+/// Does not return on success.
+pub fn exec_replace(
+    exe: &std::ffi::CStr,
+    args: &[std::ffi::CString],
+) {
+    let ptrs: Vec<*const std::ffi::c_char> =
+        args.iter().map(|a| a.as_ptr()).chain(std::iter::once(std::ptr::null())).collect();
+    unsafe {
+        execvp(exe.as_ptr(), ptrs.as_ptr());
+    }
+}
blob - /dev/null
blob + 9324e1fd2c71aeb60bba384d94604a4b3d21a0b5 (mode 644)
--- /dev/null
+++ src/xcb_conn.rs
@@ -0,0 +1,1176 @@
+use std::os::raw::c_ulong;
+
+use xcb::x;
+use xcb::{Connection, Xid};
+
+use crate::container::{Rect, SizeHints};
+use crate::xft_font::XftFont;
+
+/// Events we register on the root window.
+const ROOT_EVENT_MASK: x::EventMask = x::EventMask::from_bits_truncate(
+    x::EventMask::SUBSTRUCTURE_REDIRECT.bits()
+        | x::EventMask::SUBSTRUCTURE_NOTIFY.bits()
+        | x::EventMask::STRUCTURE_NOTIFY.bits()
+        | x::EventMask::PROPERTY_CHANGE.bits()
+        | x::EventMask::BUTTON_PRESS.bits()
+        | x::EventMask::FOCUS_CHANGE.bits()
+        | x::EventMask::ENTER_WINDOW.bits(),
+);
+
+/// Events we register on client windows.
+pub const CLIENT_EVENT_MASK: x::EventMask = x::EventMask::from_bits_truncate(
+    x::EventMask::ENTER_WINDOW.bits()
+        | x::EventMask::PROPERTY_CHANGE.bits()
+        | x::EventMask::STRUCTURE_NOTIFY.bits()
+        | x::EventMask::FOCUS_CHANGE.bits(),
+);
+
+/// Timeout in seconds before force-killing a window after WM_DELETE_WINDOW.
+pub const CLOSE_TIMEOUT_SECS: u64 = 2;
+
+/// Intern an atom on a connection, returning an explicit error on failure.
+fn intern_atom_on(
+    conn: &Connection,
+    name: &str,
+) -> Result<x::Atom, Box<dyn std::error::Error>> {
+    let cookie = conn.send_request(&x::InternAtom {
+        only_if_exists: false,
+        name: name.as_bytes(),
+    });
+    conn.wait_for_reply(cookie)
+        .map(|r| r.atom())
+        .map_err(|e| format!("failed to intern atom '{}': {}", name, e).into())
+}
+
+/// Cached X11 atoms for ICCCM and EWMH protocols.
+pub struct Atoms {
+    // ICCCM
+    pub wm_name: x::Atom,
+    pub wm_class: x::Atom,
+    pub wm_protocols: x::Atom,
+    pub wm_delete_window: x::Atom,
+    pub wm_hints: x::Atom,
+    pub wm_normal_hints: x::Atom,
+    // EWMH
+    pub net_supported: x::Atom,
+    pub net_supporting_wm_check: x::Atom,
+    pub net_wm_name: x::Atom,
+    pub net_current_desktop: x::Atom,
+    pub net_number_of_desktops: x::Atom,
+    pub net_workarea: x::Atom,
+    pub net_active_window: x::Atom,
+    pub net_wm_state: x::Atom,
+    pub net_wm_state_fullscreen: x::Atom,
+    pub net_wm_state_hidden: x::Atom,
+    pub net_client_list: x::Atom,
+    pub net_client_list_stacking: x::Atom,
+    pub net_wm_window_type: x::Atom,
+    pub net_wm_window_type_normal: x::Atom,
+    pub net_wm_window_type_dialog: x::Atom,
+    pub net_wm_window_type_splash: x::Atom,
+    pub net_wm_window_type_dock: x::Atom,
+    pub net_wm_window_type_utility: x::Atom,
+    pub net_wm_strut_partial: x::Atom,
+    pub utf8_string: x::Atom,
+}
+
+impl Atoms {
+    fn intern(conn: &Connection) -> Result<Self, Box<dyn std::error::Error>> {
+        Ok(Atoms {
+            wm_name: intern_atom_on(conn, "WM_NAME")?,
+            wm_class: intern_atom_on(conn, "WM_CLASS")?,
+            wm_protocols: intern_atom_on(conn, "WM_PROTOCOLS")?,
+            wm_delete_window: intern_atom_on(conn, "WM_DELETE_WINDOW")?,
+            wm_hints: intern_atom_on(conn, "WM_HINTS")?,
+            wm_normal_hints: intern_atom_on(conn, "WM_NORMAL_HINTS")?,
+            net_supported: intern_atom_on(conn, "_NET_SUPPORTED")?,
+            net_supporting_wm_check: intern_atom_on(
+                conn,
+                "_NET_SUPPORTING_WM_CHECK",
+            )?,
+            net_wm_name: intern_atom_on(conn, "_NET_WM_NAME")?,
+            net_current_desktop: intern_atom_on(conn, "_NET_CURRENT_DESKTOP")?,
+            net_number_of_desktops: intern_atom_on(
+                conn,
+                "_NET_NUMBER_OF_DESKTOPS",
+            )?,
+            net_workarea: intern_atom_on(conn, "_NET_WORKAREA")?,
+            net_active_window: intern_atom_on(conn, "_NET_ACTIVE_WINDOW")?,
+            net_wm_state: intern_atom_on(conn, "_NET_WM_STATE")?,
+            net_wm_state_fullscreen: intern_atom_on(
+                conn,
+                "_NET_WM_STATE_FULLSCREEN",
+            )?,
+            net_wm_state_hidden: intern_atom_on(conn, "_NET_WM_STATE_HIDDEN")?,
+            net_client_list: intern_atom_on(conn, "_NET_CLIENT_LIST")?,
+            net_client_list_stacking: intern_atom_on(
+                conn,
+                "_NET_CLIENT_LIST_STACKING",
+            )?,
+            net_wm_window_type: intern_atom_on(conn, "_NET_WM_WINDOW_TYPE")?,
+            net_wm_window_type_normal: intern_atom_on(
+                conn,
+                "_NET_WM_WINDOW_TYPE_NORMAL",
+            )?,
+            net_wm_window_type_dialog: intern_atom_on(
+                conn,
+                "_NET_WM_WINDOW_TYPE_DIALOG",
+            )?,
+            net_wm_window_type_splash: intern_atom_on(
+                conn,
+                "_NET_WM_WINDOW_TYPE_SPLASH",
+            )?,
+            net_wm_window_type_dock: intern_atom_on(
+                conn,
+                "_NET_WM_WINDOW_TYPE_DOCK",
+            )?,
+            net_wm_window_type_utility: intern_atom_on(
+                conn,
+                "_NET_WM_WINDOW_TYPE_UTILITY",
+            )?,
+            net_wm_strut_partial: intern_atom_on(
+                conn,
+                "_NET_WM_STRUT_PARTIAL",
+            )?,
+            utf8_string: intern_atom_on(conn, "UTF8_STRING")?,
+        })
+    }
+
+    /// List of EWMH atoms we support, as u32 resource IDs.
+    fn supported_list(&self) -> Vec<u32> {
+        [
+            self.net_supported,
+            self.net_supporting_wm_check,
+            self.net_wm_name,
+            self.net_current_desktop,
+            self.net_number_of_desktops,
+            self.net_workarea,
+            self.net_active_window,
+            self.net_wm_state,
+            self.net_wm_state_fullscreen,
+            self.net_wm_state_hidden,
+            self.net_client_list,
+            self.net_client_list_stacking,
+            self.net_wm_window_type,
+            self.net_wm_window_type_normal,
+            self.net_wm_window_type_dialog,
+            self.net_wm_window_type_splash,
+            self.net_wm_window_type_dock,
+            self.net_wm_window_type_utility,
+            self.net_wm_strut_partial,
+        ]
+        .iter()
+        .map(|a| a.resource_id())
+        .collect()
+    }
+}
+
+/// Window type from _NET_WM_WINDOW_TYPE.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum WindowType {
+    Normal,
+    Dialog,
+    Splash,
+    Dock,
+    Utility,
+}
+
+/// Decoration colors for a single state (focused, unfocused, urgent).
+#[derive(Debug, Clone, Copy)]
+pub struct DecoColors {
+    pub border: u32,
+    pub background: u32,
+    pub text: u32,
+}
+
+/// Parameters for drawing window decoration.
+pub struct Decoration<'a> {
+    pub frame: x::Window,
+    pub frame_w: u32,
+    pub frame_h: u32,
+    pub title: &'a str,
+    pub colors: &'a DecoColors,
+    pub border_width: u32,
+    pub deco_height: u32,
+}
+
+/// Wrapper around the XCB connection.
+pub struct XConn {
+    /// Xft font for anti-aliased text rendering.
+    /// Must be declared before `conn` so it is dropped first —
+    /// XftFontClose needs the Display* that conn owns.
+    pub xft: XftFont,
+    pub conn: Connection,
+    pub root: x::Window,
+    pub root_visual: x::Visualid,
+    pub screen_rect: Rect,
+    pub atoms: Atoms,
+    check_win: x::Window,
+    /// Graphics context for decoration drawing (rectangles only).
+    gc: x::Gcontext,
+    /// Font height in pixels (ascent + descent).
+    pub font_height: u32,
+    /// Font ascent in pixels (baseline offset from top).
+    font_ascent: u32,
+    /// Title bar (decoration) height in pixels.
+    pub deco_height: u32,
+}
+
+impl XConn {
+    /// Connect to the X server and claim the root window.
+    pub fn connect(
+        font_name: &str,
+    ) -> Result<Self, Box<dyn std::error::Error>> {
+        // Pure XCB connection for window management.
+        let (conn, screen_num) = Connection::connect(None)?;
+
+        let setup = conn.get_setup();
+        let screen =
+            setup.roots().nth(screen_num as usize).ok_or("no screen")?;
+
+        let root = screen.root();
+        let root_visual = screen.root_visual();
+        let screen_rect = Rect {
+            x: 0,
+            y: 0,
+            w: screen.width_in_pixels() as u32,
+            h: screen.height_in_pixels() as u32,
+        };
+
+        // Attempt to become the window manager by selecting SubstructureRedirect.
+        let cookie = conn.send_request_checked(&x::ChangeWindowAttributes {
+            window: root,
+            value_list: &[x::Cw::EventMask(ROOT_EVENT_MASK)],
+        });
+
+        conn.check_request(cookie)
+            .map_err(|_| "another window manager is already running")?;
+
+        // Intern all atoms up front, propagating errors explicitly.
+        let atoms = Atoms::intern(&conn)?;
+
+        // Open a separate Xlib Display for Xft text rendering.
+        let dpy = unsafe { x11::xlib::XOpenDisplay(std::ptr::null()) };
+        if dpy.is_null() {
+            return Err("failed to open Xlib display for Xft".into());
+        }
+        let xft = unsafe { XftFont::open(dpy, screen_num, font_name) }
+            .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
+        let font_height = xft.height;
+        let font_ascent = xft.ascent;
+        // deco_height = font height + 4px padding (2 top + 2 bottom), like i3
+        let deco_height = font_height + 4;
+
+        // Create a graphics context for decoration drawing (rectangles only).
+        let gc: x::Gcontext = conn.generate_id();
+        conn.send_request(&x::CreateGc {
+            cid: gc,
+            drawable: x::Drawable::Window(root),
+            value_list: &[x::Gc::GraphicsExposures(false)],
+        });
+
+        // Create a small check window for _NET_SUPPORTING_WM_CHECK.
+        let check_win: x::Window = conn.generate_id();
+        conn.send_request(&x::CreateWindow {
+            depth: 0,
+            wid: check_win,
+            parent: root,
+            x: -1,
+            y: -1,
+            width: 1,
+            height: 1,
+            border_width: 0,
+            class: x::WindowClass::InputOutput,
+            visual: root_visual,
+            value_list: &[x::Cw::OverrideRedirect(true)],
+        });
+
+        conn.flush()?;
+
+        let xconn = XConn {
+            conn,
+            root,
+            root_visual,
+            screen_rect,
+            atoms,
+            check_win,
+            gc,
+            xft,
+            font_height,
+            font_ascent,
+            deco_height,
+        };
+
+        xconn.setup_ewmh();
+
+        crate::log_info!(
+            "connected to X server, screen {} ({}x{}), font '{}' height {}px",
+            screen_num,
+            screen_rect.w,
+            screen_rect.h,
+            font_name,
+            font_height
+        );
+
+        Ok(xconn)
+    }
+
+    /// Set up EWMH properties on the root and check windows.
+    fn setup_ewmh(&self) {
+        let a = &self.atoms;
+
+        // _NET_SUPPORTING_WM_CHECK on root -> check_win
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: a.net_supporting_wm_check,
+            r#type: x::ATOM_WINDOW,
+            data: &[self.check_win.resource_id()],
+        });
+
+        // _NET_SUPPORTING_WM_CHECK on check_win -> check_win (self-reference)
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.check_win,
+            property: a.net_supporting_wm_check,
+            r#type: x::ATOM_WINDOW,
+            data: &[self.check_win.resource_id()],
+        });
+
+        // _NET_WM_NAME on check window
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.check_win,
+            property: a.net_wm_name,
+            r#type: a.utf8_string,
+            data: b"owm",
+        });
+
+        // _NET_SUPPORTED
+        let supported = a.supported_list();
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: a.net_supported,
+            r#type: x::ATOM_ATOM,
+            data: &supported,
+        });
+
+        // _NET_NUMBER_OF_DESKTOPS (initially 1)
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: a.net_number_of_desktops,
+            r#type: x::ATOM_CARDINAL,
+            data: &[1u32],
+        });
+
+        // _NET_CURRENT_DESKTOP (initially 0)
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: a.net_current_desktop,
+            r#type: x::ATOM_CARDINAL,
+            data: &[0u32],
+        });
+
+        // _NET_WORKAREA (one entry per desktop)
+        self.set_workarea(1, &self.screen_rect);
+
+        // Empty client lists
+        self.set_client_list(&[]);
+        self.set_client_list_stacking(&[]);
+
+        // _NET_ACTIVE_WINDOW (none initially)
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: a.net_active_window,
+            r#type: x::ATOM_WINDOW,
+            data: &[0u32],
+        });
+    }
+
+    /// Get the file descriptor for polling.
+    pub fn fd(&self) -> std::os::unix::io::RawFd {
+        self.conn.as_raw_fd()
+    }
+
+    /// Configure a window's geometry.
+    pub fn configure_window(
+        &self,
+        win: x::Window,
+        rect: &Rect,
+        border_width: u32,
+    ) {
+        self.conn.send_request(&x::ConfigureWindow {
+            window: win,
+            value_list: &[
+                x::ConfigWindow::X(rect.x),
+                x::ConfigWindow::Y(rect.y),
+                x::ConfigWindow::Width(rect.w),
+                x::ConfigWindow::Height(rect.h),
+                x::ConfigWindow::BorderWidth(border_width),
+            ],
+        });
+    }
+
+    /// Map a window.
+    pub fn map_window(&self, win: x::Window) {
+        self.conn.send_request(&x::MapWindow { window: win });
+    }
+
+    /// Unmap a window.
+    pub fn unmap_window(&self, win: x::Window) {
+        self.conn.send_request(&x::UnmapWindow { window: win });
+    }
+
+    /// Set input focus on a window.
+    pub fn set_focus(&self, win: x::Window) {
+        self.conn.send_request(&x::SetInputFocus {
+            revert_to: x::InputFocus::PointerRoot,
+            focus: win,
+            time: x::CURRENT_TIME,
+        });
+    }
+
+    /// Set border color on a window.
+    pub fn set_border_color(&self, win: x::Window, color: u32) {
+        self.conn.send_request(&x::ChangeWindowAttributes {
+            window: win,
+            value_list: &[x::Cw::BorderPixel(color)],
+        });
+    }
+
+    /// Try to close a window via WM_DELETE_WINDOW protocol.
+    /// Returns `true` if the delete message was sent, `false` if not supported
+    /// (caller should force-kill instead).
+    pub fn send_delete_window(&self, win: x::Window) -> bool {
+        if self.window_supports_protocol(
+            win,
+            self.atoms.wm_protocols,
+            self.atoms.wm_delete_window,
+        ) {
+            let data = x::ClientMessageData::Data32([
+                self.atoms.wm_delete_window.resource_id(),
+                0,
+                0,
+                0,
+                0,
+            ]);
+            self.conn.send_request(&x::SendEvent {
+                propagate: false,
+                destination: x::SendEventDest::Window(win),
+                event_mask: x::EventMask::NO_EVENT,
+                event: &x::ClientMessageEvent::new(
+                    win,
+                    self.atoms.wm_protocols,
+                    data,
+                ),
+            });
+            true
+        } else {
+            false
+        }
+    }
+
+    /// Force-kill a client window via XKillClient.
+    pub fn kill_window(&self, win: x::Window) {
+        self.conn.send_request(&x::KillClient {
+            resource: win.resource_id(),
+        });
+    }
+
+    /// Check if a window supports a given protocol atom.
+    fn window_supports_protocol(
+        &self,
+        win: x::Window,
+        wm_protocols: x::Atom,
+        protocol: x::Atom,
+    ) -> bool {
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: false,
+            window: win,
+            property: wm_protocols,
+            r#type: x::ATOM_ATOM,
+            long_offset: 0,
+            long_length: 64,
+        });
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            let atoms: &[x::Atom] = reply.value();
+            return atoms.contains(&protocol);
+        }
+        false
+    }
+
+    /// Check if a window has override-redirect set.
+    pub fn is_override_redirect(&self, win: x::Window) -> bool {
+        let cookie = self.conn.send_request(&x::GetWindowAttributes {
+            window: win,
+        });
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            return reply.override_redirect();
+        }
+        false
+    }
+
+    /// Check if a window has the urgency hint set in WM_HINTS.
+    pub fn window_is_urgent(&self, win: x::Window) -> bool {
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: false,
+            window: win,
+            property: self.atoms.wm_hints,
+            r#type: x::ATOM_ANY,
+            long_offset: 0,
+            long_length: 9,
+        });
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            let data: &[u32] = reply.value();
+            // WM_HINTS flags field is data[0], UrgencyHint is bit 8 (0x100)
+            if !data.is_empty() {
+                return data[0] & 0x100 != 0;
+            }
+        }
+        false
+    }
+
+    /// Read WM_NORMAL_HINTS (ICCCM size hints) for a window.
+    pub fn get_size_hints(&self, win: x::Window) -> SizeHints {
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: false,
+            window: win,
+            property: self.atoms.wm_normal_hints,
+            r#type: x::ATOM_ANY,
+            long_offset: 0,
+            long_length: 18, // WM_SIZE_HINTS has 18 u32 fields
+        });
+        let mut hints = SizeHints::default();
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            let data: &[u32] = reply.value();
+            if data.len() < 18 {
+                return hints;
+            }
+            let flags = data[0];
+            // PMinSize = 1 << 4
+            if flags & (1 << 4) != 0 {
+                hints.min_w = Some(data[5]);
+                hints.min_h = Some(data[6]);
+            }
+            // PMaxSize = 1 << 5
+            if flags & (1 << 5) != 0 {
+                hints.max_w = Some(data[7]);
+                hints.max_h = Some(data[8]);
+            }
+            // PResizeInc = 1 << 6
+            if flags & (1 << 6) != 0 {
+                hints.inc_w = Some(data[9]);
+                hints.inc_h = Some(data[10]);
+            }
+            // PBaseSize = 1 << 8
+            if flags & (1 << 8) != 0 {
+                hints.base_w = Some(data[15]);
+                hints.base_h = Some(data[16]);
+            }
+        }
+        hints
+    }
+
+    /// Raise a window to the top of the stacking order.
+    pub fn raise_window(&self, win: x::Window) {
+        self.conn.send_request(&x::ConfigureWindow {
+            window: win,
+            value_list: &[x::ConfigWindow::StackMode(x::StackMode::Above)],
+        });
+    }
+
+    /// Grab mouse buttons on the root window for floating window move/resize.
+    pub fn grab_buttons(&self, modifiers: x::ModMask) {
+        // Mod+Button1 = move
+        self.conn.send_request(&x::GrabButton {
+            owner_events: true,
+            grab_window: self.root,
+            event_mask: x::EventMask::BUTTON_PRESS
+                | x::EventMask::BUTTON_RELEASE
+                | x::EventMask::POINTER_MOTION,
+            pointer_mode: x::GrabMode::Async,
+            keyboard_mode: x::GrabMode::Async,
+            confine_to: x::WINDOW_NONE,
+            cursor: x::CURSOR_NONE,
+            button: x::ButtonIndex::N1,
+            modifiers,
+        });
+        // Mod+Button3 = resize
+        self.conn.send_request(&x::GrabButton {
+            owner_events: true,
+            grab_window: self.root,
+            event_mask: x::EventMask::BUTTON_PRESS
+                | x::EventMask::BUTTON_RELEASE
+                | x::EventMask::POINTER_MOTION,
+            pointer_mode: x::GrabMode::Async,
+            keyboard_mode: x::GrabMode::Async,
+            confine_to: x::WINDOW_NONE,
+            cursor: x::CURSOR_NONE,
+            button: x::ButtonIndex::N3,
+            modifiers,
+        });
+    }
+
+    /// Set a solid background color on the root window.
+    pub fn set_background(&self, color: u32) {
+        self.conn.send_request(&x::ChangeWindowAttributes {
+            window: self.root,
+            value_list: &[x::Cw::BackPixel(color)],
+        });
+        self.conn.send_request(&x::ClearArea {
+            exposures: false,
+            window: self.root,
+            x: 0,
+            y: 0,
+            width: 0,
+            height: 0,
+        });
+    }
+
+    // --- EWMH property setters ---
+
+    /// Set _NET_CURRENT_DESKTOP on the root window (0-indexed).
+    pub fn set_current_desktop(&self, desktop: u32) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: self.atoms.net_current_desktop,
+            r#type: x::ATOM_CARDINAL,
+            data: &[desktop],
+        });
+    }
+
+    /// Set _NET_NUMBER_OF_DESKTOPS on the root window.
+    pub fn set_number_of_desktops(&self, count: u32) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: self.atoms.net_number_of_desktops,
+            r#type: x::ATOM_CARDINAL,
+            data: &[count],
+        });
+    }
+
+    /// Set _NET_WORKAREA on the root window (one rect per desktop).
+    pub fn set_workarea(&self, num_desktops: u32, workarea: &Rect) {
+        let mut data = Vec::with_capacity(num_desktops as usize * 4);
+        for _ in 0..num_desktops {
+            data.push(workarea.x as u32);
+            data.push(workarea.y as u32);
+            data.push(workarea.w);
+            data.push(workarea.h);
+        }
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: self.atoms.net_workarea,
+            r#type: x::ATOM_CARDINAL,
+            data: &data,
+        });
+    }
+
+    /// Set _NET_WM_STRUT_PARTIAL on a window (reserve screen edge space).
+    /// Format: [left, right, top, bottom, left_start_y, left_end_y,
+    ///          right_start_y, right_end_y, top_start_x, top_end_x,
+    ///          bottom_start_x, bottom_end_x]
+    pub fn set_strut_partial(&self, win: x::Window, strut: &[u32; 12]) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: win,
+            property: self.atoms.net_wm_strut_partial,
+            r#type: x::ATOM_CARDINAL,
+            data: strut,
+        });
+    }
+
+    /// Set _NET_WM_WINDOW_TYPE to DOCK on a window.
+    pub fn set_window_type_dock(&self, win: x::Window) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: win,
+            property: self.atoms.net_wm_window_type,
+            r#type: x::ATOM_ATOM,
+            data: &[self.atoms.net_wm_window_type_dock.resource_id()],
+        });
+    }
+
+    /// Set _NET_ACTIVE_WINDOW on the root window.
+    pub fn set_active_window(&self, win: x::Window) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: self.atoms.net_active_window,
+            r#type: x::ATOM_WINDOW,
+            data: &[win.resource_id()],
+        });
+    }
+
+    /// Clear _NET_ACTIVE_WINDOW (no window focused).
+    pub fn clear_active_window(&self) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: self.atoms.net_active_window,
+            r#type: x::ATOM_WINDOW,
+            data: &[0u32],
+        });
+    }
+
+    /// Set _NET_CLIENT_LIST on the root window (mapping order).
+    pub fn set_client_list(&self, windows: &[u32]) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: self.atoms.net_client_list,
+            r#type: x::ATOM_WINDOW,
+            data: windows,
+        });
+    }
+
+    /// Set _NET_CLIENT_LIST_STACKING on the root window (bottom-to-top).
+    pub fn set_client_list_stacking(&self, windows: &[u32]) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.root,
+            property: self.atoms.net_client_list_stacking,
+            r#type: x::ATOM_WINDOW,
+            data: windows,
+        });
+    }
+
+    /// Set _NET_WM_STATE on a client window.
+    pub fn set_wm_state(&self, win: x::Window, states: &[u32]) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: win,
+            property: self.atoms.net_wm_state,
+            r#type: x::ATOM_ATOM,
+            data: states,
+        });
+    }
+
+    /// Read _NET_WM_WINDOW_TYPE to detect dialogs, splash screens, docks, etc.
+    pub fn get_window_type(&self, win: x::Window) -> WindowType {
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: false,
+            window: win,
+            property: self.atoms.net_wm_window_type,
+            r#type: x::ATOM_ATOM,
+            long_offset: 0,
+            long_length: 64,
+        });
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            let types: &[u32] = reply.value();
+            let a = &self.atoms;
+            for &t in types {
+                if t == a.net_wm_window_type_dialog.resource_id() {
+                    return WindowType::Dialog;
+                }
+                if t == a.net_wm_window_type_splash.resource_id() {
+                    return WindowType::Splash;
+                }
+                if t == a.net_wm_window_type_dock.resource_id() {
+                    return WindowType::Dock;
+                }
+                if t == a.net_wm_window_type_utility.resource_id() {
+                    return WindowType::Utility;
+                }
+            }
+        }
+        WindowType::Normal
+    }
+
+    // --- Reparenting and decoration ---
+
+    /// Create a frame window for reparenting a client.
+    /// The frame is OverrideRedirect so the WM doesn't try to manage it.
+    pub fn create_frame(&self, rect: &Rect) -> x::Window {
+        let frame: x::Window = self.conn.generate_id();
+        self.conn.send_request(&x::CreateWindow {
+            depth: 0,
+            wid: frame,
+            parent: self.root,
+            x: rect.x as i16,
+            y: rect.y as i16,
+            width: rect.w.max(1) as u16,
+            height: rect.h.max(1) as u16,
+            border_width: 0,
+            class: x::WindowClass::InputOutput,
+            visual: self.root_visual,
+            value_list: &[
+                x::Cw::OverrideRedirect(true),
+                x::Cw::EventMask(
+                    x::EventMask::SUBSTRUCTURE_REDIRECT
+                        | x::EventMask::SUBSTRUCTURE_NOTIFY
+                        | x::EventMask::BUTTON_PRESS
+                        | x::EventMask::ENTER_WINDOW
+                        | x::EventMask::EXPOSURE,
+                ),
+            ],
+        });
+        frame
+    }
+
+    /// Reparent a client window into a frame window.
+    pub fn reparent_window(
+        &self,
+        client: x::Window,
+        frame: x::Window,
+        client_x: i32,
+        client_y: i32,
+    ) {
+        // Temporarily suppress events during reparent
+        self.conn.send_request(&x::ChangeWindowAttributes {
+            window: client,
+            value_list: &[x::Cw::EventMask(x::EventMask::NO_EVENT)],
+        });
+
+        self.conn.send_request(&x::ReparentWindow {
+            window: client,
+            parent: frame,
+            x: client_x as i16,
+            y: client_y as i16,
+        });
+
+        // Re-enable client events
+        self.conn.send_request(&x::ChangeWindowAttributes {
+            window: client,
+            value_list: &[x::Cw::EventMask(CLIENT_EVENT_MASK)],
+        });
+
+        // Add to save set: if the WM crashes, the client is reparented
+        // back to root automatically by the X server.
+        self.conn.send_request(&x::ChangeSaveSet {
+            mode: x::SetMode::Insert,
+            window: client,
+        });
+    }
+
+    /// Destroy a frame window.
+    pub fn destroy_frame(&self, frame: x::Window) {
+        self.conn
+            .send_request(&x::DestroyWindow { window: frame });
+    }
+
+    /// Configure a frame window's geometry (no X border).
+    pub fn configure_frame(&self, frame: x::Window, rect: &Rect) {
+        self.conn.send_request(&x::ConfigureWindow {
+            window: frame,
+            value_list: &[
+                x::ConfigWindow::X(rect.x),
+                x::ConfigWindow::Y(rect.y),
+                x::ConfigWindow::Width(rect.w.max(1)),
+                x::ConfigWindow::Height(rect.h.max(1)),
+            ],
+        });
+    }
+
+    /// Position the client window inside its frame.
+    pub fn configure_client_in_frame(
+        &self,
+        client: x::Window,
+        x: i32,
+        y: i32,
+        w: u32,
+        h: u32,
+    ) {
+        self.conn.send_request(&x::ConfigureWindow {
+            window: client,
+            value_list: &[
+                x::ConfigWindow::X(x),
+                x::ConfigWindow::Y(y),
+                x::ConfigWindow::Width(w.max(1)),
+                x::ConfigWindow::Height(h.max(1)),
+                x::ConfigWindow::BorderWidth(0),
+            ],
+        });
+    }
+
+    /// Draw decoration on a frame window: title bar background, border, and text.
+    pub fn draw_decoration(&self, deco: &Decoration<'_>) {
+        let frame = deco.frame;
+        let frame_w = deco.frame_w;
+        let frame_h = deco.frame_h;
+        let title = deco.title;
+        let colors = deco.colors;
+        let border_width = deco.border_width;
+        let deco_height = deco.deco_height;
+        let drawable = x::Drawable::Window(frame);
+
+        // Fill entire frame with border color (acts as border around all sides)
+        self.conn.send_request(&x::ChangeGc {
+            gc: self.gc,
+            value_list: &[x::Gc::Foreground(colors.border)],
+        });
+        self.conn.send_request(&x::PolyFillRectangle {
+            drawable,
+            gc: self.gc,
+            rectangles: &[x::Rectangle {
+                x: 0,
+                y: 0,
+                width: frame_w as u16,
+                height: frame_h as u16,
+            }],
+        });
+
+        // Fill title bar background (inside border)
+        if deco_height > 0 {
+            self.conn.send_request(&x::ChangeGc {
+                gc: self.gc,
+                value_list: &[x::Gc::Foreground(colors.background)],
+            });
+            self.conn.send_request(&x::PolyFillRectangle {
+                drawable,
+                gc: self.gc,
+                rectangles: &[x::Rectangle {
+                    x: border_width as i16,
+                    y: border_width as i16,
+                    width: frame_w.saturating_sub(2 * border_width) as u16,
+                    height: deco_height
+                        .saturating_sub(border_width) as u16,
+                }],
+            });
+
+            // Draw title text.
+            // Flush XCB buffer before Xft draws — both share the same
+            // connection but maintain separate output buffers, so pending
+            // XCB fills must reach the server before Xft renders text.
+            if !title.is_empty() {
+                let _ = self.conn.flush();
+
+                let avail = frame_w.saturating_sub(2 * border_width + 8);
+                let text = self.xft.truncate_to_width(title, avail);
+                let text_x = (border_width + 4) as i32;
+                let text_y =
+                    (border_width + self.font_ascent + 2) as i32;
+
+                self.xft.draw_text(
+                    frame.resource_id() as c_ulong,
+                    text_x,
+                    text_y,
+                    text,
+                    colors.text,
+                );
+                self.xft.flush();
+            }
+        }
+    }
+
+    /// Draw a split direction indicator on the focused window's frame.
+    ///
+    /// Draws a thin colored line on the edge where the next window
+    /// would appear (right edge for SplitH, bottom edge for SplitV).
+    pub fn draw_indicator(
+        &self,
+        frame: x::Window,
+        frame_w: u32,
+        frame_h: u32,
+        split_vertical: bool,
+        color: u32,
+    ) {
+        let drawable = x::Drawable::Window(frame);
+        let thickness: u16 = 2;
+
+        self.conn.send_request(&x::ChangeGc {
+            gc: self.gc,
+            value_list: &[x::Gc::Foreground(color)],
+        });
+
+        let rect = if split_vertical {
+            // SplitV: indicator on the bottom edge (next window goes below)
+            x::Rectangle {
+                x: 0,
+                y: frame_h.saturating_sub(thickness as u32) as i16,
+                width: frame_w as u16,
+                height: thickness,
+            }
+        } else {
+            // SplitH: indicator on the right edge (next window goes right)
+            x::Rectangle {
+                x: frame_w.saturating_sub(thickness as u32) as i16,
+                y: 0,
+                width: thickness,
+                height: frame_h as u16,
+            }
+        };
+
+        self.conn.send_request(&x::PolyFillRectangle {
+            drawable,
+            gc: self.gc,
+            rectangles: &[rect],
+        });
+    }
+
+    /// Draw a single tab/title bar at an arbitrary position within a frame.
+    ///
+    /// Used by tabbed/stacked layouts where the parent frame draws all
+    /// children's title bars.  `tab_rect` is in frame-local coordinates.
+    /// Mimics i3's decoration style: border around each tab, background
+    /// fill, title text, and a 1px indicator line at the bottom for the
+    /// focused tab.
+    pub fn draw_tab(
+        &self,
+        frame: x::Window,
+        tab_rect: &Rect,
+        title: &str,
+        colors: &DecoColors,
+        focused: bool,
+    ) {
+        let drawable = x::Drawable::Window(frame);
+        let x = tab_rect.x as i16;
+        let y = tab_rect.y as i16;
+        let w = tab_rect.w as u16;
+        let h = tab_rect.h as u16;
+
+        // 1. Fill entire tab rect with border color (creates the border).
+        self.conn.send_request(&x::ChangeGc {
+            gc: self.gc,
+            value_list: &[x::Gc::Foreground(colors.border)],
+        });
+        self.conn.send_request(&x::PolyFillRectangle {
+            drawable,
+            gc: self.gc,
+            rectangles: &[x::Rectangle { x, y, width: w, height: h }],
+        });
+
+        // 2. Fill inner area with background (1px border on all sides).
+        if w > 2 && h > 2 {
+            let bg = colors.background;
+            self.conn.send_request(&x::ChangeGc {
+                gc: self.gc,
+                value_list: &[x::Gc::Foreground(bg)],
+            });
+            self.conn.send_request(&x::PolyFillRectangle {
+                drawable,
+                gc: self.gc,
+                rectangles: &[x::Rectangle {
+                    x: x + 1,
+                    y: y + 1,
+                    width: w - 2,
+                    height: h - 2,
+                }],
+            });
+        }
+
+        // 3. Focused tab: draw a 2px indicator line at the bottom
+        //    in the border color (like i3's focused indicator).
+        if focused && h > 3 {
+            self.conn.send_request(&x::ChangeGc {
+                gc: self.gc,
+                value_list: &[x::Gc::Foreground(colors.border)],
+            });
+            self.conn.send_request(&x::PolyFillRectangle {
+                drawable,
+                gc: self.gc,
+                rectangles: &[x::Rectangle {
+                    x: x + 1,
+                    y: y + h as i16 - 3,
+                    width: w - 2,
+                    height: 2,
+                }],
+            });
+        }
+
+        // 4. Title text (flush XCB before Xft)
+        if !title.is_empty() && tab_rect.w > 8 && tab_rect.h > 0 {
+            let _ = self.conn.flush();
+
+            let avail = tab_rect.w.saturating_sub(8);
+            let text = self.xft.truncate_to_width(title, avail);
+            let text_x = x as i32 + 4;
+            let text_y = y as i32 + self.font_ascent as i32 + 2;
+
+            self.xft.draw_text(
+                frame.resource_id() as c_ulong,
+                text_x,
+                text_y,
+                text,
+                colors.text,
+            );
+            self.xft.flush();
+        }
+    }
+
+    // --- Window properties ---
+
+    /// Read _NET_WM_NAME (UTF-8) or fall back to WM_NAME (Latin-1).
+    pub fn get_wm_name(&self, win: x::Window) -> String {
+        // Try _NET_WM_NAME first (UTF-8)
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: false,
+            window: win,
+            property: self.atoms.net_wm_name,
+            r#type: self.atoms.utf8_string,
+            long_offset: 0,
+            long_length: 256,
+        });
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            let data: &[u8] = reply.value();
+            if !data.is_empty() {
+                return String::from_utf8_lossy(data).into_owned();
+            }
+        }
+
+        // Fallback to WM_NAME (STRING encoding, typically Latin-1)
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: false,
+            window: win,
+            property: self.atoms.wm_name,
+            r#type: x::ATOM_STRING,
+            long_offset: 0,
+            long_length: 256,
+        });
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            let data: &[u8] = reply.value();
+            if !data.is_empty() {
+                return String::from_utf8_lossy(data).into_owned();
+            }
+        }
+
+        String::new()
+    }
+
+    /// Read WM_CLASS property, returning (instance, class).
+    pub fn get_wm_class(&self, win: x::Window) -> (String, String) {
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: false,
+            window: win,
+            property: self.atoms.wm_class,
+            r#type: x::ATOM_STRING,
+            long_offset: 0,
+            long_length: 256,
+        });
+        if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+            let data: &[u8] = reply.value();
+            // WM_CLASS is two null-terminated strings: instance\0class\0
+            let mut parts = data.splitn(3, |&b| b == 0);
+            let instance = parts
+                .next()
+                .map(|b| String::from_utf8_lossy(b).into_owned())
+                .unwrap_or_default();
+            let class = parts
+                .next()
+                .map(|b| String::from_utf8_lossy(b).into_owned())
+                .unwrap_or_default();
+            return (instance, class);
+        }
+        (String::new(), String::new())
+    }
+
+    /// Flush the connection.
+    pub fn flush(&self) {
+        let _ = self.conn.flush();
+    }
+}
+
+use std::os::unix::io::AsRawFd;
+
+impl AsRawFd for XConn {
+    fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
+        self.conn.as_raw_fd()
+    }
+}
blob - /dev/null
blob + 83cf8594fe46832f7563f404026fe3e35b68e124 (mode 644)
--- /dev/null
+++ src/xft_font.rs
@@ -0,0 +1,199 @@
+use std::ffi::CString;
+use std::os::raw::{c_int, c_uchar, c_ulong};
+
+use x11::xft;
+use x11::xlib;
+use x11::xrender;
+
+/// Xft font context for anti-aliased text rendering.
+///
+/// Wraps an Xft font handle and provides methods for measuring
+/// and drawing UTF-8 text on X11 drawables.
+pub struct XftFont {
+    dpy: *mut xlib::Display,
+    visual: *mut xlib::Visual,
+    colormap: c_ulong,
+    font: *mut xft::XftFont,
+    /// Font ascent in pixels (baseline offset from top).
+    pub ascent: u32,
+    /// Font height in pixels (ascent + descent).
+    pub height: u32,
+}
+
+impl XftFont {
+    /// Open a font by fontconfig pattern (e.g. "monospace:size=11").
+    ///
+    /// # Safety
+    /// `dpy` must be a valid Xlib Display pointer for the lifetime of this struct.
+    pub unsafe fn open(
+        dpy: *mut xlib::Display,
+        screen: c_int,
+        pattern: &str,
+    ) -> Result<Self, String> {
+        let visual = unsafe { xlib::XDefaultVisual(dpy, screen) };
+        let colormap = unsafe { xlib::XDefaultColormap(dpy, screen) };
+
+        let c_pattern = CString::new(pattern)
+            .map_err(|_| "font pattern contains null byte".to_string())?;
+
+        let font =
+            unsafe { xft::XftFontOpenName(dpy, screen, c_pattern.as_ptr()) };
+        if font.is_null() {
+            return Err(format!("failed to open font '{}'", pattern));
+        }
+
+        let ascent = unsafe { (*font).ascent as u32 };
+        let height =
+            unsafe { ((*font).ascent + (*font).descent) as u32 };
+
+        Ok(XftFont {
+            dpy,
+            visual,
+            colormap,
+            font,
+            ascent,
+            height,
+        })
+    }
+
+    /// Measure the pixel width of a UTF-8 string.
+    pub fn text_width(&self, text: &str) -> u32 {
+        if text.is_empty() {
+            return 0;
+        }
+        let mut extents = xrender::XGlyphInfo {
+            width: 0,
+            height: 0,
+            x: 0,
+            y: 0,
+            xOff: 0,
+            yOff: 0,
+        };
+        unsafe {
+            xft::XftTextExtentsUtf8(
+                self.dpy,
+                self.font,
+                text.as_ptr() as *const c_uchar,
+                text.len() as c_int,
+                &mut extents,
+            );
+        }
+        extents.xOff as u32
+    }
+
+    /// Draw UTF-8 text on a drawable at (x, y) with given foreground color.
+    ///
+    /// `y` is the baseline position. `fg` is a 0xRRGGBB color value.
+    /// The background is not filled — caller should fill the area first.
+    pub fn draw_text(
+        &self,
+        drawable: c_ulong,
+        x: i32,
+        y: i32,
+        text: &str,
+        fg: u32,
+    ) {
+        if text.is_empty() {
+            return;
+        }
+        unsafe {
+            let draw = xft::XftDrawCreate(
+                self.dpy,
+                drawable,
+                self.visual,
+                self.colormap,
+            );
+            if draw.is_null() {
+                return;
+            }
+
+            let color = make_xft_color(fg);
+
+            xft::XftDrawStringUtf8(
+                draw,
+                &color,
+                self.font,
+                x as c_int,
+                y as c_int,
+                text.as_ptr() as *const c_uchar,
+                text.len() as c_int,
+            );
+
+            xft::XftDrawDestroy(draw);
+        }
+    }
+
+    /// Flush the Xlib output buffer.
+    ///
+    /// Must be called after a batch of `draw_text` calls to ensure
+    /// the rendered text reaches the X server. In the Xlib-XCB hybrid,
+    /// XCB flush only drains the XCB buffer; Xft operations go through
+    /// Xlib's separate buffer.
+    pub fn flush(&self) {
+        unsafe {
+            xlib::XFlush(self.dpy);
+        }
+    }
+
+    /// Truncate a UTF-8 string to fit within `max_width` pixels.
+    ///
+    /// Returns the longest prefix (at char boundaries) that fits.
+    pub fn truncate_to_width<'a>(
+        &self,
+        text: &'a str,
+        max_width: u32,
+    ) -> &'a str {
+        if self.text_width(text) <= max_width {
+            return text;
+        }
+        // Binary search on char indices for efficiency.
+        let indices: Vec<usize> =
+            text.char_indices().map(|(i, _)| i).collect();
+        let mut lo = 0usize;
+        let mut hi = indices.len();
+        while lo < hi {
+            let mid = (lo + hi + 1) / 2;
+            let byte_pos = if mid < indices.len() {
+                indices[mid]
+            } else {
+                text.len()
+            };
+            if self.text_width(&text[..byte_pos]) <= max_width {
+                lo = mid;
+            } else {
+                hi = mid - 1;
+            }
+        }
+        let end = if lo < indices.len() {
+            indices[lo]
+        } else {
+            text.len()
+        };
+        &text[..end]
+    }
+}
+
+/// Build an XftColor directly from a 0xRRGGBB value without
+/// server round-trip. Works on TrueColor visuals (the common case).
+fn make_xft_color(rgb: u32) -> xft::XftColor {
+    let r = ((rgb >> 16) & 0xff) as u16;
+    let g = ((rgb >> 8) & 0xff) as u16;
+    let b = (rgb & 0xff) as u16;
+    xft::XftColor {
+        pixel: rgb as c_ulong,
+        color: xrender::XRenderColor {
+            red: r | (r << 8),
+            green: g | (g << 8),
+            blue: b | (b << 8),
+            alpha: 0xffff,
+        },
+    }
+}
+
+impl Drop for XftFont {
+    fn drop(&mut self) {
+        unsafe {
+            xft::XftFontClose(self.dpy, self.font);
+        }
+    }
+}