commit 24205f2bf2d214fb410a04d3e30287d625cf3864 from: murilo ijanc date: Fri Apr 3 02:12:49 2026 UTC import owm, an X11 tiling window manager written in Rust 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' + +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, + /// 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 { + 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 { + // 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, + state: ParseState, + header: Option, + pub blocks: Vec, + click_events: bool, +} + +impl BarChild { + /// Spawn a status command via the shell. + pub fn spawn(command: &str) -> Result { + 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 = 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 { + 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, + pub color: Option, + pub bg: Option, + pub border: Option, + pub min_width: Option, + pub align: Align, + pub name: Option, + pub instance: Option, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + 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, + color: Option, + bg: Option, + border: Option, + min_width: Option, + #[serde(default)] + align: Option, + name: Option, + instance: Option, + #[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 { + 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 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 { + 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, String> { + let raw: Vec = + 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, + filtered: Vec, // indices into entries + selected: usize, + scroll: usize, + running: bool, + result: Option, + menu_h: u16, +} + +impl Menu { + fn new() -> Result> { + // 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 { 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 = (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, Box> { + 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 = Vec::new(); + let mut substr: Vec = 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 { + 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 { + 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::::zeroed(); + let uvm_len = mem::size_of::(); + 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::::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 { + 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::::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] "); + 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 { + 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)> { + 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 = env::args().skip(1).collect(); + if args.is_empty() { + usage(); + } + + let mut socket_override: Option = 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, + /// Client window IDs in mapping order (_NET_CLIENT_LIST). + client_list: Vec, + /// 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, + /// Self-pipe for receiving SIGHUP/SIGTERM in the event loop. + signal_pipe: sys::SignalPipe, + /// Watches ~/.owmrc for modifications (kqueue/inotify). + config_watcher: Option, + /// Notification bar for config errors. + nagbar: Option, + /// Built-in status bar. + bar: Option, + /// 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), Box> { + 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> { + 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 = 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 { + 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, + 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 = std::env::args().collect(); + let cargs: Vec = 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 = 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 = 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 = 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 { + 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) { + // 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 ": restore a specific window + if let Some(wid_str) = parts.get(1) { + if let Ok(wid) = wid_str.parse::() + && let Some(idx) = self.tree.find_by_window(wid) + { + self.restore_window(idx); + } + } else { + // Find last minimized window on any workspace + let minimized: Option = 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 current|all set|plus|minus|toggle + 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 current|all set|plus|minus|toggle `. + fn handle_gaps_command(&mut self, args: &[&str]) { + if args.len() < 3 { + log_warn!("gaps: expected [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::().ok()).unwrap_or(0); + + // Collect workspace keys to modify. + let ws_keys: Vec = 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 &mut Option> = 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, + /// WM_CLASS instance to match (exact string match). + pub instance: Option, + /// Window title substring to match. + pub title: Option, + /// Border style to force on matching windows. + pub border_style: BorderStyle, + /// Border width override (only for Pixel style). + pub border_width: Option, +} + +/// 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, + pub top: Option, + pub right: Option, + pub bottom: Option, + pub left: Option, +} + +/// 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, + /// 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>, + /// Per-window rules (for_window). + pub window_rules: Vec, + /// 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, + /// Custom separator symbol between status blocks. + pub bar_separator_symbol: Option, + /// 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) { + let mut conf = Config::default(); + let mut errors: Vec = 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 ... end" block + let mut current_mode: Option = 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::() { + Ok(v) => conf.border_width = v, + Err(_) => { + config_warn(&mut errors, + format!("{}:{}: invalid borderwidth", + path.display(), n)); + } + }, + "gap" => match rest.parse::() { + 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 gaps + 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, msg: String) { + crate::log_warn!("{}", msg); + errors.push(msg); +} + +/// Parse "color " directive. +fn parse_color( + path: &Path, lineno: usize, rest: &str, conf: &mut Config, + errors: &mut Vec, +) { + 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 " directive. +fn parse_bar_color( + path: &Path, lineno: usize, rest: &str, conf: &mut Config, + errors: &mut Vec, +) { + 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 " directive. +/// Also handles "workspace gaps " when called from the +/// workspace-gaps path. +fn parse_gaps( + path: &Path, lineno: usize, rest: &str, conf: &mut Config, + errors: &mut Vec, +) { + 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::().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 gaps ". +fn parse_workspace_gaps( + path: &Path, lineno: usize, rest: &str, conf: &mut Config, + errors: &mut Vec, +) { + // rest = " gaps " + 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::().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, +) { + 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, +) { + 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, +) { + 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::().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, +) { + 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::().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, +) -> 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, +) -> Option { + 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, +) { + 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, +) { + 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> { + 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, + pub min_h: Option, + pub max_w: Option, + pub max_h: Option, + pub inc_w: Option, + pub inc_h: Option, + pub base_w: Option, + pub base_h: Option, +} + +/// 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, + + /// X11 frame window ID (reparenting WM: decoration window wrapping the client). + pub frame: Option, + + /// 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, + + /// Parent container key in the arena. + pub parent: Option, + + /// Child container keys (tiling order). + pub children: Vec, + + /// Child container keys (focus order, most recent first). + pub focus_stack: VecDeque, + + /// Floating children keys. + pub floating_children: Vec, +} + +/// 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, + next_id: ConId, + pub root: NodeKey, + pub focused: Option, +} + +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::().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 { + 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 { + 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 { + 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 { + self.nodes.iter() + } + + /// Find a container managing the given X11 window (client or frame). + pub fn find_by_window(&self, window: u32) -> Option { + 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 { + 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 = 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, + write_buf: Vec, + /// 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 { + 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, + socket_path: PathBuf, +} + +impl IpcServer { + /// Create a new IPC server. + pub fn new() -> io::Result { + 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 { + 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) -> Option<(u32, Vec)> { + 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) -> Option { + 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 { + 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>, + 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 { + 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 = 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 = 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) { + 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 = 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 { + 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 { + 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 { + 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, + /// 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 { + 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 { + 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> { + // 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> { + 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> { + 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 { + [ + 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> { + // 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 { 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 { + 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 = + 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); + } + } +}