commit - /dev/null
commit + 24205f2bf2d214fb410a04d3e30287d625cf3864
blob - /dev/null
blob + 4093c1a5c5b53bcfb3e904236b93866d14940fc3 (mode 644)
--- /dev/null
+++ .cargo/config.toml
+[build]
+rustflags = ["-L", "/usr/X11R6/lib"]
blob - /dev/null
blob + ea8c4bf7f35f6f77f75d92ad8ce8349f6e81ddba (mode 644)
--- /dev/null
+++ .gitignore
+/target
blob - /dev/null
blob + bf955640bde5a20b8c2b53bb4b40cac7e8adcfc9 (mode 644)
--- /dev/null
+++ .rustfmt.toml
+# group_imports = "StdExternalCrate"
+# imports_granularity = "Module"
+max_width = 80
+reorder_imports = true
blob - /dev/null
blob + d9f6367db80b65f86630768c0ae3bf6e66bacab8 (mode 644)
--- /dev/null
+++ Cargo.lock
+# 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
+[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
+Copyright (c) 2026 murilo ijanc' <murilo@ijanc.org>
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
blob - /dev/null
blob + 2db1edd5f7bf34e93bdd72ad4254cb88e7e6dbb3 (mode 644)
--- /dev/null
+++ Makefile
+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
+.\" $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
+.\" $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
+# 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
+use std::os::raw::c_ulong;
+use std::os::unix::io::RawFd;
+
+use xcb::x;
+use xcb::Xid;
+
+use crate::bar_child::BarChild;
+use crate::bar_proto::{self, ClickEvent};
+use crate::config::{BarPosition, Config};
+use crate::container::{ConType, ContainerTree, NodeKey, Rect};
+use crate::xcb_conn::XConn;
+
+/// Horizontal padding around each workspace label and status block.
+const PAD: u32 = 8;
+/// Width of the separator gap between status blocks.
+const SEP_WIDTH: u32 = 9;
+
+/// Result of a click on the bar.
+pub enum BarAction {
+ SwitchWorkspace(NodeKey),
+}
+
+/// Built-in status bar with optional child process for status blocks.
+pub struct Bar {
+ window: x::Window,
+ height: u32,
+ position: BarPosition,
+ child: Option<BarChild>,
+ /// Hit-test rectangles for workspace buttons: (key, x, width).
+ ws_rects: Vec<(NodeKey, i32, u32)>,
+ /// Hit-test rectangles for status blocks: (block_index, x, width).
+ block_rects: Vec<(usize, i32, u32)>,
+}
+
+impl Bar {
+ /// Create and map the status bar window, optionally spawning a child.
+ pub fn new(xconn: &XConn, config: &Config) -> Self {
+ let height = xconn.deco_height;
+ let screen = xconn.screen_rect;
+ let y = match config.bar_position {
+ BarPosition::Top => screen.y,
+ BarPosition::Bottom => {
+ screen.y + screen.h as i32 - height as i32
+ }
+ };
+
+ let window: x::Window = xconn.conn.generate_id();
+ xconn.conn.send_request(&x::CreateWindow {
+ depth: 0,
+ wid: window,
+ parent: xconn.root,
+ x: screen.x as i16,
+ y: y as i16,
+ width: screen.w as u16,
+ height: height as u16,
+ border_width: 0,
+ class: x::WindowClass::InputOutput,
+ visual: xconn.root_visual,
+ value_list: &[
+ x::Cw::BackPixel(config.bar_color_bg),
+ x::Cw::OverrideRedirect(true),
+ x::Cw::EventMask(
+ x::EventMask::EXPOSURE | x::EventMask::BUTTON_PRESS,
+ ),
+ ],
+ });
+
+ // Set EWMH properties so other apps know this is a dock.
+ xconn.set_window_type_dock(window);
+
+ // Reserve screen edge space via _NET_WM_STRUT_PARTIAL.
+ let mut strut = [0u32; 12];
+ match config.bar_position {
+ BarPosition::Top => {
+ strut[2] = height; // top
+ strut[8] = screen.x as u32; // top_start_x
+ strut[9] = screen.x as u32 + screen.w; // top_end_x
+ }
+ BarPosition::Bottom => {
+ strut[3] = height; // bottom
+ strut[10] = screen.x as u32; // bottom_start_x
+ strut[11] = screen.x as u32 + screen.w; // bottom_end_x
+ }
+ }
+ xconn.set_strut_partial(window, &strut);
+
+ xconn.conn.send_request(&x::MapWindow { window });
+ xconn.conn.send_request(&x::ConfigureWindow {
+ window,
+ value_list: &[x::ConfigWindow::StackMode(x::StackMode::Above)],
+ });
+
+ let child = config.bar_status_command.as_ref().and_then(|cmd| {
+ match BarChild::spawn(cmd) {
+ Ok(c) => Some(c),
+ Err(e) => {
+ crate::log_warn!("bar status command: {e}");
+ None
+ }
+ }
+ });
+
+ Bar {
+ window,
+ height,
+ position: config.bar_position,
+ child,
+ ws_rects: Vec::new(),
+ block_rects: Vec::new(),
+ }
+ }
+
+ /// File descriptor of the child's stdout for poll(), or None.
+ pub fn child_fd(&self) -> Option<RawFd> {
+ self.child.as_ref().map(|c| c.fd())
+ }
+
+ /// Read available output from the child. Returns true if a redraw is needed.
+ pub fn process_child_output(&mut self) -> bool {
+ match self.child.as_mut() {
+ Some(c) => c.read_available(),
+ None => false,
+ }
+ }
+
+ /// Redraw the bar: workspace buttons (left) + status blocks (right).
+ pub fn redraw(
+ &mut self,
+ xconn: &XConn,
+ config: &Config,
+ tree: &ContainerTree,
+ current_ws: NodeKey,
+ ) {
+ let screen = xconn.screen_rect;
+ let bar_w = screen.w;
+ let drawable = x::Drawable::Window(self.window);
+
+ // Create a temporary GC for drawing.
+ let gc: x::Gcontext = xconn.conn.generate_id();
+ xconn.conn.send_request(&x::CreateGc {
+ cid: gc,
+ drawable,
+ value_list: &[
+ x::Gc::Foreground(config.bar_color_bg),
+ x::Gc::Background(config.bar_color_bg),
+ ],
+ });
+
+ // Clear the bar.
+ xconn.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc,
+ rectangles: &[x::Rectangle {
+ x: 0,
+ y: 0,
+ width: bar_w as u16,
+ height: self.height as u16,
+ }],
+ });
+
+ let text_y =
+ (self.height / 2 + xconn.xft.ascent / 2) as i32;
+
+ // --- Left zone: workspace buttons ---
+ let ws_end = if config.bar_workspace_buttons {
+ self.draw_workspaces(xconn, config, tree, current_ws, gc, drawable, text_y)
+ } else {
+ self.ws_rects.clear();
+ 0
+ };
+
+ // --- Right zone: status blocks ---
+ self.draw_status_blocks(xconn, config, gc, drawable, text_y, bar_w, ws_end);
+
+ xconn.conn.send_request(&x::FreeGc { gc });
+ xconn.conn.flush().ok();
+ xconn.xft.flush();
+ }
+
+ /// Draw workspace buttons. Returns the x position after the last button.
+ fn draw_workspaces(
+ &mut self,
+ xconn: &XConn,
+ config: &Config,
+ tree: &ContainerTree,
+ current_ws: NodeKey,
+ gc: x::Gcontext,
+ drawable: x::Drawable,
+ text_y: i32,
+ ) -> i32 {
+ let mut workspaces: Vec<(NodeKey, &str, i32)> = tree
+ .iter()
+ .filter(|(_, c)| c.con_type == ConType::Workspace)
+ .map(|(k, c)| (k, c.name.as_str(), c.num))
+ .collect();
+ workspaces.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.1.cmp(b.1)));
+
+ self.ws_rects.clear();
+ let mut x_pos: i32 = 0;
+
+ for (ws_key, name, _) in &workspaces {
+ let is_active = *ws_key == current_ws;
+ let label_w = xconn.xft.text_width(name) + 2 * PAD;
+
+ let bg = if is_active {
+ config.bar_color_active
+ } else {
+ config.bar_color_bg
+ };
+
+ // Draw background.
+ xconn.conn.send_request(&x::ChangeGc {
+ gc,
+ value_list: &[x::Gc::Foreground(bg)],
+ });
+ xconn.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc,
+ rectangles: &[x::Rectangle {
+ x: x_pos as i16,
+ y: 0,
+ width: label_w as u16,
+ height: self.height as u16,
+ }],
+ });
+
+ // Draw text (flush XCB before Xft).
+ let _ = xconn.conn.flush();
+ xconn.xft.draw_text(
+ self.window.resource_id() as c_ulong,
+ x_pos + PAD as i32,
+ text_y,
+ name,
+ config.bar_color_fg,
+ );
+
+ self.ws_rects.push((*ws_key, x_pos, label_w));
+ x_pos += label_w as i32;
+ }
+
+ x_pos
+ }
+
+ /// Draw status blocks right-aligned. Falls back to short text if space is tight.
+ fn draw_status_blocks(
+ &mut self,
+ xconn: &XConn,
+ config: &Config,
+ gc: x::Gcontext,
+ drawable: x::Drawable,
+ text_y: i32,
+ bar_w: u32,
+ ws_end: i32,
+ ) {
+ self.block_rects.clear();
+
+ let blocks = match self.child.as_ref() {
+ Some(c) if !c.blocks.is_empty() => &c.blocks,
+ _ => return,
+ };
+
+ let available = (bar_w as i32 - ws_end).max(0) as u32;
+
+ // Compute widths for each block (full text first).
+ let mut widths: Vec<(u32, bool)> = blocks
+ .iter()
+ .map(|b| {
+ let tw = xconn.xft.text_width(&b.text);
+ let w = tw + 2 * PAD;
+ let w = match b.min_width {
+ Some(mw) if mw > w => mw,
+ _ => w,
+ };
+ let sep = if b.separator { SEP_WIDTH } else { 0 };
+ (w + sep, false) // (total_width, using_short)
+ })
+ .collect();
+
+ // Total width with full text.
+ let total: u32 = widths.iter().map(|(w, _)| *w).sum();
+
+ // If too wide, progressively switch to short text.
+ if total > available {
+ let mut current_total = total;
+ for (i, block) in blocks.iter().enumerate() {
+ if current_total <= available {
+ break;
+ }
+ if let Some(ref short) = block.short {
+ let short_tw = xconn.xft.text_width(short);
+ let short_w = short_tw + 2 * PAD;
+ let short_w = match block.min_width {
+ Some(mw) if mw > short_w => mw,
+ _ => short_w,
+ };
+ let sep = if block.separator { SEP_WIDTH } else { 0 };
+ let new_w = short_w + sep;
+ current_total = current_total - widths[i].0 + new_w;
+ widths[i] = (new_w, true);
+ }
+ }
+ }
+
+ // Draw from right to left.
+ let total_w: u32 = widths.iter().map(|(w, _)| *w).sum();
+ let mut x_pos = bar_w as i32 - total_w as i32;
+ // Don't overlap workspace buttons.
+ if x_pos < ws_end {
+ x_pos = ws_end;
+ }
+
+ for (i, block) in blocks.iter().enumerate() {
+ let (block_w, use_short) = widths[i];
+ let sep_w = if block.separator { SEP_WIDTH } else { 0 };
+ let content_w = block_w - sep_w;
+
+ // Draw background if specified.
+ if let Some(bg) = block.bg {
+ xconn.conn.send_request(&x::ChangeGc {
+ gc,
+ value_list: &[x::Gc::Foreground(bg)],
+ });
+ xconn.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc,
+ rectangles: &[x::Rectangle {
+ x: x_pos as i16,
+ y: 0,
+ width: content_w as u16,
+ height: self.height as u16,
+ }],
+ });
+ }
+
+ // Draw border if specified.
+ if let Some(border) = block.border {
+ xconn.conn.send_request(&x::ChangeGc {
+ gc,
+ value_list: &[x::Gc::Foreground(border)],
+ });
+ // Top border.
+ xconn.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc,
+ rectangles: &[
+ x::Rectangle {
+ x: x_pos as i16,
+ y: 0,
+ width: content_w as u16,
+ height: 1,
+ },
+ // Bottom border.
+ x::Rectangle {
+ x: x_pos as i16,
+ y: (self.height - 1) as i16,
+ width: content_w as u16,
+ height: 1,
+ },
+ // Left border.
+ x::Rectangle {
+ x: x_pos as i16,
+ y: 0,
+ width: 1,
+ height: self.height as u16,
+ },
+ // Right border.
+ x::Rectangle {
+ x: (x_pos + content_w as i32 - 1) as i16,
+ y: 0,
+ width: 1,
+ height: self.height as u16,
+ },
+ ],
+ });
+ }
+
+ // Draw text.
+ let text = if use_short {
+ block.short.as_deref().unwrap_or(&block.text)
+ } else {
+ &block.text
+ };
+ let text_color = block.color.unwrap_or(config.bar_color_fg);
+
+ // Calculate text x based on alignment.
+ let tw = xconn.xft.text_width(text);
+ let text_x = match block.align {
+ bar_proto::Align::Left => x_pos + PAD as i32,
+ bar_proto::Align::Center => {
+ x_pos + (content_w as i32 - tw as i32) / 2
+ }
+ bar_proto::Align::Right => {
+ x_pos + content_w as i32 - tw as i32 - PAD as i32
+ }
+ };
+
+ let _ = xconn.conn.flush();
+ xconn.xft.draw_text(
+ self.window.resource_id() as c_ulong,
+ text_x,
+ text_y,
+ text,
+ text_color,
+ );
+
+ // Draw separator.
+ if block.separator && i + 1 < blocks.len() {
+ let sep_x = x_pos + content_w as i32 + SEP_WIDTH as i32 / 2;
+ match config.bar_separator_symbol.as_deref() {
+ Some(sym) => {
+ let _ = xconn.conn.flush();
+ let sw = xconn.xft.text_width(sym);
+ xconn.xft.draw_text(
+ self.window.resource_id() as c_ulong,
+ sep_x - sw as i32 / 2,
+ text_y,
+ sym,
+ config.bar_color_separator,
+ );
+ }
+ None => {
+ // Draw a 1px vertical line.
+ xconn.conn.send_request(&x::ChangeGc {
+ gc,
+ value_list: &[
+ x::Gc::Foreground(config.bar_color_separator),
+ ],
+ });
+ let line_top = (self.height / 4) as i16;
+ let line_bot = (self.height * 3 / 4) as i16;
+ xconn.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc,
+ rectangles: &[x::Rectangle {
+ x: sep_x as i16,
+ y: line_top,
+ width: 1,
+ height: (line_bot - line_top) as u16,
+ }],
+ });
+ }
+ }
+ }
+
+ self.block_rects.push((i, x_pos, content_w));
+ x_pos += block_w as i32;
+ }
+ }
+
+ /// Handle a click on the bar. Returns a BarAction if a workspace was clicked.
+ pub fn handle_click(&mut self, click_x: i32, click_y: i32, button: u32) -> Option<BarAction> {
+ // Check workspace buttons.
+ for &(ws_key, x, w) in &self.ws_rects {
+ if click_x >= x && click_x < x + w as i32 {
+ return Some(BarAction::SwitchWorkspace(ws_key));
+ }
+ }
+
+ // Check status blocks — send click event to child.
+ for &(idx, x, w) in &self.block_rects {
+ if click_x >= x && click_x < x + w as i32 {
+ if let Some(ref blocks) = self.child.as_ref().map(|c| &c.blocks) {
+ if let Some(block) = blocks.get(idx) {
+ let event = ClickEvent {
+ name: block.name.clone(),
+ instance: block.instance.clone(),
+ button,
+ x: click_x - x,
+ y: click_y,
+ };
+ if let Some(ref mut child) = self.child {
+ child.send_click(event);
+ }
+ }
+ }
+ return None;
+ }
+ }
+
+ None
+ }
+
+ /// Returns the X11 window ID for event matching.
+ pub fn window_id(&self) -> u32 {
+ self.window.resource_id()
+ }
+
+ /// Compute the usable workspace rect, accounting for the bar.
+ pub fn workarea(&self, screen: Rect) -> Rect {
+ match self.position {
+ BarPosition::Top => Rect {
+ x: screen.x,
+ y: screen.y + self.height as i32,
+ w: screen.w,
+ h: screen.h.saturating_sub(self.height),
+ },
+ BarPosition::Bottom => Rect {
+ x: screen.x,
+ y: screen.y,
+ w: screen.w,
+ h: screen.h.saturating_sub(self.height),
+ },
+ }
+ }
+
+ /// Stop the child process (SIGSTOP).
+ pub fn stop_child(&self) {
+ if let Some(ref c) = self.child {
+ c.stop();
+ }
+ }
+
+ /// Resume the child process (SIGCONT).
+ pub fn resume_child(&self) {
+ if let Some(ref c) = self.child {
+ c.resume();
+ }
+ }
+
+ /// Destroy the bar window and kill the child process.
+ pub fn destroy(self, xconn: &XConn) {
+ // child is dropped here, which sends SIGTERM
+ drop(self.child);
+ xconn
+ .conn
+ .send_request(&x::DestroyWindow { window: self.window });
+ }
+}
blob - /dev/null
blob + ba92beac67f2878e2bc3d9e226a2139f08600357 (mode 644)
--- /dev/null
+++ src/bar_child.rs
+//! Child process management for the owm-bar status command.
+//!
+//! Spawns a shell command, reads its stdout line-by-line using the owm-bar
+//! protocol, and optionally writes click events to its stdin.
+
+use std::io::{Read, Write};
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::process::{Child, Command, Stdio};
+
+use crate::bar_proto::{
+ self, BarHeader, ClickEvent, StatusBlock,
+};
+use crate::sys;
+
+/// Parse state for the child's output stream.
+enum ParseState {
+ AwaitingHeader,
+ Streaming,
+}
+
+/// A running status command child process.
+pub struct BarChild {
+ child: Child,
+ stdout_fd: RawFd,
+ buf: Vec<u8>,
+ state: ParseState,
+ header: Option<BarHeader>,
+ pub blocks: Vec<StatusBlock>,
+ click_events: bool,
+}
+
+impl BarChild {
+ /// Spawn a status command via the shell.
+ pub fn spawn(command: &str) -> Result<Self, String> {
+ let shell = if cfg!(target_os = "openbsd") {
+ "/bin/ksh"
+ } else {
+ "/bin/sh"
+ };
+
+ let child = Command::new(shell)
+ .args(["-c", command])
+ .stdout(Stdio::piped())
+ .stdin(Stdio::piped())
+ .stderr(Stdio::null())
+ .spawn()
+ .map_err(|e| format!("failed to spawn status command: {e}"))?;
+
+ let stdout = child.stdout.as_ref().ok_or("no stdout")?;
+ let stdout_fd = stdout.as_raw_fd();
+ sys::set_nonblocking(stdout_fd);
+
+ Ok(BarChild {
+ child,
+ stdout_fd,
+ buf: Vec::with_capacity(4096),
+ state: ParseState::AwaitingHeader,
+ header: None,
+ blocks: Vec::new(),
+ click_events: false,
+ })
+ }
+
+ /// File descriptor for the child's stdout (for poll).
+ pub fn fd(&self) -> RawFd {
+ self.stdout_fd
+ }
+
+ /// Read available data from the child. Returns true if blocks changed.
+ pub fn read_available(&mut self) -> bool {
+ let mut tmp = [0u8; 4096];
+ let n = match self.child.stdout.as_mut() {
+ Some(stdout) => match stdout.read(&mut tmp) {
+ Ok(0) => {
+ // EOF — child exited
+ crate::log_warn!("bar child exited");
+ return false;
+ }
+ Ok(n) => n,
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+ return false;
+ }
+ Err(e) => {
+ crate::log_warn!("bar child read error: {e}");
+ return false;
+ }
+ },
+ None => return false,
+ };
+
+ self.buf.extend_from_slice(&tmp[..n]);
+ let mut changed = false;
+
+ // Process complete lines.
+ while let Some(newline_pos) = self.buf.iter().position(|&b| b == b'\n') {
+ let line: Vec<u8> = self.buf.drain(..=newline_pos).collect();
+ let line = match std::str::from_utf8(&line) {
+ Ok(s) => s.trim(),
+ Err(_) => continue,
+ };
+ if line.is_empty() {
+ continue;
+ }
+
+ match self.state {
+ ParseState::AwaitingHeader => {
+ match bar_proto::parse_header(line) {
+ Ok(header) => {
+ self.click_events = header.click_events;
+ self.header = Some(header);
+ self.state = ParseState::Streaming;
+ crate::log_info!(
+ "bar child header: click_events={}",
+ self.click_events
+ );
+ }
+ Err(e) => {
+ crate::log_warn!("bar child: {e}");
+ }
+ }
+ }
+ ParseState::Streaming => {
+ match bar_proto::parse_status_line(line) {
+ Ok(blocks) => {
+ self.blocks = blocks;
+ changed = true;
+ }
+ Err(e) => {
+ crate::log_warn!("bar child: {e}");
+ }
+ }
+ }
+ }
+ }
+
+ changed
+ }
+
+ /// Send a click event to the child's stdin.
+ pub fn send_click(&mut self, event: ClickEvent) {
+ if !self.click_events {
+ return;
+ }
+ let data = bar_proto::serialize_click(&event);
+ if let Some(ref mut stdin) = self.child.stdin {
+ // Non-blocking write; drop the event if it would block.
+ let _ = stdin.write_all(data.as_bytes());
+ }
+ }
+
+ /// Pause the child with SIGSTOP.
+ pub fn stop(&self) {
+ if let Some(pid) = self.pid() {
+ let _ = sys::kill_process(pid, sys::SIGSTOP);
+ }
+ }
+
+ /// Resume the child with SIGCONT.
+ pub fn resume(&self) {
+ if let Some(pid) = self.pid() {
+ let _ = sys::kill_process(pid, sys::SIGCONT);
+ }
+ }
+
+ fn pid(&self) -> Option<u32> {
+ Some(self.child.id())
+ }
+}
+
+impl Drop for BarChild {
+ fn drop(&mut self) {
+ if let Some(pid) = self.pid() {
+ let _ = sys::kill_process(pid, sys::SIGTERM);
+ // Reap to avoid zombie.
+ let _ = self.child.wait();
+ }
+ }
+}
blob - /dev/null
blob + f2a01b99e4e9a1bf1fd37ffa7a5820605b72279b (mode 644)
--- /dev/null
+++ src/bar_proto.rs
+//! owm-bar protocol: types and parsing for the status bar child process.
+//!
+//! The protocol is line-based JSON over stdout/stdin pipes:
+//!
+//! 1. Child sends a header line: `{"version": 1, "click_events": false}`
+//! 2. Child sends status lines, each a JSON array of blocks:
+//! `[{"text": "CPU: 25%", "color": "#ff0000"}, {"text": "14:30"}]`
+//! 3. If click_events is true, owm writes click events to the child's stdin:
+//! `{"name": "cpu", "button": 1, "x": 12, "y": 5}`
+
+use serde::{Deserialize, Serialize};
+
+/// Protocol header sent by the child on its first line.
+#[derive(Debug, Deserialize)]
+pub struct BarHeader {
+ pub version: u32,
+ #[serde(default)]
+ pub click_events: bool,
+}
+
+/// Alignment for text within a status block.
+#[derive(Debug, Clone, Copy, Default)]
+pub enum Align {
+ #[default]
+ Left,
+ Center,
+ Right,
+}
+
+/// A single status block from the child's output.
+#[derive(Debug)]
+pub struct StatusBlock {
+ pub text: String,
+ pub short: Option<String>,
+ pub color: Option<u32>,
+ pub bg: Option<u32>,
+ pub border: Option<u32>,
+ pub min_width: Option<u32>,
+ pub align: Align,
+ pub name: Option<String>,
+ pub instance: Option<String>,
+ pub urgent: bool,
+ pub separator: bool,
+}
+
+/// Click event sent to the child's stdin.
+#[derive(Debug, Serialize)]
+pub struct ClickEvent {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub instance: Option<String>,
+ pub button: u32,
+ pub x: i32,
+ pub y: i32,
+}
+
+/// Raw JSON shape for deserializing a status block.
+#[derive(Deserialize)]
+struct RawBlock {
+ text: String,
+ short: Option<String>,
+ color: Option<String>,
+ bg: Option<String>,
+ border: Option<String>,
+ min_width: Option<u32>,
+ #[serde(default)]
+ align: Option<String>,
+ name: Option<String>,
+ instance: Option<String>,
+ #[serde(default)]
+ urgent: bool,
+ #[serde(default = "default_true")]
+ separator: bool,
+}
+
+fn default_true() -> bool {
+ true
+}
+
+/// Parse a `#rrggbb` hex color string to a u32.
+pub fn parse_color(s: &str) -> Option<u32> {
+ let s = s.strip_prefix('#')?;
+ if s.len() != 6 {
+ return None;
+ }
+ u32::from_str_radix(s, 16).ok()
+}
+
+fn parse_align(s: Option<&str>) -> Align {
+ match s {
+ Some("center") => Align::Center,
+ Some("right") => Align::Right,
+ _ => Align::Left,
+ }
+}
+
+impl From<RawBlock> for StatusBlock {
+ fn from(raw: RawBlock) -> Self {
+ StatusBlock {
+ text: raw.text,
+ short: raw.short,
+ color: raw.color.as_deref().and_then(parse_color),
+ bg: raw.bg.as_deref().and_then(parse_color),
+ border: raw.border.as_deref().and_then(parse_color),
+ min_width: raw.min_width,
+ align: parse_align(raw.align.as_deref()),
+ name: raw.name,
+ instance: raw.instance,
+ urgent: raw.urgent,
+ separator: raw.separator,
+ }
+ }
+}
+
+/// Parse the protocol header. Returns an error if version != 1.
+pub fn parse_header(line: &str) -> Result<BarHeader, String> {
+ let header: BarHeader =
+ serde_json::from_str(line).map_err(|e| format!("bad header: {e}"))?;
+ if header.version != 1 {
+ return Err(format!("unsupported protocol version {}", header.version));
+ }
+ Ok(header)
+}
+
+/// Parse a status line (JSON array of blocks).
+pub fn parse_status_line(line: &str) -> Result<Vec<StatusBlock>, String> {
+ let raw: Vec<RawBlock> =
+ serde_json::from_str(line).map_err(|e| format!("bad status line: {e}"))?;
+ Ok(raw.into_iter().map(StatusBlock::from).collect())
+}
+
+/// Serialize a click event to a JSON line (with trailing newline).
+pub fn serialize_click(event: &ClickEvent) -> String {
+ let mut s = serde_json::to_string(event).unwrap_or_default();
+ s.push('\n');
+ s
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_color() {
+ assert_eq!(parse_color("#ff0000"), Some(0xff0000));
+ assert_eq!(parse_color("#00ff00"), Some(0x00ff00));
+ assert_eq!(parse_color("#0000ff"), Some(0x0000ff));
+ assert_eq!(parse_color("invalid"), None);
+ assert_eq!(parse_color("#fff"), None);
+ }
+
+ #[test]
+ fn test_parse_header() {
+ let h = parse_header(r##"{"version": 1, "click_events": true}"##).unwrap();
+ assert_eq!(h.version, 1);
+ assert!(h.click_events);
+
+ let h = parse_header(r##"{"version": 1}"##).unwrap();
+ assert!(!h.click_events);
+
+ assert!(parse_header(r##"{"version": 2}"##).is_err());
+ }
+
+ #[test]
+ fn test_parse_status_line() {
+ let blocks = parse_status_line(
+ r##"[{"text": "CPU: 25%", "color": "#ff0000"}, {"text": "14:30"}]"##,
+ )
+ .unwrap();
+ assert_eq!(blocks.len(), 2);
+ assert_eq!(blocks[0].text, "CPU: 25%");
+ assert_eq!(blocks[0].color, Some(0xff0000));
+ assert_eq!(blocks[1].text, "14:30");
+ assert!(blocks[1].separator);
+ }
+
+ #[test]
+ fn test_serialize_click() {
+ let event = ClickEvent {
+ name: Some("cpu".into()),
+ instance: None,
+ button: 1,
+ x: 10,
+ y: 5,
+ };
+ let s = serialize_click(&event);
+ assert!(s.contains("\"name\":\"cpu\""));
+ assert!(s.contains("\"button\":1"));
+ assert!(!s.contains("instance"));
+ assert!(s.ends_with('\n'));
+ }
+}
blob - /dev/null
blob + a7c08f41dab55e88d7974993248e8de3d254b016 (mode 644)
--- /dev/null
+++ src/bin/omenu.rs
+//! omenu — minimal application launcher for owm.
+//!
+//! Centered, vertical-list launcher inspired by rofi. Reads executables
+//! from $PATH, filters as you type, highlights matches. Pure xcb + Xft.
+
+use std::collections::BTreeSet;
+use std::env;
+use std::os::raw::c_ulong;
+use std::os::unix::fs::PermissionsExt;
+use std::process::Command;
+
+use xcb::x;
+use xcb::{Connection, Xid};
+
+use owm::xft_font::XftFont;
+
+// Layout
+const MENU_WIDTH: u16 = 600;
+const LINE_H: u16 = 22;
+const INPUT_H: u16 = 28;
+const BORDER_W: u16 = 2;
+const PAD_X: i32 = 8;
+const MAX_VISIBLE: usize = 20;
+
+// Colors — matched to owm light theme defaults
+const COL_BG: u32 = 0xe0e0e0;
+const COL_FG: u32 = 0x333333;
+const COL_INPUT_BG: u32 = 0xf5f5f5;
+const COL_INPUT_FG: u32 = 0x333333;
+const COL_SEL_BG: u32 = 0x5294e2;
+const COL_SEL_FG: u32 = 0xffffff;
+const COL_MATCH: u32 = 0x4c7899;
+const COL_BORDER: u32 = 0x4c7899;
+const COL_COUNT: u32 = 0x888888;
+const FONT_NAME: &str = "monospace:size=11";
+
+struct Menu {
+ xft: XftFont,
+ conn: Connection,
+ win: x::Window,
+ gc: x::Gcontext,
+ input: String,
+ entries: Vec<String>,
+ filtered: Vec<usize>, // indices into entries
+ selected: usize,
+ scroll: usize,
+ running: bool,
+ result: Option<String>,
+ menu_h: u16,
+}
+
+impl Menu {
+ fn new() -> Result<Self, Box<dyn std::error::Error>> {
+ // Pure XCB connection for window management (preserves OverrideRedirect).
+ let (conn, screen_num) = Connection::connect(None)?;
+
+ let setup = conn.get_setup();
+ let screen = setup.roots().nth(screen_num as usize).ok_or("no screen")?;
+ let root = screen.root();
+ let screen_w = screen.width_in_pixels();
+ let screen_h = screen.height_in_pixels();
+
+ // Open a separate Xlib Display for Xft rendering.
+ let dpy = unsafe { x11::xlib::XOpenDisplay(std::ptr::null()) };
+ if dpy.is_null() {
+ return Err("failed to open Xlib display for Xft".into());
+ }
+ let xft = unsafe { XftFont::open(dpy, screen_num, FONT_NAME) }
+ .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
+
+ // GC (for rectangles only)
+ let gc = conn.generate_id();
+ conn.send_request(&x::CreateGc {
+ cid: gc,
+ drawable: x::Drawable::Window(root),
+ value_list: &[
+ x::Gc::Foreground(COL_FG),
+ x::Gc::Background(COL_BG),
+ ],
+ });
+
+ let entries = collect_executables();
+ let filtered: Vec<usize> = (0..entries.len()).collect();
+ let visible = filtered.len().min(MAX_VISIBLE);
+ let menu_h =
+ INPUT_H + (visible as u16) * LINE_H + 2 * BORDER_W;
+ let menu_x = (screen_w - MENU_WIDTH) as i16 / 2;
+ let menu_y = (screen_h - menu_h) as i16 / 3; // upper third
+
+ let win: x::Window = conn.generate_id();
+ conn.send_request(&x::CreateWindow {
+ depth: x::COPY_FROM_PARENT as u8,
+ wid: win,
+ parent: root,
+ x: menu_x,
+ y: menu_y,
+ width: MENU_WIDTH,
+ height: menu_h,
+ border_width: BORDER_W,
+ class: x::WindowClass::InputOutput,
+ visual: screen.root_visual(),
+ value_list: &[
+ x::Cw::BackPixel(COL_BG),
+ x::Cw::BorderPixel(COL_BORDER),
+ x::Cw::OverrideRedirect(true),
+ x::Cw::EventMask(
+ x::EventMask::EXPOSURE | x::EventMask::KEY_PRESS,
+ ),
+ ],
+ });
+
+ conn.send_request(&x::MapWindow { window: win });
+ conn.send_request(&x::ConfigureWindow {
+ window: win,
+ value_list: &[x::ConfigWindow::StackMode(x::StackMode::Above)],
+ });
+ conn.flush()?;
+
+ // Grab keyboard — retry until the grab succeeds.
+ // The WM may briefly hold a passive grab from the key binding
+ // that launched us, so we need to wait for it to release.
+ let mut grabbed = false;
+ for _ in 0..50 {
+ let cookie = conn.send_request(&x::GrabKeyboard {
+ owner_events: false,
+ grab_window: win,
+ time: x::CURRENT_TIME,
+ pointer_mode: x::GrabMode::Async,
+ keyboard_mode: x::GrabMode::Async,
+ });
+ if let Ok(reply) = conn.wait_for_reply(cookie) {
+ if reply.status() == x::GrabStatus::Success {
+ grabbed = true;
+ break;
+ }
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ if !grabbed {
+ return Err("failed to grab keyboard".into());
+ }
+
+ conn.send_request(&x::SetInputFocus {
+ revert_to: x::InputFocus::PointerRoot,
+ focus: win,
+ time: x::CURRENT_TIME,
+ });
+ conn.flush()?;
+
+ Ok(Menu {
+ conn,
+ win,
+ gc,
+ xft,
+ input: String::new(),
+ entries,
+ filtered,
+ selected: 0,
+ scroll: 0,
+ running: true,
+ result: None,
+ menu_h,
+ })
+ }
+
+ fn run(&mut self) -> Result<Option<String>, Box<dyn std::error::Error>> {
+ self.draw();
+ while self.running {
+ let event = self.conn.wait_for_event()?;
+ match event {
+ xcb::Event::X(x::Event::Expose(_)) => self.draw(),
+ xcb::Event::X(x::Event::KeyPress(ev)) => self.on_key(ev),
+ _ => {}
+ }
+ }
+ Ok(self.result.take())
+ }
+
+ fn on_key(&mut self, ev: x::KeyPressEvent) {
+ let keycode = ev.detail();
+ let state = ev.state();
+ let shift = state.contains(x::KeyButMask::SHIFT);
+ let ctrl = state.contains(x::KeyButMask::CONTROL);
+
+ match keycode {
+ 9 => self.running = false, // Escape
+ 36 => {
+ // Return
+ if let Some(&idx) = self.filtered.get(self.selected) {
+ self.result = Some(self.entries[idx].clone());
+ } else if !self.input.is_empty() {
+ self.result = Some(self.input.clone());
+ }
+ self.running = false;
+ }
+ 22 => {
+ // BackSpace
+ if ctrl {
+ let trimmed = self.input.trim_end();
+ if let Some(pos) = trimmed.rfind(' ') {
+ self.input.truncate(pos + 1);
+ } else {
+ self.input.clear();
+ }
+ } else {
+ self.input.pop();
+ }
+ self.update_filter();
+ self.resize_and_draw();
+ }
+ 23 => {
+ // Tab
+ if let Some(&idx) = self.filtered.get(self.selected) {
+ self.input = self.entries[idx].clone();
+ self.update_filter();
+ self.resize_and_draw();
+ }
+ }
+ 111 => {
+ // Up
+ self.move_sel(-1);
+ }
+ 116 => {
+ // Down
+ self.move_sel(1);
+ }
+ _ if ctrl && keycode == 45 => self.move_sel(-1), // Ctrl+k
+ _ if ctrl && keycode == 44 => self.move_sel(1), // Ctrl+j
+ _ if ctrl && keycode == 33 => { // Ctrl+p
+ self.move_sel(-1);
+ }
+ _ if ctrl && keycode == 57 => { // Ctrl+n
+ self.move_sel(1);
+ }
+ _ if ctrl && keycode == 30 => {
+ // Ctrl+u
+ self.input.clear();
+ self.update_filter();
+ self.resize_and_draw();
+ }
+ _ => {
+ if let Some(ch) = keycode_to_char(keycode, shift) {
+ self.input.push(ch);
+ self.update_filter();
+ self.resize_and_draw();
+ }
+ }
+ }
+ }
+
+ fn move_sel(&mut self, delta: i32) {
+ if self.filtered.is_empty() {
+ return;
+ }
+ if delta < 0 && self.selected > 0 {
+ self.selected -= 1;
+ } else if delta > 0 && self.selected + 1 < self.filtered.len() {
+ self.selected += 1;
+ }
+ self.ensure_visible();
+ self.draw();
+ }
+
+ fn update_filter(&mut self) {
+ let query = self.input.to_lowercase();
+ self.filtered = if query.is_empty() {
+ (0..self.entries.len()).collect()
+ } else {
+ // Prefix matches first, then substring
+ let mut prefix: Vec<usize> = Vec::new();
+ let mut substr: Vec<usize> = Vec::new();
+ for (i, e) in self.entries.iter().enumerate() {
+ let lower = e.to_lowercase();
+ if lower.starts_with(&query) {
+ prefix.push(i);
+ } else if lower.contains(&query) {
+ substr.push(i);
+ }
+ }
+ prefix.extend(substr);
+ prefix
+ };
+ self.selected = 0;
+ self.scroll = 0;
+ }
+
+ fn ensure_visible(&mut self) {
+ if self.selected < self.scroll {
+ self.scroll = self.selected;
+ }
+ if self.selected >= self.scroll + MAX_VISIBLE {
+ self.scroll = self.selected - MAX_VISIBLE + 1;
+ }
+ }
+
+ fn resize_and_draw(&mut self) {
+ let visible = self.filtered.len().min(MAX_VISIBLE);
+ let new_h = INPUT_H + (visible as u16) * LINE_H + 2 * BORDER_W;
+ if new_h != self.menu_h {
+ self.menu_h = new_h;
+ // Keep top position fixed — only resize height.
+ self.conn.send_request(&x::ConfigureWindow {
+ window: self.win,
+ value_list: &[
+ x::ConfigWindow::Height(new_h as u32),
+ ],
+ });
+ }
+ self.draw();
+ }
+
+ fn draw(&self) {
+ let d = x::Drawable::Window(self.win);
+ let drawable_id = self.win.resource_id() as c_ulong;
+ let w = MENU_WIDTH;
+
+ // Clear background
+ self.set_fg(COL_BG);
+ self.fill_rect(d, 0, 0, w, self.menu_h);
+
+ // Input area background
+ self.set_fg(COL_INPUT_BG);
+ self.fill_rect(d, 0, 0, w, INPUT_H);
+
+ // Flush XCB before Xft draws
+ let _ = self.conn.flush();
+
+ // Input text
+ let ty = self.xft.ascent as i32
+ + (INPUT_H as i32 - self.xft.ascent as i32) / 2;
+ self.xft.draw_text(
+ drawable_id,
+ PAD_X,
+ ty,
+ &self.input,
+ COL_INPUT_FG,
+ );
+
+ // Cursor
+ let cursor_x = PAD_X + self.xft.text_width(&self.input) as i32;
+ self.set_fg(COL_INPUT_FG);
+ self.fill_rect(d, cursor_x as u16, 4, 1, INPUT_H - 8);
+
+ // Counter "N/total" at top-right
+ let count_str = format!(
+ "{}/{}",
+ self.filtered.len(),
+ self.entries.len()
+ );
+ let count_w = self.xft.text_width(&count_str) as i32;
+ let count_x = w as i32 - PAD_X - count_w;
+ self.xft.draw_text(
+ drawable_id,
+ count_x,
+ ty,
+ &count_str,
+ COL_COUNT,
+ );
+
+ // Separator line below input
+ self.set_fg(COL_BORDER);
+ self.fill_rect(d, 0, INPUT_H, w, 1);
+
+ // List entries
+ let query = self.input.to_lowercase();
+ let list_y_start = INPUT_H + 1;
+
+ for (vi, &entry_idx) in self
+ .filtered
+ .iter()
+ .skip(self.scroll)
+ .take(MAX_VISIBLE)
+ .enumerate()
+ {
+ let abs_idx = self.scroll + vi;
+ let is_sel = abs_idx == self.selected;
+ let y = list_y_start + (vi as u16) * LINE_H;
+ let text = &self.entries[entry_idx];
+
+ // Row background
+ let (bg, fg) = if is_sel {
+ (COL_SEL_BG, COL_SEL_FG)
+ } else {
+ (COL_BG, COL_FG)
+ };
+ self.set_fg(bg);
+ self.fill_rect(d, 0, y, w, LINE_H);
+
+ // Flush XCB before Xft text
+ let _ = self.conn.flush();
+
+ // Draw text with match highlighting
+ let text_y = y as i32 + self.xft.ascent as i32
+ + (LINE_H as i32 - self.xft.ascent as i32) / 2;
+ if !query.is_empty() {
+ self.draw_highlighted(
+ drawable_id, PAD_X, text_y, text, &query, fg, is_sel,
+ );
+ } else {
+ self.xft.draw_text(drawable_id, PAD_X, text_y, text, fg);
+ }
+ }
+
+ self.xft.flush();
+ let _ = self.conn.flush();
+ }
+
+ /// Draw text with matched characters in bright white.
+ fn draw_highlighted(
+ &self,
+ drawable: c_ulong,
+ x: i32,
+ y: i32,
+ text: &str,
+ query: &str,
+ fg: u32,
+ is_sel: bool,
+ ) {
+ let lower = text.to_lowercase();
+ let match_start = lower.find(query);
+
+ if let Some(start) = match_start {
+ let end = start + query.len();
+ let mut cx = x;
+
+ // Before match
+ if start > 0 {
+ let before = &text[..start];
+ self.xft.draw_text(drawable, cx, y, before, fg);
+ cx += self.xft.text_width(before) as i32;
+ }
+
+ // Match portion: bright white
+ let matched = &text[start..end];
+ let match_fg = if is_sel { COL_SEL_FG } else { COL_MATCH };
+ self.xft.draw_text(drawable, cx, y, matched, match_fg);
+ cx += self.xft.text_width(matched) as i32;
+
+ // After match
+ if end < text.len() {
+ let after = &text[end..];
+ self.xft.draw_text(drawable, cx, y, after, fg);
+ }
+ } else {
+ self.xft.draw_text(drawable, x, y, text, fg);
+ }
+ }
+
+ fn set_fg(&self, color: u32) {
+ self.conn.send_request(&x::ChangeGc {
+ gc: self.gc,
+ value_list: &[x::Gc::Foreground(color)],
+ });
+ }
+
+ fn fill_rect(&self, d: x::Drawable, x: u16, y: u16, w: u16, h: u16) {
+ self.conn.send_request(&x::PolyFillRectangle {
+ drawable: d,
+ gc: self.gc,
+ rectangles: &[x::Rectangle {
+ x: x as i16,
+ y: y as i16,
+ width: w,
+ height: h,
+ }],
+ });
+ }
+}
+
+fn collect_executables() -> Vec<String> {
+ let path = env::var("PATH").unwrap_or_default();
+ let mut names = BTreeSet::new();
+ for dir in path.split(':') {
+ if dir.is_empty() {
+ continue;
+ }
+ let entries = match std::fs::read_dir(dir) {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
+ for entry in entries.flatten() {
+ // Follow symlinks: entry.metadata() uses lstat, but we
+ // want stat to resolve symlinks like /usr/local/bin/firefox.
+ let meta = match std::fs::metadata(entry.path()) {
+ Ok(m) => m,
+ Err(_) => continue,
+ };
+ if !meta.is_file() || meta.permissions().mode() & 0o111 == 0 {
+ continue;
+ }
+ if let Some(name) = entry.file_name().to_str() {
+ names.insert(name.to_string());
+ }
+ }
+ }
+ names.into_iter().collect()
+}
+
+fn keycode_to_char(keycode: u8, shift: bool) -> Option<char> {
+ let ch = match keycode {
+ 38 => 'a', 56 => 'b', 54 => 'c', 40 => 'd', 26 => 'e',
+ 41 => 'f', 42 => 'g', 43 => 'h', 31 => 'i', 44 => 'j',
+ 45 => 'k', 46 => 'l', 58 => 'm', 57 => 'n', 32 => 'o',
+ 33 => 'p', 24 => 'q', 27 => 'r', 39 => 's', 28 => 't',
+ 30 => 'u', 55 => 'v', 25 => 'w', 53 => 'x', 29 => 'y',
+ 52 => 'z',
+ 19 => '0', 10 => '1', 11 => '2', 12 => '3', 13 => '4',
+ 14 => '5', 15 => '6', 16 => '7', 17 => '8', 18 => '9',
+ 65 => ' ', 20 => '-', 21 => '=', 61 => '/', 60 => '.',
+ 59 => ',', 48 => '\'', 47 => ';',
+ _ => return None,
+ };
+ if shift {
+ Some(ch.to_uppercase().next().unwrap_or(ch))
+ } else {
+ Some(ch)
+ }
+}
+
+fn main() {
+ let mut menu = match Menu::new() {
+ Ok(m) => m,
+ Err(e) => {
+ eprintln!("omenu: {}", e);
+ std::process::exit(1);
+ }
+ };
+
+ match menu.run() {
+ Ok(Some(cmd)) => {
+ let _ = Command::new("sh").arg("-c").arg(&cmd).spawn();
+ }
+ Ok(None) => {}
+ Err(e) => {
+ eprintln!("omenu: {}", e);
+ std::process::exit(1);
+ }
+ }
+}
blob - /dev/null
blob + 38555e538370be37e2df9b4b3f655723fd3294cd (mode 644)
--- /dev/null
+++ src/bin/ostatus.rs
+//! ostatus — lightweight status bar feeder for owm-bar.
+//!
+//! Outputs the owm-bar JSON protocol on stdout:
+//! 1. Header: {"version": 1}
+//! 2. Repeated status lines: [{"text": "...", "name": "...", ...}, ...]
+//!
+//! Modules: cpu, memory, disk, network, battery, datetime.
+//! Designed for OpenBSD; uses sysctl(2), statvfs(2), ioctl(2), getifaddrs(3).
+
+use std::ffi::CString;
+use std::io::{self, Write};
+use std::mem::{self, MaybeUninit};
+use std::thread;
+use std::time::Duration;
+
+// ---------------------------------------------------------------------------
+// OpenBSD sysctl constants
+// ---------------------------------------------------------------------------
+
+const CTL_KERN: libc::c_int = 1;
+const CTL_HW: libc::c_int = 6;
+const CTL_VM: libc::c_int = 2;
+
+const KERN_CPTIME: libc::c_int = 40;
+const HW_PHYSMEM64: libc::c_int = 19;
+const VM_UVMEXP: libc::c_int = 4;
+
+// CPU states (from sys/sched.h)
+const _CP_USER: usize = 0;
+const _CP_NICE: usize = 1;
+const _CP_SYS: usize = 2;
+const _CP_SPIN: usize = 3;
+const _CP_INTR: usize = 4;
+const CP_IDLE: usize = 5;
+const CPUSTATES: usize = 6;
+
+// APM (battery) ioctl
+// APM_IOC_GETPOWER = _IOR('A', 3, struct apm_power_info)
+// sizeof(apm_power_info) = 32
+const APM_IOC_GETPOWER: libc::c_ulong = 0x40204103;
+
+const APM_AC_ON: u8 = 0x01;
+const APM_BATT_CHARGING: u8 = 0x03;
+const APM_BATT_ABSENT: u8 = 0x04;
+const APM_BATT_UNKNOWN: u8 = 0xff;
+
+// ---------------------------------------------------------------------------
+// Structs matching kernel definitions
+// ---------------------------------------------------------------------------
+
+/// Partial uvmexp — we only need the first few fields.
+#[repr(C)]
+struct UvmExp {
+ pagesize: i32,
+ pagemask: i32,
+ pageshift: i32,
+ npages: i32,
+ free: i32,
+ active: i32,
+ inactive: i32,
+ paging: i32,
+ wired: i32,
+ // rest unused
+}
+
+#[repr(C)]
+struct ApmPowerInfo {
+ battery_state: u8,
+ ac_state: u8,
+ battery_life: u8,
+ spare1: u8,
+ minutes_left: u32,
+ spare2: [u32; 6],
+}
+
+// ---------------------------------------------------------------------------
+// sysctl helper
+// ---------------------------------------------------------------------------
+
+unsafe fn sysctl_raw(mib: &[libc::c_int], buf: *mut u8, len: &mut usize) -> bool {
+ unsafe {
+ libc::sysctl(
+ mib.as_ptr(),
+ mib.len() as libc::c_uint,
+ buf as *mut libc::c_void,
+ len,
+ std::ptr::null_mut(),
+ 0,
+ ) == 0
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CPU
+// ---------------------------------------------------------------------------
+
+type CpTime = [i64; CPUSTATES];
+
+fn read_cp_time() -> CpTime {
+ let mib = [CTL_KERN, KERN_CPTIME];
+ let mut cp = [0i64; CPUSTATES];
+ let mut len = mem::size_of_val(&cp);
+ unsafe {
+ sysctl_raw(
+ &mib,
+ cp.as_mut_ptr() as *mut u8,
+ &mut len,
+ );
+ }
+ cp
+}
+
+fn cpu_usage(prev: &CpTime, cur: &CpTime) -> u32 {
+ let mut delta = [0i64; CPUSTATES];
+ let mut total: i64 = 0;
+ for i in 0..CPUSTATES {
+ delta[i] = cur[i] - prev[i];
+ total += delta[i];
+ }
+ if total == 0 {
+ return 0;
+ }
+ let idle = delta[CP_IDLE];
+ let busy = total - idle;
+ ((busy * 100) / total) as u32
+}
+
+// ---------------------------------------------------------------------------
+// Memory
+// ---------------------------------------------------------------------------
+
+struct MemInfo {
+ used_mb: u64,
+ total_mb: u64,
+ pct: u32,
+}
+
+fn read_memory() -> MemInfo {
+ // Total physical memory via hw.physmem64
+ let mib_phys = [CTL_HW, HW_PHYSMEM64];
+ let mut physmem: i64 = 0;
+ let mut len = mem::size_of_val(&physmem);
+ unsafe {
+ sysctl_raw(&mib_phys, &mut physmem as *mut i64 as *mut u8, &mut len);
+ }
+
+ // Active/wired pages via vm.uvmexp
+ let mib_uvm = [CTL_VM, VM_UVMEXP];
+ let mut uvm = MaybeUninit::<UvmExp>::zeroed();
+ let uvm_len = mem::size_of::<UvmExp>();
+ unsafe {
+ // uvmexp struct is larger than our partial definition, but sysctl
+ // will fill only up to uvm_len bytes, which is fine.
+ // We need to pass the full struct size the kernel expects.
+ let mut full_len: usize = 0;
+ // First query the size
+ libc::sysctl(
+ mib_uvm.as_ptr(),
+ mib_uvm.len() as libc::c_uint,
+ std::ptr::null_mut(),
+ &mut full_len,
+ std::ptr::null_mut(),
+ 0,
+ );
+ // Allocate enough space
+ let mut buf = vec![0u8; full_len];
+ libc::sysctl(
+ mib_uvm.as_ptr(),
+ mib_uvm.len() as libc::c_uint,
+ buf.as_mut_ptr() as *mut libc::c_void,
+ &mut full_len,
+ std::ptr::null_mut(),
+ 0,
+ );
+ // Copy the part we care about
+ let copy_len = uvm_len.min(full_len);
+ std::ptr::copy_nonoverlapping(
+ buf.as_ptr(),
+ uvm.as_mut_ptr() as *mut u8,
+ copy_len,
+ );
+ }
+
+ let uvm = unsafe { uvm.assume_init() };
+ let pagesize = uvm.pagesize as u64;
+ let total = physmem as u64;
+ let used = (uvm.active as u64 + uvm.wired as u64) * pagesize;
+
+ let total_mb = total / (1024 * 1024);
+ let used_mb = used / (1024 * 1024);
+ let pct = if total > 0 {
+ ((used * 100) / total) as u32
+ } else {
+ 0
+ };
+
+ MemInfo {
+ used_mb,
+ total_mb,
+ pct,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Disk
+// ---------------------------------------------------------------------------
+
+struct DiskInfo {
+ used_gb: u64,
+ total_gb: u64,
+ pct: u32,
+}
+
+fn read_disk(path: &str) -> DiskInfo {
+ let c_path = CString::new(path).unwrap();
+ let mut st = MaybeUninit::<libc::statvfs>::zeroed();
+ let ok = unsafe { libc::statvfs(c_path.as_ptr(), st.as_mut_ptr()) == 0 };
+ if !ok {
+ return DiskInfo {
+ used_gb: 0,
+ total_gb: 0,
+ pct: 0,
+ };
+ }
+ let st = unsafe { st.assume_init() };
+ let bsize = st.f_frsize;
+ let total = st.f_blocks * bsize;
+ let avail = st.f_bavail * bsize;
+ let used = total - avail;
+ let gb = 1024 * 1024 * 1024;
+
+ DiskInfo {
+ used_gb: used / gb,
+ total_gb: total / gb,
+ pct: if total > 0 {
+ ((used * 100) / total) as u32
+ } else {
+ 0
+ },
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Network
+// ---------------------------------------------------------------------------
+
+fn read_network() -> String {
+ unsafe {
+ let mut addrs: *mut libc::ifaddrs = std::ptr::null_mut();
+ if libc::getifaddrs(&mut addrs) != 0 {
+ return "NET: down".into();
+ }
+
+ let mut result = String::from("NET: down");
+ let mut cur = addrs;
+ while !cur.is_null() {
+ let ifa = &*cur;
+ cur = ifa.ifa_next;
+
+ // Skip loopback
+ if (ifa.ifa_flags & libc::IFF_LOOPBACK as libc::c_uint) != 0 {
+ continue;
+ }
+ // Only UP interfaces
+ if (ifa.ifa_flags & libc::IFF_UP as libc::c_uint) == 0 {
+ continue;
+ }
+
+ let sa = ifa.ifa_addr;
+ if sa.is_null() {
+ continue;
+ }
+ // IPv4 only
+ if (*sa).sa_family as i32 != libc::AF_INET {
+ continue;
+ }
+
+ let sa_in = sa as *const libc::sockaddr_in;
+ let ip = (*sa_in).sin_addr.s_addr;
+ let a = ip & 0xff;
+ let b = (ip >> 8) & 0xff;
+ let c = (ip >> 16) & 0xff;
+ let d = (ip >> 24) & 0xff;
+
+ let name = std::ffi::CStr::from_ptr(ifa.ifa_name)
+ .to_str()
+ .unwrap_or("?");
+
+ result = format!("{name}: {a}.{b}.{c}.{d}");
+ break;
+ }
+
+ libc::freeifaddrs(addrs);
+ result
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Battery
+// ---------------------------------------------------------------------------
+
+fn read_battery() -> Option<ApmPowerInfo> {
+ let fd = unsafe {
+ let path = CString::new("/dev/apm").unwrap();
+ libc::open(path.as_ptr(), libc::O_RDONLY)
+ };
+ if fd < 0 {
+ return None;
+ }
+
+ let mut info = MaybeUninit::<ApmPowerInfo>::zeroed();
+ let ok = unsafe { libc::ioctl(fd, APM_IOC_GETPOWER, info.as_mut_ptr()) == 0 };
+ unsafe {
+ libc::close(fd);
+ }
+ if !ok {
+ return None;
+ }
+
+ let info = unsafe { info.assume_init() };
+ if info.battery_state == APM_BATT_ABSENT || info.battery_state == APM_BATT_UNKNOWN {
+ return None;
+ }
+ Some(info)
+}
+
+fn format_battery(info: &ApmPowerInfo) -> String {
+ let pct = info.battery_life;
+ let charging = info.battery_state == APM_BATT_CHARGING || info.ac_state == APM_AC_ON;
+ let icon = if charging { "CHR" } else { "BAT" };
+
+ let mut s = format!("{icon}: {pct}%");
+ // minutes_left is only meaningful when discharging and within sane range
+ if !charging && info.minutes_left > 0 && info.minutes_left < 6000 {
+ let h = info.minutes_left / 60;
+ let m = info.minutes_left % 60;
+ s.push_str(&format!(" ({h}:{m:02})"));
+ }
+ s
+}
+
+// ---------------------------------------------------------------------------
+// Date / Time
+// ---------------------------------------------------------------------------
+
+fn read_datetime() -> String {
+ unsafe {
+ let mut now: libc::time_t = 0;
+ libc::time(&mut now);
+ let tm = libc::localtime(&now);
+ if tm.is_null() {
+ return String::new();
+ }
+ let tm = &*tm;
+ format!(
+ "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
+ tm.tm_year + 1900,
+ tm.tm_mon + 1,
+ tm.tm_mday,
+ tm.tm_hour,
+ tm.tm_min,
+ tm.tm_sec,
+ )
+ }
+}
+
+// ---------------------------------------------------------------------------
+// JSON block formatting
+// ---------------------------------------------------------------------------
+
+fn block(name: &str, text: &str, color: Option<&str>) -> String {
+ // Escape text for JSON (handle quotes and backslashes)
+ let text = text.replace('\\', "\\\\").replace('"', "\\\"");
+ match color {
+ Some(c) => {
+ format!(r#"{{"text":"{text}","name":"{name}","color":"{c}"}}"#)
+ }
+ None => {
+ format!(r#"{{"text":"{text}","name":"{name}"}}"#)
+ }
+ }
+}
+
+fn threshold_color(pct: u32) -> Option<&'static str> {
+ if pct >= 90 {
+ Some("#ff0000")
+ } else if pct >= 70 {
+ Some("#ffaa00")
+ } else {
+ None
+ }
+}
+
+fn bat_color(pct: u8, charging: bool) -> Option<&'static str> {
+ if charging {
+ return Some("#00ff00");
+ }
+ if pct <= 10 {
+ Some("#ff0000")
+ } else if pct <= 25 {
+ Some("#ffaa00")
+ } else {
+ None
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Main loop
+// ---------------------------------------------------------------------------
+
+fn main() {
+ // Protocol header
+ println!(r#"{{"version":1}}"#);
+ let mut stdout = io::stdout().lock();
+
+ let mut prev_cp = read_cp_time();
+
+ loop {
+ thread::sleep(Duration::from_secs(1));
+
+ // CPU
+ let cur_cp = read_cp_time();
+ let cpu_pct = cpu_usage(&prev_cp, &cur_cp);
+ prev_cp = cur_cp;
+
+ // Memory
+ let mem = read_memory();
+
+ // Disk
+ let disk = read_disk("/");
+
+ // Network
+ let net = read_network();
+
+ // Battery
+ let bat = read_battery();
+
+ // Date/time
+ let dt = read_datetime();
+
+ // Build blocks
+ let mut blocks = Vec::with_capacity(6);
+ blocks.push(block(
+ "cpu",
+ &format!("CPU: {cpu_pct}%"),
+ threshold_color(cpu_pct),
+ ));
+ blocks.push(block(
+ "mem",
+ &format!("MEM: {}% ({}/{}M)", mem.pct, mem.used_mb, mem.total_mb),
+ threshold_color(mem.pct),
+ ));
+ blocks.push(block(
+ "disk",
+ &format!("DISK: {}% ({}/{}G)", disk.pct, disk.used_gb, disk.total_gb),
+ threshold_color(disk.pct),
+ ));
+ blocks.push(block("net", &net, None));
+ if let Some(ref info) = bat {
+ let charging =
+ info.battery_state == APM_BATT_CHARGING || info.ac_state == APM_AC_ON;
+ let text = format_battery(info);
+ blocks.push(block("bat", &text, bat_color(info.battery_life, charging)));
+ }
+ blocks.push(block("time", &dt, None));
+
+ // Output JSON array
+ let line = format!("[{}]", blocks.join(","));
+ if writeln!(stdout, "{line}").is_err() {
+ break;
+ }
+ if stdout.flush().is_err() {
+ break;
+ }
+ }
+}
blob - /dev/null
blob + 1fa1374a85ac6d7e11b27c5fa5a41f448c1c9dd0 (mode 644)
--- /dev/null
+++ src/bin/owm-msg.rs
+use std::env;
+use std::io::{self, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::process;
+
+use owm::ipc::IPC_MAGIC;
+use owm::sys;
+
+/// IPC message types.
+const MSG_RUN_COMMAND: u32 = 0;
+const MSG_GET_WORKSPACES: u32 = 1;
+const MSG_GET_TREE: u32 = 3;
+const MSG_GET_VERSION: u32 = 4;
+const MSG_GET_MARKS: u32 = 6;
+const MSG_GET_CONFIG: u32 = 7;
+
+fn usage() -> ! {
+ eprintln!("usage: owm-msg [-s socket] <command|query>");
+ eprintln!();
+ eprintln!("queries:");
+ eprintln!(" get_workspaces get_tree get_version");
+ eprintln!(" get_marks get_config");
+ eprintln!();
+ eprintln!("commands:");
+ eprintln!(" Any RUN_COMMAND string, e.g.:");
+ eprintln!(" owm-msg reload");
+ eprintln!(" owm-msg 'gaps inner current plus 5'");
+ process::exit(1);
+}
+
+fn find_socket() -> Option<PathBuf> {
+ if let Ok(p) = env::var("OWM_SOCKET") {
+ return Some(PathBuf::from(p));
+ }
+
+ let uid = unsafe { sys::getuid() };
+ let dir = PathBuf::from(format!("/tmp/owm-{uid}"));
+ let mut newest: Option<(PathBuf, std::time::SystemTime)> = None;
+ if let Ok(entries) = std::fs::read_dir(&dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|e| e == "sock") {
+ let mtime = entry.metadata().ok()
+ .and_then(|m| m.modified().ok())
+ .unwrap_or(std::time::UNIX_EPOCH);
+ if newest.as_ref().is_none_or(|(_, t)| mtime > *t) {
+ newest = Some((path, mtime));
+ }
+ }
+ }
+ }
+
+ newest.map(|(p, _)| p)
+}
+
+fn send_message(
+ stream: &mut UnixStream,
+ msg_type: u32,
+ payload: &[u8],
+) -> io::Result<()> {
+ let mut header = [0u8; 14];
+ header[..6].copy_from_slice(IPC_MAGIC);
+ header[6..10].copy_from_slice(&(payload.len() as u32).to_ne_bytes());
+ header[10..14].copy_from_slice(&msg_type.to_ne_bytes());
+ stream.write_all(&header)?;
+ if !payload.is_empty() {
+ stream.write_all(payload)?;
+ }
+ stream.flush()
+}
+
+fn recv_response(stream: &mut UnixStream) -> io::Result<(u32, Vec<u8>)> {
+ let mut header = [0u8; 14];
+ stream.read_exact(&mut header)?;
+
+ if &header[..6] != IPC_MAGIC {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "invalid magic bytes in response",
+ ));
+ }
+
+ let size = u32::from_ne_bytes(header[6..10].try_into().unwrap()) as usize;
+ let msg_type = u32::from_ne_bytes(header[10..14].try_into().unwrap());
+
+ let mut payload = vec![0u8; size];
+ if size > 0 {
+ stream.read_exact(&mut payload)?;
+ }
+
+ Ok((msg_type, payload))
+}
+
+fn main() {
+ let args: Vec<String> = env::args().skip(1).collect();
+ if args.is_empty() {
+ usage();
+ }
+
+ let mut socket_override: Option<PathBuf> = None;
+ let mut cmd_args: &[String] = &args;
+
+ if args[0] == "-s" {
+ if args.len() < 3 {
+ usage();
+ }
+ socket_override = Some(PathBuf::from(&args[1]));
+ cmd_args = &args[2..];
+ }
+
+ if cmd_args.is_empty() {
+ usage();
+ }
+
+ let socket_path = socket_override.or_else(find_socket).unwrap_or_else(|| {
+ eprintln!("owm-msg: cannot find owm socket");
+ eprintln!("hint: is owm running? set OWM_SOCKET if needed");
+ process::exit(1);
+ });
+
+ let mut stream = match UnixStream::connect(&socket_path) {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("owm-msg: connect {}: {}", socket_path.display(), e);
+ process::exit(1);
+ }
+ };
+
+ let command = cmd_args.join(" ");
+ let msg_type = match command.as_str() {
+ "get_workspaces" => MSG_GET_WORKSPACES,
+ "get_tree" => MSG_GET_TREE,
+ "get_version" => MSG_GET_VERSION,
+ "get_marks" => MSG_GET_MARKS,
+ "get_config" => MSG_GET_CONFIG,
+ _ => MSG_RUN_COMMAND,
+ };
+
+ let payload = if msg_type == MSG_RUN_COMMAND {
+ command.as_bytes()
+ } else {
+ b""
+ };
+
+ if let Err(e) = send_message(&mut stream, msg_type, payload) {
+ eprintln!("owm-msg: send: {}", e);
+ process::exit(1);
+ }
+
+ match recv_response(&mut stream) {
+ Ok((_reply_type, data)) => {
+ if let Ok(text) = String::from_utf8(data) {
+ if !text.is_empty() {
+ println!("{text}");
+ }
+ }
+ }
+ Err(e) => {
+ if e.kind() != io::ErrorKind::UnexpectedEof {
+ eprintln!("owm-msg: recv: {}", e);
+ process::exit(1);
+ }
+ }
+ }
+}
blob - /dev/null
blob + ee8c74b278baf710c4f632a0f489758197def6e1 (mode 644)
--- /dev/null
+++ src/bin/owm.rs
+use std::process::Command;
+use std::time::Instant;
+
+use xcb::x;
+use xcb::{Xid, XidNew};
+
+use owm::{log_debug, log_error, log_info, log_warn};
+use owm::{bar, config, container, ipc, keybind, layout, log, nagbar, sys, xcb_conn};
+use config::Config;
+use container::{
+ BorderStyle, ConType, ContainerTree, Layout, NodeKey, Rect, SizeHints,
+};
+use ipc::{EventType, IpcServer};
+use keybind::{Action, KeybindManager, ResizeDir};
+use xcb_conn::{DecoColors, Decoration, WindowType, XConn};
+
+/// Mouse drag operation in progress.
+#[derive(Debug, Clone, Copy)]
+enum DragMode {
+ Move,
+ Resize,
+}
+
+/// Snapshot of a visible container's layout state for relayout.
+struct FrameSnapshot {
+ key: NodeKey,
+ frame_id: u32,
+ win_id: u32,
+ rect: Rect,
+ window_rect: Rect,
+ is_floating: bool,
+ focused: bool,
+ hidden: bool,
+ border_style: BorderStyle,
+ border_width: u32,
+ title: String,
+}
+
+/// State for an active mouse drag.
+#[derive(Debug, Clone, Copy)]
+struct DragState {
+ mode: DragMode,
+ /// Container being dragged.
+ target: NodeKey,
+ /// Pointer position at drag start.
+ start_x: i32,
+ start_y: i32,
+ /// Window geometry at drag start.
+ start_rect: Rect,
+}
+
+/// Window manager state.
+struct Owm {
+ xconn: XConn,
+ tree: ContainerTree,
+ keybinds: KeybindManager,
+ ipc: IpcServer,
+ config: Config,
+ running: bool,
+ /// Key of the output container in the tree.
+ output_idx: NodeKey,
+ /// Current workspace key in the tree.
+ current_ws: NodeKey,
+ /// Next split direction for new windows.
+ next_split: Layout,
+ /// Active mouse drag operation.
+ drag: Option<DragState>,
+ /// Client window IDs in mapping order (_NET_CLIENT_LIST).
+ client_list: Vec<u32>,
+ /// Windows pending graceful close via WM_DELETE_WINDOW.
+ pending_closes: Vec<(x::Window, Instant)>,
+ /// Scratchpad: hidden windows that can be toggled on/off.
+ scratchpad: Vec<NodeKey>,
+ /// Self-pipe for receiving SIGHUP/SIGTERM in the event loop.
+ signal_pipe: sys::SignalPipe,
+ /// Watches ~/.owmrc for modifications (kqueue/inotify).
+ config_watcher: Option<sys::ConfigWatcher>,
+ /// Notification bar for config errors.
+ nagbar: Option<nagbar::Nagbar>,
+ /// Built-in status bar.
+ bar: Option<bar::Bar>,
+ /// Suppress EnterNotify events until the next real pointer motion.
+ /// Set after relayout to prevent focus-follows-mouse from stealing
+ /// focus from a newly mapped window.
+ suppress_enter: bool,
+}
+
+impl Owm {
+ fn new() -> Result<(Self, Vec<String>), Box<dyn std::error::Error>> {
+ let (config, config_errors) = Config::load();
+
+ let xconn = XConn::connect(&config.font)?;
+ let screen_rect = xconn.screen_rect;
+
+ let mut tree = ContainerTree::new(screen_rect);
+
+ // Build initial tree: Root -> Output -> Workspace 1
+ let output_idx =
+ tree.create_child(tree.root, ConType::Output, "default");
+ if let Some(output) = tree.get_mut(output_idx) {
+ output.rect = screen_rect;
+ }
+
+ let ws_idx = tree.create_child(output_idx, ConType::Workspace, "1");
+ if let Some(ws) = tree.get_mut(ws_idx) {
+ ws.rect = screen_rect;
+ ws.layout = Layout::SplitH;
+ }
+
+ tree.focused = Some(ws_idx);
+
+ xconn.set_background(config.color_background);
+
+ let mut keybinds = KeybindManager::new();
+ keybinds.setup(&xconn.conn, xconn.root, &config.keybindings);
+ for (mode_name, mode_bindings) in &config.modes {
+ keybinds.setup_mode(&xconn.conn, mode_name, mode_bindings);
+ }
+ xconn.flush();
+
+ let ipc = IpcServer::new()?;
+ let signal_pipe = sys::SignalPipe::new()?;
+
+ let config_watcher = std::env::var("HOME")
+ .ok()
+ .and_then(|home| {
+ let path = format!("{}/.owmrc", home);
+ match sys::ConfigWatcher::new(&path) {
+ Ok(w) => {
+ log_info!("watching {} for changes", path);
+ Some(w)
+ }
+ Err(e) => {
+ log_warn!("config watcher: {}", e);
+ None
+ }
+ }
+ });
+
+ let bar = if config.bar_enabled {
+ Some(bar::Bar::new(&xconn, &config))
+ } else {
+ None
+ };
+
+ // Grab Mod+Button1 (move) and Mod+Button3 (resize) for floating windows
+ xconn.grab_buttons(x::ModMask::N4);
+ xconn.flush();
+
+ log_info!("owm initialized");
+
+ Ok((Owm {
+ xconn,
+ tree,
+ keybinds,
+ ipc,
+ config,
+ running: true,
+ output_idx,
+ current_ws: ws_idx,
+ next_split: Layout::SplitH,
+ drag: None,
+ client_list: Vec::new(),
+ pending_closes: Vec::new(),
+ scratchpad: Vec::new(),
+ signal_pipe,
+ config_watcher,
+ nagbar: None,
+ bar,
+ suppress_enter: false,
+ }, config_errors))
+ }
+
+ /// Main event loop using poll(2).
+ fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
+ while self.running {
+ let xcb_fd = self.xconn.fd();
+ let ipc_fd = self.ipc.fd();
+ let sig_fd = self.signal_pipe.fd();
+
+ // fd < 0 is ignored by poll(2), so absent watcher is safe.
+ let cw_fd = self
+ .config_watcher
+ .as_ref()
+ .map_or(-1, |w| w.fd());
+
+ // fd < 0 is ignored by poll(2), so absent child is safe.
+ let bar_fd = self
+ .bar
+ .as_ref()
+ .and_then(|b| b.child_fd())
+ .unwrap_or(-1);
+
+ let mut fds = [
+ sys::pollfd {
+ fd: xcb_fd,
+ events: sys::POLLIN,
+ revents: 0,
+ },
+ sys::pollfd {
+ fd: ipc_fd,
+ events: sys::POLLIN,
+ revents: 0,
+ },
+ sys::pollfd {
+ fd: sig_fd,
+ events: sys::POLLIN,
+ revents: 0,
+ },
+ sys::pollfd {
+ fd: cw_fd,
+ events: sys::POLLIN,
+ revents: 0,
+ },
+ sys::pollfd {
+ fd: bar_fd,
+ events: sys::POLLIN,
+ revents: 0,
+ },
+ ];
+
+ // SAFETY: fds array is valid for the duration of the call
+ let ret =
+ unsafe { sys::poll(fds.as_mut_ptr(), fds.len() as _, 1000) };
+ if ret < 0 {
+ let errno = std::io::Error::last_os_error();
+ if errno.raw_os_error() == Some(sys::EINTR) {
+ continue;
+ }
+ return Err(format!("poll error: {}", errno).into());
+ }
+
+ // Process signals from self-pipe
+ if fds[2].revents & sys::POLLIN != 0 {
+ self.process_signals();
+ }
+
+ // Process config file watcher
+ if let Some(ref mut watcher) = self.config_watcher {
+ let readable = fds[3].revents & sys::POLLIN != 0;
+ if (readable || watcher.needs_rewatch())
+ && watcher.check()
+ {
+ log_info!("config file changed, reloading");
+ let errors = self.reload_config();
+ self.update_nagbar(errors);
+ }
+ }
+
+ // Process bar child output
+ if fds[4].revents & sys::POLLIN != 0 {
+ if let Some(ref mut b) = self.bar {
+ if b.process_child_output() {
+ self.redraw_bar();
+ }
+ }
+ }
+
+ // Process X11 events
+ self.process_x11_events();
+
+ // Process IPC
+ let commands = self.ipc.poll(
+ &self.tree,
+ &self.config,
+ self.keybinds.current_mode(),
+ );
+ for cmd in commands {
+ self.handle_ipc_command(&cmd);
+ }
+
+ // Check pending close timeouts
+ self.check_close_timeouts();
+
+ self.xconn.flush();
+ }
+
+ Ok(())
+ }
+
+ /// Process pending signals from the self-pipe.
+ fn process_signals(&mut self) {
+ for sig in self.signal_pipe.drain() {
+ match sig as i32 {
+ sys::SIGHUP => {
+ log_info!("received SIGHUP, reloading configuration");
+ let errors = self.reload_config();
+ self.update_nagbar(errors);
+ }
+ sys::SIGTERM => {
+ log_info!("received SIGTERM, shutting down");
+ self.running = false;
+ }
+ _ => {
+ log_debug!("received unexpected signal {}", sig);
+ }
+ }
+ }
+ }
+
+ /// Drain and handle all pending X11 events.
+ ///
+ /// Collects all queued events first, coalesces redundant MotionNotify
+ /// events (keeping only the last one), then processes the batch.
+ /// This avoids wasted work from intermediate mouse positions and
+ /// reduces relayouts when multiple events arrive between polls.
+ fn process_x11_events(&mut self) {
+ let mut events = Vec::new();
+
+ loop {
+ match self.xconn.conn.poll_for_event() {
+ Err(xcb::Error::Connection(_)) => {
+ log_error!("X connection lost");
+ self.running = false;
+ return;
+ }
+ Err(e) => {
+ log_warn!("X event error: {:?}", e);
+ break;
+ }
+ Ok(None) => break,
+ Ok(Some(event)) => events.push(event),
+ }
+ }
+
+ // Coalesce MotionNotify: keep only the last one (like i3)
+ let last_motion = events.iter().rposition(|e| {
+ matches!(e, xcb::Event::X(x::Event::MotionNotify(_)))
+ });
+ if let Some(last_idx) = last_motion {
+ let mut i = 0;
+ events.retain(|e| {
+ let cur = i;
+ i += 1;
+ !matches!(e, xcb::Event::X(x::Event::MotionNotify(_)))
+ || cur == last_idx
+ });
+ }
+
+ for event in events {
+ self.handle_event(event);
+ }
+ }
+
+ /// Dispatch an X11 event.
+ fn handle_event(&mut self, event: xcb::Event) {
+ match event {
+ xcb::Event::X(x::Event::MapRequest(ev)) => self.on_map_request(ev),
+ xcb::Event::X(x::Event::UnmapNotify(ev)) => {
+ self.on_unmap_notify(ev)
+ }
+ xcb::Event::X(x::Event::DestroyNotify(ev)) => {
+ self.on_destroy_notify(ev)
+ }
+ xcb::Event::X(x::Event::ConfigureRequest(ev)) => {
+ self.on_configure_request(ev)
+ }
+ xcb::Event::X(x::Event::KeyPress(ev)) => {
+ self.suppress_enter = false;
+ // Dismiss nagbar on any keypress if it's focused
+ if self.nagbar.as_ref().is_some_and(|n| {
+ ev.event().resource_id() == n.window_id()
+ }) {
+ self.dismiss_nagbar();
+ } else {
+ self.on_key_press(ev);
+ }
+ }
+ xcb::Event::X(x::Event::EnterNotify(ev)) => {
+ self.on_enter_notify(ev)
+ }
+ xcb::Event::X(x::Event::PropertyNotify(ev)) => {
+ self.on_property_notify(ev)
+ }
+ xcb::Event::X(x::Event::ButtonPress(ev)) => {
+ self.suppress_enter = false;
+ // Dismiss nagbar on click
+ if self.nagbar.as_ref().is_some_and(|n| {
+ ev.event().resource_id() == n.window_id()
+ }) {
+ self.dismiss_nagbar();
+ } else if self.bar.as_ref().is_some_and(|b| {
+ ev.event().resource_id() == b.window_id()
+ }) {
+ self.handle_bar_click(&ev);
+ } else {
+ self.on_button_press(ev);
+ }
+ }
+ xcb::Event::X(x::Event::MotionNotify(ev)) => {
+ self.on_motion_notify(ev)
+ }
+ xcb::Event::X(x::Event::ButtonRelease(ev)) => {
+ self.on_button_release(ev)
+ }
+ xcb::Event::X(x::Event::ClientMessage(ev)) => {
+ self.on_client_message(ev)
+ }
+ xcb::Event::X(x::Event::Expose(ev)) => {
+ let wid = ev.window().resource_id();
+ if self.nagbar.as_ref().is_some_and(|n| wid == n.window_id()) {
+ if let Some(ref nag) = self.nagbar {
+ nag.redraw(&self.xconn);
+ }
+ } else if self.bar.as_ref().is_some_and(|b| wid == b.window_id()) {
+ self.redraw_bar();
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// A window wants to be mapped (shown).
+ fn on_map_request(&mut self, ev: x::MapRequestEvent) {
+ let win = ev.window();
+ log_debug!("map request: {:?}", win);
+
+ // Never manage override-redirect windows (popups, menus, etc.)
+ let is_or = self.xconn.is_override_redirect(win);
+ log_debug!("map request: win={:?} override_redirect={}", win, is_or);
+ if is_or {
+ self.xconn.map_window(win);
+ return;
+ }
+
+ // Already managed: skip duplicate MapRequest
+ if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+ if self.tree.workspace_for(idx) != Some(self.current_ws)
+ && let Some(frame) =
+ self.tree.get(idx).and_then(|c| c.frame)
+ {
+ self.xconn.map_window(x::Window::new(frame));
+ }
+ return;
+ }
+
+ // Read window title and class for rules
+ let title = self.xconn.get_wm_name(win);
+ let (wm_instance, wm_class) = self.xconn.get_wm_class(win);
+
+ // Determine border style: check for_window rules first, then defaults
+ let win_type = self.xconn.get_window_type(win);
+ let should_float = matches!(
+ win_type,
+ WindowType::Dialog | WindowType::Splash | WindowType::Utility
+ );
+
+ let (mut border_style, mut bw) = if should_float {
+ (self.config.default_floating_border, self.config.border_width)
+ } else {
+ (self.config.default_border, self.config.border_width)
+ };
+
+ // Apply for_window rules
+ for rule in &self.config.window_rules {
+ let class_match = rule
+ .class
+ .as_ref()
+ .map(|c| c == &wm_class)
+ .unwrap_or(true);
+ let inst_match = rule
+ .instance
+ .as_ref()
+ .map(|i| i == &wm_instance)
+ .unwrap_or(true);
+ let title_match = rule
+ .title
+ .as_ref()
+ .map(|t| title.contains(t.as_str()))
+ .unwrap_or(true);
+ if class_match && inst_match && title_match {
+ border_style = rule.border_style;
+ if let Some(w) = rule.border_width {
+ bw = w;
+ }
+ }
+ }
+
+ // Determine where to insert.
+ // With autotiling, the split direction is chosen based on the
+ // focused container's dimensions: wider → SplitV (new window
+ // appears to the right), taller → SplitH (new window below).
+ let split_dir = if self.config.autotiling {
+ match self.tree.focused.and_then(|f| self.tree.get(f)) {
+ Some(c) if c.rect.w > c.rect.h => Layout::SplitH,
+ _ => Layout::SplitV,
+ }
+ } else {
+ self.next_split
+ };
+
+ let parent_idx = match self.tree.focused {
+ Some(focused) => {
+ let con = self.tree.get(focused);
+ match con {
+ Some(c) if c.con_type == ConType::Workspace => focused,
+ Some(c) if c.window.is_some() => {
+ if let Some(p) = c.parent {
+ let parent_layout =
+ self.tree.get(p).map(|pc| pc.layout);
+ if parent_layout == Some(split_dir) {
+ p
+ } else {
+ self.tree.split(focused, split_dir)
+ }
+ } else {
+ self.current_ws
+ }
+ }
+ _ => self.current_ws,
+ }
+ }
+ None => self.current_ws,
+ };
+
+ if should_float {
+ let con_idx = self.tree.create_child(parent_idx, ConType::Con, "");
+ let hints = self.xconn.get_size_hints(win);
+
+ // Create frame window
+ let screen = self.xconn.screen_rect;
+ let rect = Rect {
+ x: screen.w as i32 / 4,
+ y: screen.h as i32 / 4,
+ w: screen.w / 2,
+ h: screen.h / 2,
+ };
+ let rect = self.clamp_floating_rect(rect, &hints);
+ let frame = self.xconn.create_frame(&rect);
+ let deco_h = match border_style {
+ BorderStyle::Normal => self.xconn.deco_height,
+ _ => 0,
+ };
+
+ if let Some(con) = self.tree.get_mut(con_idx) {
+ con.window = Some(win.resource_id());
+ con.frame = Some(frame.resource_id());
+ con.title = title;
+ con.border_style = border_style;
+ con.border_width = bw;
+ con.size_hints = hints;
+ con.rect = rect;
+ con.window_rect = match border_style {
+ BorderStyle::Normal => Rect {
+ x: bw as i32,
+ y: deco_h as i32,
+ w: rect.w.saturating_sub(2 * bw),
+ h: rect.h.saturating_sub(deco_h + bw),
+ },
+ BorderStyle::Pixel => Rect {
+ x: bw as i32,
+ y: bw as i32,
+ w: rect.w.saturating_sub(2 * bw),
+ h: rect.h.saturating_sub(2 * bw),
+ },
+ BorderStyle::None => Rect {
+ x: 0, y: 0, w: rect.w, h: rect.h,
+ },
+ };
+ }
+
+ // Reparent client into frame
+ self.xconn
+ .reparent_window(win, frame, bw as i32, deco_h as i32);
+
+ // Detach from tiling and reattach as floating
+ self.tree.detach(con_idx);
+ self.tree.attach_floating(con_idx, self.current_ws);
+ if let Some(con) = self.tree.get_mut(con_idx) {
+ con.rect = rect;
+ }
+
+ self.relayout();
+ self.xconn.map_window(win);
+ self.xconn.map_window(frame);
+ if let Some(con) = self.tree.get_mut(con_idx) {
+ con.frame_mapped = true;
+ }
+ self.apply_window_geometry(con_idx);
+ self.xconn.raise_window(frame);
+ self.set_focus(con_idx);
+ } else {
+ let con_idx = self.tree.create_child(parent_idx, ConType::Con, "");
+
+ // Create frame (positioned later by relayout)
+ let initial_rect = Rect { x: 0, y: 0, w: 1, h: 1 };
+ let frame = self.xconn.create_frame(&initial_rect);
+ let deco_h = match border_style {
+ BorderStyle::Normal => self.xconn.deco_height,
+ _ => 0,
+ };
+
+ if let Some(con) = self.tree.get_mut(con_idx) {
+ con.window = Some(win.resource_id());
+ con.frame = Some(frame.resource_id());
+ con.title = title;
+ con.border_style = border_style;
+ con.border_width = bw;
+ }
+
+ // Reparent client into frame
+ self.xconn
+ .reparent_window(win, frame, bw as i32, deco_h as i32);
+
+ self.relayout();
+ self.xconn.map_window(win);
+ self.xconn.map_window(frame);
+ if let Some(con) = self.tree.get_mut(con_idx) {
+ con.frame_mapped = true;
+ }
+ self.set_focus(con_idx);
+ }
+
+ // Suppress EnterNotify events caused by relayout moving windows
+ // under the pointer — the new window should keep focus.
+ self.suppress_enter = true;
+
+ // Track in client list and update EWMH
+ self.client_list.push(win.resource_id());
+ self.update_client_lists();
+
+ self.ipc.broadcast_event(
+ EventType::Window,
+ &format!(r#"{{"change":"new","window":{}}}"#, win.resource_id()),
+ );
+ }
+
+ /// A window was unmapped (hidden).
+ fn on_unmap_notify(&mut self, ev: x::UnmapNotifyEvent) {
+ let win = ev.window();
+ self.pending_closes
+ .retain(|&(w, _)| w.resource_id() != win.resource_id());
+ if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+ // WM-initiated unmaps (workspace switch, minimize, scratchpad,
+ // move-to-workspace) increment ignore_unmap. Decrement and
+ // skip removal — like i3's ignore_unmap counter.
+ let ignore = self
+ .tree
+ .get(idx)
+ .map(|c| c.ignore_unmap)
+ .unwrap_or(0);
+ if ignore > 0 {
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.ignore_unmap -= 1;
+ }
+ return;
+ }
+ self.remove_client(idx);
+ }
+ }
+
+ /// A window was destroyed.
+ fn on_destroy_notify(&mut self, ev: x::DestroyNotifyEvent) {
+ let win = ev.window();
+ self.pending_closes
+ .retain(|&(w, _)| w.resource_id() != win.resource_id());
+ if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+ self.remove_client(idx);
+ }
+ }
+
+ /// A window wants to configure itself.
+ fn on_configure_request(&mut self, ev: x::ConfigureRequestEvent) {
+ let win = ev.window();
+
+ // If we manage this window, apply our layout geometry
+ if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+ if let Some(con) = self.tree.get(idx) {
+ self.xconn
+ .configure_window(win, &con.rect, con.border_width);
+ }
+ return;
+ }
+
+ // For unmanaged windows, honor their request
+ let mut values = Vec::new();
+ let mask = ev.value_mask();
+
+ if mask.contains(x::ConfigWindowMask::X) {
+ values.push(x::ConfigWindow::X(ev.x() as i32));
+ }
+ if mask.contains(x::ConfigWindowMask::Y) {
+ values.push(x::ConfigWindow::Y(ev.y() as i32));
+ }
+ if mask.contains(x::ConfigWindowMask::WIDTH) {
+ values.push(x::ConfigWindow::Width(ev.width() as u32));
+ }
+ if mask.contains(x::ConfigWindowMask::HEIGHT) {
+ values.push(x::ConfigWindow::Height(ev.height() as u32));
+ }
+ if mask.contains(x::ConfigWindowMask::BORDER_WIDTH) {
+ values.push(x::ConfigWindow::BorderWidth(ev.border_width() as u32));
+ }
+
+ self.xconn.conn.send_request(&x::ConfigureWindow {
+ window: win,
+ value_list: &values,
+ });
+ }
+
+ /// A key was pressed.
+ fn on_key_press(&mut self, ev: x::KeyPressEvent) {
+ let action = self
+ .keybinds
+ .lookup(ev.state().bits(), ev.detail())
+ .cloned();
+
+ if let Some(action) = action {
+ self.handle_action(action);
+ }
+ }
+
+ /// Pointer entered a window.
+ fn on_enter_notify(&mut self, ev: x::EnterNotifyEvent) {
+ if !self.config.focus_follows_mouse {
+ return;
+ }
+ // Ignore synthetic EnterNotify events generated by relayout
+ // (window geometry changes cause the pointer to "enter" a window
+ // even though the user didn't move the mouse).
+ if self.suppress_enter {
+ return;
+ }
+ let win = ev.event();
+ if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+ self.set_focus(idx);
+ }
+ }
+
+ /// A window property changed (urgency hints, title, etc).
+ fn on_property_notify(&mut self, ev: x::PropertyNotifyEvent) {
+ let win = ev.window();
+ if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+ let is_urgent = self.xconn.window_is_urgent(win);
+
+ // Update size hints if WM_NORMAL_HINTS changed on a floating window
+ if ev.atom() == self.xconn.atoms.wm_normal_hints {
+ let hints = self.xconn.get_size_hints(win);
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.size_hints = hints;
+ }
+ }
+
+ // Update title if _NET_WM_NAME or WM_NAME changed
+ if ev.atom() == self.xconn.atoms.net_wm_name
+ || ev.atom() == self.xconn.atoms.wm_name
+ {
+ let new_title = self.xconn.get_wm_name(win);
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.title = new_title;
+ }
+ self.redraw_decoration(idx);
+ }
+
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.urgent = is_urgent;
+ }
+
+ // Redraw decoration based on urgency
+ self.redraw_decoration(idx);
+ }
+ }
+
+ /// Handle a keybinding action.
+ fn handle_action(&mut self, action: Action) {
+ match action {
+ Action::FocusNext => self.focus_adjacent(1),
+ Action::FocusPrev => self.focus_adjacent(-1),
+ Action::FocusParent => {
+ if let Some(focused) = self.tree.focused
+ && let Some(parent) =
+ self.tree.get(focused).and_then(|c| c.parent)
+ && self.tree.get(parent).map(|c| c.con_type)
+ != Some(ConType::Workspace)
+ {
+ self.set_focus(parent);
+ }
+ }
+ Action::FocusChild => {
+ if let Some(focused) = self.tree.focused
+ && let Some(child) = self.tree.focused_child(focused)
+ {
+ self.set_focus(child);
+ }
+ }
+ Action::SwapNext => self.swap_adjacent(1),
+ Action::SwapPrev => self.swap_adjacent(-1),
+ Action::SplitVertical => {
+ self.next_split = Layout::SplitV;
+ }
+ Action::SplitHorizontal => {
+ self.next_split = Layout::SplitH;
+ }
+ Action::LayoutStacked => self.set_layout(Layout::Stacked),
+ Action::LayoutTabbed => self.set_layout(Layout::Tabbed),
+ Action::LayoutToggleSplit => {
+ if let Some(focused) = self.tree.focused
+ && let Some(parent) =
+ self.tree.get(focused).and_then(|c| c.parent)
+ {
+ let current = self.tree.get(parent).map(|c| c.layout);
+ let new_layout = match current {
+ Some(Layout::SplitH) => Layout::SplitV,
+ _ => Layout::SplitH,
+ };
+ if let Some(p) = self.tree.get_mut(parent) {
+ p.layout = new_layout;
+ }
+ self.relayout();
+ }
+ }
+ Action::ToggleFullscreen => self.toggle_fullscreen(),
+ Action::ToggleFloating => self.toggle_floating(),
+ Action::ToggleSticky => self.toggle_sticky(),
+ Action::Minimize => self.minimize_focused(),
+ Action::MoveScratchpad => self.move_to_scratchpad(),
+ Action::ScratchpadShow => self.scratchpad_show(),
+ Action::CloseWindow => {
+ if let Some(focused) = self.tree.focused
+ && let Some(win) =
+ self.tree.get(focused).and_then(|c| c.window)
+ {
+ let xwin = x::Window::new(win);
+ if self.xconn.send_delete_window(xwin) {
+ // Window supports WM_DELETE_WINDOW; track timeout
+ self.pending_closes.push((xwin, Instant::now()));
+ } else {
+ // No protocol support; force kill immediately
+ self.xconn.kill_window(xwin);
+ }
+ }
+ }
+ Action::Spawn(cmd) => {
+ self.spawn(&cmd);
+ }
+ Action::Workspace(ref name) => {
+ self.switch_workspace(name);
+ }
+ Action::MoveToWorkspace(ref name) => {
+ self.move_to_workspace(name);
+ }
+ Action::Resize(dir) => {
+ self.resize_focused(dir);
+ }
+ Action::Mode(ref name) => {
+ self.keybinds.switch_mode(
+ name,
+ &self.xconn.conn,
+ self.xconn.root,
+ );
+ self.xconn.flush();
+ }
+ Action::Restart => self.restart(),
+ Action::Exit => {
+ log_info!("exiting");
+ self.running = false;
+ }
+ }
+ }
+
+ /// Focus the next or previous sibling.
+ fn focus_adjacent(&mut self, direction: i32) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ let parent_idx = match self.tree.get(focused).and_then(|c| c.parent) {
+ Some(p) => p,
+ None => return,
+ };
+
+ let target_key = match self.tree.get(parent_idx) {
+ Some(p) => {
+ let children = &p.children;
+ match children.iter().position(|&c| c == focused) {
+ Some(pos) => {
+ let new_pos = if direction > 0 {
+ (pos + 1) % children.len()
+ } else {
+ (pos + children.len() - 1) % children.len()
+ };
+ children[new_pos]
+ }
+ None => return,
+ }
+ }
+ None => return,
+ };
+ let target = self.tree.focused_leaf(target_key);
+
+ // In tabbed/stacked, relayout first to map/unmap the correct
+ // frames before setting X11 focus (which requires a mapped window).
+ let is_tab_stack = self.tree.get(parent_idx)
+ .is_some_and(|p| matches!(p.layout, Layout::Tabbed | Layout::Stacked));
+ if is_tab_stack {
+ // Update focus_stack before relayout so is_hidden() sees
+ // the new focused child.
+ if let Some(con) = self.tree.get_mut(target) {
+ con.focused = true;
+ }
+ if let Some(prev) = self.tree.focused {
+ if let Some(con) = self.tree.get_mut(prev) {
+ con.focused = false;
+ }
+ }
+ // Update focus_stack up the tree for target
+ let mut child = target;
+ let mut cur = self.tree.get(target).and_then(|c| c.parent);
+ while let Some(pidx) = cur {
+ if let Some(parent) = self.tree.get_mut(pidx) {
+ parent.focus_stack.retain(|&c| c != child);
+ parent.focus_stack.push_front(child);
+ }
+ child = pidx;
+ cur = self.tree.get(pidx).and_then(|c| c.parent);
+ }
+ self.tree.focused = Some(target);
+ self.relayout();
+ // Now set X11 focus (frame is mapped by relayout).
+ if let Some(win) = self.tree.get(target).and_then(|c| c.window) {
+ let xwin = x::Window::new(win);
+ self.xconn.set_focus(xwin);
+ self.xconn.set_active_window(xwin);
+ }
+ } else {
+ self.set_focus(target);
+ }
+ }
+
+ /// Swap the focused container with an adjacent sibling.
+ fn swap_adjacent(&mut self, direction: i32) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ let parent_idx = match self.tree.get(focused).and_then(|c| c.parent) {
+ Some(p) => p,
+ None => return,
+ };
+
+ let swap = match self.tree.get(parent_idx) {
+ Some(p) => {
+ match p.children.iter().position(|&c| c == focused) {
+ Some(pos) => {
+ let new_pos = if direction > 0 {
+ if pos + 1 >= p.children.len() {
+ return;
+ }
+ pos + 1
+ } else {
+ if pos == 0 {
+ return;
+ }
+ pos - 1
+ };
+ (pos, new_pos)
+ }
+ None => return,
+ }
+ }
+ None => return,
+ };
+
+ if let Some(parent) = self.tree.get_mut(parent_idx) {
+ parent.children.swap(swap.0, swap.1);
+ }
+
+ self.relayout();
+ }
+
+ /// Set the layout of the focused container's parent.
+ fn set_layout(&mut self, layout: Layout) {
+ if let Some(focused) = self.tree.focused
+ && let Some(parent) = self.tree.get(focused).and_then(|c| c.parent)
+ {
+ if let Some(p) = self.tree.get_mut(parent) {
+ p.layout = layout;
+ }
+ self.relayout();
+ }
+ }
+
+ /// Toggle fullscreen on the focused container.
+ fn toggle_fullscreen(&mut self) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ let is_fullscreen = self
+ .tree
+ .get(focused)
+ .map(|c| c.fullscreen)
+ .unwrap_or(false);
+
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.fullscreen = !is_fullscreen;
+ if con.fullscreen {
+ // Fullscreen: frame fills screen, no decoration
+ let screen = self.xconn.screen_rect;
+ con.rect = screen;
+ con.window_rect = Rect {
+ x: 0,
+ y: 0,
+ w: screen.w,
+ h: screen.h,
+ };
+ } else {
+ con.border_width = self.config.border_width;
+ }
+ }
+
+ // Update _NET_WM_STATE on the window
+ if let Some(win) = self.tree.get(focused).and_then(|c| c.window) {
+ let xwin = x::Window::new(win);
+ if !is_fullscreen {
+ self.xconn.set_wm_state(
+ xwin,
+ &[self.xconn.atoms.net_wm_state_fullscreen.resource_id()],
+ );
+ } else {
+ self.xconn.set_wm_state(xwin, &[]);
+ }
+ }
+
+ if is_fullscreen {
+ self.relayout();
+ } else {
+ self.apply_window_geometry(focused);
+ // Raise fullscreen frame above everything
+ if let Some(frame) =
+ self.tree.get(focused).and_then(|c| c.frame)
+ {
+ self.xconn.raise_window(x::Window::new(frame));
+ }
+ }
+ }
+
+ /// Toggle floating state on the focused window.
+ fn toggle_floating(&mut self) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ // Only toggle leaf containers with windows
+ let (has_window, is_floating) = match self.tree.get(focused) {
+ Some(c) => (c.window.is_some(), c.is_floating),
+ None => return,
+ };
+ if !has_window {
+ return;
+ }
+
+ let ws = match self.tree.workspace_for(focused) {
+ Some(ws) => ws,
+ None => return,
+ };
+
+ if is_floating {
+ // Float -> Tiling: detach from floating, reattach to workspace as tiling
+ self.tree.detach(focused);
+ self.tree.attach_tiling(focused, ws);
+ } else {
+ // Tiling -> Float: save current geometry, detach, reattach as floating
+ let saved_rect = self.tree.get(focused).map(|c| c.rect);
+ let win_id = self.tree.get(focused).and_then(|c| c.window);
+ let hints = match win_id {
+ Some(w) => self.xconn.get_size_hints(x::Window::new(w)),
+ None => SizeHints::default(),
+ };
+ self.tree.detach(focused);
+ self.tree.attach_floating(focused, ws);
+ // Center floating window with reasonable size, respecting hints
+ let screen = self.xconn.screen_rect;
+ let rect = match saved_rect {
+ Some(r) if r.w > 0 && r.h > 0 => Rect {
+ x: (screen.w as i32 - r.w as i32) / 2,
+ y: (screen.h as i32 - r.h as i32) / 2,
+ w: r.w,
+ h: r.h,
+ },
+ _ => Rect {
+ x: screen.w as i32 / 4,
+ y: screen.h as i32 / 4,
+ w: screen.w / 2,
+ h: screen.h / 2,
+ },
+ };
+ let rect = self.clamp_floating_rect(rect, &hints);
+ let bw = self.config.border_width;
+ let deco_h = match self.config.default_floating_border {
+ BorderStyle::Normal => self.xconn.deco_height,
+ _ => 0,
+ };
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.rect = rect;
+ con.border_style = self.config.default_floating_border;
+ con.border_width = bw;
+ con.size_hints = hints;
+ // Compute window_rect for the floating frame.
+ con.window_rect = match con.border_style {
+ BorderStyle::Normal => Rect {
+ x: bw as i32,
+ y: deco_h as i32,
+ w: rect.w.saturating_sub(2 * bw),
+ h: rect.h.saturating_sub(deco_h + bw),
+ },
+ BorderStyle::Pixel => Rect {
+ x: bw as i32,
+ y: bw as i32,
+ w: rect.w.saturating_sub(2 * bw),
+ h: rect.h.saturating_sub(2 * bw),
+ },
+ BorderStyle::None => Rect {
+ x: 0, y: 0, w: rect.w, h: rect.h,
+ },
+ };
+ }
+ }
+
+ // Clean up empty parents from the tiling tree
+ if is_floating {
+ // Was floating, now tiling — relayout
+ self.relayout();
+ } else {
+ // Now floating — relayout tiling, then position floating window
+ self.relayout();
+ self.apply_window_geometry(focused);
+ if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame)
+ {
+ self.xconn.raise_window(x::Window::new(frame));
+ }
+ }
+ }
+
+ /// Toggle sticky state on the focused floating window.
+ /// Sticky windows follow workspace switches (like i3).
+ fn toggle_sticky(&mut self) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ // Only floating windows can be sticky (like i3)
+ match self.tree.get(focused) {
+ Some(c) if c.is_floating && c.window.is_some() => {}
+ _ => return,
+ }
+
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.sticky = !con.sticky;
+ log_debug!(
+ "sticky toggled: {} for window {:?}",
+ con.sticky,
+ con.window
+ );
+ }
+ }
+
+ /// Minimize the focused window: unmap and set _NET_WM_STATE_HIDDEN.
+ fn minimize_focused(&mut self) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ let (has_window, already_minimized) = match self.tree.get(focused) {
+ Some(c) => (c.window.is_some(), c.minimized),
+ None => return,
+ };
+ if !has_window || already_minimized {
+ return;
+ }
+
+ // Mark as minimized and unmap
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.minimized = true;
+ }
+
+ // Unmap the frame (hides both frame and client)
+ if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame) {
+ self.xconn.unmap_window(x::Window::new(frame));
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.ignore_unmap += 1;
+ }
+ }
+
+ if let Some(win) = self.tree.get(focused).and_then(|c| c.window) {
+ let xwin = x::Window::new(win);
+ // Build state list: hidden + any existing states (e.g. fullscreen)
+ let mut states =
+ vec![self.xconn.atoms.net_wm_state_hidden.resource_id()];
+ if self
+ .tree
+ .get(focused)
+ .map(|c| c.fullscreen)
+ .unwrap_or(false)
+ {
+ states.push(
+ self.xconn.atoms.net_wm_state_fullscreen.resource_id(),
+ );
+ }
+ self.xconn.set_wm_state(xwin, &states);
+ }
+
+ // Focus next window in current workspace
+ let next = self.tree.focused_leaf(self.current_ws);
+ if next != self.current_ws {
+ self.set_focus(next);
+ } else {
+ self.tree.focused = Some(self.current_ws);
+ self.xconn.clear_active_window();
+ }
+ }
+
+ /// Restore a minimized window by key: re-map and clear HIDDEN state.
+ fn restore_window(&mut self, idx: NodeKey) {
+ let (is_minimized, has_window) = match self.tree.get(idx) {
+ Some(c) => (c.minimized, c.window.is_some()),
+ None => return,
+ };
+ if !is_minimized || !has_window {
+ return;
+ }
+
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.minimized = false;
+ }
+
+ // Map the frame (makes both frame and client visible)
+ if let Some(frame) = self.tree.get(idx).and_then(|c| c.frame) {
+ self.xconn.map_window(x::Window::new(frame));
+ }
+
+ if let Some(win) = self.tree.get(idx).and_then(|c| c.window) {
+ let xwin = x::Window::new(win);
+ // Rebuild _NET_WM_STATE without HIDDEN
+ let mut states = Vec::new();
+ if self.tree.get(idx).map(|c| c.fullscreen).unwrap_or(false) {
+ states.push(
+ self.xconn.atoms.net_wm_state_fullscreen.resource_id(),
+ );
+ }
+ self.xconn.set_wm_state(xwin, &states);
+ }
+
+ self.set_focus(idx);
+ }
+
+ /// Move the focused window to the scratchpad (hide it).
+ fn move_to_scratchpad(&mut self) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ // Only move leaf containers with windows
+ match self.tree.get(focused) {
+ Some(c) if c.window.is_some() => {}
+ _ => return,
+ }
+
+ // Already in scratchpad
+ if self.scratchpad.contains(&focused) {
+ return;
+ }
+
+ // Detach from the tree and make it floating
+ let was_floating = self
+ .tree
+ .get(focused)
+ .map(|c| c.is_floating)
+ .unwrap_or(false);
+ self.tree.detach(focused);
+
+ // Mark as floating (scratchpad windows appear as floating when shown)
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.is_floating = true;
+ }
+
+ // Unmap the frame (hides both frame and client)
+ if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame) {
+ self.xconn.unmap_window(x::Window::new(frame));
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.ignore_unmap += 1;
+ }
+ }
+
+ self.scratchpad.push(focused);
+
+ // Focus next window in current workspace
+ let next = self.tree.focused_leaf(self.current_ws);
+ if next != self.current_ws {
+ self.set_focus(next);
+ } else {
+ self.tree.focused = Some(self.current_ws);
+ self.xconn.clear_active_window();
+ }
+
+ if !was_floating {
+ self.relayout();
+ }
+ }
+
+ /// Toggle the most recent scratchpad window: show if hidden, hide if visible.
+ fn scratchpad_show(&mut self) {
+ if self.scratchpad.is_empty() {
+ return;
+ }
+
+ // Check if the last scratchpad window is currently visible
+ // (attached to a workspace). If so, hide it again.
+ let last = *self.scratchpad.last().unwrap();
+ let is_attached = self.tree.get(last).and_then(|c| c.parent).is_some();
+
+ if is_attached {
+ // Currently shown: detach and hide
+ self.tree.detach(last);
+ if let Some(frame) = self.tree.get(last).and_then(|c| c.frame) {
+ self.xconn.unmap_window(x::Window::new(frame));
+ if let Some(con) = self.tree.get_mut(last) {
+ con.ignore_unmap += 1;
+ }
+ }
+
+ // Focus next window in current workspace
+ let next = self.tree.focused_leaf(self.current_ws);
+ if next != self.current_ws {
+ self.set_focus(next);
+ } else {
+ self.tree.focused = Some(self.current_ws);
+ self.xconn.clear_active_window();
+ }
+ } else {
+ // Hidden: attach as floating to current workspace and show
+ self.tree.attach_floating(last, self.current_ws);
+
+ // Center on screen
+ let screen = self.xconn.screen_rect;
+ let hints = self
+ .tree
+ .get(last)
+ .map(|c| c.size_hints)
+ .unwrap_or_default();
+ let rect = Rect {
+ x: screen.w as i32 / 6,
+ y: screen.h as i32 / 6,
+ w: screen.w * 2 / 3,
+ h: screen.h * 2 / 3,
+ };
+ let rect = self.clamp_floating_rect(rect, &hints);
+ if let Some(con) = self.tree.get_mut(last) {
+ con.rect = rect;
+ con.border_width = self.config.border_width;
+ }
+
+ if let Some(frame) = self.tree.get(last).and_then(|c| c.frame) {
+ let xframe = x::Window::new(frame);
+ self.xconn.map_window(xframe);
+ self.xconn.configure_frame(xframe, &rect);
+ self.xconn.raise_window(xframe);
+ }
+ // Position client inside frame
+ self.apply_window_geometry(last);
+ self.set_focus(last);
+
+ // Rotate: move last to front so next show cycles
+ let popped = self.scratchpad.pop().unwrap();
+ self.scratchpad.insert(0, popped);
+ }
+ }
+
+ /// Handle a mouse button press (for floating move/resize).
+ fn on_button_press(&mut self, ev: x::ButtonPressEvent) {
+ let win = ev.child();
+ if win == x::WINDOW_NONE {
+ return;
+ }
+
+ let idx = match self.tree.find_by_window(win.resource_id()) {
+ Some(i) => i,
+ None => return,
+ };
+
+ let is_floating =
+ self.tree.get(idx).map(|c| c.is_floating).unwrap_or(false);
+ if !is_floating {
+ return;
+ }
+
+ self.set_focus(idx);
+
+ let mode = match ev.detail() {
+ 1 => DragMode::Move,
+ 3 => DragMode::Resize,
+ _ => return,
+ };
+
+ let start_rect = match self.tree.get(idx) {
+ Some(c) => c.rect,
+ None => return,
+ };
+
+ self.drag = Some(DragState {
+ mode,
+ target: idx,
+ start_x: ev.root_x() as i32,
+ start_y: ev.root_y() as i32,
+ start_rect,
+ });
+ }
+
+ /// Handle mouse motion (drag floating windows).
+ fn on_motion_notify(&mut self, ev: x::MotionNotifyEvent) {
+ // Real pointer motion: allow EnterNotify events again.
+ self.suppress_enter = false;
+
+ let drag = match self.drag {
+ Some(d) => d,
+ None => return,
+ };
+
+ let dx = ev.root_x() as i32 - drag.start_x;
+ let dy = ev.root_y() as i32 - drag.start_y;
+
+ let hints = self
+ .tree
+ .get(drag.target)
+ .map(|c| c.size_hints)
+ .unwrap_or_default();
+
+ let raw_rect = match drag.mode {
+ DragMode::Move => Rect {
+ x: drag.start_rect.x + dx,
+ y: drag.start_rect.y + dy,
+ w: drag.start_rect.w,
+ h: drag.start_rect.h,
+ },
+ DragMode::Resize => Rect {
+ x: drag.start_rect.x,
+ y: drag.start_rect.y,
+ w: (drag.start_rect.w as i32 + dx).max(1) as u32,
+ h: (drag.start_rect.h as i32 + dy).max(1) as u32,
+ },
+ };
+
+ let new_rect = self.clamp_floating_rect(raw_rect, &hints);
+
+ if let Some(con) = self.tree.get_mut(drag.target) {
+ con.rect = new_rect;
+ }
+ self.apply_window_geometry(drag.target);
+ }
+
+ /// Handle mouse button release (end drag).
+ fn on_button_release(&mut self, _ev: x::ButtonReleaseEvent) {
+ self.drag = None;
+ }
+
+ /// Switch to workspace by name, creating it if needed.
+ fn switch_workspace(&mut self, name: &str) {
+ // Find existing workspace or create new one
+ let ws_idx = self.find_workspace(name).unwrap_or_else(|| {
+ let idx = self.tree.create_child(
+ self.output_idx,
+ ConType::Workspace,
+ name,
+ );
+ if let Some(ws) = self.tree.get_mut(idx) {
+ ws.rect = self.xconn.screen_rect;
+ ws.layout = Layout::SplitH;
+ }
+ idx
+ });
+
+ if ws_idx == self.current_ws {
+ return;
+ }
+
+ let old_ws = self.current_ws;
+
+ // Move sticky floating windows to the target workspace (like i3)
+ let sticky_windows: Vec<NodeKey> = self
+ .tree
+ .get(old_ws)
+ .map(|ws| {
+ ws.floating_children
+ .iter()
+ .copied()
+ .filter(|&k| {
+ self.tree.get(k).map(|c| c.sticky).unwrap_or(false)
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ for key in sticky_windows {
+ self.tree.detach(key);
+ self.tree.attach_floating(key, ws_idx);
+ }
+
+ // Hide current workspace windows
+ self.set_workspace_visible(old_ws, false);
+
+ // Show target workspace windows
+ self.set_workspace_visible(ws_idx, true);
+
+ self.current_ws = ws_idx;
+
+ // Focus the most recently focused window in the new workspace,
+ // but avoid focusing the workspace container itself if it's empty.
+ let focus_target = self.tree.focused_leaf(ws_idx);
+ if focus_target != ws_idx {
+ self.set_focus(focus_target);
+ } else {
+ // Empty workspace: clear focus, no window to focus
+ if let Some(prev) = self.tree.focused {
+ if let Some(con) = self.tree.get_mut(prev) {
+ con.focused = false;
+ }
+ if let Some(win) = self.tree.get(prev).and_then(|c| c.window) {
+ self.xconn.set_border_color(
+ x::Window::new(win),
+ self.config.color_unfocused,
+ );
+ }
+ }
+ self.tree.focused = Some(ws_idx);
+ self.xconn.clear_active_window();
+ }
+ self.relayout();
+
+ // Update EWMH desktop properties
+ let desktop_index = self.workspace_index(ws_idx);
+ self.xconn.set_current_desktop(desktop_index);
+ let ws_count = self.workspace_count();
+ self.xconn.set_number_of_desktops(ws_count);
+ self.xconn.set_workarea(ws_count, &self.workarea());
+
+ self.ipc.broadcast_event(
+ EventType::Workspace,
+ &format!(r#"{{"change":"focus","current":"{name}"}}"#),
+ );
+
+ // Clean up empty old workspace (like i3's workspace_show)
+ self.cleanup_empty_workspace(old_ws);
+ }
+
+ /// Remove a workspace if it has no tiling or floating children.
+ fn cleanup_empty_workspace(&mut self, ws_key: NodeKey) {
+ // Don't remove the current workspace
+ if ws_key == self.current_ws {
+ return;
+ }
+ let is_empty = self
+ .tree
+ .get(ws_key)
+ .map(|ws| ws.children.is_empty() && ws.floating_children.is_empty())
+ .unwrap_or(false);
+ if is_empty {
+ log_debug!("removing empty workspace");
+ self.tree.remove(ws_key);
+
+ let ws_count = self.workspace_count();
+ self.xconn.set_number_of_desktops(ws_count);
+ self.xconn.set_workarea(ws_count, &self.workarea());
+ }
+ }
+
+ /// Move the focused window to another workspace.
+ fn move_to_workspace(&mut self, name: &str) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ // Don't move workspace containers
+ if self.tree.get(focused).map(|c| c.con_type) != Some(ConType::Con) {
+ return;
+ }
+
+ let target_ws = self.find_workspace(name).unwrap_or_else(|| {
+ let idx = self.tree.create_child(
+ self.output_idx,
+ ConType::Workspace,
+ name,
+ );
+ if let Some(ws) = self.tree.get_mut(idx) {
+ ws.rect = self.xconn.screen_rect;
+ ws.layout = Layout::SplitH;
+ }
+ idx
+ });
+
+ if target_ws == self.current_ws {
+ return;
+ }
+
+ let is_floating = self
+ .tree
+ .get(focused)
+ .map(|c| c.is_floating)
+ .unwrap_or(false);
+
+ // Detach from current parent
+ self.tree.detach(focused);
+
+ // Attach to target workspace (preserving floating state)
+ if is_floating {
+ self.tree.attach_floating(focused, target_ws);
+ } else {
+ self.tree.attach_tiling(focused, target_ws);
+ }
+
+ // Hide the moved window (it's on a hidden workspace)
+ if let Some(frame) = self.tree.get(focused).and_then(|c| c.frame) {
+ self.xconn.unmap_window(x::Window::new(frame));
+ if let Some(con) = self.tree.get_mut(focused) {
+ con.ignore_unmap += 1;
+ }
+ }
+
+ // Focus next window in current workspace
+ let next = self.tree.focused_leaf(self.current_ws);
+ if next != self.current_ws {
+ self.set_focus(next);
+ } else {
+ self.tree.focused = Some(self.current_ws);
+ self.xconn.clear_active_window();
+ }
+ self.relayout();
+ }
+
+ /// Find a workspace by name (linear scan, like i3).
+ fn find_workspace(&self, name: &str) -> Option<NodeKey> {
+ self.tree
+ .iter()
+ .find(|(_, c)| c.con_type == ConType::Workspace && c.name == name)
+ .map(|(i, _)| i)
+ }
+
+ /// Get the index of a workspace among all workspaces (for EWMH).
+ fn workspace_index(&self, ws_key: NodeKey) -> u32 {
+ match self.tree.get(self.output_idx) {
+ Some(output) => output
+ .children
+ .iter()
+ .filter(|&&k| {
+ self.tree
+ .get(k)
+ .map(|c| c.con_type == ConType::Workspace)
+ .unwrap_or(false)
+ })
+ .position(|&k| k == ws_key)
+ .unwrap_or(0) as u32,
+ None => 0,
+ }
+ }
+
+ /// Show or hide all windows in a workspace.
+ fn set_workspace_visible(&mut self, ws_idx: NodeKey, visible: bool) {
+ let Some(ws) = self.tree.get(ws_idx) else {
+ return;
+ };
+ let mut stack = Vec::with_capacity(
+ ws.children.len() + ws.floating_children.len(),
+ );
+ stack.extend_from_slice(&ws.children);
+ stack.extend_from_slice(&ws.floating_children);
+ self.set_subtree_visible_iter(&mut stack, visible);
+ }
+
+ fn set_subtree_visible_iter(
+ &mut self,
+ stack: &mut Vec<NodeKey>,
+ visible: bool,
+ ) {
+ while let Some(idx) = stack.pop() {
+ if let Some(con) = self.tree.get(idx) {
+ let frame = con.frame;
+ let children = con.children.clone();
+ if let Some(frame) = frame {
+ let xframe = x::Window::new(frame);
+ if visible {
+ self.xconn.map_window(xframe);
+ } else {
+ self.xconn.unmap_window(xframe);
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.ignore_unmap += 1;
+ }
+ }
+ }
+ stack.extend_from_slice(&children);
+ }
+ }
+ }
+
+ /// Restart owm in-place (like i3's restart).
+ ///
+ /// Reparents all client windows back to the root so they survive the
+ /// exec, unregisters as the WM, flushes X, then replaces the process.
+ fn restart(&mut self) {
+ log_info!("restarting owm");
+
+ // Reparent every managed client back to the root window so the
+ // windows survive across the exec boundary.
+ for (_, con) in self.tree.iter() {
+ if let (Some(win), Some(_frame)) = (con.window, con.frame) {
+ let xwin = x::Window::new(win);
+ self.xconn.conn.send_request(&x::ReparentWindow {
+ window: xwin,
+ parent: self.xconn.root,
+ x: con.rect.x as i16,
+ y: con.rect.y as i16,
+ });
+ }
+ }
+
+ // Unregister as the window manager so the new instance can claim
+ // SubstructureRedirect on the root.
+ self.xconn.conn.send_request(&x::ChangeWindowAttributes {
+ window: self.xconn.root,
+ value_list: &[x::Cw::EventMask(x::EventMask::NO_EVENT)],
+ });
+
+ self.xconn.flush();
+
+ // Remove IPC socket before exec (Drop won't run after execvp).
+ let _ = std::fs::remove_file(IpcServer::socket_path());
+
+ // Replace this process with a fresh owm.
+ let exe = std::env::current_exe().unwrap_or_else(|_| "owm".into());
+ let args: Vec<String> = std::env::args().collect();
+ let cargs: Vec<std::ffi::CString> = args
+ .iter()
+ .map(|a| std::ffi::CString::new(a.as_str()).unwrap())
+ .collect();
+ let cexe = std::ffi::CString::new(
+ exe.to_string_lossy().as_ref(),
+ )
+ .unwrap();
+
+ // execvp replaces the process; if it returns, something went wrong.
+ sys::exec_replace(&cexe, &cargs);
+
+ // Fallback: if exec failed, just exit.
+ log_warn!("exec failed, exiting");
+ self.running = false;
+ }
+
+ /// Remove a client container from the tree.
+ fn remove_client(&mut self, idx: NodeKey) {
+ let parent_idx = self.tree.get(idx).and_then(|c| c.parent);
+ let was_focused = self.tree.focused == Some(idx);
+ let win_id = self.tree.get(idx).and_then(|c| c.window);
+ let frame_id = self.tree.get(idx).and_then(|c| c.frame);
+
+ // Destroy the frame window
+ if let Some(fid) = frame_id {
+ self.xconn.destroy_frame(x::Window::new(fid));
+ }
+
+ // Clean up scratchpad references
+ self.scratchpad.retain(|&k| k != idx);
+
+ self.tree.remove(idx);
+
+ // Clean up empty split containers
+ if let Some(pidx) = parent_idx {
+ self.cleanup_empty(pidx);
+ }
+
+ if was_focused {
+ let next = self.tree.focused_leaf(self.current_ws);
+ if next != self.current_ws {
+ self.set_focus(next);
+ } else {
+ // No windows left: clear X11 focus
+ self.tree.focused = Some(self.current_ws);
+ self.xconn.clear_active_window();
+ }
+ }
+
+ self.relayout();
+
+ // Remove from client list and update EWMH
+ if let Some(wid) = win_id {
+ self.client_list.retain(|&w| w != wid);
+ self.update_client_lists();
+
+ self.ipc.broadcast_event(
+ EventType::Window,
+ &format!(r#"{{"change":"close","window":{wid}}}"#),
+ );
+ }
+ }
+
+ /// Remove empty non-workspace containers.
+ fn cleanup_empty(&mut self, idx: NodeKey) {
+ let (con_type, children_len, parent) = match self.tree.get(idx) {
+ Some(c) => (c.con_type, c.children.len(), c.parent),
+ None => return,
+ };
+
+ if con_type == ConType::Con && children_len == 0 {
+ self.tree.remove(idx);
+ if let Some(pidx) = parent {
+ self.cleanup_empty(pidx);
+ }
+ }
+ }
+
+ /// Set focus on a container.
+ ///
+ /// Only containers with a window (leaf nodes) should receive X11 focus.
+ /// If called with a non-leaf (workspace, output, root), we skip the
+ /// xconn calls and just update tree state.
+ fn set_focus(&mut self, idx: NodeKey) {
+ // Validate: only focus Con/FloatingCon containers with a window.
+ match self.tree.get(idx).map(|c| c.con_type) {
+ Some(ConType::Con) | Some(ConType::FloatingCon) => {}
+ _ => {
+ log_debug!("set_focus: ignoring non-leaf container");
+ return;
+ }
+ }
+
+ // Unfocus previous — redraw decoration with unfocused colors
+ if let Some(prev) = self.tree.focused {
+ if let Some(con) = self.tree.get_mut(prev) {
+ con.focused = false;
+ }
+ self.redraw_decoration(prev);
+ }
+
+ // Focus new — clear urgency on focus
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.focused = true;
+ con.urgent = false;
+ }
+ self.tree.focused = Some(idx);
+
+ // Update focus stack up the tree
+ let mut child = idx;
+ let mut cur = self.tree.get(idx).and_then(|c| c.parent);
+ while let Some(pidx) = cur {
+ if let Some(parent) = self.tree.get_mut(pidx) {
+ parent.focus_stack.retain(|&c| c != child);
+ parent.focus_stack.push_front(child);
+ }
+ child = pidx;
+ cur = self.tree.get(pidx).and_then(|c| c.parent);
+ }
+
+ // Set X11 focus and redraw decoration with focused colors.
+ // Skip SetInputFocus if the window is hidden (frame unmapped)
+ // inside a tabbed/stacked parent — it would cause a BadMatch.
+ let is_hidden = self.tree.is_hidden(idx);
+ if !is_hidden
+ && let Some(win) = self.tree.get(idx).and_then(|c| c.window)
+ {
+ let xwin = x::Window::new(win);
+ self.xconn.set_focus(xwin);
+ self.xconn.set_active_window(xwin);
+ self.redraw_decoration(idx);
+
+ self.ipc.broadcast_event(
+ EventType::Window,
+ &format!(r#"{{"change":"focus","window":{win}}}"#),
+ );
+ } else {
+ self.xconn.clear_active_window();
+ }
+ }
+
+ /// Redraw decoration on a single container's frame.
+ ///
+ /// Skips containers inside tabbed/stacked parents — their
+ /// decorations are drawn collectively by `relayout()`.
+ fn redraw_decoration(&self, idx: NodeKey) {
+ // In tabbed/stacked, the parent draws all tabs during relayout.
+ if self.tree.tab_siblings(idx).is_some() {
+ return;
+ }
+
+ if let Some(con) = self.tree.get(idx)
+ && let Some(frame_id) = con.frame
+ {
+ let frame = x::Window::new(frame_id);
+ let deco_h = match con.border_style {
+ BorderStyle::Normal => self.xconn.deco_height,
+ _ => 0,
+ };
+ let colors = self.deco_colors(con.focused, con.urgent);
+ self.xconn.draw_decoration(&Decoration {
+ frame,
+ frame_w: con.rect.w,
+ frame_h: con.rect.h,
+ title: &con.title,
+ colors: &colors,
+ border_width: con.border_width,
+ deco_height: deco_h,
+ });
+ }
+ }
+
+ /// Recompute layout and apply to all windows.
+ /// Compute the usable workspace rect (screen minus bar).
+ fn workarea(&self) -> Rect {
+ match &self.bar {
+ Some(b) => b.workarea(self.xconn.screen_rect),
+ None => self.xconn.screen_rect,
+ }
+ }
+
+ /// Resolve the effective gaps for a workspace, merging per-workspace
+ /// overrides from config and from the container itself, then clamping.
+ fn resolve_gaps(&self, ws_key: NodeKey) -> config::Gaps {
+ let global = &self.config.gaps;
+
+ // Start with container-level override (set via IPC).
+ let con_ovr = self.tree.get(ws_key)
+ .map(|c| c.gaps_override)
+ .unwrap_or_default();
+
+ // Then layer config-level per-workspace override.
+ let cfg_ovr = self.tree.get(ws_key)
+ .and_then(|c| self.config.workspace_gaps.get(&c.name))
+ .copied()
+ .unwrap_or_default();
+
+ // Config overrides global, container overrides config.
+ let resolved = config::Gaps {
+ inner: con_ovr.inner
+ .or(cfg_ovr.inner)
+ .unwrap_or(global.inner),
+ top: con_ovr.top
+ .or(cfg_ovr.top)
+ .unwrap_or(global.top),
+ right: con_ovr.right
+ .or(cfg_ovr.right)
+ .unwrap_or(global.right),
+ bottom: con_ovr.bottom
+ .or(cfg_ovr.bottom)
+ .unwrap_or(global.bottom),
+ left: con_ovr.left
+ .or(cfg_ovr.left)
+ .unwrap_or(global.left),
+ };
+
+ resolved.clamped()
+ }
+
+ fn relayout(&mut self) {
+ // Set workspace rect (accounting for bar)
+ let wa = self.workarea();
+ if let Some(ws) = self.tree.get_mut(self.current_ws) {
+ ws.rect = wa;
+ }
+
+ // Resolve per-workspace gap overrides against global gaps.
+ let gaps = self.resolve_gaps(self.current_ws);
+
+ layout::apply_layout(
+ &mut self.tree,
+ self.current_ws,
+ &gaps,
+ self.config.smart_gaps,
+ self.xconn.deco_height,
+ );
+
+ // Collect all containers with frames in the current workspace
+ let containers: Vec<FrameSnapshot> = self
+ .tree
+ .iter()
+ .filter(|(_, c)| c.window.is_some() && c.frame.is_some())
+ .filter(|(idx, _)| {
+ self.tree.workspace_for(*idx) == Some(self.current_ws)
+ })
+ .map(|(idx, c)| FrameSnapshot {
+ key: idx,
+ frame_id: c.frame.unwrap(),
+ win_id: c.window.unwrap(),
+ rect: c.rect,
+ window_rect: c.window_rect,
+ is_floating: c.is_floating,
+ focused: c.focused,
+ hidden: self.tree.is_hidden(idx),
+ border_style: c.border_style,
+ border_width: c.border_width,
+ title: c.title.clone(),
+ })
+ .collect();
+
+ // Pass 1: geometry + map/unmap
+ for snap in &containers {
+ let frame = x::Window::new(snap.frame_id);
+ let client = x::Window::new(snap.win_id);
+
+ if snap.hidden {
+ if let Some(con) = self.tree.get_mut(snap.key) {
+ if con.frame_mapped {
+ con.frame_mapped = false;
+ con.ignore_unmap += 1;
+ self.xconn.unmap_window(frame);
+ }
+ }
+ continue;
+ }
+
+ self.xconn.configure_frame(frame, &snap.rect);
+ self.xconn.configure_client_in_frame(
+ client,
+ snap.window_rect.x,
+ snap.window_rect.y,
+ snap.window_rect.w,
+ snap.window_rect.h,
+ );
+
+ if let Some(con) = self.tree.get_mut(snap.key) {
+ if !con.frame_mapped {
+ con.frame_mapped = true;
+ self.xconn.map_window(frame);
+ }
+ }
+
+ if snap.is_floating {
+ self.xconn.raise_window(frame);
+ }
+ }
+
+ // Flush geometry/map/unmap before drawing decorations.
+ self.xconn.flush();
+
+ // Pass 2: decorations (only on visible frames)
+ for snap in &containers {
+ if snap.hidden {
+ continue;
+ }
+
+ let frame = x::Window::new(snap.frame_id);
+ let deco_h = match snap.border_style {
+ BorderStyle::Normal => self.xconn.deco_height,
+ _ => 0,
+ };
+
+ let tab_info = self.tree.tab_siblings(snap.key);
+
+ if let Some((_, ref tabs)) = tab_info {
+ // Fill frame background (border color).
+ let colors_focused = self.deco_colors(true, false);
+ self.xconn.draw_decoration(&Decoration {
+ frame,
+ frame_w: snap.rect.w,
+ frame_h: snap.rect.h,
+ title: "",
+ colors: &colors_focused,
+ border_width: snap.border_width,
+ deco_height: 0,
+ });
+
+ // Draw each tab
+ for &(sib_key, deco_rect) in tabs {
+ let sib_title = self.tree.get(sib_key)
+ .map(|c| c.title.as_str())
+ .unwrap_or("");
+ let sib_focused = sib_key == snap.key;
+ let colors = self.deco_colors(sib_focused, false);
+ self.xconn.draw_tab(
+ frame,
+ &deco_rect,
+ sib_title,
+ &colors,
+ sib_focused,
+ );
+ }
+ } else {
+ let colors = self.deco_colors(snap.focused, false);
+ self.xconn.draw_decoration(&Decoration {
+ frame,
+ frame_w: snap.rect.w,
+ frame_h: snap.rect.h,
+ title: &snap.title,
+ colors: &colors,
+ border_width: snap.border_width,
+ deco_height: deco_h,
+ });
+
+ // Draw split direction indicator on the focused window.
+ if snap.focused && !snap.is_floating {
+ let split_v = if self.config.autotiling {
+ snap.rect.h >= snap.rect.w
+ } else {
+ self.next_split == Layout::SplitV
+ };
+ self.xconn.draw_indicator(
+ frame,
+ snap.rect.w,
+ snap.rect.h,
+ split_v,
+ self.config.color_indicator,
+ );
+ }
+ }
+ }
+
+ self.xconn.flush();
+
+ // Redraw the status bar
+ self.redraw_bar();
+ }
+
+ /// Redraw the status bar if enabled.
+ fn redraw_bar(&mut self) {
+ if let Some(ref mut b) = self.bar {
+ b.redraw(&self.xconn, &self.config, &self.tree, self.current_ws);
+ }
+ }
+
+ /// Handle a click on the status bar.
+ fn handle_bar_click(&mut self, ev: &x::ButtonPressEvent) {
+ let click_x = ev.event_x() as i32;
+ let click_y = ev.event_y() as i32;
+ let button = ev.detail() as u32;
+
+ // Extract the action without holding a borrow on self.bar.
+ let action = self.bar.as_mut().and_then(|b| {
+ b.handle_click(click_x, click_y, button)
+ });
+
+ if let Some(bar::BarAction::SwitchWorkspace(ws_key)) = action {
+ if let Some(name) = self.tree.get(ws_key).map(|c| c.name.clone()) {
+ self.switch_workspace(&name);
+ }
+ }
+ }
+
+ /// Get decoration colors for a container state.
+ ///
+ /// Matches i3 defaults: border is the indicator/edge color,
+ /// background is the fill behind title text.
+ fn deco_colors(&self, focused: bool, urgent: bool) -> DecoColors {
+ if urgent {
+ DecoColors {
+ border: self.config.color_urgent,
+ background: self.config.color_urgent_bg,
+ text: self.config.color_title_focused,
+ }
+ } else if focused {
+ DecoColors {
+ border: self.config.color_focused,
+ background: self.config.color_focused_bg,
+ text: self.config.color_title_focused,
+ }
+ } else {
+ DecoColors {
+ border: self.config.color_unfocused,
+ background: self.config.color_unfocused_bg,
+ text: self.config.color_title_unfocused,
+ }
+ }
+ }
+
+ /// Minimum pixels of a floating window that must remain visible on screen.
+ const MIN_VISIBLE: i32 = 50;
+
+ /// Clamp a floating window rect: apply size hints and keep on screen.
+ fn clamp_floating_rect(&self, mut rect: Rect, hints: &SizeHints) -> Rect {
+ let screen = self.xconn.screen_rect;
+
+ // Apply min size from hints (fallback to 50px hard minimum)
+ let min_w = hints.min_w.unwrap_or(50).max(1);
+ let min_h = hints.min_h.unwrap_or(50).max(1);
+ if rect.w < min_w {
+ rect.w = min_w;
+ }
+ if rect.h < min_h {
+ rect.h = min_h;
+ }
+
+ // Apply max size from hints
+ if let Some(max_w) = hints.max_w
+ && max_w > 0 && rect.w > max_w
+ {
+ rect.w = max_w;
+ }
+ if let Some(max_h) = hints.max_h
+ && max_h > 0 && rect.h > max_h
+ {
+ rect.h = max_h;
+ }
+
+ // Apply resize increments
+ if let Some(inc_w) = hints.inc_w
+ && inc_w > 1
+ {
+ let base = hints.base_w.unwrap_or(min_w);
+ let over = rect.w.saturating_sub(base);
+ rect.w = base + (over / inc_w) * inc_w;
+ }
+ if let Some(inc_h) = hints.inc_h
+ && inc_h > 1
+ {
+ let base = hints.base_h.unwrap_or(min_h);
+ let over = rect.h.saturating_sub(base);
+ rect.h = base + (over / inc_h) * inc_h;
+ }
+
+ // Keep at least MIN_VISIBLE pixels on screen in each axis
+ let max_x = screen.x + screen.w as i32 - Self::MIN_VISIBLE;
+ let min_x = screen.x - rect.w as i32 + Self::MIN_VISIBLE;
+ let max_y = screen.y + screen.h as i32 - Self::MIN_VISIBLE;
+ let min_y = screen.y - rect.h as i32 + Self::MIN_VISIBLE;
+
+ rect.x = rect.x.clamp(min_x, max_x);
+ rect.y = rect.y.clamp(min_y, max_y);
+
+ rect
+ }
+
+ /// Apply geometry for a single window.
+ fn apply_window_geometry(&self, idx: NodeKey) {
+ if let Some(con) = self.tree.get(idx)
+ && let Some(frame_id) = con.frame
+ && let Some(win_id) = con.window
+ {
+ let frame = x::Window::new(frame_id);
+ let client = x::Window::new(win_id);
+ self.xconn.configure_frame(frame, &con.rect);
+ self.xconn.configure_client_in_frame(
+ client,
+ con.window_rect.x,
+ con.window_rect.y,
+ con.window_rect.w,
+ con.window_rect.h,
+ );
+
+ let deco_h = match con.border_style {
+ BorderStyle::Normal => self.xconn.deco_height,
+ _ => 0,
+ };
+ let colors = self.deco_colors(con.focused, con.urgent);
+ self.xconn.draw_decoration(&Decoration {
+ frame,
+ frame_w: con.rect.w,
+ frame_h: con.rect.h,
+ title: &con.title,
+ colors: &colors,
+ border_width: con.border_width,
+ deco_height: deco_h,
+ });
+ }
+ }
+
+ /// Spawn a command.
+ fn spawn(&self, cmd: &str) {
+ let parts: Vec<&str> = cmd.split_whitespace().collect();
+ if parts.is_empty() {
+ return;
+ }
+
+ match Command::new(parts[0])
+ .args(&parts[1..])
+ .env("OWM_SOCKET", self.ipc.socket_path_str())
+ .spawn()
+ {
+ Ok(_) => log_debug!("spawned: {}", cmd),
+ Err(e) => log_error!("failed to spawn '{}': {}", cmd, e),
+ }
+ }
+
+ /// Handle a ClientMessage event (EWMH requests from clients).
+ fn on_client_message(&mut self, ev: x::ClientMessageEvent) {
+ let win = ev.window();
+ let msg_type = ev.r#type();
+
+ if msg_type == self.xconn.atoms.net_wm_state {
+ // _NET_WM_STATE change request (e.g. fullscreen toggle)
+ let x::ClientMessageData::Data32(data) = ev.data() else {
+ return;
+ };
+ let action = data[0];
+ let fs_id = self.xconn.atoms.net_wm_state_fullscreen.resource_id();
+
+ if (data[1] == fs_id || data[2] == fs_id)
+ && let Some(idx) = self.tree.find_by_window(win.resource_id())
+ {
+ let is_fs =
+ self.tree.get(idx).map(|c| c.fullscreen).unwrap_or(false);
+ let want_fs = match action {
+ 0 => false, // _NET_WM_STATE_REMOVE
+ 1 => true, // _NET_WM_STATE_ADD
+ 2 => !is_fs, // _NET_WM_STATE_TOGGLE
+ _ => return,
+ };
+ if want_fs != is_fs {
+ self.set_focus(idx);
+ self.toggle_fullscreen();
+ }
+ }
+ } else if msg_type == self.xconn.atoms.net_active_window {
+ // _NET_ACTIVE_WINDOW focus request.
+ // data[0] = source indication: 1 = normal app, 2 = pager/taskbar.
+ // Like i3: only pagers (source=2) may steal focus directly.
+ // Normal apps (source=1) just get urgency set.
+ let x::ClientMessageData::Data32(data) = ev.data() else {
+ return;
+ };
+ let source = data[0];
+
+ if let Some(idx) = self.tree.find_by_window(win.resource_id()) {
+ if source == 2 {
+ // Pager/taskbar: allow focus change
+ if let Some(ws) = self.tree.workspace_for(idx)
+ && ws != self.current_ws
+ {
+ let ws_name = self
+ .tree
+ .get(ws)
+ .map(|c| c.name.clone())
+ .unwrap_or_default();
+ self.switch_workspace(&ws_name);
+ }
+ self.set_focus(idx);
+ } else {
+ // Normal app: set urgency instead of stealing focus
+ let is_focused = self.tree.focused == Some(idx);
+ if !is_focused {
+ if let Some(con) = self.tree.get_mut(idx) {
+ con.urgent = true;
+ }
+ self.xconn
+ .set_border_color(win, self.config.color_urgent);
+ log_debug!(
+ "focus steal blocked for 0x{:x}, set urgent",
+ win.resource_id()
+ );
+ }
+ }
+ }
+ }
+ }
+
+ /// Update _NET_CLIENT_LIST and _NET_CLIENT_LIST_STACKING on the root window.
+ fn update_client_lists(&self) {
+ // _NET_CLIENT_LIST: mapping order
+ self.xconn.set_client_list(&self.client_list);
+
+ // _NET_CLIENT_LIST_STACKING: bottom-to-top (tiling first, then floating)
+ let mut stacking: Vec<u32> = Vec::new();
+ for (_, c) in self.tree.iter() {
+ if let Some(win) = c.window
+ && !c.is_floating
+ {
+ stacking.push(win);
+ }
+ }
+ for (_, c) in self.tree.iter() {
+ if let Some(win) = c.window
+ && c.is_floating
+ {
+ stacking.push(win);
+ }
+ }
+ self.xconn.set_client_list_stacking(&stacking);
+ }
+
+ /// Force-kill windows that haven't closed within the timeout.
+ fn check_close_timeouts(&mut self) {
+ let timeout =
+ std::time::Duration::from_secs(xcb_conn::CLOSE_TIMEOUT_SECS);
+ let mut to_kill = Vec::new();
+ self.pending_closes.retain(|&(win, started)| {
+ if started.elapsed() >= timeout {
+ to_kill.push(win);
+ false
+ } else {
+ true
+ }
+ });
+ for win in to_kill {
+ log_warn!(
+ "window 0x{:x} did not respond to WM_DELETE_WINDOW, killing",
+ win.resource_id()
+ );
+ self.xconn.kill_window(win);
+ }
+ }
+
+ /// Resize the focused tiling container by adjusting its percent.
+ fn resize_focused(&mut self, dir: ResizeDir) {
+ let focused = match self.tree.focused {
+ Some(f) => f,
+ None => return,
+ };
+
+ let parent_key = match self.tree.get(focused).and_then(|c| c.parent) {
+ Some(p) => p,
+ None => return,
+ };
+
+ let parent_layout = match self.tree.get(parent_key) {
+ Some(p) => p.layout,
+ None => return,
+ };
+
+ // Only resize along the parent's split axis
+ let is_width =
+ matches!(dir, ResizeDir::GrowWidth | ResizeDir::ShrinkWidth);
+ let axis_match = match parent_layout {
+ Layout::SplitH => is_width,
+ Layout::SplitV => !is_width,
+ _ => return, // stacked/tabbed don't split space
+ };
+ if !axis_match {
+ return;
+ }
+
+ let siblings: Vec<NodeKey> = match self.tree.get(parent_key) {
+ Some(p) => p.children.clone(),
+ None => return,
+ };
+ if siblings.len() < 2 {
+ return;
+ }
+
+ let grow = matches!(dir, ResizeDir::GrowWidth | ResizeDir::GrowHeight);
+ let delta = 0.05_f64;
+
+ let cur_pct = self.tree.get(focused).map(|c| c.percent).unwrap_or(0.0);
+ if grow && cur_pct + delta > 0.95 {
+ return;
+ }
+ if !grow && cur_pct - delta < 0.05 {
+ return;
+ }
+
+ // Apply delta to focused, distribute inverse among siblings
+ let other_count = (siblings.len() - 1) as f64;
+ let per_other = delta / other_count;
+
+ if let Some(con) = self.tree.get_mut(focused) {
+ if grow {
+ con.percent += delta;
+ } else {
+ con.percent -= delta;
+ }
+ }
+
+ for &sib in &siblings {
+ if sib == focused {
+ continue;
+ }
+ if let Some(con) = self.tree.get_mut(sib) {
+ if grow {
+ con.percent = (con.percent - per_other).max(0.05);
+ } else {
+ con.percent += per_other;
+ }
+ }
+ }
+
+ self.relayout();
+ }
+
+ /// Count the number of workspaces in the tree.
+ fn workspace_count(&self) -> u32 {
+ self.tree
+ .iter()
+ .filter(|(_, c)| c.con_type == ConType::Workspace)
+ .count() as u32
+ }
+
+ /// Handle an IPC command string.
+ /// Reload configuration from ~/.owmrc and re-register keybindings.
+ fn reload_config(&mut self) -> Vec<String> {
+ let (new_config, errors) = Config::load();
+
+ self.keybinds.clear(&self.xconn.conn, self.xconn.root);
+ self.keybinds.setup(
+ &self.xconn.conn,
+ self.xconn.root,
+ &new_config.keybindings,
+ );
+ for (mode_name, mode_bindings) in &new_config.modes {
+ self.keybinds.setup_mode(
+ &self.xconn.conn,
+ mode_name,
+ mode_bindings,
+ );
+ }
+ self.xconn.flush();
+
+ self.config = new_config;
+
+ // Recreate or destroy bar based on new config
+ if let Some(old_bar) = self.bar.take() {
+ old_bar.destroy(&self.xconn);
+ }
+ if self.config.bar_enabled {
+ self.bar = Some(bar::Bar::new(&self.xconn, &self.config));
+ }
+
+ self.ipc.broadcast_event(
+ EventType::Config,
+ r#"{"change":"reload"}"#,
+ );
+
+ self.relayout();
+ log_info!("configuration reloaded");
+ errors
+ }
+
+ /// Show or dismiss the nagbar based on config errors.
+ fn update_nagbar(&mut self, errors: Vec<String>) {
+ // Dismiss existing nagbar
+ if let Some(nag) = self.nagbar.take() {
+ nag.dismiss(&self.xconn);
+ }
+ // Show new one if there are errors
+ if !errors.is_empty() {
+ self.nagbar = Some(nagbar::Nagbar::show(&self.xconn, &errors));
+ }
+ }
+
+ /// Dismiss the nagbar if present.
+ fn dismiss_nagbar(&mut self) {
+ if let Some(nag) = self.nagbar.take() {
+ nag.dismiss(&self.xconn);
+ }
+ }
+
+ fn handle_ipc_command(&mut self, cmd: &str) {
+ let parts: Vec<&str> = cmd.split_whitespace().collect();
+ match parts.first().copied() {
+ Some("focus") => match parts.get(1).copied() {
+ Some("next") => self.focus_adjacent(1),
+ Some("prev") => self.focus_adjacent(-1),
+ Some("parent") => self.handle_action(Action::FocusParent),
+ Some("child") => self.handle_action(Action::FocusChild),
+ _ => {}
+ },
+ Some("split") => match parts.get(1).copied() {
+ Some("v") | Some("vertical") => {
+ self.next_split = Layout::SplitV
+ }
+ Some("h") | Some("horizontal") => {
+ self.next_split = Layout::SplitH
+ }
+ _ => {}
+ },
+ Some("layout") => match parts.get(1).copied() {
+ Some("stacking") => self.set_layout(Layout::Stacked),
+ Some("tabbed") => self.set_layout(Layout::Tabbed),
+ Some("splith") => self.set_layout(Layout::SplitH),
+ Some("splitv") => self.set_layout(Layout::SplitV),
+ _ => {}
+ },
+ Some("kill") => self.handle_action(Action::CloseWindow),
+ Some("fullscreen") => self.toggle_fullscreen(),
+ Some("workspace") => {
+ if let Some(name) = parts.get(1) {
+ self.switch_workspace(name);
+ }
+ }
+ Some("move") => {
+ if parts.get(1).copied() == Some("workspace")
+ && let Some(name) = parts.get(2)
+ {
+ self.move_to_workspace(name);
+ }
+ }
+ Some("exec") => {
+ let cmd = parts[1..].join(" ");
+ self.spawn(&cmd);
+ }
+ Some("mode") => {
+ if let Some(name) = parts.get(1) {
+ self.keybinds.switch_mode(
+ name,
+ &self.xconn.conn,
+ self.xconn.root,
+ );
+ self.xconn.flush();
+ }
+ }
+ Some("resize") => {
+ // resize grow|shrink width|height
+ let dir = match (parts.get(1).copied(), parts.get(2).copied()) {
+ (Some("grow"), Some("width")) => Some(ResizeDir::GrowWidth),
+ (Some("shrink"), Some("width")) => {
+ Some(ResizeDir::ShrinkWidth)
+ }
+ (Some("grow"), Some("height")) => {
+ Some(ResizeDir::GrowHeight)
+ }
+ (Some("shrink"), Some("height")) => {
+ Some(ResizeDir::ShrinkHeight)
+ }
+ _ => None,
+ };
+ if let Some(d) = dir {
+ self.resize_focused(d);
+ }
+ }
+ Some("mark") => {
+ if let Some(name) = parts.get(1)
+ && let Some(focused) = self.tree.focused
+ {
+ self.tree.set_mark(focused, name);
+ }
+ }
+ Some("unmark") => match parts.get(1) {
+ Some(name) => self.tree.unmark(name),
+ None => self.tree.unmark_all(),
+ },
+ Some("sticky") => self.toggle_sticky(),
+ Some("minimize") => self.minimize_focused(),
+ Some("restore") => {
+ // "restore" without args: restore the last minimized window
+ // "restore <window_id>": restore a specific window
+ if let Some(wid_str) = parts.get(1) {
+ if let Ok(wid) = wid_str.parse::<u32>()
+ && let Some(idx) = self.tree.find_by_window(wid)
+ {
+ self.restore_window(idx);
+ }
+ } else {
+ // Find last minimized window on any workspace
+ let minimized: Option<NodeKey> = self
+ .tree
+ .iter()
+ .find(|(_, c)| c.minimized && c.window.is_some())
+ .map(|(k, _)| k);
+ if let Some(idx) = minimized {
+ self.restore_window(idx);
+ }
+ }
+ }
+ Some("scratchpad") => match parts.get(1).copied() {
+ Some("show") => self.scratchpad_show(),
+ _ => self.move_to_scratchpad(),
+ },
+ Some("gaps") => {
+ // gaps <type> current|all set|plus|minus|toggle <px>
+ self.handle_gaps_command(&parts[1..]);
+ }
+ Some("reload") => {
+ let errors = self.reload_config();
+ self.update_nagbar(errors);
+ }
+ Some("restart") => self.restart(),
+ Some("exit") => self.running = false,
+ _ => log_warn!("unknown IPC command: {}", cmd),
+ }
+ }
+
+ /// Handle `gaps <type> current|all set|plus|minus|toggle <px>`.
+ fn handle_gaps_command(&mut self, args: &[&str]) {
+ if args.len() < 3 {
+ log_warn!("gaps: expected <type> <scope> <op> [px]");
+ return;
+ }
+
+ let gap_type = args[0];
+ let scope = args[1];
+ let op = args[2];
+ let px = args.get(3).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
+
+ // Collect workspace keys to modify.
+ let ws_keys: Vec<NodeKey> = match scope {
+ "current" => {
+ vec![self.current_ws]
+ }
+ "all" => {
+ self.tree.iter()
+ .filter(|(_, c)| c.con_type == ConType::Workspace)
+ .map(|(k, _)| k)
+ .collect()
+ }
+ _ => {
+ log_warn!("gaps: scope must be 'current' or 'all'");
+ return;
+ }
+ };
+
+ for ws_key in &ws_keys {
+ // Verify workspace exists.
+ if self.tree.get(*ws_key).is_none() {
+ continue;
+ }
+
+ // Which fields to modify.
+ let fields: Vec<fn(&mut config::GapsOverride) -> &mut Option<i32>> = match gap_type {
+ "inner" => vec![|o: &mut config::GapsOverride| &mut o.inner],
+ "outer" => vec![
+ |o: &mut config::GapsOverride| &mut o.top,
+ |o: &mut config::GapsOverride| &mut o.right,
+ |o: &mut config::GapsOverride| &mut o.bottom,
+ |o: &mut config::GapsOverride| &mut o.left,
+ ],
+ "horizontal" => vec![
+ |o: &mut config::GapsOverride| &mut o.left,
+ |o: &mut config::GapsOverride| &mut o.right,
+ ],
+ "vertical" => vec![
+ |o: &mut config::GapsOverride| &mut o.top,
+ |o: &mut config::GapsOverride| &mut o.bottom,
+ ],
+ "top" => vec![|o: &mut config::GapsOverride| &mut o.top],
+ "right" => vec![|o: &mut config::GapsOverride| &mut o.right],
+ "bottom" => vec![|o: &mut config::GapsOverride| &mut o.bottom],
+ "left" => vec![|o: &mut config::GapsOverride| &mut o.left],
+ _ => {
+ log_warn!("gaps: unknown type '{}'", gap_type);
+ return;
+ }
+ };
+
+ // Resolve current global value for fallback.
+ let global = &self.config.gaps;
+
+ // For the "inner" field, get the global inner; for directional
+ // fields we need the specific global value, but since we apply
+ // the accessor to ovr, the current value is ovr.field or global.
+ // We'll read the current effective value, apply the op, then store.
+ for accessor in &fields {
+ let ovr = match self.tree.get_mut(*ws_key) {
+ Some(ws) => &mut ws.gaps_override,
+ None => continue,
+ };
+ let field = accessor(ovr);
+ let global_val = {
+ // Determine which global field this corresponds to by
+ // examining which field the accessor modifies.
+ let mut probe = config::GapsOverride::default();
+ *accessor(&mut probe) = Some(9999);
+ if probe.inner == Some(9999) { global.inner }
+ else if probe.top == Some(9999) { global.top }
+ else if probe.right == Some(9999) { global.right }
+ else if probe.bottom == Some(9999) { global.bottom }
+ else { global.left }
+ };
+ let current = field.unwrap_or(global_val);
+
+ match op {
+ "set" => *field = Some(px),
+ "plus" => *field = Some(current + px),
+ "minus" => *field = Some(current - px),
+ "toggle" => {
+ if current != 0 {
+ *field = Some(0);
+ } else {
+ *field = Some(px);
+ }
+ }
+ _ => {
+ log_warn!("gaps: unknown op '{}' (set|plus|minus|toggle)", op);
+ return;
+ }
+ }
+ }
+ }
+
+ self.relayout();
+ }
+}
+
+fn main() {
+ log::init();
+
+ log_info!("starting owm v{}", env!("CARGO_PKG_VERSION"));
+
+ let mut wm = match Owm::new() {
+ Ok((mut wm, errors)) => {
+ if !errors.is_empty() {
+ wm.nagbar = Some(nagbar::Nagbar::show(&wm.xconn, &errors));
+ }
+ wm
+ }
+ Err(e) => {
+ eprintln!("owm: failed to initialize: {}", e);
+ std::process::exit(1);
+ }
+ };
+
+ // TODO: re-enable sandbox after Xft/fontconfig paths are sorted out
+ // let socket_dir = IpcServer::socket_path()
+ // .parent()
+ // .unwrap_or(std::path::Path::new("/tmp"))
+ // .to_string_lossy()
+ // .into_owned();
+ // if let Err(e) = sys::sandbox(&socket_dir) {
+ // eprintln!("owm: sandbox: {}", e);
+ // std::process::exit(1);
+ // }
+
+ if let Err(e) = wm.run() {
+ eprintln!("owm: {}", e);
+ std::process::exit(1);
+ }
+}
blob - /dev/null
blob + dcb52b150ff54d03b28b305688e7126e5c4fab8c (mode 644)
--- /dev/null
+++ src/config.rs
+use std::collections::HashMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use crate::container::BorderStyle;
+use crate::keybind::{Action, ResizeDir};
+
+/// A per-window rule applied via `for_window` in owmrc.
+#[derive(Debug, Clone)]
+pub struct WindowRule {
+ /// WM_CLASS class to match (exact string match).
+ pub class: Option<String>,
+ /// WM_CLASS instance to match (exact string match).
+ pub instance: Option<String>,
+ /// Window title substring to match.
+ pub title: Option<String>,
+ /// Border style to force on matching windows.
+ pub border_style: BorderStyle,
+ /// Border width override (only for Pixel style).
+ pub border_width: Option<u32>,
+}
+
+/// Gap configuration with per-direction control (i3-gaps style).
+///
+/// `inner` is the gap between adjacent tiled windows.
+/// `top`, `right`, `bottom`, `left` are outer gaps between windows and
+/// the workspace edge. Outer values may be negative to compensate for
+/// inner gaps (minimum: -inner).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Gaps {
+ pub inner: i32,
+ pub top: i32,
+ pub right: i32,
+ pub bottom: i32,
+ pub left: i32,
+}
+
+impl Default for Gaps {
+ fn default() -> Self {
+ Gaps { inner: 0, top: 0, right: 0, bottom: 0, left: 0 }
+ }
+}
+
+impl Gaps {
+ /// Clamp outer values so they never go below -inner.
+ pub fn clamped(&self) -> Gaps {
+ let min = -self.inner.max(0);
+ Gaps {
+ inner: self.inner.max(0),
+ top: self.top.max(min),
+ right: self.right.max(min),
+ bottom: self.bottom.max(min),
+ left: self.left.max(min),
+ }
+ }
+}
+
+/// Smart gaps behaviour.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum SmartGaps {
+ /// Gaps are always shown (default).
+ #[default]
+ Off,
+ /// Hide all gaps when a workspace has only one window.
+ On,
+ /// Hide inner gaps but keep (inverted) outer gaps with one window.
+ InverseOuter,
+}
+
+/// Per-workspace gap overrides. Each field is `Some` only when
+/// explicitly set in the config; `None` means "inherit global".
+#[derive(Debug, Clone, Copy, Default)]
+pub struct GapsOverride {
+ pub inner: Option<i32>,
+ pub top: Option<i32>,
+ pub right: Option<i32>,
+ pub bottom: Option<i32>,
+ pub left: Option<i32>,
+}
+
+/// Runtime configuration loaded from ~/.owmrc.
+pub struct Config {
+ pub border_width: u32,
+ pub gaps: Gaps,
+ pub smart_gaps: SmartGaps,
+ /// Per-workspace gap overrides keyed by workspace name.
+ pub workspace_gaps: HashMap<String, GapsOverride>,
+ /// Focused window: border color (i3 default: #4c7899).
+ pub color_focused: u32,
+ /// Focused window: title bar background (i3 default: #285577).
+ pub color_focused_bg: u32,
+ /// Unfocused window: border color (i3 default: #333333).
+ pub color_unfocused: u32,
+ /// Unfocused window: title bar background (i3 default: #222222).
+ pub color_unfocused_bg: u32,
+ /// Urgent window: border color (i3 default: #2f343a).
+ pub color_urgent: u32,
+ /// Urgent window: title bar background (i3 default: #900000).
+ pub color_urgent_bg: u32,
+ /// Split direction indicator on focused window (i3 default: #2e9ef4).
+ pub color_indicator: u32,
+ /// Root window background (i3 default: #000000).
+ pub color_background: u32,
+ /// Title bar text color for focused windows.
+ pub color_title_focused: u32,
+ /// Title bar text color for unfocused windows.
+ pub color_title_unfocused: u32,
+ /// Default border style for new windows.
+ pub default_border: BorderStyle,
+ /// Default border style for new floating windows.
+ pub default_floating_border: BorderStyle,
+ /// Font name (fontconfig pattern, e.g. "monospace:size=11").
+ pub font: String,
+ pub terminal: String,
+ pub focus_follows_mouse: bool,
+ pub keybindings: Vec<(u32, u32, Action)>,
+ /// Named binding modes (e.g. "resize") with their own keybindings.
+ pub modes: HashMap<String, Vec<(u32, u32, Action)>>,
+ /// Per-window rules (for_window).
+ pub window_rules: Vec<WindowRule>,
+ /// Whether the built-in status bar is enabled.
+ pub bar_enabled: bool,
+ /// Bar position: top or bottom of the screen.
+ pub bar_position: BarPosition,
+ /// Bar background color.
+ pub bar_color_bg: u32,
+ /// Bar foreground (text) color.
+ pub bar_color_fg: u32,
+ /// Bar active workspace background color.
+ pub bar_color_active: u32,
+ /// Bar separator color.
+ pub bar_color_separator: u32,
+ /// Bar urgent workspace background color.
+ pub bar_color_urgent_ws: u32,
+ /// Command to run for status information (owm-bar protocol).
+ pub bar_status_command: Option<String>,
+ /// Custom separator symbol between status blocks.
+ pub bar_separator_symbol: Option<String>,
+ /// Whether to show workspace buttons in the bar.
+ pub bar_workspace_buttons: bool,
+ /// Automatically choose split direction based on container dimensions.
+ /// When enabled, new windows split along the longer axis of the
+ /// focused container (wider → vertical split, taller → horizontal).
+ pub autotiling: bool,
+}
+
+/// Bar position on screen.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum BarPosition {
+ #[default]
+ Top,
+ Bottom,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ let terminal = "xterm".to_string();
+ Config {
+ border_width: 2,
+ gaps: Gaps::default(),
+ smart_gaps: SmartGaps::Off,
+ workspace_gaps: HashMap::new(),
+ color_focused: 0x4c7899,
+ color_focused_bg: 0x5294e2,
+ color_unfocused: 0xc0c0c0,
+ color_unfocused_bg: 0xe0e0e0,
+ color_urgent: 0xd32f2f,
+ color_urgent_bg: 0xffcdd2,
+ color_indicator: 0x2e9ef4,
+ color_background: 0xf5f5f5,
+ color_title_focused: 0xffffff,
+ color_title_unfocused: 0x555555,
+ default_border: BorderStyle::Normal,
+ default_floating_border: BorderStyle::Normal,
+ font: "monospace:size=11".to_string(),
+ terminal: terminal.clone(),
+ focus_follows_mouse: true,
+ keybindings: default_keybindings(&terminal),
+ modes: default_modes(),
+ window_rules: Vec::new(),
+ bar_enabled: true,
+ bar_position: BarPosition::Bottom,
+ bar_color_bg: 0xe0e0e0,
+ bar_color_fg: 0x333333,
+ bar_color_active: 0x5294e2,
+ bar_color_separator: 0x666666,
+ bar_color_urgent_ws: 0xd32f2f,
+ bar_status_command: Some("ostatus".to_string()),
+ bar_separator_symbol: None,
+ bar_workspace_buttons: true,
+ autotiling: true,
+ }
+ }
+}
+
+impl Config {
+ /// Load configuration from ~/.owmrc, falling back to defaults.
+ /// Returns the config and a list of any errors encountered during parsing.
+ pub fn load() -> (Self, Vec<String>) {
+ let mut conf = Config::default();
+ let mut errors: Vec<String> = Vec::new();
+
+ let path = match std::env::var("HOME") {
+ Ok(home) => PathBuf::from(home).join(".owmrc"),
+ Err(_) => return (conf, errors),
+ };
+
+ let contents = match fs::read_to_string(&path) {
+ Ok(c) => c,
+ Err(e) => {
+ if e.kind() != std::io::ErrorKind::NotFound {
+ let msg = format!(
+ "failed to read {}: {}",
+ path.display(),
+ e
+ );
+ crate::log_error!("{}", msg);
+ errors.push(msg);
+ }
+ return (conf, errors);
+ }
+ };
+
+ let keysym_map = build_keysym_map();
+ let mut has_binds = false;
+ // Track whether we're inside a "mode <name> ... end" block
+ let mut current_mode: Option<String> = None;
+
+ for (lineno, raw_line) in contents.lines().enumerate() {
+ let line = raw_line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+
+ let n = lineno + 1;
+
+ let mut parts = line.splitn(2, char::is_whitespace);
+ let keyword = match parts.next() {
+ Some(k) => k,
+ None => continue,
+ };
+ let rest = parts.next().map(|s| s.trim()).unwrap_or("");
+
+ // Inside a mode block: only accept bind-key and end
+ if let Some(ref mode_name) = current_mode {
+ match keyword {
+ "bind-key" => {
+ let bindings = conf
+ .modes
+ .entry(mode_name.clone())
+ .or_insert_with(Vec::new);
+ parse_bind_key_into(
+ &path, n, rest, &keysym_map, bindings,
+ &mut errors,
+ );
+ }
+ "end" => {
+ crate::log_debug!("mode '{}' loaded", mode_name);
+ current_mode = None;
+ }
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: unexpected '{}' inside mode block",
+ path.display(), n, keyword));
+ }
+ }
+ continue;
+ }
+
+ match keyword {
+ "borderwidth" => match rest.parse::<u32>() {
+ Ok(v) => conf.border_width = v,
+ Err(_) => {
+ config_warn(&mut errors,
+ format!("{}:{}: invalid borderwidth",
+ path.display(), n));
+ }
+ },
+ "gap" => match rest.parse::<i32>() {
+ Ok(v) => conf.gaps.inner = v.max(0),
+ Err(_) => {
+ config_warn(&mut errors,
+ format!("{}:{}: invalid gap",
+ path.display(), n));
+ }
+ },
+ "gaps" => {
+ parse_gaps(&path, n, rest, &mut conf, &mut errors);
+ }
+ "smart_gaps" | "smart-gaps" => {
+ match rest {
+ "on" | "yes" => conf.smart_gaps = SmartGaps::On,
+ "off" | "no" => conf.smart_gaps = SmartGaps::Off,
+ "inverse_outer" => conf.smart_gaps = SmartGaps::InverseOuter,
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: expected off|on|inverse_outer",
+ path.display(), n));
+ }
+ }
+ },
+ "autotiling" => match rest {
+ "yes" | "on" => conf.autotiling = true,
+ "no" | "off" => conf.autotiling = false,
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: expected yes|no",
+ path.display(), n));
+ }
+ },
+ "color" => {
+ parse_color(&path, n, rest, &mut conf, &mut errors);
+ }
+ "terminal" => {
+ if rest.is_empty() {
+ config_warn(&mut errors,
+ format!("{}:{}: missing terminal value",
+ path.display(), n));
+ } else {
+ conf.terminal = rest.to_string();
+ }
+ }
+ "focus-follows-mouse" => match rest {
+ "yes" => conf.focus_follows_mouse = true,
+ "no" => conf.focus_follows_mouse = false,
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: expected yes|no",
+ path.display(), n));
+ }
+ },
+ "bind-key" => {
+ if !has_binds {
+ conf.keybindings.clear();
+ has_binds = true;
+ }
+ parse_bind_key(
+ &path, n, rest, &keysym_map, &mut conf,
+ &mut errors,
+ );
+ }
+ "font" => {
+ if rest.is_empty() {
+ config_warn(&mut errors,
+ format!("{}:{}: missing font name",
+ path.display(), n));
+ } else {
+ conf.font = rest.to_string();
+ }
+ }
+ "default_border" | "default-border" => {
+ parse_default_border(
+ &path, n, rest, &mut conf.default_border,
+ &mut conf.border_width, &mut errors,
+ );
+ }
+ "default_floating_border" | "default-floating-border" => {
+ parse_default_border(
+ &path, n, rest, &mut conf.default_floating_border,
+ &mut conf.border_width, &mut errors,
+ );
+ }
+ "for_window" | "for-window" => {
+ parse_for_window(&path, n, rest, &mut conf, &mut errors);
+ }
+ "bar_enabled" | "bar-enabled" => match rest {
+ "yes" => conf.bar_enabled = true,
+ "no" => conf.bar_enabled = false,
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: expected yes|no",
+ path.display(), n));
+ }
+ },
+ "bar_position" | "bar-position" => match rest {
+ "top" => conf.bar_position = BarPosition::Top,
+ "bottom" => conf.bar_position = BarPosition::Bottom,
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: expected top|bottom",
+ path.display(), n));
+ }
+ },
+ "bar_color" | "bar-color" => {
+ parse_bar_color(&path, n, rest, &mut conf, &mut errors);
+ }
+ "bar_status_command" | "bar-status-command" => {
+ if rest.is_empty() {
+ conf.bar_status_command = None;
+ } else {
+ conf.bar_status_command = Some(rest.to_string());
+ }
+ }
+ "bar_separator_symbol" | "bar-separator-symbol" => {
+ if rest.is_empty() {
+ conf.bar_separator_symbol = None;
+ } else {
+ conf.bar_separator_symbol = Some(rest.to_string());
+ }
+ }
+ "bar_workspace_buttons" | "bar-workspace-buttons" => match rest {
+ "yes" => conf.bar_workspace_buttons = true,
+ "no" => conf.bar_workspace_buttons = false,
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: expected yes|no",
+ path.display(), n));
+ }
+ },
+ "workspace" => {
+ // workspace <name> gaps <type> <px>
+ parse_workspace_gaps(&path, n, rest, &mut conf, &mut errors);
+ }
+ "mode" => {
+ if rest.is_empty() {
+ config_warn(&mut errors,
+ format!("{}:{}: missing mode name",
+ path.display(), n));
+ } else {
+ current_mode = Some(rest.to_string());
+ }
+ }
+ _ => {
+ config_warn(&mut errors,
+ format!("{}:{}: unknown keyword '{}'",
+ path.display(), n, keyword));
+ }
+ }
+ }
+
+ if current_mode.is_some() {
+ config_warn(&mut errors,
+ format!("{}: unterminated mode block", path.display()));
+ }
+
+ crate::log_info!("loaded config from {}", path.display());
+ (conf, errors)
+ }
+}
+
+/// Log a config warning and collect it for the nagbar.
+fn config_warn(errors: &mut Vec<String>, msg: String) {
+ crate::log_warn!("{}", msg);
+ errors.push(msg);
+}
+
+/// Parse "color <name> <hex>" directive.
+fn parse_color(
+ path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+ errors: &mut Vec<String>,
+) {
+ let mut parts = rest.splitn(2, char::is_whitespace);
+ let name = match parts.next() {
+ Some(n) => n,
+ None => {
+ config_warn(errors,
+ format!("{}:{}: missing color name", path.display(), lineno));
+ return;
+ }
+ };
+ let hex = match parts.next().map(|s| s.trim()) {
+ Some(h) if !h.is_empty() => h,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: missing color value", path.display(), lineno));
+ return;
+ }
+ };
+
+ let hex = hex.strip_prefix('#').unwrap_or(hex);
+ let value = match u32::from_str_radix(hex, 16) {
+ Ok(v) => v,
+ Err(_) => {
+ config_warn(errors,
+ format!("{}:{}: invalid hex color '{}'", path.display(), lineno, hex));
+ return;
+ }
+ };
+
+ match name {
+ "activeborder" => conf.color_focused = value,
+ "activebg" => conf.color_focused_bg = value,
+ "inactiveborder" => conf.color_unfocused = value,
+ "inactivebg" => conf.color_unfocused_bg = value,
+ "urgencyborder" => conf.color_urgent = value,
+ "urgencybg" => conf.color_urgent_bg = value,
+ "indicator" => conf.color_indicator = value,
+ "background" => conf.color_background = value,
+ "titlefocused" => conf.color_title_focused = value,
+ "titleunfocused" => conf.color_title_unfocused = value,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: unknown color name '{}'", path.display(), lineno, name));
+ }
+ }
+}
+
+/// Parse "bar_color <name> <hex>" directive.
+fn parse_bar_color(
+ path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+ errors: &mut Vec<String>,
+) {
+ let mut parts = rest.splitn(2, char::is_whitespace);
+ let name = match parts.next() {
+ Some(n) => n,
+ None => {
+ config_warn(errors,
+ format!("{}:{}: missing bar_color name", path.display(), lineno));
+ return;
+ }
+ };
+ let hex = match parts.next().map(|s| s.trim()) {
+ Some(h) if !h.is_empty() => h,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: missing bar_color value", path.display(), lineno));
+ return;
+ }
+ };
+ let hex = hex.strip_prefix('#').unwrap_or(hex);
+ let value = match u32::from_str_radix(hex, 16) {
+ Ok(v) => v,
+ Err(_) => {
+ config_warn(errors,
+ format!("{}:{}: invalid hex color '{}'", path.display(), lineno, hex));
+ return;
+ }
+ };
+ match name {
+ "bg" => conf.bar_color_bg = value,
+ "fg" => conf.bar_color_fg = value,
+ "active" => conf.bar_color_active = value,
+ "separator" => conf.bar_color_separator = value,
+ "urgent_ws" => conf.bar_color_urgent_ws = value,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: unknown bar_color name '{}' \
+ (expected bg|fg|active|separator|urgent_ws)",
+ path.display(), lineno, name));
+ }
+ }
+}
+
+/// Parse "gaps inner|outer|horizontal|vertical|top|right|bottom|left <px>" directive.
+/// Also handles "workspace <name> gaps <type> <px>" when called from the
+/// workspace-gaps path.
+fn parse_gaps(
+ path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+ errors: &mut Vec<String>,
+) {
+ let mut parts = rest.split_whitespace();
+ let gap_type = match parts.next() {
+ Some(t) => t,
+ None => {
+ config_warn(errors,
+ format!("{}:{}: gaps: missing type", path.display(), lineno));
+ return;
+ }
+ };
+ let value = match parts.next().and_then(|s| s.parse::<i32>().ok()) {
+ Some(v) => v,
+ None => {
+ config_warn(errors,
+ format!("{}:{}: gaps: missing or invalid pixel value",
+ path.display(), lineno));
+ return;
+ }
+ };
+
+ apply_gap_type(&mut conf.gaps, gap_type, value, path, lineno, errors);
+}
+
+/// Parse per-workspace gaps: "workspace <name> gaps <type> <px>".
+fn parse_workspace_gaps(
+ path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+ errors: &mut Vec<String>,
+) {
+ // rest = "<name> gaps <type> <px>"
+ let mut parts = rest.split_whitespace();
+ let ws_name = match parts.next() {
+ Some(n) => n.to_string(),
+ None => {
+ config_warn(errors,
+ format!("{}:{}: workspace: missing name", path.display(), lineno));
+ return;
+ }
+ };
+ // expect "gaps"
+ match parts.next() {
+ Some("gaps") => {}
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: expected 'gaps' after workspace name",
+ path.display(), lineno));
+ return;
+ }
+ }
+ let gap_type = match parts.next() {
+ Some(t) => t,
+ None => {
+ config_warn(errors,
+ format!("{}:{}: workspace gaps: missing type",
+ path.display(), lineno));
+ return;
+ }
+ };
+ let value = match parts.next().and_then(|s| s.parse::<i32>().ok()) {
+ Some(v) => v,
+ None => {
+ config_warn(errors,
+ format!("{}:{}: workspace gaps: missing or invalid pixel value",
+ path.display(), lineno));
+ return;
+ }
+ };
+
+ let ovr = conf.workspace_gaps.entry(ws_name).or_insert_with(GapsOverride::default);
+ apply_gap_type_override(ovr, gap_type, value, path, lineno, errors);
+}
+
+/// Set gap fields on a Gaps struct by type keyword.
+fn apply_gap_type(
+ gaps: &mut Gaps, gap_type: &str, value: i32,
+ path: &Path, lineno: usize, errors: &mut Vec<String>,
+) {
+ match gap_type {
+ "inner" => gaps.inner = value,
+ "outer" => {
+ gaps.top = value;
+ gaps.right = value;
+ gaps.bottom = value;
+ gaps.left = value;
+ }
+ "horizontal" => {
+ gaps.left = value;
+ gaps.right = value;
+ }
+ "vertical" => {
+ gaps.top = value;
+ gaps.bottom = value;
+ }
+ "top" => gaps.top = value,
+ "right" => gaps.right = value,
+ "bottom" => gaps.bottom = value,
+ "left" => gaps.left = value,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: unknown gap type '{}'", path.display(), lineno, gap_type));
+ }
+ }
+}
+
+/// Set gap override fields by type keyword.
+fn apply_gap_type_override(
+ ovr: &mut GapsOverride, gap_type: &str, value: i32,
+ path: &Path, lineno: usize, errors: &mut Vec<String>,
+) {
+ match gap_type {
+ "inner" => ovr.inner = Some(value),
+ "outer" => {
+ ovr.top = Some(value);
+ ovr.right = Some(value);
+ ovr.bottom = Some(value);
+ ovr.left = Some(value);
+ }
+ "horizontal" => {
+ ovr.left = Some(value);
+ ovr.right = Some(value);
+ }
+ "vertical" => {
+ ovr.top = Some(value);
+ ovr.bottom = Some(value);
+ }
+ "top" => ovr.top = Some(value),
+ "right" => ovr.right = Some(value),
+ "bottom" => ovr.bottom = Some(value),
+ "left" => ovr.left = Some(value),
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: unknown gap type '{}'", path.display(), lineno, gap_type));
+ }
+ }
+}
+
+/// Parse "default_border normal|pixel [N]|none" directive.
+fn parse_default_border(
+ path: &Path, lineno: usize, rest: &str,
+ border_style: &mut BorderStyle, border_width: &mut u32,
+ errors: &mut Vec<String>,
+) {
+ let mut parts = rest.splitn(2, char::is_whitespace);
+ match parts.next() {
+ Some("normal") => *border_style = BorderStyle::Normal,
+ Some("pixel") => {
+ *border_style = BorderStyle::Pixel;
+ if let Some(w) = parts.next().and_then(|s| s.trim().parse::<u32>().ok()) {
+ *border_width = w;
+ }
+ }
+ Some("none") => *border_style = BorderStyle::None,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: expected normal|pixel [N]|none",
+ path.display(), lineno));
+ }
+ }
+}
+
+/// Parse "for_window [class=X] border pixel|normal|none" directive.
+fn parse_for_window(
+ path: &Path, lineno: usize, rest: &str, conf: &mut Config,
+ errors: &mut Vec<String>,
+) {
+ let rest = rest.trim();
+ if !rest.starts_with('[') {
+ config_warn(errors,
+ format!("{}:{}: for_window requires [criteria]", path.display(), lineno));
+ return;
+ }
+ let bracket_end = match rest.find(']') {
+ Some(i) => i,
+ None => {
+ config_warn(errors,
+ format!("{}:{}: missing ']' in for_window", path.display(), lineno));
+ return;
+ }
+ };
+ let criteria_str = &rest[1..bracket_end];
+ let command_str = rest[bracket_end + 1..].trim();
+
+ let mut rule = WindowRule {
+ class: None,
+ instance: None,
+ title: None,
+ border_style: BorderStyle::Normal,
+ border_width: None,
+ };
+ for criterion in criteria_str.split_whitespace() {
+ if let Some((key, value)) = criterion.split_once('=') {
+ let value = value.trim_matches('"');
+ match key {
+ "class" => rule.class = Some(value.to_string()),
+ "instance" => rule.instance = Some(value.to_string()),
+ "title" => rule.title = Some(value.to_string()),
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: unknown criterion '{}'",
+ path.display(), lineno, key));
+ }
+ }
+ }
+ }
+
+ let mut cparts = command_str.splitn(3, char::is_whitespace);
+ match cparts.next() {
+ Some("border") => match cparts.next() {
+ Some("normal") => rule.border_style = BorderStyle::Normal,
+ Some("pixel") => {
+ rule.border_style = BorderStyle::Pixel;
+ if let Some(w) = cparts.next().and_then(|s| s.trim().parse::<u32>().ok()) {
+ rule.border_width = Some(w);
+ }
+ }
+ Some("none") => rule.border_style = BorderStyle::None,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: expected 'border normal|pixel [N]|none'",
+ path.display(), lineno));
+ return;
+ }
+ },
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: for_window only supports 'border' command",
+ path.display(), lineno));
+ return;
+ }
+ }
+
+ conf.window_rules.push(rule);
+}
+
+/// Parse a key combo string into (modifiers, keysym).
+///
+/// Modifier letters: 4 = Mod4 (Super), S = Shift, C = Control, M = Mod1 (Alt).
+/// Example: "4S-Return" -> (Mod4|Shift, 0xff0d)
+fn parse_key_combo(
+ path: &Path, lineno: usize, combo: &str,
+ keysym_map: &HashMap<&str, u32>, errors: &mut Vec<String>,
+) -> Option<(u32, u32)> {
+ let (modifiers, keysym_name) = match combo.rsplit_once('-') {
+ Some((mods, key)) => {
+ let mut mask = 0u32;
+ for ch in mods.chars() {
+ match ch {
+ '4' => mask |= xcb::x::ModMask::N4.bits(),
+ 'S' => mask |= xcb::x::ModMask::SHIFT.bits(),
+ 'C' => mask |= xcb::x::ModMask::CONTROL.bits(),
+ 'M' => mask |= xcb::x::ModMask::N1.bits(),
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: unknown modifier '{}'",
+ path.display(), lineno, ch));
+ return None;
+ }
+ }
+ }
+ (mask, key)
+ }
+ None => {
+ config_warn(errors,
+ format!("{}:{}: invalid key combo '{}'",
+ path.display(), lineno, combo));
+ return None;
+ }
+ };
+
+ match keysym_map.get(keysym_name) {
+ Some(&ks) => Some((modifiers, ks)),
+ None => {
+ config_warn(errors,
+ format!("{}:{}: unknown keysym '{}'",
+ path.display(), lineno, keysym_name));
+ None
+ }
+ }
+}
+
+/// Parse an action string into an Action.
+fn parse_action(
+ path: &Path, lineno: usize, action_str: &str,
+ errors: &mut Vec<String>,
+) -> Option<Action> {
+ let mut action_parts = action_str.splitn(2, char::is_whitespace);
+ let action_name = action_parts.next().unwrap();
+ let action_arg = action_parts.next().map(|s| s.trim());
+
+ let action = match action_name {
+ "focus-next" => Action::FocusNext,
+ "focus-prev" => Action::FocusPrev,
+ "focus-parent" => Action::FocusParent,
+ "focus-child" => Action::FocusChild,
+ "swap-next" => Action::SwapNext,
+ "swap-prev" => Action::SwapPrev,
+ "split-vertical" => Action::SplitVertical,
+ "split-horizontal" => Action::SplitHorizontal,
+ "layout-stacked" => Action::LayoutStacked,
+ "layout-tabbed" => Action::LayoutTabbed,
+ "layout-toggle-split" => Action::LayoutToggleSplit,
+ "toggle-fullscreen" => Action::ToggleFullscreen,
+ "toggle-floating" => Action::ToggleFloating,
+ "toggle-sticky" | "sticky" => Action::ToggleSticky,
+ "minimize" => Action::Minimize,
+ "close-window" => Action::CloseWindow,
+ "move-scratchpad" => Action::MoveScratchpad,
+ "scratchpad-show" => Action::ScratchpadShow,
+ "restart" => Action::Restart,
+ "exit" => Action::Exit,
+ "mode" => match action_arg {
+ Some(name) => Action::Mode(name.to_string()),
+ None => {
+ config_warn(errors,
+ format!("{}:{}: mode requires a name",
+ path.display(), lineno));
+ return None;
+ }
+ },
+ "resize" => {
+ let mut rparts =
+ action_arg.unwrap_or("").splitn(2, char::is_whitespace);
+ let dir = match (rparts.next(), rparts.next().map(|s| s.trim())) {
+ (Some("grow"), Some("width")) => ResizeDir::GrowWidth,
+ (Some("shrink"), Some("width")) => ResizeDir::ShrinkWidth,
+ (Some("grow"), Some("height")) => ResizeDir::GrowHeight,
+ (Some("shrink"), Some("height")) => ResizeDir::ShrinkHeight,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: resize requires grow|shrink width|height",
+ path.display(), lineno));
+ return None;
+ }
+ };
+ Action::Resize(dir)
+ }
+ "spawn" => match action_arg {
+ Some(cmd) => Action::Spawn(cmd.to_string()),
+ None => {
+ config_warn(errors,
+ format!("{}:{}: spawn requires a command",
+ path.display(), lineno));
+ return None;
+ }
+ },
+ "workspace" => match action_arg {
+ Some(name) if !name.is_empty() => {
+ Action::Workspace(name.to_string())
+ }
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: workspace requires a name",
+ path.display(), lineno));
+ return None;
+ }
+ },
+ "move-to-workspace" => match action_arg {
+ Some(name) if !name.is_empty() => {
+ Action::MoveToWorkspace(name.to_string())
+ }
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: move-to-workspace requires a name",
+ path.display(), lineno));
+ return None;
+ }
+ },
+ _ => {
+ // Unknown action name: treat as spawn command
+ Action::Spawn(action_str.to_string())
+ }
+ };
+
+ Some(action)
+}
+
+/// Parse a "bind-key" line and push the result into an arbitrary bindings vec.
+fn parse_bind_key_into(
+ path: &Path, lineno: usize, rest: &str,
+ keysym_map: &HashMap<&str, u32>,
+ bindings: &mut Vec<(u32, u32, Action)>,
+ errors: &mut Vec<String>,
+) {
+ let mut parts = rest.splitn(2, char::is_whitespace);
+ let combo_str = match parts.next() {
+ Some(c) if !c.is_empty() => c,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: missing key combo", path.display(), lineno));
+ return;
+ }
+ };
+ let action_str = match parts.next().map(|s| s.trim()) {
+ Some(a) if !a.is_empty() => a,
+ _ => {
+ config_warn(errors,
+ format!("{}:{}: missing action", path.display(), lineno));
+ return;
+ }
+ };
+
+ let Some((modifiers, keysym)) =
+ parse_key_combo(path, lineno, combo_str, keysym_map, errors)
+ else {
+ return;
+ };
+
+ let Some(action) = parse_action(path, lineno, action_str, errors) else {
+ return;
+ };
+
+ bindings.push((modifiers, keysym, action));
+}
+
+/// Parse "bind-key" directive into the default keybindings.
+fn parse_bind_key(
+ path: &Path, lineno: usize, rest: &str,
+ keysym_map: &HashMap<&str, u32>, conf: &mut Config,
+ errors: &mut Vec<String>,
+) {
+ parse_bind_key_into(path, lineno, rest, keysym_map, &mut conf.keybindings, errors);
+}
+
+/// Default keybindings when no bind-key directives are in the config.
+fn default_keybindings(terminal: &str) -> Vec<(u32, u32, Action)> {
+ let m = xcb::x::ModMask::N1.bits(); // Alt (Mod1)
+ let ms = m | xcb::x::ModMask::SHIFT.bits();
+
+ vec![
+ // Window focus
+ (m, keysym::XK_j, Action::FocusNext),
+ (m, keysym::XK_k, Action::FocusPrev),
+ (m, keysym::XK_h, Action::FocusParent),
+ (m, keysym::XK_l, Action::FocusChild),
+ // Window movement
+ (ms, keysym::XK_j, Action::SwapNext),
+ (ms, keysym::XK_k, Action::SwapPrev),
+ // Split direction
+ (m, keysym::XK_v, Action::SplitVertical),
+ (m, keysym::XK_b, Action::SplitHorizontal),
+ // Layout
+ (m, keysym::XK_s, Action::LayoutStacked),
+ (m, keysym::XK_w, Action::LayoutTabbed),
+ (m, keysym::XK_e, Action::LayoutToggleSplit),
+ // Fullscreen
+ (m, keysym::XK_f, Action::ToggleFullscreen),
+ // Floating
+ (ms, keysym::XK_space, Action::ToggleFloating),
+ // Kill window
+ (ms, keysym::XK_q, Action::CloseWindow),
+ // Spawn terminal / launcher
+ (m, keysym::XK_Return, Action::Spawn(terminal.to_string())),
+ (m, keysym::XK_d, Action::Spawn("omenu".to_string())),
+ // Workspaces 1-9
+ (m, keysym::XK_1, Action::Workspace("1".into())),
+ (m, keysym::XK_2, Action::Workspace("2".into())),
+ (m, keysym::XK_3, Action::Workspace("3".into())),
+ (m, keysym::XK_4, Action::Workspace("4".into())),
+ (m, keysym::XK_5, Action::Workspace("5".into())),
+ (m, keysym::XK_6, Action::Workspace("6".into())),
+ (m, keysym::XK_7, Action::Workspace("7".into())),
+ (m, keysym::XK_8, Action::Workspace("8".into())),
+ (m, keysym::XK_9, Action::Workspace("9".into())),
+ // Move to workspace
+ (ms, keysym::XK_1, Action::MoveToWorkspace("1".into())),
+ (ms, keysym::XK_2, Action::MoveToWorkspace("2".into())),
+ (ms, keysym::XK_3, Action::MoveToWorkspace("3".into())),
+ (ms, keysym::XK_4, Action::MoveToWorkspace("4".into())),
+ (ms, keysym::XK_5, Action::MoveToWorkspace("5".into())),
+ (ms, keysym::XK_6, Action::MoveToWorkspace("6".into())),
+ (ms, keysym::XK_7, Action::MoveToWorkspace("7".into())),
+ (ms, keysym::XK_8, Action::MoveToWorkspace("8".into())),
+ (ms, keysym::XK_9, Action::MoveToWorkspace("9".into())),
+ // Resize mode
+ (m, keysym::XK_r, Action::Mode("resize".into())),
+ // Restart
+ (ms, keysym::XK_r, Action::Restart),
+ // Exit
+ (ms, keysym::XK_e, Action::Exit),
+ ]
+}
+
+/// Default binding modes (resize).
+fn default_modes() -> HashMap<String, Vec<(u32, u32, Action)>> {
+ let m = xcb::x::ModMask::N1.bits(); // Alt
+ let mut modes = HashMap::new();
+ modes.insert(
+ "resize".to_string(),
+ vec![
+ (m, keysym::XK_h, Action::Resize(ResizeDir::ShrinkWidth)),
+ (m, keysym::XK_l, Action::Resize(ResizeDir::GrowWidth)),
+ (m, keysym::XK_j, Action::Resize(ResizeDir::GrowHeight)),
+ (m, keysym::XK_k, Action::Resize(ResizeDir::ShrinkHeight)),
+ (m, keysym::XK_Escape, Action::Mode("default".into())),
+ (m, keysym::XK_Return, Action::Mode("default".into())),
+ ],
+ );
+ modes
+}
+
+/// Build a map from keysym names (as used in .owmrc) to keysym values.
+fn build_keysym_map() -> HashMap<&'static str, u32> {
+ let mut m = HashMap::new();
+ // Letters
+ for (name, val) in [
+ ("a", 0x0061u32),
+ ("b", 0x0062),
+ ("c", 0x0063),
+ ("d", 0x0064),
+ ("e", 0x0065),
+ ("f", 0x0066),
+ ("g", 0x0067),
+ ("h", 0x0068),
+ ("i", 0x0069),
+ ("j", 0x006a),
+ ("k", 0x006b),
+ ("l", 0x006c),
+ ("m", 0x006d),
+ ("n", 0x006e),
+ ("o", 0x006f),
+ ("p", 0x0070),
+ ("q", 0x0071),
+ ("r", 0x0072),
+ ("s", 0x0073),
+ ("t", 0x0074),
+ ("u", 0x0075),
+ ("v", 0x0076),
+ ("w", 0x0077),
+ ("x", 0x0078),
+ ("y", 0x0079),
+ ("z", 0x007a),
+ ] {
+ m.insert(name, val);
+ }
+ // Numbers
+ for (name, val) in [
+ ("1", 0x0031u32),
+ ("2", 0x0032),
+ ("3", 0x0033),
+ ("4", 0x0034),
+ ("5", 0x0035),
+ ("6", 0x0036),
+ ("7", 0x0037),
+ ("8", 0x0038),
+ ("9", 0x0039),
+ ("0", 0x0030),
+ ] {
+ m.insert(name, val);
+ }
+ // Special keys
+ m.insert("Return", 0xff0d);
+ m.insert("space", 0x0020);
+ m.insert("Tab", 0xff09);
+ m.insert("Escape", 0xff1b);
+ m.insert("BackSpace", 0xff08);
+ m.insert("Delete", 0xffff);
+ m.insert("Left", 0xff51);
+ m.insert("Up", 0xff52);
+ m.insert("Right", 0xff53);
+ m.insert("Down", 0xff54);
+ m.insert("Home", 0xff50);
+ m.insert("End", 0xff57);
+ m.insert("Page_Up", 0xff55);
+ m.insert("Page_Down", 0xff56);
+ m.insert("F1", 0xffbe);
+ m.insert("F2", 0xffbf);
+ m.insert("F3", 0xffc0);
+ m.insert("F4", 0xffc1);
+ m.insert("F5", 0xffc2);
+ m.insert("F6", 0xffc3);
+ m.insert("F7", 0xffc4);
+ m.insert("F8", 0xffc5);
+ m.insert("F9", 0xffc6);
+ m.insert("F10", 0xffc7);
+ m.insert("F11", 0xffc8);
+ m.insert("F12", 0xffc9);
+ m.insert("minus", 0x002d);
+ m.insert("equal", 0x003d);
+ m.insert("bracketleft", 0x005b);
+ m.insert("bracketright", 0x005d);
+ m.insert("semicolon", 0x003b);
+ m.insert("apostrophe", 0x0027);
+ m.insert("grave", 0x0060);
+ m.insert("comma", 0x002c);
+ m.insert("period", 0x002e);
+ m.insert("slash", 0x002f);
+ m.insert("backslash", 0x005c);
+ m
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+ use std::sync::Mutex;
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ static TEST_LOCK: Mutex<()> = Mutex::new(());
+ static TEST_SEQ: AtomicU64 = AtomicU64::new(0);
+
+ /// Helper: create a temp dir with a .owmrc, set HOME, call Config::load().
+ /// Serialized via mutex since we mutate the HOME env var.
+ fn load_config(content: &str) -> Config {
+ let _guard = TEST_LOCK.lock().unwrap();
+ let seq = TEST_SEQ.fetch_add(1, Ordering::Relaxed);
+ let dir = std::env::temp_dir().join(format!(
+ "owm-test-{}-{}",
+ std::process::id(),
+ seq
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let rc = dir.join(".owmrc");
+ let mut f = fs::File::create(&rc).unwrap();
+ f.write_all(content.as_bytes()).unwrap();
+
+ // SAFETY: serialized by TEST_LOCK.
+ let old_home = std::env::var("HOME").ok();
+ unsafe { std::env::set_var("HOME", &dir) };
+ let (conf, _errors) = Config::load();
+ match old_home {
+ Some(h) => unsafe { std::env::set_var("HOME", h) },
+ None => unsafe { std::env::remove_var("HOME") },
+ }
+ let _ = fs::remove_file(&rc);
+ let _ = fs::remove_dir(&dir);
+ conf
+ }
+
+ // --- defaults ---
+
+ #[test]
+ fn default_values() {
+ let conf = Config::default();
+ assert_eq!(conf.border_width, 2);
+ assert_eq!(conf.gaps, Gaps::default());
+ assert_eq!(conf.terminal, "xterm");
+ assert!(conf.focus_follows_mouse);
+ assert!(!conf.keybindings.is_empty());
+ assert!(conf.modes.is_empty());
+ }
+
+ // --- valid config ---
+
+ #[test]
+ fn parse_borderwidth_and_gap() {
+ let conf = load_config("borderwidth 5\ngap 10\n");
+ assert_eq!(conf.border_width, 5);
+ assert_eq!(conf.gaps.inner, 10);
+ }
+
+ #[test]
+ fn parse_gaps_directives() {
+ let conf = load_config("gaps inner 8\ngaps outer 4\n");
+ assert_eq!(conf.gaps.inner, 8);
+ assert_eq!(conf.gaps.top, 4);
+ assert_eq!(conf.gaps.right, 4);
+ assert_eq!(conf.gaps.bottom, 4);
+ assert_eq!(conf.gaps.left, 4);
+ }
+
+ #[test]
+ fn parse_gaps_directional() {
+ let conf = load_config(
+ "gaps inner 10\ngaps top 2\ngaps horizontal 6\n",
+ );
+ assert_eq!(conf.gaps.inner, 10);
+ assert_eq!(conf.gaps.top, 2);
+ assert_eq!(conf.gaps.left, 6);
+ assert_eq!(conf.gaps.right, 6);
+ assert_eq!(conf.gaps.bottom, 0);
+ }
+
+ #[test]
+ fn parse_smart_gaps() {
+ let conf = load_config("smart_gaps on\n");
+ assert_eq!(conf.smart_gaps, SmartGaps::On);
+
+ let conf2 = load_config("smart_gaps inverse_outer\n");
+ assert_eq!(conf2.smart_gaps, SmartGaps::InverseOuter);
+ }
+
+ #[test]
+ fn parse_workspace_gaps() {
+ let conf = load_config("workspace 2 gaps inner 12\n");
+ let ovr = conf.workspace_gaps.get("2").unwrap();
+ assert_eq!(ovr.inner, Some(12));
+ assert_eq!(ovr.top, None);
+ }
+
+ #[test]
+ fn negative_outer_gaps_clamped() {
+ let g = Gaps { inner: 10, top: -20, right: -5, bottom: 0, left: 3 };
+ let c = g.clamped();
+ assert_eq!(c.top, -10); // clamped to -inner
+ assert_eq!(c.right, -5); // within range
+ assert_eq!(c.bottom, 0);
+ assert_eq!(c.left, 3);
+ }
+
+ #[test]
+ fn parse_terminal() {
+ let conf = load_config("terminal alacritty\n");
+ assert_eq!(conf.terminal, "alacritty");
+ }
+
+ #[test]
+ fn parse_focus_follows_mouse() {
+ let conf = load_config("focus-follows-mouse no\n");
+ assert!(!conf.focus_follows_mouse);
+
+ let conf2 = load_config("focus-follows-mouse yes\n");
+ assert!(conf2.focus_follows_mouse);
+ }
+
+ #[test]
+ fn parse_colors() {
+ let conf = load_config(
+ "color activeborder #ff0000\ncolor inactiveborder 00ff00\ncolor urgencyborder #0000ff\ncolor background 1a1a1a\n",
+ );
+ assert_eq!(conf.color_focused, 0xff0000);
+ assert_eq!(conf.color_unfocused, 0x00ff00);
+ assert_eq!(conf.color_urgent, 0x0000ff);
+ assert_eq!(conf.color_background, 0x1a1a1a);
+ }
+
+ #[test]
+ fn parse_bind_key_replaces_defaults() {
+ let conf = load_config("bind-key 4-Return spawn st\n");
+ // When bind-key is present, default bindings are cleared
+ assert_eq!(conf.keybindings.len(), 1);
+ let (mods, ks, ref action) = conf.keybindings[0];
+ assert_eq!(ks, 0xff0d); // Return
+ assert_eq!(mods, xcb::x::ModMask::N4.bits());
+ assert_eq!(*action, crate::keybind::Action::Spawn("st".to_string()));
+ }
+
+ #[test]
+ fn parse_mode_block() {
+ let conf =
+ load_config("mode resize\nbind-key 4-h resize shrink width\nend\n");
+ assert!(conf.modes.contains_key("resize"));
+ let binds = &conf.modes["resize"];
+ assert_eq!(binds.len(), 1);
+ assert_eq!(
+ binds[0].2,
+ crate::keybind::Action::Resize(
+ crate::keybind::ResizeDir::ShrinkWidth
+ )
+ );
+ }
+
+ #[test]
+ fn parse_action_variants() {
+ let path = PathBuf::from("test");
+ let cases = [
+ ("focus-next", Action::FocusNext),
+ ("focus-prev", Action::FocusPrev),
+ ("focus-parent", Action::FocusParent),
+ ("focus-child", Action::FocusChild),
+ ("swap-next", Action::SwapNext),
+ ("swap-prev", Action::SwapPrev),
+ ("split-vertical", Action::SplitVertical),
+ ("split-horizontal", Action::SplitHorizontal),
+ ("layout-stacked", Action::LayoutStacked),
+ ("layout-tabbed", Action::LayoutTabbed),
+ ("layout-toggle-split", Action::LayoutToggleSplit),
+ ("toggle-fullscreen", Action::ToggleFullscreen),
+ ("toggle-floating", Action::ToggleFloating),
+ ("toggle-sticky", Action::ToggleSticky),
+ ("close-window", Action::CloseWindow),
+ ("exit", Action::Exit),
+ ("workspace web", Action::Workspace("web".to_string())),
+ (
+ "move-to-workspace 3",
+ Action::MoveToWorkspace("3".to_string()),
+ ),
+ ("mode resize", Action::Mode("resize".to_string())),
+ ("resize grow width", Action::Resize(ResizeDir::GrowWidth)),
+ (
+ "resize shrink height",
+ Action::Resize(ResizeDir::ShrinkHeight),
+ ),
+ ("spawn dmenu_run", Action::Spawn("dmenu_run".to_string())),
+ ];
+ for (input, expected) in &cases {
+ let result = parse_action(&path, 1, input, &mut Vec::new());
+ assert_eq!(result, Some(expected.clone()), "failed for: {}", input);
+ }
+ }
+
+ // --- invalid config ---
+
+ #[test]
+ fn invalid_borderwidth_keeps_default() {
+ let conf = load_config("borderwidth abc\n");
+ assert_eq!(conf.border_width, 2); // default
+ }
+
+ #[test]
+ fn invalid_color_hex_keeps_default() {
+ let conf = load_config("color activeborder gggggg\n");
+ assert_eq!(conf.color_focused, 0x4c7899); // default
+ }
+
+ #[test]
+ fn unknown_keyword_ignored() {
+ let conf = load_config("foobar 123\nterminal st\n");
+ assert_eq!(conf.terminal, "st");
+ }
+
+ #[test]
+ fn comments_and_blank_lines_ignored() {
+ let conf = load_config("# comment\n\n \nterminal foot\n");
+ assert_eq!(conf.terminal, "foot");
+ }
+
+ #[test]
+ fn missing_config_file_returns_defaults() {
+ let _guard = TEST_LOCK.lock().unwrap();
+ let seq = TEST_SEQ.fetch_add(1, Ordering::Relaxed);
+ let dir = std::env::temp_dir().join(format!(
+ "owm-norc-{}-{}",
+ std::process::id(),
+ seq
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let old_home = std::env::var("HOME").ok();
+ unsafe { std::env::set_var("HOME", &dir) };
+ let (conf, _errors) = Config::load();
+ match old_home {
+ Some(h) => unsafe { std::env::set_var("HOME", h) },
+ None => unsafe { std::env::remove_var("HOME") },
+ }
+ let _ = fs::remove_dir(&dir);
+ assert_eq!(conf.border_width, 2);
+ assert_eq!(conf.terminal, "xterm");
+ }
+}
+
+/// X11 keysym constants (following X11 naming convention).
+#[allow(non_upper_case_globals)]
+pub mod keysym {
+ pub const XK_Return: u32 = 0xff0d;
+ pub const XK_space: u32 = 0x0020;
+ pub const XK_1: u32 = 0x0031;
+ pub const XK_2: u32 = 0x0032;
+ pub const XK_3: u32 = 0x0033;
+ pub const XK_4: u32 = 0x0034;
+ pub const XK_5: u32 = 0x0035;
+ pub const XK_6: u32 = 0x0036;
+ pub const XK_7: u32 = 0x0037;
+ pub const XK_8: u32 = 0x0038;
+ pub const XK_9: u32 = 0x0039;
+ pub const XK_b: u32 = 0x0062;
+ pub const XK_d: u32 = 0x0064;
+ pub const XK_e: u32 = 0x0065;
+ pub const XK_f: u32 = 0x0066;
+ pub const XK_h: u32 = 0x0068;
+ pub const XK_j: u32 = 0x006a;
+ pub const XK_k: u32 = 0x006b;
+ pub const XK_l: u32 = 0x006c;
+ pub const XK_q: u32 = 0x0071;
+ pub const XK_r: u32 = 0x0072;
+ pub const XK_s: u32 = 0x0073;
+ pub const XK_v: u32 = 0x0076;
+ pub const XK_w: u32 = 0x0077;
+ pub const XK_Escape: u32 = 0xff1b;
+}
blob - /dev/null
blob + 37f7fc46e38cb2c68ad365c1b4375ec78df95e00 (mode 644)
--- /dev/null
+++ src/container.rs
+use std::collections::VecDeque;
+
+use slotmap::{SlotMap, new_key_type};
+
+use crate::config::GapsOverride;
+
+new_key_type! {
+ /// Generational key for container nodes in the arena.
+ pub struct NodeKey;
+}
+
+/// Unique identifier for containers.
+pub type ConId = u64;
+
+/// Rectangle geometry.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub struct Rect {
+ pub x: i32,
+ pub y: i32,
+ pub w: u32,
+ pub h: u32,
+}
+
+/// Size hints from WM_NORMAL_HINTS (ICCCM §4.1.2.3).
+#[derive(Debug, Clone, Copy, Default)]
+pub struct SizeHints {
+ pub min_w: Option<u32>,
+ pub min_h: Option<u32>,
+ pub max_w: Option<u32>,
+ pub max_h: Option<u32>,
+ pub inc_w: Option<u32>,
+ pub inc_h: Option<u32>,
+ pub base_w: Option<u32>,
+ pub base_h: Option<u32>,
+}
+
+/// Container type in the tree hierarchy.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ConType {
+ Root,
+ Output,
+ Workspace,
+ Con,
+ FloatingCon,
+}
+
+/// Layout direction for split containers.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum Layout {
+ #[default]
+ SplitH,
+ SplitV,
+ Stacked,
+ Tabbed,
+}
+
+/// Border style for window decorations.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub enum BorderStyle {
+ /// Title bar + border (default).
+ #[default]
+ Normal,
+ /// Border only, no title bar.
+ Pixel,
+ /// No decoration at all.
+ None,
+}
+
+/// A container in the window tree.
+///
+/// The tree hierarchy:
+/// Root -> Output(s) -> Workspace(s) -> Con(s) -> Window leaf nodes
+///
+/// Inspired by i3's Con structure, using a SlotMap with generational
+/// keys instead of raw pointers or plain indices for memory safety.
+#[derive(Debug)]
+pub struct Container {
+ pub id: ConId,
+ pub con_type: ConType,
+ pub layout: Layout,
+ pub rect: Rect,
+ /// Decoration bar position/size, relative to parent's rect.
+ /// Used in tabbed/stacked layouts where the parent draws all
+ /// children's title bars.
+ pub deco_rect: Rect,
+ pub window_rect: Rect,
+ pub name: String,
+ /// Workspace number extracted from name, or -1 for named-only workspaces.
+ pub num: i32,
+ pub urgent: bool,
+ pub focused: bool,
+ pub fullscreen: bool,
+ pub is_floating: bool,
+ /// Sticky floating windows follow workspace switches (like i3).
+ pub sticky: bool,
+ /// Window is minimized (hidden via _NET_WM_STATE_HIDDEN).
+ pub minimized: bool,
+
+ /// Percentage of parent space this container occupies.
+ pub percent: f64,
+
+ /// X11 window ID, if this is a leaf container managing a window.
+ pub window: Option<u32>,
+
+ /// X11 frame window ID (reparenting WM: decoration window wrapping the client).
+ pub frame: Option<u32>,
+
+ /// Window title from _NET_WM_NAME or WM_NAME.
+ pub title: String,
+
+ /// Border style: Normal (title bar + border), Pixel (border only), None.
+ pub border_style: BorderStyle,
+
+ /// Border width in pixels.
+ pub border_width: u32,
+
+ /// Size hints from WM_NORMAL_HINTS.
+ pub size_hints: SizeHints,
+
+ /// Number of pending UnmapNotify events to ignore (WM-initiated unmaps).
+ /// Incremented when the WM unmaps a frame (workspace switch, minimize,
+ /// move-to-workspace, scratchpad); decremented in on_unmap_notify.
+ pub ignore_unmap: u32,
+
+ /// Whether the frame window is currently mapped (visible on screen).
+ /// Tracked to avoid redundant map/unmap calls and spurious events.
+ pub frame_mapped: bool,
+
+ /// Per-workspace gap overrides (only meaningful for Workspace containers).
+ pub gaps_override: GapsOverride,
+
+ /// User-assigned mark (label) for quick navigation.
+ pub mark: Option<String>,
+
+ /// Parent container key in the arena.
+ pub parent: Option<NodeKey>,
+
+ /// Child container keys (tiling order).
+ pub children: Vec<NodeKey>,
+
+ /// Child container keys (focus order, most recent first).
+ pub focus_stack: VecDeque<NodeKey>,
+
+ /// Floating children keys.
+ pub floating_children: Vec<NodeKey>,
+}
+
+/// Arena-based container tree using generational indices.
+///
+/// All containers live in a SlotMap, referenced by NodeKey.
+/// Generational keys prevent use-after-free bugs from stale indices.
+pub struct ContainerTree {
+ nodes: SlotMap<NodeKey, Container>,
+ next_id: ConId,
+ pub root: NodeKey,
+ pub focused: Option<NodeKey>,
+}
+
+impl ContainerTree {
+ pub fn new(screen_rect: Rect) -> Self {
+ let mut nodes = SlotMap::with_key();
+
+ let root = Container {
+ id: 0,
+ con_type: ConType::Root,
+ layout: Layout::SplitH,
+ rect: screen_rect,
+ deco_rect: Rect::default(),
+ window_rect: Rect::default(),
+ name: String::from("root"),
+ num: -1,
+ urgent: false,
+ focused: false,
+ fullscreen: false,
+ is_floating: false,
+ sticky: false,
+ minimized: false,
+ percent: 1.0,
+ window: None,
+ frame: None,
+ title: String::new(),
+ border_style: BorderStyle::Normal,
+ border_width: 0,
+ size_hints: SizeHints::default(),
+ ignore_unmap: 0,
+ frame_mapped: false,
+ gaps_override: GapsOverride::default(),
+ mark: None,
+ parent: None,
+ children: Vec::new(),
+ focus_stack: VecDeque::new(),
+ floating_children: Vec::new(),
+ };
+
+ let root_key = nodes.insert(root);
+
+ ContainerTree {
+ nodes,
+ next_id: 1,
+ root: root_key,
+ focused: None,
+ }
+ }
+
+ /// Allocate a new container in the arena, returning its key.
+ pub fn alloc(&mut self, mut con: Container) -> NodeKey {
+ con.id = self.next_id;
+ self.next_id += 1;
+ self.nodes.insert(con)
+ }
+
+ /// Get a reference to a container by key.
+ pub fn get(&self, key: NodeKey) -> Option<&Container> {
+ self.nodes.get(key)
+ }
+
+ /// Get a mutable reference to a container by key.
+ pub fn get_mut(&mut self, key: NodeKey) -> Option<&mut Container> {
+ self.nodes.get_mut(key)
+ }
+
+ /// Create a new container and attach it as a child of `parent_key`.
+ pub fn create_child(
+ &mut self,
+ parent_key: NodeKey,
+ con_type: ConType,
+ name: &str,
+ ) -> NodeKey {
+ let num = if con_type == ConType::Workspace {
+ name.parse::<i32>().unwrap_or(-1)
+ } else {
+ -1
+ };
+ let con = Container {
+ id: 0, // will be set by alloc
+ con_type,
+ layout: Layout::default(),
+ rect: Rect::default(),
+ deco_rect: Rect::default(),
+ window_rect: Rect::default(),
+ name: name.to_string(),
+ num,
+ urgent: false,
+ focused: false,
+ fullscreen: false,
+ is_floating: false,
+ sticky: false,
+ minimized: false,
+ percent: 0.0,
+ window: None,
+ frame: None,
+ title: String::new(),
+ border_style: BorderStyle::Normal,
+ border_width: 0,
+ size_hints: SizeHints::default(),
+ ignore_unmap: 0,
+ frame_mapped: false,
+ gaps_override: GapsOverride::default(),
+ mark: None,
+ parent: Some(parent_key),
+ children: Vec::new(),
+ focus_stack: VecDeque::new(),
+ floating_children: Vec::new(),
+ };
+
+ let key = self.alloc(con);
+ if let Some(parent) = self.get_mut(parent_key) {
+ parent.children.push(key);
+ parent.focus_stack.push_front(key);
+ }
+ self.fix_percent(parent_key);
+ key
+ }
+
+ /// Remove a container and all its descendants from the tree.
+ pub fn remove(&mut self, key: NodeKey) {
+ // Collect all descendants iteratively (avoids clone + recursion)
+ let mut to_remove = vec![key];
+ let mut i = 0;
+ while i < to_remove.len() {
+ if let Some(con) = self.nodes.get(to_remove[i]) {
+ to_remove.extend_from_slice(&con.children);
+ }
+ i += 1;
+ }
+
+ // Remove from parent's children/focus lists
+ if let Some(parent_key) = self.nodes.get(key).and_then(|c| c.parent) {
+ if let Some(parent) = self.nodes.get_mut(parent_key) {
+ parent.children.retain(|&c| c != key);
+ parent.focus_stack.retain(|&c| c != key);
+ parent.floating_children.retain(|&c| c != key);
+ }
+ self.fix_percent(parent_key);
+ }
+
+ // Free all collected nodes
+ for &node_key in &to_remove {
+ self.nodes.remove(node_key);
+ if self.focused == Some(node_key) {
+ self.focused = None;
+ }
+ }
+ }
+
+ /// Detach a container from its parent's children/focus lists without freeing it.
+ /// Returns the old parent key.
+ pub fn detach(&mut self, key: NodeKey) -> Option<NodeKey> {
+ let parent_key = self.get(key).and_then(|c| c.parent);
+ if let Some(pkey) = parent_key {
+ if let Some(parent) = self.get_mut(pkey) {
+ parent.children.retain(|&c| c != key);
+ parent.focus_stack.retain(|&c| c != key);
+ parent.floating_children.retain(|&c| c != key);
+ }
+ self.fix_percent(pkey);
+ }
+ parent_key
+ }
+
+ /// Attach a container as a floating child of a workspace.
+ pub fn attach_floating(&mut self, key: NodeKey, ws_key: NodeKey) {
+ if let Some(con) = self.get_mut(key) {
+ con.parent = Some(ws_key);
+ con.is_floating = true;
+ con.con_type = ConType::FloatingCon;
+ }
+ if let Some(ws) = self.get_mut(ws_key) {
+ ws.floating_children.push(key);
+ ws.focus_stack.push_front(key);
+ }
+ }
+
+ /// Attach a container as a tiling child of a parent.
+ pub fn attach_tiling(&mut self, key: NodeKey, parent_key: NodeKey) {
+ if let Some(con) = self.get_mut(key) {
+ con.parent = Some(parent_key);
+ con.is_floating = false;
+ con.con_type = ConType::Con;
+ }
+ if let Some(parent) = self.get_mut(parent_key) {
+ parent.children.push(key);
+ parent.focus_stack.push_front(key);
+ }
+ self.fix_percent(parent_key);
+ }
+
+ /// Recalculate percent values for children of a container.
+ pub fn fix_percent(&mut self, parent_key: NodeKey) {
+ let count = match self.nodes.get(parent_key) {
+ Some(p) => p.children.len(),
+ None => return,
+ };
+ if count == 0 {
+ return;
+ }
+ let each = 1.0 / count as f64;
+ for i in 0..count {
+ let child_key = self.nodes[parent_key].children[i];
+ if let Some(child) = self.nodes.get_mut(child_key) {
+ child.percent = each;
+ }
+ }
+ }
+
+ /// Split a container: insert a new split container between it and its parent,
+ /// then move the original container as a child of the new split.
+ pub fn split(&mut self, target_key: NodeKey, layout: Layout) -> NodeKey {
+ let parent_key = match self.get(target_key) {
+ Some(c) => c.parent,
+ None => return target_key,
+ };
+
+ let split = Container {
+ id: 0,
+ con_type: ConType::Con,
+ layout,
+ rect: Rect::default(),
+ deco_rect: Rect::default(),
+ window_rect: Rect::default(),
+ name: String::new(),
+ num: -1,
+ urgent: false,
+ focused: false,
+ fullscreen: false,
+ is_floating: false,
+ sticky: false,
+ minimized: false,
+ percent: 0.0,
+ window: None,
+ frame: None,
+ title: String::new(),
+ border_style: BorderStyle::Normal,
+ border_width: 0,
+ size_hints: SizeHints::default(),
+ ignore_unmap: 0,
+ frame_mapped: false,
+ gaps_override: GapsOverride::default(),
+ mark: None,
+ parent: parent_key,
+ children: vec![target_key],
+ focus_stack: {
+ let mut fs = VecDeque::with_capacity(1);
+ fs.push_back(target_key);
+ fs
+ },
+ floating_children: Vec::new(),
+ };
+
+ let split_key = self.alloc(split);
+
+ // Replace target with split in parent's children list
+ if let Some(pkey) = parent_key
+ && let Some(parent) = self.get_mut(pkey)
+ {
+ for c in &mut parent.children {
+ if *c == target_key {
+ *c = split_key;
+ }
+ }
+ for c in parent.focus_stack.iter_mut() {
+ if *c == target_key {
+ *c = split_key;
+ }
+ }
+ }
+
+ // Reparent target
+ if let Some(target) = self.get_mut(target_key) {
+ target.parent = Some(split_key);
+ }
+
+ split_key
+ }
+
+ /// Find the workspace containing the given container.
+ pub fn workspace_for(&self, key: NodeKey) -> Option<NodeKey> {
+ let mut cur = key;
+ loop {
+ match self.get(cur) {
+ Some(c) if c.con_type == ConType::Workspace => {
+ return Some(cur);
+ }
+ Some(c) => match c.parent {
+ Some(p) => cur = p,
+ None => return None,
+ },
+ None => return None,
+ }
+ }
+ }
+
+ /// Find the focused child in a container's focus stack.
+ pub fn focused_child(&self, key: NodeKey) -> Option<NodeKey> {
+ self.get(key).and_then(|c| c.focus_stack.front().copied())
+ }
+
+ /// Find a leaf (window) container by descending the focus stack.
+ pub fn focused_leaf(&self, key: NodeKey) -> NodeKey {
+ let mut cur = key;
+ loop {
+ match self.focused_child(cur) {
+ Some(child) => cur = child,
+ None => return cur,
+ }
+ }
+ }
+
+ /// Iterate all containers.
+ pub fn iter(&self) -> impl Iterator<Item = (NodeKey, &Container)> {
+ self.nodes.iter()
+ }
+
+ /// Find a container managing the given X11 window (client or frame).
+ pub fn find_by_window(&self, window: u32) -> Option<NodeKey> {
+ self.iter()
+ .find(|(_, c)| {
+ c.window == Some(window) || c.frame == Some(window)
+ })
+ .map(|(key, _)| key)
+ }
+
+ /// Find a container by its mark.
+ pub fn find_by_mark(&self, mark: &str) -> Option<NodeKey> {
+ self.iter()
+ .find(|(_, c)| c.mark.as_deref() == Some(mark))
+ .map(|(key, _)| key)
+ }
+
+ /// Set a mark on a container, removing it from any other container first.
+ pub fn set_mark(&mut self, key: NodeKey, mark: &str) {
+ // Remove the mark from any existing container
+ if let Some(old_key) = self.find_by_mark(mark)
+ && let Some(old) = self.get_mut(old_key)
+ {
+ old.mark = None;
+ }
+ if let Some(con) = self.get_mut(key) {
+ con.mark = Some(mark.to_string());
+ }
+ }
+
+ /// Remove a mark from any container that has it.
+ pub fn unmark(&mut self, mark: &str) {
+ if let Some(key) = self.find_by_mark(mark)
+ && let Some(con) = self.get_mut(key)
+ {
+ con.mark = None;
+ }
+ }
+
+ /// Remove all marks from all containers.
+ pub fn unmark_all(&mut self) {
+ let keys: Vec<NodeKey> = self
+ .iter()
+ .filter(|(_, c)| c.mark.is_some())
+ .map(|(k, _)| k)
+ .collect();
+ for key in keys {
+ if let Some(con) = self.get_mut(key) {
+ con.mark = None;
+ }
+ }
+ }
+
+ /// Collect all marks in the tree.
+ pub fn marks(&self) -> Vec<&str> {
+ self.iter().filter_map(|(_, c)| c.mark.as_deref()).collect()
+ }
+
+ /// Check if a container is hidden inside a tabbed/stacked parent.
+ ///
+ /// Like i3's `con_is_hidden()`: walks up the tree; if any ancestor
+ /// has Tabbed or Stacked layout and this container is not the
+ /// focused child of that ancestor, the container is hidden.
+ pub fn is_hidden(&self, key: NodeKey) -> bool {
+ let mut cur = key;
+ loop {
+ let parent_key = match self.get(cur).and_then(|c| c.parent) {
+ Some(p) => p,
+ None => return false,
+ };
+ let parent = match self.get(parent_key) {
+ Some(p) => p,
+ None => return false,
+ };
+ // Check layout BEFORE stopping at workspace — a workspace
+ // itself can have tabbed/stacked layout.
+ if matches!(parent.layout, Layout::Tabbed | Layout::Stacked) {
+ if parent.focus_stack.front() != Some(&cur) {
+ return true;
+ }
+ }
+ if matches!(
+ parent.con_type,
+ ConType::Workspace | ConType::Output | ConType::Root
+ ) {
+ return false;
+ }
+ cur = parent_key;
+ }
+ }
+
+ /// Get siblings info for tab drawing: returns vec of (NodeKey, title, is_focused)
+ /// for all children of the given container's parent, if parent is tabbed/stacked.
+ pub fn tab_siblings(&self, key: NodeKey) -> Option<(Layout, Vec<(NodeKey, Rect)>)> {
+ let parent_key = self.get(key)?.parent?;
+ let parent = self.get(parent_key)?;
+ if !matches!(parent.layout, Layout::Tabbed | Layout::Stacked) {
+ return None;
+ }
+ let tabs: Vec<(NodeKey, Rect)> = parent.children.iter()
+ .filter_map(|&k| {
+ let c = self.get(k)?;
+ Some((k, c.deco_rect))
+ })
+ .collect();
+ Some((parent.layout, tabs))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn screen() -> Rect {
+ Rect {
+ x: 0,
+ y: 0,
+ w: 1920,
+ h: 1080,
+ }
+ }
+
+ // --- create ---
+
+ #[test]
+ fn new_tree_has_root() {
+ let tree = ContainerTree::new(screen());
+ let root = tree.get(tree.root).unwrap();
+ assert_eq!(root.con_type, ConType::Root);
+ assert_eq!(root.name, "root");
+ assert_eq!(root.rect, screen());
+ assert!(root.children.is_empty());
+ }
+
+ #[test]
+ fn create_child_adds_to_parent() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "eDP-1");
+
+ let root = tree.get(tree.root).unwrap();
+ assert_eq!(root.children.len(), 1);
+ assert_eq!(root.children[0], out);
+ assert_eq!(root.focus_stack[0], out);
+
+ let output = tree.get(out).unwrap();
+ assert_eq!(output.con_type, ConType::Output);
+ assert_eq!(output.name, "eDP-1");
+ assert_eq!(output.parent, Some(tree.root));
+ }
+
+ #[test]
+ fn create_workspace_parses_num() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "eDP-1");
+ let ws1 = tree.create_child(out, ConType::Workspace, "1");
+ let wsn = tree.create_child(out, ConType::Workspace, "media");
+
+ assert_eq!(tree.get(ws1).unwrap().num, 1);
+ assert_eq!(tree.get(wsn).unwrap().num, -1);
+ }
+
+ #[test]
+ fn create_child_assigns_unique_ids() {
+ let mut tree = ContainerTree::new(screen());
+ let a = tree.create_child(tree.root, ConType::Output, "a");
+ let b = tree.create_child(tree.root, ConType::Output, "b");
+ assert_ne!(tree.get(a).unwrap().id, tree.get(b).unwrap().id);
+ }
+
+ #[test]
+ fn fix_percent_distributes_evenly() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+ let c2 = tree.create_child(ws, ConType::Con, "c2");
+ let c3 = tree.create_child(ws, ConType::Con, "c3");
+
+ let eps = 1e-9;
+ let third = 1.0 / 3.0;
+ assert!((tree.get(c1).unwrap().percent - third).abs() < eps);
+ assert!((tree.get(c2).unwrap().percent - third).abs() < eps);
+ assert!((tree.get(c3).unwrap().percent - third).abs() < eps);
+ }
+
+ // --- remove ---
+
+ #[test]
+ fn remove_detaches_from_parent() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c = tree.create_child(ws, ConType::Con, "c");
+
+ tree.remove(c);
+ let ws_node = tree.get(ws).unwrap();
+ assert!(ws_node.children.is_empty());
+ assert!(ws_node.focus_stack.is_empty());
+ assert!(tree.get(c).is_none());
+ }
+
+ #[test]
+ fn remove_cascades_to_descendants() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let parent = tree.create_child(ws, ConType::Con, "p");
+ let child = tree.create_child(parent, ConType::Con, "c");
+
+ tree.remove(parent);
+ assert!(tree.get(parent).is_none());
+ assert!(tree.get(child).is_none());
+ }
+
+ #[test]
+ fn remove_clears_focused() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c = tree.create_child(ws, ConType::Con, "c");
+ tree.focused = Some(c);
+
+ tree.remove(c);
+ assert_eq!(tree.focused, None);
+ }
+
+ // --- split ---
+
+ #[test]
+ fn split_inserts_intermediate_container() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let leaf = tree.create_child(ws, ConType::Con, "leaf");
+
+ let split = tree.split(leaf, Layout::SplitV);
+
+ // split container is now a child of ws
+ let ws_node = tree.get(ws).unwrap();
+ assert!(ws_node.children.contains(&split));
+ assert!(!ws_node.children.contains(&leaf));
+
+ // leaf is now a child of split
+ let split_node = tree.get(split).unwrap();
+ assert_eq!(split_node.layout, Layout::SplitV);
+ assert_eq!(split_node.children, vec![leaf]);
+ assert_eq!(tree.get(leaf).unwrap().parent, Some(split));
+ }
+
+ // --- detach ---
+
+ #[test]
+ fn detach_removes_from_parent_keeps_node() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c = tree.create_child(ws, ConType::Con, "c");
+
+ let old_parent = tree.detach(c);
+ assert_eq!(old_parent, Some(ws));
+ assert!(tree.get(c).is_some()); // node still exists
+ let ws_node = tree.get(ws).unwrap();
+ assert!(!ws_node.children.contains(&c));
+ assert!(!ws_node.focus_stack.contains(&c));
+ }
+
+ // --- attach ---
+
+ #[test]
+ fn attach_tiling_reparents() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws1 = tree.create_child(out, ConType::Workspace, "1");
+ let ws2 = tree.create_child(out, ConType::Workspace, "2");
+ let c = tree.create_child(ws1, ConType::Con, "c");
+
+ tree.detach(c);
+ tree.attach_tiling(c, ws2);
+
+ assert_eq!(tree.get(c).unwrap().parent, Some(ws2));
+ assert!(tree.get(ws2).unwrap().children.contains(&c));
+ assert!(!tree.get(ws1).unwrap().children.contains(&c));
+ }
+
+ #[test]
+ fn attach_floating_sets_type() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c = tree.create_child(ws, ConType::Con, "c");
+
+ tree.detach(c);
+ tree.attach_floating(c, ws);
+
+ let con = tree.get(c).unwrap();
+ assert!(con.is_floating);
+ assert_eq!(con.con_type, ConType::FloatingCon);
+ assert!(tree.get(ws).unwrap().floating_children.contains(&c));
+ }
+
+ // --- utility ---
+
+ #[test]
+ fn workspace_for_finds_ancestor() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c = tree.create_child(ws, ConType::Con, "c");
+
+ assert_eq!(tree.workspace_for(c), Some(ws));
+ assert_eq!(tree.workspace_for(ws), Some(ws));
+ assert_eq!(tree.workspace_for(out), None);
+ }
+
+ #[test]
+ fn find_by_window_and_mark() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c = tree.create_child(ws, ConType::Con, "c");
+
+ tree.get_mut(c).unwrap().window = Some(0x1234);
+ assert_eq!(tree.find_by_window(0x1234), Some(c));
+ assert_eq!(tree.find_by_window(0x9999), None);
+
+ tree.set_mark(c, "test");
+ assert_eq!(tree.find_by_mark("test"), Some(c));
+ assert_eq!(tree.marks(), vec!["test"]);
+
+ tree.unmark("test");
+ assert_eq!(tree.find_by_mark("test"), None);
+ assert!(tree.marks().is_empty());
+ }
+
+ #[test]
+ fn focused_leaf_descends_focus_stack() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+ let _c2 = tree.create_child(ws, ConType::Con, "c2");
+
+ // c2 was created last so it's at front of focus_stack
+ // add a child to c1
+ let leaf = tree.create_child(c1, ConType::Con, "leaf");
+
+ // focused_leaf from c1 should reach leaf
+ assert_eq!(tree.focused_leaf(c1), leaf);
+ }
+
+ // --- is_hidden (tabbed/stacked visibility) ---
+
+ #[test]
+ fn is_hidden_split_layout_never_hides() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+ let c2 = tree.create_child(ws, ConType::Con, "c2");
+
+ // Default layout is SplitH — nothing is hidden.
+ assert!(!tree.is_hidden(c1));
+ assert!(!tree.is_hidden(c2));
+ }
+
+ #[test]
+ fn is_hidden_tabbed_workspace() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+ let c2 = tree.create_child(ws, ConType::Con, "c2");
+ let c3 = tree.create_child(ws, ConType::Con, "c3");
+
+ // Set workspace to tabbed. focus_stack front = c3 (last created).
+ tree.get_mut(ws).unwrap().layout = Layout::Tabbed;
+
+ // c3 is focused (front of focus_stack), others are hidden.
+ assert!(tree.is_hidden(c1));
+ assert!(tree.is_hidden(c2));
+ assert!(!tree.is_hidden(c3));
+ }
+
+ #[test]
+ fn is_hidden_stacked_workspace() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+ let c2 = tree.create_child(ws, ConType::Con, "c2");
+
+ tree.get_mut(ws).unwrap().layout = Layout::Stacked;
+
+ // c2 is at front of focus_stack
+ assert!(tree.is_hidden(c1));
+ assert!(!tree.is_hidden(c2));
+
+ // Change focus to c1
+ tree.get_mut(ws).unwrap().focus_stack.retain(|&c| c != c1);
+ tree.get_mut(ws).unwrap().focus_stack.push_front(c1);
+
+ assert!(!tree.is_hidden(c1));
+ assert!(tree.is_hidden(c2));
+ }
+
+ #[test]
+ fn is_hidden_nested_tabbed() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ // Split container with tabbed layout
+ let split = tree.create_child(ws, ConType::Con, "split");
+ tree.get_mut(split).unwrap().layout = Layout::Tabbed;
+ let c1 = tree.create_child(split, ConType::Con, "c1");
+ let c2 = tree.create_child(split, ConType::Con, "c2");
+
+ // c2 at front of focus_stack
+ assert!(tree.is_hidden(c1));
+ assert!(!tree.is_hidden(c2));
+ }
+
+ #[test]
+ fn is_hidden_single_child_not_hidden() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+
+ tree.get_mut(ws).unwrap().layout = Layout::Tabbed;
+
+ // Single child is always the focused one.
+ assert!(!tree.is_hidden(c1));
+ }
+
+ // --- tab_siblings ---
+
+ #[test]
+ fn tab_siblings_returns_none_for_split() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+
+ assert!(tree.tab_siblings(c1).is_none());
+ }
+
+ #[test]
+ fn tab_siblings_returns_all_for_tabbed() {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ let c1 = tree.create_child(ws, ConType::Con, "c1");
+ let c2 = tree.create_child(ws, ConType::Con, "c2");
+ let c3 = tree.create_child(ws, ConType::Con, "c3");
+
+ tree.get_mut(ws).unwrap().layout = Layout::Tabbed;
+
+ let (layout, tabs) = tree.tab_siblings(c1).unwrap();
+ assert_eq!(layout, Layout::Tabbed);
+ assert_eq!(tabs.len(), 3);
+ assert_eq!(tabs[0].0, c1);
+ assert_eq!(tabs[1].0, c2);
+ assert_eq!(tabs[2].0, c3);
+ }
+}
blob - /dev/null
blob + 522b83a31ea2a76892af95f5f77aaac17bcee984 (mode 644)
--- /dev/null
+++ src/ipc.rs
+use std::fmt::Write as FmtWrite;
+use std::io::{self, Read, Write};
+use std::os::unix::fs::PermissionsExt;
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::PathBuf;
+use std::time::Instant;
+
+use crate::container::{ConType, ContainerTree, NodeKey};
+
+/// IPC magic bytes.
+pub const IPC_MAGIC: &[u8; 6] = b"owm-ip";
+
+/// IPC message types (client -> wm).
+#[allow(dead_code)]
+#[repr(u32)]
+pub enum MsgType {
+ RunCommand = 0,
+ GetWorkspaces = 1,
+ GetTree = 3,
+ GetVersion = 4,
+ Subscribe = 5,
+ GetMarks = 6,
+ GetConfig = 7,
+}
+
+/// IPC event types (wm -> client).
+#[derive(Clone, Copy)]
+#[repr(u32)]
+pub enum EventType {
+ Workspace = 0x8000_0000,
+ Window = 0x8000_0001,
+ Config = 0x8000_0002,
+}
+
+/// Subscription bitmask bits matching EventType.
+const SUB_WORKSPACE: u32 = 1 << 0;
+const SUB_WINDOW: u32 = 1 << 1;
+const SUB_CONFIG: u32 = 1 << 2;
+
+/// Maximum messages per second per client before disconnection.
+const RATE_LIMIT: u32 = 100;
+
+/// Maximum command payload size in bytes.
+const MAX_COMMAND_LEN: usize = 4096;
+
+/// State for a connected IPC client.
+struct IpcClient {
+ stream: UnixStream,
+ read_buf: Vec<u8>,
+ write_buf: Vec<u8>,
+ /// Bitmask of subscribed event types.
+ subscriptions: u32,
+ /// Message count in the current rate-limit window.
+ msg_count: u32,
+ /// Start of the current rate-limit window.
+ window_start: Instant,
+}
+
+impl IpcClient {
+ fn new(stream: UnixStream) -> io::Result<Self> {
+ stream.set_nonblocking(true)?;
+ Ok(IpcClient {
+ stream,
+ read_buf: Vec::new(),
+ write_buf: Vec::new(),
+ subscriptions: 0,
+ msg_count: 0,
+ window_start: Instant::now(),
+ })
+ }
+
+ /// Check rate limit. Returns false if the client exceeded the limit.
+ fn check_rate(&mut self) -> bool {
+ let now = Instant::now();
+ if now.duration_since(self.window_start).as_secs() >= 1 {
+ self.msg_count = 0;
+ self.window_start = now;
+ }
+ self.msg_count += 1;
+ self.msg_count <= RATE_LIMIT
+ }
+}
+
+/// IPC server listening on a Unix socket.
+pub struct IpcServer {
+ listener: UnixListener,
+ clients: Vec<IpcClient>,
+ socket_path: PathBuf,
+}
+
+impl IpcServer {
+ /// Create a new IPC server.
+ pub fn new() -> io::Result<Self> {
+ let socket_path = Self::socket_path();
+
+ // Remove stale socket
+ let _ = std::fs::remove_file(&socket_path);
+
+ // Ensure parent directory exists with restricted permissions
+ if let Some(parent) = socket_path.parent() {
+ std::fs::create_dir_all(parent)?;
+ std::fs::set_permissions(
+ parent,
+ std::fs::Permissions::from_mode(0o700),
+ )?;
+ }
+
+ let listener = UnixListener::bind(&socket_path)?;
+ listener.set_nonblocking(true)?;
+
+ crate::log_info!("IPC listening on {}", socket_path.display());
+
+ Ok(IpcServer {
+ listener,
+ clients: Vec::new(),
+ socket_path,
+ })
+ }
+
+ /// Get the IPC socket path.
+ pub fn socket_path() -> PathBuf {
+ if let Ok(path) = std::env::var("OWM_SOCKET") {
+ return PathBuf::from(path);
+ }
+
+ let uid = unsafe { crate::sys::getuid() };
+ let dir = PathBuf::from(format!("/tmp/owm-{uid}"));
+ dir.join(format!("ipc-{}.sock", std::process::id()))
+ }
+
+ /// Get the socket path as a string for child process env vars.
+ pub fn socket_path_str(&self) -> &str {
+ self.socket_path.to_str().unwrap_or("")
+ }
+
+ /// Get the listener file descriptor for polling.
+ pub fn fd(&self) -> RawFd {
+ self.listener.as_raw_fd()
+ }
+
+ /// Accept new connections and process messages.
+ /// Returns a list of commands to execute.
+ pub fn poll(
+ &mut self,
+ tree: &ContainerTree,
+ config: &crate::config::Config,
+ current_mode: &str,
+ ) -> Vec<String> {
+ let mut commands = Vec::new();
+
+ // Accept new connections
+ loop {
+ match self.listener.accept() {
+ Ok((stream, _)) => match IpcClient::new(stream) {
+ Ok(client) => {
+ crate::log_debug!("IPC client connected");
+ self.clients.push(client);
+ }
+ Err(e) => {
+ crate::log_warn!("failed to init IPC client: {}", e);
+ }
+ },
+ Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
+ Err(e) => {
+ crate::log_warn!("IPC accept error: {}", e);
+ break;
+ }
+ }
+ }
+
+ // Process each client
+ let mut to_remove = Vec::new();
+ for (i, client) in self.clients.iter_mut().enumerate() {
+ let mut buf = [0u8; 4096];
+ match client.stream.read(&mut buf) {
+ Ok(0) => {
+ to_remove.push(i);
+ }
+ Ok(n) => {
+ client.read_buf.extend_from_slice(&buf[..n]);
+ while let Some((msg_type, payload)) =
+ parse_message(&mut client.read_buf)
+ {
+ // Rate limiting
+ if !client.check_rate() {
+ crate::log_warn!(
+ "IPC client exceeded rate limit, disconnecting"
+ );
+ to_remove.push(i);
+ break;
+ }
+
+ // Handle Subscribe (msg type 5)
+ if msg_type == MsgType::Subscribe as u32 {
+ if payload.len() >= 4 {
+ let mask = u32::from_ne_bytes([
+ payload[0], payload[1], payload[2],
+ payload[3],
+ ]);
+ client.subscriptions |= mask;
+ crate::log_debug!(
+ "IPC client subscribed: 0x{:08x}",
+ client.subscriptions
+ );
+ }
+ let resp = r#"{"success":true}"#.as_bytes();
+ let header =
+ make_header(msg_type, resp.len() as u32);
+ client.write_buf.extend_from_slice(&header);
+ client.write_buf.extend_from_slice(resp);
+ continue;
+ }
+
+ let response = handle_message(
+ msg_type, &payload, tree, config, current_mode,
+ );
+
+ // Validate and collect RUN_COMMAND
+ if msg_type == MsgType::RunCommand as u32
+ && let Some(cmd) = validate_command(payload)
+ {
+ commands.push(cmd);
+ }
+
+ let response_bytes = response.into_bytes();
+ let header =
+ make_header(msg_type, response_bytes.len() as u32);
+ client.write_buf.extend_from_slice(&header);
+ client.write_buf.extend_from_slice(&response_bytes);
+ }
+ if !client.write_buf.is_empty() {
+ let _ = client.stream.write_all(&client.write_buf);
+ client.write_buf.clear();
+ }
+ }
+ Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}
+ Err(_) => {
+ to_remove.push(i);
+ }
+ }
+ }
+
+ // Deduplicate indices and remove in reverse order
+ to_remove.sort_unstable();
+ to_remove.dedup();
+ for i in to_remove.into_iter().rev() {
+ crate::log_debug!("IPC client disconnected");
+ self.clients.remove(i);
+ }
+
+ commands
+ }
+
+ /// Broadcast an event only to clients subscribed to this event type.
+ pub fn broadcast_event(&mut self, event_type: EventType, json: &str) {
+ let sub_bit = match event_type {
+ EventType::Workspace => SUB_WORKSPACE,
+ EventType::Window => SUB_WINDOW,
+ EventType::Config => SUB_CONFIG,
+ };
+
+ let payload = json.as_bytes();
+ let header = make_header(event_type as u32, payload.len() as u32);
+
+ let mut to_remove = Vec::new();
+ for (i, client) in self.clients.iter_mut().enumerate() {
+ if client.subscriptions & sub_bit == 0 {
+ continue;
+ }
+ if client.stream.write_all(&header).is_err()
+ || client.stream.write_all(payload).is_err()
+ {
+ to_remove.push(i);
+ }
+ }
+ for i in to_remove.into_iter().rev() {
+ self.clients.remove(i);
+ }
+ }
+}
+
+impl Drop for IpcServer {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_file(&self.socket_path);
+ }
+}
+
+/// Parse a message from the read buffer.
+fn parse_message(buf: &mut Vec<u8>) -> Option<(u32, Vec<u8>)> {
+ const HEADER_SIZE: usize = 14;
+ if buf.len() < HEADER_SIZE {
+ return None;
+ }
+
+ if &buf[..6] != IPC_MAGIC {
+ crate::log_warn!(
+ "IPC: invalid magic bytes {:02x?}, dropping buffer",
+ &buf[..6]
+ );
+ buf.clear();
+ return None;
+ }
+
+ let size = u32::from_ne_bytes([buf[6], buf[7], buf[8], buf[9]]) as usize;
+ let msg_type = u32::from_ne_bytes([buf[10], buf[11], buf[12], buf[13]]);
+
+ if buf.len() < HEADER_SIZE + size {
+ return None;
+ }
+
+ let payload = buf[HEADER_SIZE..HEADER_SIZE + size].to_vec();
+ buf.drain(..HEADER_SIZE + size);
+
+ Some((msg_type, payload))
+}
+
+/// Create a response header (stack-allocated).
+fn make_header(msg_type: u32, size: u32) -> [u8; 14] {
+ let mut header = [0u8; 14];
+ header[..6].copy_from_slice(IPC_MAGIC);
+ header[6..10].copy_from_slice(&size.to_ne_bytes());
+ header[10..14].copy_from_slice(&msg_type.to_ne_bytes());
+ header
+}
+
+/// Allowed command prefixes for RUN_COMMAND.
+const ALLOWED_COMMANDS: &[&str] = &[
+ "focus",
+ "split",
+ "layout",
+ "kill",
+ "fullscreen",
+ "workspace",
+ "move",
+ "exec",
+ "reload",
+ "restart",
+ "exit",
+ "mark",
+ "unmark",
+ "mode",
+ "resize",
+ "sticky",
+ "minimize",
+ "restore",
+ "scratchpad",
+ "gaps",
+];
+
+/// Validate and sanitize a RUN_COMMAND payload.
+/// Returns the command string if valid, None otherwise.
+fn validate_command(payload: Vec<u8>) -> Option<String> {
+ if payload.len() > MAX_COMMAND_LEN {
+ crate::log_warn!(
+ "IPC: command too long ({} bytes), rejecting",
+ payload.len()
+ );
+ return None;
+ }
+
+ let cmd = match String::from_utf8(payload) {
+ Ok(s) => s,
+ Err(_) => {
+ crate::log_warn!("IPC: command is not valid UTF-8, rejecting");
+ return None;
+ }
+ };
+
+ // Reject control characters (except space/tab)
+ if cmd.bytes().any(|b| b < 0x20 && b != b' ' && b != b'\t') {
+ crate::log_warn!("IPC: command contains control characters, rejecting");
+ return None;
+ }
+
+ let first_word = cmd.split_whitespace().next().unwrap_or("");
+ if !ALLOWED_COMMANDS.contains(&first_word) {
+ crate::log_warn!("IPC: unknown command '{}', rejecting", first_word);
+ return None;
+ }
+
+ Some(cmd)
+}
+
+/// Handle an IPC message and return a JSON response string.
+fn handle_message(
+ msg_type: u32,
+ _payload: &[u8],
+ tree: &ContainerTree,
+ config: &crate::config::Config,
+ current_mode: &str,
+) -> String {
+ match msg_type {
+ // RUN_COMMAND
+ 0 => r#"{"success":true}"#.to_string(),
+ // GET_WORKSPACES
+ 1 => {
+ let data = dump_workspaces(tree);
+ format!(r#"{{"success":true,"data":{data}}}"#)
+ }
+ // GET_TREE
+ 3 => {
+ let data = dump_tree(tree, tree.root);
+ format!(r#"{{"success":true,"data":{data}}}"#)
+ }
+ // GET_VERSION
+ 4 => {
+ format!(
+ r#"{{"success":true,"data":{{"version":"{}","name":"owm"}}}}"#,
+ env!("CARGO_PKG_VERSION")
+ )
+ }
+ // SUBSCRIBE handled in poll(), shouldn't reach here
+ 5 => r#"{"success":true}"#.to_string(),
+ // GET_MARKS
+ 6 => {
+ let data = dump_marks(tree);
+ format!(r#"{{"success":true,"data":{data}}}"#)
+ }
+ // GET_CONFIG
+ 7 => {
+ let data = dump_config(config, current_mode);
+ format!(r#"{{"success":true,"data":{data}}}"#)
+ }
+ _ => {
+ format!(
+ r#"{{"success":false,"error":"unknown message type: {msg_type}"}}"#
+ )
+ }
+ }
+}
+
+/// Escape a string for JSON output.
+fn json_escape(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ c if c < '\x20' => {
+ let _ = write!(out, "\\u{:04x}", c as u32);
+ }
+ c => out.push(c),
+ }
+ }
+ out
+}
+
+/// Recursively dump the container tree as a JSON string.
+fn dump_tree(tree: &ContainerTree, idx: NodeKey) -> String {
+ let con = match tree.get(idx) {
+ Some(c) => c,
+ None => return "null".to_string(),
+ };
+
+ let mut nodes = String::new();
+ for (i, &child) in con.children.iter().enumerate() {
+ if i > 0 {
+ nodes.push(',');
+ }
+ nodes.push_str(&dump_tree(tree, child));
+ }
+
+ let name = json_escape(&con.name);
+ let window = match con.window {
+ Some(w) => w.to_string(),
+ None => "null".to_string(),
+ };
+ let mark = match &con.mark {
+ Some(m) => format!("\"{}\"", json_escape(m)),
+ None => "null".to_string(),
+ };
+
+ format!(
+ concat!(
+ r#"{{"id":{},"type":"{:?}","name":"{}","layout":"{:?}","#,
+ r#""rect":{{"x":{},"y":{},"w":{},"h":{}}},"#,
+ r#""window":{},"focused":{},"urgent":{},"fullscreen":{},"#,
+ r#""percent":{},"mark":{},"nodes":[{}]}}"#,
+ ),
+ con.id,
+ con.con_type,
+ name,
+ con.layout,
+ con.rect.x,
+ con.rect.y,
+ con.rect.w,
+ con.rect.h,
+ window,
+ con.focused,
+ con.urgent,
+ con.fullscreen,
+ con.percent,
+ mark,
+ nodes,
+ )
+}
+
+/// Dump all marks as a JSON array string.
+fn dump_marks(tree: &ContainerTree) -> String {
+ let marks = tree.marks();
+ let mut out = String::from("[");
+ for (i, m) in marks.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ out.push('"');
+ out.push_str(&json_escape(m));
+ out.push('"');
+ }
+ out.push(']');
+ out
+}
+
+/// Dump current configuration as a JSON object string.
+fn dump_config(config: &crate::config::Config, current_mode: &str) -> String {
+ let terminal = json_escape(&config.terminal);
+ let mode = json_escape(current_mode);
+ let g = &config.gaps;
+ let smart = match config.smart_gaps {
+ crate::config::SmartGaps::Off => "off",
+ crate::config::SmartGaps::On => "on",
+ crate::config::SmartGaps::InverseOuter => "inverse_outer",
+ };
+ let mut s = String::new();
+ let _ = write!(
+ s,
+ concat!(
+ "{{\"border_width\":{},",
+ "\"gaps\":{{\"inner\":{},\"top\":{},\"right\":{},\"bottom\":{},\"left\":{}}},",
+ "\"smart_gaps\":\"{}\",",
+ "\"color_focused\":\"#{:06x}\",\"color_unfocused\":\"#{:06x}\",",
+ "\"color_urgent\":\"#{:06x}\",\"color_background\":\"#{:06x}\",",
+ "\"terminal\":\"{}\",\"focus_follows_mouse\":{},",
+ "\"mode\":\"{}\"}}"
+ ),
+ config.border_width,
+ g.inner, g.top, g.right, g.bottom, g.left,
+ smart,
+ config.color_focused,
+ config.color_unfocused,
+ config.color_urgent,
+ config.color_background,
+ terminal,
+ config.focus_follows_mouse,
+ mode,
+ );
+ s
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Build a valid IPC message buffer.
+ fn make_message(msg_type: u32, payload: &[u8]) -> Vec<u8> {
+ let mut buf = Vec::new();
+ buf.extend_from_slice(IPC_MAGIC);
+ buf.extend_from_slice(&(payload.len() as u32).to_ne_bytes());
+ buf.extend_from_slice(&msg_type.to_ne_bytes());
+ buf.extend_from_slice(payload);
+ buf
+ }
+
+ // --- parse_message ---
+
+ #[test]
+ fn parse_valid_message() {
+ let payload = b"focus next";
+ let mut buf = make_message(0, payload);
+ let result = parse_message(&mut buf);
+ assert!(result.is_some());
+ let (msg_type, data) = result.unwrap();
+ assert_eq!(msg_type, 0);
+ assert_eq!(data, payload);
+ assert!(buf.is_empty());
+ }
+
+ #[test]
+ fn parse_empty_payload() {
+ let mut buf = make_message(4, b"");
+ let result = parse_message(&mut buf);
+ assert!(result.is_some());
+ let (msg_type, data) = result.unwrap();
+ assert_eq!(msg_type, 4);
+ assert!(data.is_empty());
+ }
+
+ #[test]
+ fn parse_incomplete_header() {
+ let mut buf = vec![b'o', b'w', b'm'];
+ let result = parse_message(&mut buf);
+ assert!(result.is_none());
+ assert_eq!(buf.len(), 3); // buffer unchanged
+ }
+
+ #[test]
+ fn parse_incomplete_payload() {
+ let mut buf = make_message(0, b"hello");
+ buf.truncate(buf.len() - 2); // remove last 2 bytes of payload
+ let result = parse_message(&mut buf);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn parse_invalid_magic_clears_buffer() {
+ let mut buf = vec![
+ 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00,
+ ];
+ let result = parse_message(&mut buf);
+ assert!(result.is_none());
+ assert!(buf.is_empty());
+ }
+
+ #[test]
+ fn parse_two_messages() {
+ let mut buf = make_message(0, b"cmd1");
+ buf.extend(make_message(1, b"cmd2"));
+
+ let r1 = parse_message(&mut buf);
+ assert!(r1.is_some());
+ assert_eq!(r1.unwrap().1, b"cmd1");
+
+ let r2 = parse_message(&mut buf);
+ assert!(r2.is_some());
+ assert_eq!(r2.unwrap().1, b"cmd2");
+
+ assert!(buf.is_empty());
+ }
+
+ // --- validate_command ---
+
+ #[test]
+ fn validate_allowed_commands() {
+ for cmd in ALLOWED_COMMANDS {
+ let payload = cmd.as_bytes().to_vec();
+ assert!(
+ validate_command(payload).is_some(),
+ "should accept: {}",
+ cmd
+ );
+ }
+ }
+
+ #[test]
+ fn validate_command_with_args() {
+ let r = validate_command(b"workspace 3".to_vec());
+ assert_eq!(r, Some("workspace 3".to_string()));
+ }
+
+ #[test]
+ fn validate_rejects_unknown_command() {
+ let r = validate_command(b"shutdown".to_vec());
+ assert!(r.is_none());
+ }
+
+ #[test]
+ fn validate_rejects_empty() {
+ let r = validate_command(b"".to_vec());
+ assert!(r.is_none());
+ }
+
+ #[test]
+ fn validate_rejects_too_long() {
+ let long = vec![b'a'; MAX_COMMAND_LEN + 1];
+ let r = validate_command(long);
+ assert!(r.is_none());
+ }
+
+ #[test]
+ fn validate_rejects_invalid_utf8() {
+ let r = validate_command(vec![0xff, 0xfe]);
+ assert!(r.is_none());
+ }
+
+ #[test]
+ fn validate_rejects_control_chars() {
+ let r = validate_command(b"focus\x01next".to_vec());
+ assert!(r.is_none());
+ }
+
+ #[test]
+ fn validate_max_length_accepted() {
+ let mut cmd = String::from("exec ");
+ while cmd.len() < MAX_COMMAND_LEN {
+ cmd.push('a');
+ }
+ let cmd = cmd[..MAX_COMMAND_LEN].to_string();
+ let r = validate_command(cmd.into_bytes());
+ assert!(r.is_some());
+ }
+}
+
+/// Dump workspace list as a JSON array string.
+fn dump_workspaces(tree: &ContainerTree) -> String {
+ let mut out = String::from("[");
+ let mut first = true;
+ for (idx, con) in tree.iter() {
+ if con.con_type == ConType::Workspace {
+ if !first {
+ out.push(',');
+ }
+ first = false;
+ let focused = tree
+ .focused
+ .is_some_and(|f| tree.workspace_for(f) == Some(idx));
+ let name = json_escape(&con.name);
+ let _ = write!(
+ out,
+ r#"{{"id":{},"name":"{}","num":{},"focused":{},"rect":{{"x":{},"y":{},"w":{},"h":{}}}}}"#,
+ con.id, name, con.num, focused, con.rect.x, con.rect.y, con.rect.w, con.rect.h,
+ );
+ }
+ }
+ out.push(']');
+ out
+}
blob - /dev/null
blob + af9276213e1af669f20ac438a8c5beb9180b72bc (mode 644)
--- /dev/null
+++ src/keybind.rs
+use std::collections::HashMap;
+use xcb::x;
+
+/// Resize direction.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ResizeDir {
+ GrowWidth,
+ ShrinkWidth,
+ GrowHeight,
+ ShrinkHeight,
+}
+
+/// Actions that can be triggered by key bindings.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Action {
+ FocusNext,
+ FocusPrev,
+ FocusParent,
+ FocusChild,
+ SwapNext,
+ SwapPrev,
+ SplitVertical,
+ SplitHorizontal,
+ LayoutStacked,
+ LayoutTabbed,
+ LayoutToggleSplit,
+ ToggleFullscreen,
+ ToggleFloating,
+ ToggleSticky,
+ Minimize,
+ CloseWindow,
+ MoveScratchpad,
+ ScratchpadShow,
+ Spawn(String),
+ Workspace(String),
+ MoveToWorkspace(String),
+ Resize(ResizeDir),
+ Mode(String),
+ Restart,
+ Exit,
+}
+
+/// A key binding: modifier mask + keycode -> action.
+#[derive(Debug, Clone, Hash, Eq, PartialEq)]
+pub struct KeyCombo {
+ pub modifiers: u32,
+ pub keycode: x::Keycode,
+}
+
+/// Default mode name.
+pub const MODE_DEFAULT: &str = "default";
+
+/// Manages key bindings organized by mode, with mode switching.
+pub struct KeybindManager {
+ modes: HashMap<String, HashMap<KeyCombo, Action>>,
+ current_mode: String,
+}
+
+/// Ignored modifier bits (NumLock=0x10, CapsLock=0x02).
+const IGNORE_MASK: u32 = 0x02 | 0x10;
+
+impl KeybindManager {
+ pub fn new() -> Self {
+ KeybindManager {
+ modes: HashMap::new(),
+ current_mode: MODE_DEFAULT.to_string(),
+ }
+ }
+
+ /// Current active mode name.
+ pub fn current_mode(&self) -> &str {
+ &self.current_mode
+ }
+
+ /// Switch to a named mode. Returns false if mode doesn't exist.
+ pub fn switch_mode(
+ &mut self,
+ mode: &str,
+ conn: &xcb::Connection,
+ root: x::Window,
+ ) -> bool {
+ if !self.modes.contains_key(mode) {
+ crate::log_warn!("unknown binding mode: {}", mode);
+ return false;
+ }
+ self.current_mode = mode.to_string();
+ // Re-grab keys for the new mode
+ self.grab_current_mode(conn, root);
+ crate::log_info!("switched to binding mode: {}", mode);
+ true
+ }
+
+ /// Remove all keybindings and ungrab keys.
+ pub fn clear(&mut self, conn: &xcb::Connection, root: x::Window) {
+ conn.send_request(&x::UngrabKey {
+ key: x::GRAB_ANY,
+ grab_window: root,
+ modifiers: x::ModMask::ANY,
+ });
+ self.modes.clear();
+ self.current_mode = MODE_DEFAULT.to_string();
+ }
+
+ /// Register keybindings for a given mode, converting keysyms to keycodes.
+ pub fn setup_mode(
+ &mut self,
+ conn: &xcb::Connection,
+ mode: &str,
+ bindings: &[(u32, u32, Action)],
+ ) {
+ let setup = conn.get_setup();
+ let min_keycode = setup.min_keycode();
+ let max_keycode = setup.max_keycode();
+
+ let cookie = conn.send_request(&x::GetKeyboardMapping {
+ first_keycode: min_keycode,
+ count: max_keycode - min_keycode + 1,
+ });
+
+ let reply = match conn.wait_for_reply(cookie) {
+ Ok(r) => r,
+ Err(e) => {
+ crate::log_error!("failed to get keyboard mapping: {}", e);
+ return;
+ }
+ };
+
+ let keysyms_per_keycode = reply.keysyms_per_keycode() as usize;
+ let all_keysyms = reply.keysyms();
+
+ let mode_bindings = self.modes.entry(mode.to_string()).or_default();
+
+ for &(modifiers, keysym, ref action) in bindings {
+ if let Some(keycode) = keysym_to_keycode(
+ all_keysyms,
+ keysyms_per_keycode,
+ min_keycode,
+ keysym,
+ ) {
+ let combo = KeyCombo { modifiers, keycode };
+ mode_bindings.insert(combo, action.clone());
+ crate::log_debug!(
+ "[{}] bound keysym {:#x} (keycode {}) -> {:?}",
+ mode,
+ keysym,
+ keycode,
+ action
+ );
+ } else {
+ crate::log_warn!("no keycode found for keysym {:#x}", keysym);
+ }
+ }
+ }
+
+ /// Register keybindings for the default mode (convenience wrapper).
+ pub fn setup(
+ &mut self,
+ conn: &xcb::Connection,
+ root: x::Window,
+ bindings: &[(u32, u32, Action)],
+ ) {
+ self.setup_mode(conn, MODE_DEFAULT, bindings);
+ self.grab_current_mode(conn, root);
+ }
+
+ /// Grab X keys for the current mode.
+ fn grab_current_mode(&self, conn: &xcb::Connection, root: x::Window) {
+ // Ungrab everything first
+ conn.send_request(&x::UngrabKey {
+ key: x::GRAB_ANY,
+ grab_window: root,
+ modifiers: x::ModMask::ANY,
+ });
+
+ let Some(bindings) = self.modes.get(&self.current_mode) else {
+ return;
+ };
+
+ for combo in bindings.keys() {
+ for extra in &[0u32, 0x02, 0x10, 0x12] {
+ conn.send_request(&x::GrabKey {
+ owner_events: true,
+ grab_window: root,
+ modifiers: x::ModMask::from_bits_truncate(
+ combo.modifiers | extra,
+ ),
+ key: combo.keycode,
+ pointer_mode: x::GrabMode::Async,
+ keyboard_mode: x::GrabMode::Async,
+ });
+ }
+ }
+ }
+
+ /// Look up the action for a key event in the current mode.
+ pub fn lookup(
+ &self,
+ modifiers: u32,
+ keycode: x::Keycode,
+ ) -> Option<&Action> {
+ let clean = modifiers & !IGNORE_MASK;
+ let combo = KeyCombo {
+ modifiers: clean,
+ keycode,
+ };
+ self.modes
+ .get(&self.current_mode)
+ .and_then(|b| b.get(&combo))
+ }
+}
+
+/// Convert a keysym to a keycode using the keyboard mapping.
+fn keysym_to_keycode(
+ keysyms: &[x::Keysym],
+ per_keycode: usize,
+ min_keycode: x::Keycode,
+ target: u32,
+) -> Option<x::Keycode> {
+ for (i, chunk) in keysyms.chunks(per_keycode).enumerate() {
+ for &ks in chunk {
+ if ks == target {
+ let offset = u8::try_from(i).ok()?;
+ return min_keycode.checked_add(offset);
+ }
+ }
+ }
+ None
+}
blob - /dev/null
blob + 529b030f3ccc45b98c3fcb45dd76124e7618dc6b (mode 644)
--- /dev/null
+++ src/layout.rs
+use crate::config::{Gaps, SmartGaps};
+use crate::container::{BorderStyle, ContainerTree, Layout, NodeKey, Rect};
+
+/// Recursively compute the geometry of all containers in the tree.
+///
+/// `gaps` is the effective (already resolved and clamped) gap configuration
+/// for the workspace being laid out. `smart` controls whether gaps are
+/// suppressed when a workspace has only one tiled child.
+pub fn apply_layout(
+ tree: &mut ContainerTree,
+ ws_key: NodeKey,
+ gaps: &Gaps,
+ smart: SmartGaps,
+ deco_height: u32,
+) {
+ let tiled_count = tree
+ .get(ws_key)
+ .map(|c| c.children.len())
+ .unwrap_or(0);
+
+ let effective = effective_gaps(gaps, smart, tiled_count);
+
+ // Apply outer gaps as inset on the workspace rect.
+ if let Some(ws) = tree.get_mut(ws_key) {
+ let r = ws.rect;
+ ws.rect = Rect {
+ x: r.x + effective.left,
+ y: r.y + effective.top,
+ w: r.w.saturating_sub((effective.left + effective.right).max(0) as u32),
+ h: r.h.saturating_sub((effective.top + effective.bottom).max(0) as u32),
+ };
+ }
+
+ let inner = effective.inner.max(0) as u32;
+ layout_recurse(tree, ws_key, inner, deco_height);
+}
+
+/// Compute effective gaps given smart_gaps policy.
+fn effective_gaps(gaps: &Gaps, smart: SmartGaps, tiled_count: usize) -> Gaps {
+ if tiled_count <= 1 {
+ match smart {
+ SmartGaps::Off => *gaps,
+ SmartGaps::On => Gaps {
+ inner: 0, top: 0, right: 0, bottom: 0, left: 0,
+ },
+ SmartGaps::InverseOuter => Gaps {
+ inner: 0,
+ top: gaps.top,
+ right: gaps.right,
+ bottom: gaps.bottom,
+ left: gaps.left,
+ },
+ }
+ } else {
+ *gaps
+ }
+}
+
+/// Recursive layout pass (uses inner gap only; outer already applied).
+fn layout_recurse(
+ tree: &mut ContainerTree,
+ idx: NodeKey,
+ gap: u32,
+ deco_height: u32,
+) {
+ let (layout, rect, children) = match tree.get(idx) {
+ Some(c) => (c.layout, c.rect, c.children.clone()),
+ None => return,
+ };
+
+ if children.is_empty() {
+ return;
+ }
+
+ match layout {
+ Layout::SplitH => layout_split_h(tree, &children, rect, gap),
+ Layout::SplitV => layout_split_v(tree, &children, rect, gap),
+ Layout::Stacked => layout_stacked_impl(tree, idx, &children, rect, deco_height),
+ Layout::Tabbed => layout_tabbed_impl(tree, idx, &children, rect, deco_height),
+ }
+
+ // Compute window_rect (client position within frame) for each child.
+ // For stacked/tabbed children whose parent draws the tabs,
+ // compute_window_rect uses the child's (already-adjusted) rect.
+ for &child_idx in &children {
+ compute_window_rect(tree, child_idx, deco_height);
+ }
+
+ // Recurse into children
+ for child_idx in children {
+ layout_recurse(tree, child_idx, gap, deco_height);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Split layouts
+// ---------------------------------------------------------------------------
+
+/// Horizontal split: divide width among children.
+fn layout_split_h(
+ tree: &mut ContainerTree,
+ children: &[NodeKey],
+ parent_rect: Rect,
+ gap: u32,
+) {
+ let n = children.len() as u32;
+ if n == 0 {
+ return;
+ }
+
+ let total_gaps = gap * (n - 1);
+ let available_w = parent_rect.w.saturating_sub(total_gaps);
+ if available_w == 0 {
+ return;
+ }
+
+ let default_pct = 1.0 / n as f64;
+ let percents: Vec<f64> = children
+ .iter()
+ .map(|&k| tree.get(k).map(|c| c.percent).unwrap_or(default_pct))
+ .collect();
+ let all_equal = percents
+ .windows(2)
+ .all(|w| (w[0] - w[1]).abs() < f64::EPSILON);
+
+ let mut x = parent_rect.x;
+
+ for (i, &child_idx) in children.iter().enumerate() {
+ let percent = if all_equal { default_pct } else { percents[i] };
+ let w = if i == children.len() - 1 {
+ let remaining = parent_rect
+ .x
+ .saturating_add(parent_rect.w as i32)
+ .saturating_sub(x);
+ (remaining.max(0)) as u32
+ } else {
+ (available_w as f64 * percent) as u32
+ };
+
+ if let Some(child) = tree.get_mut(child_idx) {
+ child.rect = Rect { x, y: parent_rect.y, w, h: parent_rect.h };
+ child.deco_rect = Rect::default();
+ }
+
+ x = x.saturating_add(w as i32).saturating_add(gap as i32);
+ }
+}
+
+/// Vertical split: divide height among children.
+fn layout_split_v(
+ tree: &mut ContainerTree,
+ children: &[NodeKey],
+ parent_rect: Rect,
+ gap: u32,
+) {
+ let n = children.len() as u32;
+ if n == 0 {
+ return;
+ }
+
+ let total_gaps = gap * (n - 1);
+ let available_h = parent_rect.h.saturating_sub(total_gaps);
+ if available_h == 0 {
+ return;
+ }
+
+ let default_pct = 1.0 / n as f64;
+ let percents: Vec<f64> = children
+ .iter()
+ .map(|&k| tree.get(k).map(|c| c.percent).unwrap_or(default_pct))
+ .collect();
+ let all_equal = percents
+ .windows(2)
+ .all(|w| (w[0] - w[1]).abs() < f64::EPSILON);
+
+ let mut y = parent_rect.y;
+
+ for (i, &child_idx) in children.iter().enumerate() {
+ let percent = if all_equal { default_pct } else { percents[i] };
+ let h = if i == children.len() - 1 {
+ let remaining = parent_rect
+ .y
+ .saturating_add(parent_rect.h as i32)
+ .saturating_sub(y);
+ (remaining.max(0)) as u32
+ } else {
+ (available_h as f64 * percent) as u32
+ };
+
+ if let Some(child) = tree.get_mut(child_idx) {
+ child.rect = Rect { x: parent_rect.x, y, w: parent_rect.w, h };
+ child.deco_rect = Rect::default();
+ }
+
+ y = y.saturating_add(h as i32).saturating_add(gap as i32);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Stacked layout (like i3: vertical title bars, one per child)
+// ---------------------------------------------------------------------------
+
+/// Stacked: all children share the same full rect (including tab area).
+///
+/// Each child gets a `deco_rect` for its title bar (stacked vertically).
+/// The child's rect covers the full parent (tabs + content); the
+/// window_rect computation pushes the client below the tab area.
+fn layout_stacked_impl(
+ tree: &mut ContainerTree,
+ _parent_key: NodeKey,
+ children: &[NodeKey],
+ parent_rect: Rect,
+ deco_height: u32,
+) {
+ let n = children.len() as u32;
+ if n == 0 {
+ return;
+ }
+
+ // Each child's frame covers the entire parent area (tabs + content).
+ // The tabs are drawn in the top portion of each child's frame.
+ for (i, &child_idx) in children.iter().enumerate() {
+ if let Some(child) = tree.get_mut(child_idx) {
+ child.rect = parent_rect;
+
+ // deco_rect: this child's title bar within the frame
+ // (frame-local coordinates since rect == parent_rect).
+ child.deco_rect = Rect {
+ x: 0,
+ y: (i as u32 * deco_height) as i32,
+ w: parent_rect.w,
+ h: deco_height,
+ };
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tabbed layout (like i3: horizontal tabs, one row)
+// ---------------------------------------------------------------------------
+
+/// Tabbed: all children share the same full rect (including tab row).
+///
+/// Each child gets a `deco_rect` for its tab (horizontally distributed).
+/// The child's rect covers the full parent; window_rect pushes the
+/// client below the single tab row.
+fn layout_tabbed_impl(
+ tree: &mut ContainerTree,
+ _parent_key: NodeKey,
+ children: &[NodeKey],
+ parent_rect: Rect,
+ deco_height: u32,
+) {
+ let n = children.len() as u32;
+ if n == 0 {
+ return;
+ }
+
+ let tab_w = parent_rect.w / n;
+
+ for (i, &child_idx) in children.iter().enumerate() {
+ let this_w = if i as u32 == n - 1 {
+ parent_rect.w - tab_w * (n - 1)
+ } else {
+ tab_w
+ };
+
+ if let Some(child) = tree.get_mut(child_idx) {
+ child.rect = parent_rect;
+
+ child.deco_rect = Rect {
+ x: (i as u32 * tab_w) as i32,
+ y: 0,
+ w: this_w,
+ h: deco_height,
+ };
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Window rect (client position inside frame)
+// ---------------------------------------------------------------------------
+
+/// Compute window_rect for a container based on its border_style.
+/// window_rect defines where the client window sits inside the frame.
+fn compute_window_rect(
+ tree: &mut ContainerTree,
+ idx: NodeKey,
+ deco_height: u32,
+) {
+ let (border_style, border_width, rect, parent_info) = match tree.get(idx) {
+ Some(c) => {
+ let pi = c.parent.and_then(|p| tree.get(p)).map(|p| {
+ (p.layout, p.children.len() as u32)
+ });
+ (c.border_style, c.border_width, c.rect, pi)
+ }
+ None => return,
+ };
+
+ let (parent_layout, sibling_count) = parent_info.unwrap_or((Layout::SplitH, 1));
+
+ // Total height consumed by tab/title bars at the top of the frame.
+ let tab_area_h = match parent_layout {
+ Layout::Stacked => deco_height * sibling_count,
+ Layout::Tabbed => deco_height,
+ _ => 0,
+ };
+
+ let window_rect = if tab_area_h > 0 {
+ // In tabbed/stacked: tabs are drawn in the top portion of the frame;
+ // the client window sits below all tabs, with optional border.
+ let bw = match border_style {
+ BorderStyle::None => 0,
+ _ => border_width,
+ };
+ Rect {
+ x: bw as i32,
+ y: tab_area_h as i32 + bw as i32,
+ w: rect.w.saturating_sub(2 * bw),
+ h: rect.h.saturating_sub(tab_area_h + 2 * bw),
+ }
+ } else {
+ match border_style {
+ BorderStyle::Normal => {
+ let bw = border_width;
+ Rect {
+ x: bw as i32,
+ y: deco_height as i32,
+ w: rect.w.saturating_sub(2 * bw),
+ h: rect.h.saturating_sub(deco_height + bw),
+ }
+ }
+ BorderStyle::Pixel => {
+ let bw = border_width;
+ Rect {
+ x: bw as i32,
+ y: bw as i32,
+ w: rect.w.saturating_sub(2 * bw),
+ h: rect.h.saturating_sub(2 * bw),
+ }
+ }
+ BorderStyle::None => {
+ Rect { x: 0, y: 0, w: rect.w, h: rect.h }
+ }
+ }
+ };
+
+ if let Some(con) = tree.get_mut(idx) {
+ con.window_rect = window_rect;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Gaps;
+ use crate::container::{ConType, ContainerTree};
+
+ fn screen() -> Rect {
+ Rect { x: 0, y: 0, w: 1200, h: 800 }
+ }
+
+ const DECO_H: u32 = 20;
+
+ fn make_tree_with_children(n: usize, layout: Layout) -> (ContainerTree, NodeKey, Vec<NodeKey>) {
+ let mut tree = ContainerTree::new(screen());
+ let out = tree.create_child(tree.root, ConType::Output, "out");
+ let ws = tree.create_child(out, ConType::Workspace, "1");
+ if let Some(w) = tree.get_mut(ws) {
+ w.rect = screen();
+ w.layout = layout;
+ }
+ let mut children = Vec::new();
+ for i in 0..n {
+ let c = tree.create_child(ws, ConType::Con, &format!("c{i}"));
+ children.push(c);
+ }
+ (tree, ws, children)
+ }
+
+ // --- Tabbed layout geometry ---
+
+ #[test]
+ fn tabbed_all_children_same_rect() {
+ let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ // All children should have the same rect (full workspace area).
+ let r0 = tree.get(children[0]).unwrap().rect;
+ let r1 = tree.get(children[1]).unwrap().rect;
+ let r2 = tree.get(children[2]).unwrap().rect;
+ assert_eq!(r0, r1);
+ assert_eq!(r1, r2);
+ assert_eq!(r0, screen());
+ }
+
+ #[test]
+ fn tabbed_deco_rects_horizontal() {
+ let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ let d0 = tree.get(children[0]).unwrap().deco_rect;
+ let d1 = tree.get(children[1]).unwrap().deco_rect;
+ let d2 = tree.get(children[2]).unwrap().deco_rect;
+
+ // Tabs distributed horizontally, all at y=0.
+ assert_eq!(d0.y, 0);
+ assert_eq!(d1.y, 0);
+ assert_eq!(d2.y, 0);
+
+ assert_eq!(d0.h, DECO_H);
+ assert_eq!(d1.h, DECO_H);
+ assert_eq!(d2.h, DECO_H);
+
+ // Each tab ~400px wide (1200/3), last absorbs remainder.
+ assert_eq!(d0.x, 0);
+ assert_eq!(d0.w, 400);
+ assert_eq!(d1.x, 400);
+ assert_eq!(d1.w, 400);
+ assert_eq!(d2.x, 800);
+ assert_eq!(d2.w, 400);
+ }
+
+ #[test]
+ fn tabbed_window_rect_below_tabs() {
+ let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ let wr = tree.get(children[0]).unwrap().window_rect;
+ // Client starts below one row of tabs (DECO_H) + border.
+ let bw = tree.get(children[0]).unwrap().border_width;
+ assert_eq!(wr.y, DECO_H as i32 + bw as i32);
+ }
+
+ // --- Stacked layout geometry ---
+
+ #[test]
+ fn stacked_all_children_same_rect() {
+ let (mut tree, ws, children) = make_tree_with_children(3, Layout::Stacked);
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ let r0 = tree.get(children[0]).unwrap().rect;
+ let r1 = tree.get(children[1]).unwrap().rect;
+ let r2 = tree.get(children[2]).unwrap().rect;
+ assert_eq!(r0, r1);
+ assert_eq!(r1, r2);
+ assert_eq!(r0, screen());
+ }
+
+ #[test]
+ fn stacked_deco_rects_vertical() {
+ let (mut tree, ws, children) = make_tree_with_children(3, Layout::Stacked);
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ let d0 = tree.get(children[0]).unwrap().deco_rect;
+ let d1 = tree.get(children[1]).unwrap().deco_rect;
+ let d2 = tree.get(children[2]).unwrap().deco_rect;
+
+ // Tabs stacked vertically, each full width.
+ assert_eq!(d0.x, 0);
+ assert_eq!(d1.x, 0);
+ assert_eq!(d2.x, 0);
+
+ assert_eq!(d0.w, 1200);
+ assert_eq!(d1.w, 1200);
+ assert_eq!(d2.w, 1200);
+
+ assert_eq!(d0.y, 0);
+ assert_eq!(d0.h, DECO_H);
+ assert_eq!(d1.y, DECO_H as i32);
+ assert_eq!(d1.h, DECO_H);
+ assert_eq!(d2.y, 2 * DECO_H as i32);
+ assert_eq!(d2.h, DECO_H);
+ }
+
+ #[test]
+ fn stacked_window_rect_below_all_tabs() {
+ let (mut tree, ws, children) = make_tree_with_children(3, Layout::Stacked);
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ let wr = tree.get(children[0]).unwrap().window_rect;
+ let bw = tree.get(children[0]).unwrap().border_width;
+ // Client starts below 3 stacked title bars + border.
+ assert_eq!(wr.y, 3 * DECO_H as i32 + bw as i32);
+ }
+
+ // --- Single child ---
+
+ #[test]
+ fn tabbed_single_child() {
+ let (mut tree, ws, children) = make_tree_with_children(1, Layout::Tabbed);
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ let d = tree.get(children[0]).unwrap().deco_rect;
+ assert_eq!(d.x, 0);
+ assert_eq!(d.y, 0);
+ assert_eq!(d.w, 1200);
+ assert_eq!(d.h, DECO_H);
+ }
+
+ // --- Tab width rounding ---
+
+ #[test]
+ fn tabbed_last_tab_absorbs_remainder() {
+ let (mut tree, ws, children) = make_tree_with_children(3, Layout::Tabbed);
+ // Use 1201 width to force uneven division.
+ if let Some(w) = tree.get_mut(ws) {
+ w.rect.w = 1201;
+ }
+ let gaps = Gaps::default();
+ apply_layout(&mut tree, ws, &gaps, crate::config::SmartGaps::Off, DECO_H);
+
+ let d0 = tree.get(children[0]).unwrap().deco_rect;
+ let d2 = tree.get(children[2]).unwrap().deco_rect;
+ // 1201 / 3 = 400, remainder 1 goes to last tab.
+ assert_eq!(d0.w, 400);
+ assert_eq!(d2.w, 401);
+ assert_eq!(d0.w + tree.get(children[1]).unwrap().deco_rect.w + d2.w, 1201);
+ }
+}
blob - /dev/null
blob + 608ee3fb44f1b2a76c0ff0301193dbf8dc87abc7 (mode 644)
--- /dev/null
+++ src/lib.rs
+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
+use std::sync::OnceLock;
+
+/// Log level, controlled by OWM_LOG env var.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Level {
+ Debug,
+ Info,
+ Warn,
+ Error,
+}
+
+static LOG_LEVEL: OnceLock<Level> = OnceLock::new();
+
+pub fn init() {
+ let level = match std::env::var("OWM_LOG").as_deref() {
+ Ok("debug") => Level::Debug,
+ Ok("warn") => Level::Warn,
+ Ok("error") => Level::Error,
+ _ => Level::Info,
+ };
+ let _ = LOG_LEVEL.set(level);
+}
+
+#[inline]
+pub fn level() -> Level {
+ LOG_LEVEL.get().copied().unwrap_or(Level::Info)
+}
+
+#[macro_export]
+macro_rules! log_debug {
+ ($($arg:tt)*) => {
+ if $crate::log::level() <= $crate::log::Level::Debug {
+ eprintln!("owm [DEBUG] {}", format_args!($($arg)*));
+ }
+ };
+}
+
+#[macro_export]
+macro_rules! log_info {
+ ($($arg:tt)*) => {
+ if $crate::log::level() <= $crate::log::Level::Info {
+ eprintln!("owm [INFO] {}", format_args!($($arg)*));
+ }
+ };
+}
+
+#[macro_export]
+macro_rules! log_warn {
+ ($($arg:tt)*) => {
+ if $crate::log::level() <= $crate::log::Level::Warn {
+ eprintln!("owm [WARN] {}", format_args!($($arg)*));
+ }
+ };
+}
+
+#[macro_export]
+macro_rules! log_error {
+ ($($arg:tt)*) => {
+ if $crate::log::level() <= $crate::log::Level::Error {
+ eprintln!("owm [ERROR] {}", format_args!($($arg)*));
+ }
+ };
+}
blob - /dev/null
blob + de977daff73cf9aba40e34d9806145983054c52c (mode 644)
--- /dev/null
+++ src/nagbar.rs
+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
+use std::os::unix::io::RawFd;
+
+pub const POLLIN: i16 = 0x0001;
+pub const EINTR: i32 = 4;
+
+/// Signal numbers.
+pub const SIGHUP: std::ffi::c_int = 1;
+pub const SIGTERM: std::ffi::c_int = 15;
+
+#[cfg(target_os = "openbsd")]
+pub const SIGSTOP: std::ffi::c_int = 17;
+#[cfg(target_os = "openbsd")]
+pub const SIGCONT: std::ffi::c_int = 19;
+#[cfg(target_os = "linux")]
+pub const SIGSTOP: std::ffi::c_int = 19;
+#[cfg(target_os = "linux")]
+pub const SIGCONT: std::ffi::c_int = 18;
+
+#[repr(C)]
+pub struct pollfd {
+ pub fd: RawFd,
+ pub events: i16,
+ pub revents: i16,
+}
+
+#[cfg(target_os = "openbsd")]
+type NfdsT = std::ffi::c_uint;
+
+#[cfg(target_os = "linux")]
+type NfdsT = std::ffi::c_ulong;
+
+/// Minimal sigaction structure (POSIX).
+#[repr(C)]
+struct SigAction {
+ sa_handler: extern "C" fn(std::ffi::c_int),
+ sa_mask: u32,
+ sa_flags: std::ffi::c_int,
+}
+
+unsafe extern "C" {
+ pub fn poll(
+ fds: *mut pollfd,
+ nfds: NfdsT,
+ timeout: std::ffi::c_int,
+ ) -> std::ffi::c_int;
+ pub fn getuid() -> u32;
+ fn pipe(fds: *mut std::ffi::c_int) -> std::ffi::c_int;
+ fn write(fd: std::ffi::c_int, buf: *const u8, count: usize) -> isize;
+ fn read(fd: std::ffi::c_int, buf: *mut u8, count: usize) -> isize;
+ fn close(fd: std::ffi::c_int) -> std::ffi::c_int;
+ fn sigaction(
+ sig: std::ffi::c_int,
+ act: *const SigAction,
+ oact: *mut SigAction,
+ ) -> std::ffi::c_int;
+ fn fcntl(
+ fd: std::ffi::c_int,
+ cmd: std::ffi::c_int,
+ arg: std::ffi::c_int,
+ ) -> std::ffi::c_int;
+ fn execvp(
+ file: *const std::ffi::c_char,
+ argv: *const *const std::ffi::c_char,
+ ) -> std::ffi::c_int;
+ fn kill(pid: std::ffi::c_int, sig: std::ffi::c_int) -> std::ffi::c_int;
+ fn waitpid(
+ pid: std::ffi::c_int,
+ status: *mut std::ffi::c_int,
+ options: std::ffi::c_int,
+ ) -> std::ffi::c_int;
+}
+
+const WNOHANG: std::ffi::c_int = 1;
+
+/// Send a signal to a process.
+pub fn kill_process(pid: u32, sig: std::ffi::c_int) -> Result<(), std::io::Error> {
+ let ret = unsafe { kill(pid as std::ffi::c_int, sig) };
+ if ret == -1 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+}
+
+/// Non-blocking waitpid. Returns Some(status) if child exited, None otherwise.
+pub fn waitpid_nohang(pid: u32) -> Option<i32> {
+ let mut status: std::ffi::c_int = 0;
+ let ret = unsafe { waitpid(pid as std::ffi::c_int, &mut status, WNOHANG) };
+ if ret > 0 { Some(status) } else { None }
+}
+
+/// Set a file descriptor to non-blocking mode.
+pub fn set_nonblocking(fd: RawFd) {
+ unsafe { fcntl(fd, F_SETFL, O_NONBLOCK); }
+}
+
+/// Write end of the signal self-pipe (global, written from signal handler).
+static mut SIGNAL_WRITE_FD: std::ffi::c_int = -1;
+
+/// Signal handler: writes the signal number byte to the pipe.
+extern "C" fn signal_handler(sig: std::ffi::c_int) {
+ let b = sig as u8;
+ unsafe {
+ let _ = write(SIGNAL_WRITE_FD, &b as *const u8, 1);
+ }
+}
+
+/// Self-pipe for receiving signals in the poll loop.
+pub struct SignalPipe {
+ read_fd: RawFd,
+}
+
+/// F_SETFL and O_NONBLOCK for setting pipe to non-blocking.
+const F_SETFL: std::ffi::c_int = 4;
+#[cfg(target_os = "openbsd")]
+const O_NONBLOCK: std::ffi::c_int = 0x0004;
+#[cfg(target_os = "linux")]
+const O_NONBLOCK: std::ffi::c_int = 0x0800;
+
+impl SignalPipe {
+ /// Create a self-pipe and register handlers for SIGHUP and SIGTERM.
+ pub fn new() -> Result<Self, std::io::Error> {
+ let mut fds = [0i32; 2];
+ let ret = unsafe { pipe(fds.as_mut_ptr()) };
+ if ret == -1 {
+ return Err(std::io::Error::last_os_error());
+ }
+
+ // Set both ends non-blocking
+ unsafe {
+ fcntl(fds[0], F_SETFL, O_NONBLOCK);
+ fcntl(fds[1], F_SETFL, O_NONBLOCK);
+ }
+
+ // Store write fd globally for signal handler
+ unsafe {
+ SIGNAL_WRITE_FD = fds[1];
+ }
+
+ // Install signal handlers
+ let sa = SigAction {
+ sa_handler: signal_handler,
+ sa_mask: 0,
+ sa_flags: 0,
+ };
+ unsafe {
+ sigaction(SIGHUP, &sa, std::ptr::null_mut());
+ sigaction(SIGTERM, &sa, std::ptr::null_mut());
+ }
+
+ Ok(SignalPipe { read_fd: fds[0] })
+ }
+
+ /// File descriptor for polling.
+ pub fn fd(&self) -> RawFd {
+ self.read_fd
+ }
+
+ /// Read pending signal bytes from the pipe.
+ /// Returns a vec of signal numbers received.
+ pub fn drain(&self) -> Vec<u8> {
+ let mut signals = Vec::new();
+ let mut buf = [0u8; 16];
+ loop {
+ let n = unsafe { read(self.read_fd, buf.as_mut_ptr(), buf.len()) };
+ if n <= 0 {
+ break;
+ }
+ for &b in &buf[..n as usize] {
+ signals.push(b);
+ }
+ }
+ signals
+ }
+}
+
+impl Drop for SignalPipe {
+ fn drop(&mut self) {
+ unsafe {
+ close(self.read_fd);
+ if SIGNAL_WRITE_FD >= 0 {
+ close(SIGNAL_WRITE_FD);
+ SIGNAL_WRITE_FD = -1;
+ }
+ }
+ }
+}
+
+/// Restrict process syscalls with pledge(2).
+///
+/// On non-OpenBSD systems this is a no-op.
+#[cfg(target_os = "openbsd")]
+pub fn pledge(
+ promises: &str,
+ execpromises: Option<&str>,
+) -> Result<(), std::io::Error> {
+ use std::ffi::CString;
+
+ unsafe extern "C" {
+ fn pledge(
+ promises: *const std::ffi::c_char,
+ execpromises: *const std::ffi::c_char,
+ ) -> std::ffi::c_int;
+ }
+
+ let promises_c = CString::new(promises).unwrap();
+ let exec_c = execpromises.map(|e| CString::new(e).unwrap());
+
+ let exec_ptr = match &exec_c {
+ Some(c) => c.as_ptr(),
+ None => std::ptr::null(),
+ };
+
+ let ret = unsafe { pledge(promises_c.as_ptr(), exec_ptr) };
+ if ret == -1 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+}
+
+#[cfg(not(target_os = "openbsd"))]
+pub fn pledge(
+ _promises: &str,
+ _execpromises: Option<&str>,
+) -> Result<(), std::io::Error> {
+ Ok(())
+}
+
+/// Restrict filesystem visibility with unveil(2).
+///
+/// Call with `(path, permissions)` to reveal a path, or `(None, None)` to lock.
+/// On non-OpenBSD systems this is a no-op.
+#[cfg(target_os = "openbsd")]
+pub fn unveil(
+ path: Option<&str>,
+ permissions: Option<&str>,
+) -> Result<(), std::io::Error> {
+ use std::ffi::CString;
+
+ unsafe extern "C" {
+ fn unveil(
+ path: *const std::ffi::c_char,
+ permissions: *const std::ffi::c_char,
+ ) -> std::ffi::c_int;
+ }
+
+ let path_c = path.map(|p| CString::new(p).unwrap());
+ let perm_c = permissions.map(|p| CString::new(p).unwrap());
+
+ let path_ptr = match &path_c {
+ Some(c) => c.as_ptr(),
+ None => std::ptr::null(),
+ };
+ let perm_ptr = match &perm_c {
+ Some(c) => c.as_ptr(),
+ None => std::ptr::null(),
+ };
+
+ let ret = unsafe { unveil(path_ptr, perm_ptr) };
+ if ret == -1 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+}
+
+#[cfg(not(target_os = "openbsd"))]
+pub fn unveil(
+ _path: Option<&str>,
+ _permissions: Option<&str>,
+) -> Result<(), std::io::Error> {
+ Ok(())
+}
+
+// --- Config file watcher (kqueue on OpenBSD, inotify on Linux) ---
+
+#[cfg(target_os = "openbsd")]
+const EVFILT_VNODE: i16 = -4;
+#[cfg(target_os = "openbsd")]
+const EV_ADD: u16 = 0x0001;
+#[cfg(target_os = "openbsd")]
+const EV_CLEAR: u16 = 0x0020;
+#[cfg(target_os = "openbsd")]
+const NOTE_DELETE: u32 = 0x0001;
+#[cfg(target_os = "openbsd")]
+const NOTE_WRITE: u32 = 0x0002;
+#[cfg(target_os = "openbsd")]
+const NOTE_RENAME: u32 = 0x0020;
+
+#[cfg(target_os = "openbsd")]
+#[repr(C)]
+struct Kevent {
+ ident: usize,
+ filter: i16,
+ flags: u16,
+ fflags: u32,
+ data: i64,
+ udata: *mut std::ffi::c_void,
+}
+
+#[cfg(target_os = "openbsd")]
+#[repr(C)]
+struct Timespec {
+ tv_sec: i64,
+ tv_nsec: i64,
+}
+
+#[cfg(target_os = "openbsd")]
+unsafe extern "C" {
+ fn kqueue() -> std::ffi::c_int;
+ fn kevent(
+ kq: std::ffi::c_int,
+ changelist: *const Kevent,
+ nchanges: std::ffi::c_int,
+ eventlist: *mut Kevent,
+ nevents: std::ffi::c_int,
+ timeout: *const Timespec,
+ ) -> std::ffi::c_int;
+}
+
+#[cfg(target_os = "linux")]
+const IN_NONBLOCK: std::ffi::c_int = 0x800;
+#[cfg(target_os = "linux")]
+const IN_CLOSE_WRITE: u32 = 0x0000_0008;
+#[cfg(target_os = "linux")]
+const IN_DELETE_SELF: u32 = 0x0000_0400;
+#[cfg(target_os = "linux")]
+const IN_MOVE_SELF: u32 = 0x0000_0800;
+
+#[cfg(target_os = "linux")]
+unsafe extern "C" {
+ fn inotify_init1(flags: std::ffi::c_int) -> std::ffi::c_int;
+ fn inotify_add_watch(
+ fd: std::ffi::c_int,
+ pathname: *const std::ffi::c_char,
+ mask: u32,
+ ) -> std::ffi::c_int;
+}
+
+/// Watches a config file for modifications.
+///
+/// Uses kqueue `EVFILT_VNODE` on OpenBSD and inotify on Linux.
+/// Handles editors that delete-and-recreate the file by automatically
+/// re-establishing the watch when the file reappears.
+pub struct ConfigWatcher {
+ /// kqueue fd (OpenBSD) or inotify fd (Linux).
+ watch_fd: RawFd,
+ /// File kept open for kqueue EVFILT_VNODE (OpenBSD).
+ #[cfg(target_os = "openbsd")]
+ file: Option<std::fs::File>,
+ /// inotify watch descriptor (Linux).
+ #[cfg(target_os = "linux")]
+ wd: std::ffi::c_int,
+ /// Path to the watched file.
+ path: String,
+}
+
+#[cfg(target_os = "openbsd")]
+impl ConfigWatcher {
+ /// Create a new config watcher for the given path.
+ pub fn new(path: &str) -> Result<Self, std::io::Error> {
+ let kq = unsafe { kqueue() };
+ if kq < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ // Set kqueue fd non-blocking
+ unsafe {
+ fcntl(kq, F_SETFL, O_NONBLOCK);
+ }
+ let mut watcher = ConfigWatcher {
+ watch_fd: kq,
+ file: None,
+ path: path.to_string(),
+ };
+ watcher.try_watch();
+ Ok(watcher)
+ }
+
+ /// Try to open the file and register a kqueue vnode watch.
+ fn try_watch(&mut self) -> bool {
+ use std::os::unix::io::AsRawFd;
+
+ let file = match std::fs::File::open(&self.path) {
+ Ok(f) => f,
+ Err(_) => return false,
+ };
+
+ let fd = file.as_raw_fd();
+ let mut ev: Kevent = unsafe { std::mem::zeroed() };
+ ev.ident = fd as usize;
+ ev.filter = EVFILT_VNODE;
+ ev.flags = EV_ADD | EV_CLEAR;
+ ev.fflags = NOTE_WRITE | NOTE_DELETE | NOTE_RENAME;
+
+ let ts = Timespec {
+ tv_sec: 0,
+ tv_nsec: 0,
+ };
+ let ret = unsafe {
+ kevent(self.watch_fd, &ev, 1, std::ptr::null_mut(), 0, &ts)
+ };
+ if ret < 0 {
+ return false;
+ }
+
+ self.file = Some(file);
+ true
+ }
+
+ /// File descriptor for poll().
+ pub fn fd(&self) -> RawFd {
+ self.watch_fd
+ }
+
+ /// Returns true if the watch is broken and needs periodic retry.
+ pub fn needs_rewatch(&self) -> bool {
+ self.file.is_none()
+ }
+
+ /// Check for file changes. Returns true if the config was modified.
+ ///
+ /// Call when `fd()` is readable, or periodically if `needs_rewatch()`.
+ pub fn check(&mut self) -> bool {
+ if self.file.is_none() {
+ // File was deleted; try to re-establish watch.
+ return self.try_watch();
+ }
+
+ // Drain kqueue events.
+ let mut events: [Kevent; 4] = unsafe { std::mem::zeroed() };
+ let ts = Timespec {
+ tv_sec: 0,
+ tv_nsec: 0,
+ };
+ let n = unsafe {
+ kevent(
+ self.watch_fd,
+ std::ptr::null(),
+ 0,
+ events.as_mut_ptr(),
+ 4,
+ &ts,
+ )
+ };
+ if n <= 0 {
+ return false;
+ }
+
+ // Drop old file fd and re-establish watch for subsequent changes.
+ self.file = None;
+ self.try_watch();
+ true
+ }
+}
+
+#[cfg(target_os = "linux")]
+impl ConfigWatcher {
+ /// Create a new config watcher for the given path.
+ pub fn new(path: &str) -> Result<Self, std::io::Error> {
+ let fd = unsafe { inotify_init1(IN_NONBLOCK) };
+ if fd < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let mut watcher = ConfigWatcher {
+ watch_fd: fd,
+ wd: -1,
+ path: path.to_string(),
+ };
+ watcher.try_watch();
+ Ok(watcher)
+ }
+
+ /// Try to add an inotify watch on the config file.
+ fn try_watch(&mut self) -> bool {
+ let cpath = match std::ffi::CString::new(self.path.as_str()) {
+ Ok(c) => c,
+ Err(_) => return false,
+ };
+ let wd = unsafe {
+ inotify_add_watch(
+ self.watch_fd,
+ cpath.as_ptr(),
+ IN_CLOSE_WRITE | IN_DELETE_SELF | IN_MOVE_SELF,
+ )
+ };
+ if wd < 0 {
+ return false;
+ }
+ self.wd = wd;
+ true
+ }
+
+ /// File descriptor for poll().
+ pub fn fd(&self) -> RawFd {
+ self.watch_fd
+ }
+
+ /// Returns true if the watch is broken and needs periodic retry.
+ pub fn needs_rewatch(&self) -> bool {
+ self.wd < 0
+ }
+
+ /// Check for file changes. Returns true if the config was modified.
+ ///
+ /// Call when `fd()` is readable, or periodically if `needs_rewatch()`.
+ pub fn check(&mut self) -> bool {
+ if self.wd < 0 {
+ return self.try_watch();
+ }
+
+ // Drain inotify events.
+ let mut buf = [0u8; 256];
+ let n = unsafe { read(self.watch_fd, buf.as_mut_ptr(), buf.len()) };
+ if n <= 0 {
+ return false;
+ }
+
+ // Check if file was deleted/moved (watch auto-removed by kernel).
+ let mut offset = 0;
+ while offset + 16 <= n as usize {
+ let mask = u32::from_ne_bytes([
+ buf[offset + 4],
+ buf[offset + 5],
+ buf[offset + 6],
+ buf[offset + 7],
+ ]);
+ let name_len = u32::from_ne_bytes([
+ buf[offset + 12],
+ buf[offset + 13],
+ buf[offset + 14],
+ buf[offset + 15],
+ ]) as usize;
+ if mask & (IN_DELETE_SELF | IN_MOVE_SELF) != 0 {
+ self.wd = -1;
+ self.try_watch();
+ }
+ offset += 16 + name_len;
+ }
+
+ true
+ }
+}
+
+impl Drop for ConfigWatcher {
+ fn drop(&mut self) {
+ unsafe {
+ close(self.watch_fd);
+ }
+ }
+}
+
+/// Apply OpenBSD security restrictions after initialization.
+///
+/// Must be called after X connection, IPC socket binding, and config loading
+/// are complete.
+pub fn sandbox(socket_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
+ // Reveal only the paths the WM needs at runtime.
+ if let Ok(home) = std::env::var("HOME") {
+ let owmrc = format!("{}/.owmrc", home);
+ unveil(Some(&owmrc), Some("r"))?;
+ }
+ unveil(Some(socket_dir), Some("rwc"))?;
+ // /usr/X11R6/lib is needed by libxcb for auth/transport.
+ unveil(Some("/usr/X11R6/lib"), Some("r"))?;
+ // /usr/local/lib may be needed for shared libraries.
+ unveil(Some("/usr/local/lib"), Some("r"))?;
+ // /usr/lib for libc and other base libraries.
+ unveil(Some("/usr/lib"), Some("r"))?;
+ // /dev is needed by status commands reading hardware sensors.
+ unveil(Some("/dev"), Some("r"))?;
+ // Allow executing programs (terminal, user commands).
+ unveil(Some("/usr/local/bin"), Some("rx"))?;
+ unveil(Some("/usr/X11R6/bin"), Some("rx"))?;
+ unveil(Some("/usr/bin"), Some("rx"))?;
+ unveil(Some("/bin"), Some("rx"))?;
+ // Lock unveil — no further unveil calls allowed.
+ unveil(None, None)?;
+
+ // Restrict syscalls.
+ // stdio: basic I/O (read/write/close/poll)
+ // rpath: read files (config reload)
+ // wpath: write files (IPC socket)
+ // cpath: create files (socket dir)
+ // unix: Unix domain sockets (X11 + IPC)
+ // proc: fork (spawning commands)
+ // exec: exec (spawning commands)
+ pledge("stdio rpath wpath cpath unix proc exec", None)?;
+
+ crate::log_info!("sandbox: pledge and unveil applied");
+ Ok(())
+}
+
+/// Replace the current process with the given executable and arguments.
+/// Does not return on success.
+pub fn exec_replace(
+ exe: &std::ffi::CStr,
+ args: &[std::ffi::CString],
+) {
+ let ptrs: Vec<*const std::ffi::c_char> =
+ args.iter().map(|a| a.as_ptr()).chain(std::iter::once(std::ptr::null())).collect();
+ unsafe {
+ execvp(exe.as_ptr(), ptrs.as_ptr());
+ }
+}
blob - /dev/null
blob + 9324e1fd2c71aeb60bba384d94604a4b3d21a0b5 (mode 644)
--- /dev/null
+++ src/xcb_conn.rs
+use std::os::raw::c_ulong;
+
+use xcb::x;
+use xcb::{Connection, Xid};
+
+use crate::container::{Rect, SizeHints};
+use crate::xft_font::XftFont;
+
+/// Events we register on the root window.
+const ROOT_EVENT_MASK: x::EventMask = x::EventMask::from_bits_truncate(
+ x::EventMask::SUBSTRUCTURE_REDIRECT.bits()
+ | x::EventMask::SUBSTRUCTURE_NOTIFY.bits()
+ | x::EventMask::STRUCTURE_NOTIFY.bits()
+ | x::EventMask::PROPERTY_CHANGE.bits()
+ | x::EventMask::BUTTON_PRESS.bits()
+ | x::EventMask::FOCUS_CHANGE.bits()
+ | x::EventMask::ENTER_WINDOW.bits(),
+);
+
+/// Events we register on client windows.
+pub const CLIENT_EVENT_MASK: x::EventMask = x::EventMask::from_bits_truncate(
+ x::EventMask::ENTER_WINDOW.bits()
+ | x::EventMask::PROPERTY_CHANGE.bits()
+ | x::EventMask::STRUCTURE_NOTIFY.bits()
+ | x::EventMask::FOCUS_CHANGE.bits(),
+);
+
+/// Timeout in seconds before force-killing a window after WM_DELETE_WINDOW.
+pub const CLOSE_TIMEOUT_SECS: u64 = 2;
+
+/// Intern an atom on a connection, returning an explicit error on failure.
+fn intern_atom_on(
+ conn: &Connection,
+ name: &str,
+) -> Result<x::Atom, Box<dyn std::error::Error>> {
+ let cookie = conn.send_request(&x::InternAtom {
+ only_if_exists: false,
+ name: name.as_bytes(),
+ });
+ conn.wait_for_reply(cookie)
+ .map(|r| r.atom())
+ .map_err(|e| format!("failed to intern atom '{}': {}", name, e).into())
+}
+
+/// Cached X11 atoms for ICCCM and EWMH protocols.
+pub struct Atoms {
+ // ICCCM
+ pub wm_name: x::Atom,
+ pub wm_class: x::Atom,
+ pub wm_protocols: x::Atom,
+ pub wm_delete_window: x::Atom,
+ pub wm_hints: x::Atom,
+ pub wm_normal_hints: x::Atom,
+ // EWMH
+ pub net_supported: x::Atom,
+ pub net_supporting_wm_check: x::Atom,
+ pub net_wm_name: x::Atom,
+ pub net_current_desktop: x::Atom,
+ pub net_number_of_desktops: x::Atom,
+ pub net_workarea: x::Atom,
+ pub net_active_window: x::Atom,
+ pub net_wm_state: x::Atom,
+ pub net_wm_state_fullscreen: x::Atom,
+ pub net_wm_state_hidden: x::Atom,
+ pub net_client_list: x::Atom,
+ pub net_client_list_stacking: x::Atom,
+ pub net_wm_window_type: x::Atom,
+ pub net_wm_window_type_normal: x::Atom,
+ pub net_wm_window_type_dialog: x::Atom,
+ pub net_wm_window_type_splash: x::Atom,
+ pub net_wm_window_type_dock: x::Atom,
+ pub net_wm_window_type_utility: x::Atom,
+ pub net_wm_strut_partial: x::Atom,
+ pub utf8_string: x::Atom,
+}
+
+impl Atoms {
+ fn intern(conn: &Connection) -> Result<Self, Box<dyn std::error::Error>> {
+ Ok(Atoms {
+ wm_name: intern_atom_on(conn, "WM_NAME")?,
+ wm_class: intern_atom_on(conn, "WM_CLASS")?,
+ wm_protocols: intern_atom_on(conn, "WM_PROTOCOLS")?,
+ wm_delete_window: intern_atom_on(conn, "WM_DELETE_WINDOW")?,
+ wm_hints: intern_atom_on(conn, "WM_HINTS")?,
+ wm_normal_hints: intern_atom_on(conn, "WM_NORMAL_HINTS")?,
+ net_supported: intern_atom_on(conn, "_NET_SUPPORTED")?,
+ net_supporting_wm_check: intern_atom_on(
+ conn,
+ "_NET_SUPPORTING_WM_CHECK",
+ )?,
+ net_wm_name: intern_atom_on(conn, "_NET_WM_NAME")?,
+ net_current_desktop: intern_atom_on(conn, "_NET_CURRENT_DESKTOP")?,
+ net_number_of_desktops: intern_atom_on(
+ conn,
+ "_NET_NUMBER_OF_DESKTOPS",
+ )?,
+ net_workarea: intern_atom_on(conn, "_NET_WORKAREA")?,
+ net_active_window: intern_atom_on(conn, "_NET_ACTIVE_WINDOW")?,
+ net_wm_state: intern_atom_on(conn, "_NET_WM_STATE")?,
+ net_wm_state_fullscreen: intern_atom_on(
+ conn,
+ "_NET_WM_STATE_FULLSCREEN",
+ )?,
+ net_wm_state_hidden: intern_atom_on(conn, "_NET_WM_STATE_HIDDEN")?,
+ net_client_list: intern_atom_on(conn, "_NET_CLIENT_LIST")?,
+ net_client_list_stacking: intern_atom_on(
+ conn,
+ "_NET_CLIENT_LIST_STACKING",
+ )?,
+ net_wm_window_type: intern_atom_on(conn, "_NET_WM_WINDOW_TYPE")?,
+ net_wm_window_type_normal: intern_atom_on(
+ conn,
+ "_NET_WM_WINDOW_TYPE_NORMAL",
+ )?,
+ net_wm_window_type_dialog: intern_atom_on(
+ conn,
+ "_NET_WM_WINDOW_TYPE_DIALOG",
+ )?,
+ net_wm_window_type_splash: intern_atom_on(
+ conn,
+ "_NET_WM_WINDOW_TYPE_SPLASH",
+ )?,
+ net_wm_window_type_dock: intern_atom_on(
+ conn,
+ "_NET_WM_WINDOW_TYPE_DOCK",
+ )?,
+ net_wm_window_type_utility: intern_atom_on(
+ conn,
+ "_NET_WM_WINDOW_TYPE_UTILITY",
+ )?,
+ net_wm_strut_partial: intern_atom_on(
+ conn,
+ "_NET_WM_STRUT_PARTIAL",
+ )?,
+ utf8_string: intern_atom_on(conn, "UTF8_STRING")?,
+ })
+ }
+
+ /// List of EWMH atoms we support, as u32 resource IDs.
+ fn supported_list(&self) -> Vec<u32> {
+ [
+ self.net_supported,
+ self.net_supporting_wm_check,
+ self.net_wm_name,
+ self.net_current_desktop,
+ self.net_number_of_desktops,
+ self.net_workarea,
+ self.net_active_window,
+ self.net_wm_state,
+ self.net_wm_state_fullscreen,
+ self.net_wm_state_hidden,
+ self.net_client_list,
+ self.net_client_list_stacking,
+ self.net_wm_window_type,
+ self.net_wm_window_type_normal,
+ self.net_wm_window_type_dialog,
+ self.net_wm_window_type_splash,
+ self.net_wm_window_type_dock,
+ self.net_wm_window_type_utility,
+ self.net_wm_strut_partial,
+ ]
+ .iter()
+ .map(|a| a.resource_id())
+ .collect()
+ }
+}
+
+/// Window type from _NET_WM_WINDOW_TYPE.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum WindowType {
+ Normal,
+ Dialog,
+ Splash,
+ Dock,
+ Utility,
+}
+
+/// Decoration colors for a single state (focused, unfocused, urgent).
+#[derive(Debug, Clone, Copy)]
+pub struct DecoColors {
+ pub border: u32,
+ pub background: u32,
+ pub text: u32,
+}
+
+/// Parameters for drawing window decoration.
+pub struct Decoration<'a> {
+ pub frame: x::Window,
+ pub frame_w: u32,
+ pub frame_h: u32,
+ pub title: &'a str,
+ pub colors: &'a DecoColors,
+ pub border_width: u32,
+ pub deco_height: u32,
+}
+
+/// Wrapper around the XCB connection.
+pub struct XConn {
+ /// Xft font for anti-aliased text rendering.
+ /// Must be declared before `conn` so it is dropped first —
+ /// XftFontClose needs the Display* that conn owns.
+ pub xft: XftFont,
+ pub conn: Connection,
+ pub root: x::Window,
+ pub root_visual: x::Visualid,
+ pub screen_rect: Rect,
+ pub atoms: Atoms,
+ check_win: x::Window,
+ /// Graphics context for decoration drawing (rectangles only).
+ gc: x::Gcontext,
+ /// Font height in pixels (ascent + descent).
+ pub font_height: u32,
+ /// Font ascent in pixels (baseline offset from top).
+ font_ascent: u32,
+ /// Title bar (decoration) height in pixels.
+ pub deco_height: u32,
+}
+
+impl XConn {
+ /// Connect to the X server and claim the root window.
+ pub fn connect(
+ font_name: &str,
+ ) -> Result<Self, Box<dyn std::error::Error>> {
+ // Pure XCB connection for window management.
+ let (conn, screen_num) = Connection::connect(None)?;
+
+ let setup = conn.get_setup();
+ let screen =
+ setup.roots().nth(screen_num as usize).ok_or("no screen")?;
+
+ let root = screen.root();
+ let root_visual = screen.root_visual();
+ let screen_rect = Rect {
+ x: 0,
+ y: 0,
+ w: screen.width_in_pixels() as u32,
+ h: screen.height_in_pixels() as u32,
+ };
+
+ // Attempt to become the window manager by selecting SubstructureRedirect.
+ let cookie = conn.send_request_checked(&x::ChangeWindowAttributes {
+ window: root,
+ value_list: &[x::Cw::EventMask(ROOT_EVENT_MASK)],
+ });
+
+ conn.check_request(cookie)
+ .map_err(|_| "another window manager is already running")?;
+
+ // Intern all atoms up front, propagating errors explicitly.
+ let atoms = Atoms::intern(&conn)?;
+
+ // Open a separate Xlib Display for Xft text rendering.
+ let dpy = unsafe { x11::xlib::XOpenDisplay(std::ptr::null()) };
+ if dpy.is_null() {
+ return Err("failed to open Xlib display for Xft".into());
+ }
+ let xft = unsafe { XftFont::open(dpy, screen_num, font_name) }
+ .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
+ let font_height = xft.height;
+ let font_ascent = xft.ascent;
+ // deco_height = font height + 4px padding (2 top + 2 bottom), like i3
+ let deco_height = font_height + 4;
+
+ // Create a graphics context for decoration drawing (rectangles only).
+ let gc: x::Gcontext = conn.generate_id();
+ conn.send_request(&x::CreateGc {
+ cid: gc,
+ drawable: x::Drawable::Window(root),
+ value_list: &[x::Gc::GraphicsExposures(false)],
+ });
+
+ // Create a small check window for _NET_SUPPORTING_WM_CHECK.
+ let check_win: x::Window = conn.generate_id();
+ conn.send_request(&x::CreateWindow {
+ depth: 0,
+ wid: check_win,
+ parent: root,
+ x: -1,
+ y: -1,
+ width: 1,
+ height: 1,
+ border_width: 0,
+ class: x::WindowClass::InputOutput,
+ visual: root_visual,
+ value_list: &[x::Cw::OverrideRedirect(true)],
+ });
+
+ conn.flush()?;
+
+ let xconn = XConn {
+ conn,
+ root,
+ root_visual,
+ screen_rect,
+ atoms,
+ check_win,
+ gc,
+ xft,
+ font_height,
+ font_ascent,
+ deco_height,
+ };
+
+ xconn.setup_ewmh();
+
+ crate::log_info!(
+ "connected to X server, screen {} ({}x{}), font '{}' height {}px",
+ screen_num,
+ screen_rect.w,
+ screen_rect.h,
+ font_name,
+ font_height
+ );
+
+ Ok(xconn)
+ }
+
+ /// Set up EWMH properties on the root and check windows.
+ fn setup_ewmh(&self) {
+ let a = &self.atoms;
+
+ // _NET_SUPPORTING_WM_CHECK on root -> check_win
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: a.net_supporting_wm_check,
+ r#type: x::ATOM_WINDOW,
+ data: &[self.check_win.resource_id()],
+ });
+
+ // _NET_SUPPORTING_WM_CHECK on check_win -> check_win (self-reference)
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.check_win,
+ property: a.net_supporting_wm_check,
+ r#type: x::ATOM_WINDOW,
+ data: &[self.check_win.resource_id()],
+ });
+
+ // _NET_WM_NAME on check window
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.check_win,
+ property: a.net_wm_name,
+ r#type: a.utf8_string,
+ data: b"owm",
+ });
+
+ // _NET_SUPPORTED
+ let supported = a.supported_list();
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: a.net_supported,
+ r#type: x::ATOM_ATOM,
+ data: &supported,
+ });
+
+ // _NET_NUMBER_OF_DESKTOPS (initially 1)
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: a.net_number_of_desktops,
+ r#type: x::ATOM_CARDINAL,
+ data: &[1u32],
+ });
+
+ // _NET_CURRENT_DESKTOP (initially 0)
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: a.net_current_desktop,
+ r#type: x::ATOM_CARDINAL,
+ data: &[0u32],
+ });
+
+ // _NET_WORKAREA (one entry per desktop)
+ self.set_workarea(1, &self.screen_rect);
+
+ // Empty client lists
+ self.set_client_list(&[]);
+ self.set_client_list_stacking(&[]);
+
+ // _NET_ACTIVE_WINDOW (none initially)
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: a.net_active_window,
+ r#type: x::ATOM_WINDOW,
+ data: &[0u32],
+ });
+ }
+
+ /// Get the file descriptor for polling.
+ pub fn fd(&self) -> std::os::unix::io::RawFd {
+ self.conn.as_raw_fd()
+ }
+
+ /// Configure a window's geometry.
+ pub fn configure_window(
+ &self,
+ win: x::Window,
+ rect: &Rect,
+ border_width: u32,
+ ) {
+ self.conn.send_request(&x::ConfigureWindow {
+ window: win,
+ value_list: &[
+ x::ConfigWindow::X(rect.x),
+ x::ConfigWindow::Y(rect.y),
+ x::ConfigWindow::Width(rect.w),
+ x::ConfigWindow::Height(rect.h),
+ x::ConfigWindow::BorderWidth(border_width),
+ ],
+ });
+ }
+
+ /// Map a window.
+ pub fn map_window(&self, win: x::Window) {
+ self.conn.send_request(&x::MapWindow { window: win });
+ }
+
+ /// Unmap a window.
+ pub fn unmap_window(&self, win: x::Window) {
+ self.conn.send_request(&x::UnmapWindow { window: win });
+ }
+
+ /// Set input focus on a window.
+ pub fn set_focus(&self, win: x::Window) {
+ self.conn.send_request(&x::SetInputFocus {
+ revert_to: x::InputFocus::PointerRoot,
+ focus: win,
+ time: x::CURRENT_TIME,
+ });
+ }
+
+ /// Set border color on a window.
+ pub fn set_border_color(&self, win: x::Window, color: u32) {
+ self.conn.send_request(&x::ChangeWindowAttributes {
+ window: win,
+ value_list: &[x::Cw::BorderPixel(color)],
+ });
+ }
+
+ /// Try to close a window via WM_DELETE_WINDOW protocol.
+ /// Returns `true` if the delete message was sent, `false` if not supported
+ /// (caller should force-kill instead).
+ pub fn send_delete_window(&self, win: x::Window) -> bool {
+ if self.window_supports_protocol(
+ win,
+ self.atoms.wm_protocols,
+ self.atoms.wm_delete_window,
+ ) {
+ let data = x::ClientMessageData::Data32([
+ self.atoms.wm_delete_window.resource_id(),
+ 0,
+ 0,
+ 0,
+ 0,
+ ]);
+ self.conn.send_request(&x::SendEvent {
+ propagate: false,
+ destination: x::SendEventDest::Window(win),
+ event_mask: x::EventMask::NO_EVENT,
+ event: &x::ClientMessageEvent::new(
+ win,
+ self.atoms.wm_protocols,
+ data,
+ ),
+ });
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Force-kill a client window via XKillClient.
+ pub fn kill_window(&self, win: x::Window) {
+ self.conn.send_request(&x::KillClient {
+ resource: win.resource_id(),
+ });
+ }
+
+ /// Check if a window supports a given protocol atom.
+ fn window_supports_protocol(
+ &self,
+ win: x::Window,
+ wm_protocols: x::Atom,
+ protocol: x::Atom,
+ ) -> bool {
+ let cookie = self.conn.send_request(&x::GetProperty {
+ delete: false,
+ window: win,
+ property: wm_protocols,
+ r#type: x::ATOM_ATOM,
+ long_offset: 0,
+ long_length: 64,
+ });
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ let atoms: &[x::Atom] = reply.value();
+ return atoms.contains(&protocol);
+ }
+ false
+ }
+
+ /// Check if a window has override-redirect set.
+ pub fn is_override_redirect(&self, win: x::Window) -> bool {
+ let cookie = self.conn.send_request(&x::GetWindowAttributes {
+ window: win,
+ });
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ return reply.override_redirect();
+ }
+ false
+ }
+
+ /// Check if a window has the urgency hint set in WM_HINTS.
+ pub fn window_is_urgent(&self, win: x::Window) -> bool {
+ let cookie = self.conn.send_request(&x::GetProperty {
+ delete: false,
+ window: win,
+ property: self.atoms.wm_hints,
+ r#type: x::ATOM_ANY,
+ long_offset: 0,
+ long_length: 9,
+ });
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ let data: &[u32] = reply.value();
+ // WM_HINTS flags field is data[0], UrgencyHint is bit 8 (0x100)
+ if !data.is_empty() {
+ return data[0] & 0x100 != 0;
+ }
+ }
+ false
+ }
+
+ /// Read WM_NORMAL_HINTS (ICCCM size hints) for a window.
+ pub fn get_size_hints(&self, win: x::Window) -> SizeHints {
+ let cookie = self.conn.send_request(&x::GetProperty {
+ delete: false,
+ window: win,
+ property: self.atoms.wm_normal_hints,
+ r#type: x::ATOM_ANY,
+ long_offset: 0,
+ long_length: 18, // WM_SIZE_HINTS has 18 u32 fields
+ });
+ let mut hints = SizeHints::default();
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ let data: &[u32] = reply.value();
+ if data.len() < 18 {
+ return hints;
+ }
+ let flags = data[0];
+ // PMinSize = 1 << 4
+ if flags & (1 << 4) != 0 {
+ hints.min_w = Some(data[5]);
+ hints.min_h = Some(data[6]);
+ }
+ // PMaxSize = 1 << 5
+ if flags & (1 << 5) != 0 {
+ hints.max_w = Some(data[7]);
+ hints.max_h = Some(data[8]);
+ }
+ // PResizeInc = 1 << 6
+ if flags & (1 << 6) != 0 {
+ hints.inc_w = Some(data[9]);
+ hints.inc_h = Some(data[10]);
+ }
+ // PBaseSize = 1 << 8
+ if flags & (1 << 8) != 0 {
+ hints.base_w = Some(data[15]);
+ hints.base_h = Some(data[16]);
+ }
+ }
+ hints
+ }
+
+ /// Raise a window to the top of the stacking order.
+ pub fn raise_window(&self, win: x::Window) {
+ self.conn.send_request(&x::ConfigureWindow {
+ window: win,
+ value_list: &[x::ConfigWindow::StackMode(x::StackMode::Above)],
+ });
+ }
+
+ /// Grab mouse buttons on the root window for floating window move/resize.
+ pub fn grab_buttons(&self, modifiers: x::ModMask) {
+ // Mod+Button1 = move
+ self.conn.send_request(&x::GrabButton {
+ owner_events: true,
+ grab_window: self.root,
+ event_mask: x::EventMask::BUTTON_PRESS
+ | x::EventMask::BUTTON_RELEASE
+ | x::EventMask::POINTER_MOTION,
+ pointer_mode: x::GrabMode::Async,
+ keyboard_mode: x::GrabMode::Async,
+ confine_to: x::WINDOW_NONE,
+ cursor: x::CURSOR_NONE,
+ button: x::ButtonIndex::N1,
+ modifiers,
+ });
+ // Mod+Button3 = resize
+ self.conn.send_request(&x::GrabButton {
+ owner_events: true,
+ grab_window: self.root,
+ event_mask: x::EventMask::BUTTON_PRESS
+ | x::EventMask::BUTTON_RELEASE
+ | x::EventMask::POINTER_MOTION,
+ pointer_mode: x::GrabMode::Async,
+ keyboard_mode: x::GrabMode::Async,
+ confine_to: x::WINDOW_NONE,
+ cursor: x::CURSOR_NONE,
+ button: x::ButtonIndex::N3,
+ modifiers,
+ });
+ }
+
+ /// Set a solid background color on the root window.
+ pub fn set_background(&self, color: u32) {
+ self.conn.send_request(&x::ChangeWindowAttributes {
+ window: self.root,
+ value_list: &[x::Cw::BackPixel(color)],
+ });
+ self.conn.send_request(&x::ClearArea {
+ exposures: false,
+ window: self.root,
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ });
+ }
+
+ // --- EWMH property setters ---
+
+ /// Set _NET_CURRENT_DESKTOP on the root window (0-indexed).
+ pub fn set_current_desktop(&self, desktop: u32) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: self.atoms.net_current_desktop,
+ r#type: x::ATOM_CARDINAL,
+ data: &[desktop],
+ });
+ }
+
+ /// Set _NET_NUMBER_OF_DESKTOPS on the root window.
+ pub fn set_number_of_desktops(&self, count: u32) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: self.atoms.net_number_of_desktops,
+ r#type: x::ATOM_CARDINAL,
+ data: &[count],
+ });
+ }
+
+ /// Set _NET_WORKAREA on the root window (one rect per desktop).
+ pub fn set_workarea(&self, num_desktops: u32, workarea: &Rect) {
+ let mut data = Vec::with_capacity(num_desktops as usize * 4);
+ for _ in 0..num_desktops {
+ data.push(workarea.x as u32);
+ data.push(workarea.y as u32);
+ data.push(workarea.w);
+ data.push(workarea.h);
+ }
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: self.atoms.net_workarea,
+ r#type: x::ATOM_CARDINAL,
+ data: &data,
+ });
+ }
+
+ /// Set _NET_WM_STRUT_PARTIAL on a window (reserve screen edge space).
+ /// Format: [left, right, top, bottom, left_start_y, left_end_y,
+ /// right_start_y, right_end_y, top_start_x, top_end_x,
+ /// bottom_start_x, bottom_end_x]
+ pub fn set_strut_partial(&self, win: x::Window, strut: &[u32; 12]) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: win,
+ property: self.atoms.net_wm_strut_partial,
+ r#type: x::ATOM_CARDINAL,
+ data: strut,
+ });
+ }
+
+ /// Set _NET_WM_WINDOW_TYPE to DOCK on a window.
+ pub fn set_window_type_dock(&self, win: x::Window) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: win,
+ property: self.atoms.net_wm_window_type,
+ r#type: x::ATOM_ATOM,
+ data: &[self.atoms.net_wm_window_type_dock.resource_id()],
+ });
+ }
+
+ /// Set _NET_ACTIVE_WINDOW on the root window.
+ pub fn set_active_window(&self, win: x::Window) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: self.atoms.net_active_window,
+ r#type: x::ATOM_WINDOW,
+ data: &[win.resource_id()],
+ });
+ }
+
+ /// Clear _NET_ACTIVE_WINDOW (no window focused).
+ pub fn clear_active_window(&self) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: self.atoms.net_active_window,
+ r#type: x::ATOM_WINDOW,
+ data: &[0u32],
+ });
+ }
+
+ /// Set _NET_CLIENT_LIST on the root window (mapping order).
+ pub fn set_client_list(&self, windows: &[u32]) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: self.atoms.net_client_list,
+ r#type: x::ATOM_WINDOW,
+ data: windows,
+ });
+ }
+
+ /// Set _NET_CLIENT_LIST_STACKING on the root window (bottom-to-top).
+ pub fn set_client_list_stacking(&self, windows: &[u32]) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: self.root,
+ property: self.atoms.net_client_list_stacking,
+ r#type: x::ATOM_WINDOW,
+ data: windows,
+ });
+ }
+
+ /// Set _NET_WM_STATE on a client window.
+ pub fn set_wm_state(&self, win: x::Window, states: &[u32]) {
+ self.conn.send_request(&x::ChangeProperty {
+ mode: x::PropMode::Replace,
+ window: win,
+ property: self.atoms.net_wm_state,
+ r#type: x::ATOM_ATOM,
+ data: states,
+ });
+ }
+
+ /// Read _NET_WM_WINDOW_TYPE to detect dialogs, splash screens, docks, etc.
+ pub fn get_window_type(&self, win: x::Window) -> WindowType {
+ let cookie = self.conn.send_request(&x::GetProperty {
+ delete: false,
+ window: win,
+ property: self.atoms.net_wm_window_type,
+ r#type: x::ATOM_ATOM,
+ long_offset: 0,
+ long_length: 64,
+ });
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ let types: &[u32] = reply.value();
+ let a = &self.atoms;
+ for &t in types {
+ if t == a.net_wm_window_type_dialog.resource_id() {
+ return WindowType::Dialog;
+ }
+ if t == a.net_wm_window_type_splash.resource_id() {
+ return WindowType::Splash;
+ }
+ if t == a.net_wm_window_type_dock.resource_id() {
+ return WindowType::Dock;
+ }
+ if t == a.net_wm_window_type_utility.resource_id() {
+ return WindowType::Utility;
+ }
+ }
+ }
+ WindowType::Normal
+ }
+
+ // --- Reparenting and decoration ---
+
+ /// Create a frame window for reparenting a client.
+ /// The frame is OverrideRedirect so the WM doesn't try to manage it.
+ pub fn create_frame(&self, rect: &Rect) -> x::Window {
+ let frame: x::Window = self.conn.generate_id();
+ self.conn.send_request(&x::CreateWindow {
+ depth: 0,
+ wid: frame,
+ parent: self.root,
+ x: rect.x as i16,
+ y: rect.y as i16,
+ width: rect.w.max(1) as u16,
+ height: rect.h.max(1) as u16,
+ border_width: 0,
+ class: x::WindowClass::InputOutput,
+ visual: self.root_visual,
+ value_list: &[
+ x::Cw::OverrideRedirect(true),
+ x::Cw::EventMask(
+ x::EventMask::SUBSTRUCTURE_REDIRECT
+ | x::EventMask::SUBSTRUCTURE_NOTIFY
+ | x::EventMask::BUTTON_PRESS
+ | x::EventMask::ENTER_WINDOW
+ | x::EventMask::EXPOSURE,
+ ),
+ ],
+ });
+ frame
+ }
+
+ /// Reparent a client window into a frame window.
+ pub fn reparent_window(
+ &self,
+ client: x::Window,
+ frame: x::Window,
+ client_x: i32,
+ client_y: i32,
+ ) {
+ // Temporarily suppress events during reparent
+ self.conn.send_request(&x::ChangeWindowAttributes {
+ window: client,
+ value_list: &[x::Cw::EventMask(x::EventMask::NO_EVENT)],
+ });
+
+ self.conn.send_request(&x::ReparentWindow {
+ window: client,
+ parent: frame,
+ x: client_x as i16,
+ y: client_y as i16,
+ });
+
+ // Re-enable client events
+ self.conn.send_request(&x::ChangeWindowAttributes {
+ window: client,
+ value_list: &[x::Cw::EventMask(CLIENT_EVENT_MASK)],
+ });
+
+ // Add to save set: if the WM crashes, the client is reparented
+ // back to root automatically by the X server.
+ self.conn.send_request(&x::ChangeSaveSet {
+ mode: x::SetMode::Insert,
+ window: client,
+ });
+ }
+
+ /// Destroy a frame window.
+ pub fn destroy_frame(&self, frame: x::Window) {
+ self.conn
+ .send_request(&x::DestroyWindow { window: frame });
+ }
+
+ /// Configure a frame window's geometry (no X border).
+ pub fn configure_frame(&self, frame: x::Window, rect: &Rect) {
+ self.conn.send_request(&x::ConfigureWindow {
+ window: frame,
+ value_list: &[
+ x::ConfigWindow::X(rect.x),
+ x::ConfigWindow::Y(rect.y),
+ x::ConfigWindow::Width(rect.w.max(1)),
+ x::ConfigWindow::Height(rect.h.max(1)),
+ ],
+ });
+ }
+
+ /// Position the client window inside its frame.
+ pub fn configure_client_in_frame(
+ &self,
+ client: x::Window,
+ x: i32,
+ y: i32,
+ w: u32,
+ h: u32,
+ ) {
+ self.conn.send_request(&x::ConfigureWindow {
+ window: client,
+ value_list: &[
+ x::ConfigWindow::X(x),
+ x::ConfigWindow::Y(y),
+ x::ConfigWindow::Width(w.max(1)),
+ x::ConfigWindow::Height(h.max(1)),
+ x::ConfigWindow::BorderWidth(0),
+ ],
+ });
+ }
+
+ /// Draw decoration on a frame window: title bar background, border, and text.
+ pub fn draw_decoration(&self, deco: &Decoration<'_>) {
+ let frame = deco.frame;
+ let frame_w = deco.frame_w;
+ let frame_h = deco.frame_h;
+ let title = deco.title;
+ let colors = deco.colors;
+ let border_width = deco.border_width;
+ let deco_height = deco.deco_height;
+ let drawable = x::Drawable::Window(frame);
+
+ // Fill entire frame with border color (acts as border around all sides)
+ self.conn.send_request(&x::ChangeGc {
+ gc: self.gc,
+ value_list: &[x::Gc::Foreground(colors.border)],
+ });
+ self.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc: self.gc,
+ rectangles: &[x::Rectangle {
+ x: 0,
+ y: 0,
+ width: frame_w as u16,
+ height: frame_h as u16,
+ }],
+ });
+
+ // Fill title bar background (inside border)
+ if deco_height > 0 {
+ self.conn.send_request(&x::ChangeGc {
+ gc: self.gc,
+ value_list: &[x::Gc::Foreground(colors.background)],
+ });
+ self.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc: self.gc,
+ rectangles: &[x::Rectangle {
+ x: border_width as i16,
+ y: border_width as i16,
+ width: frame_w.saturating_sub(2 * border_width) as u16,
+ height: deco_height
+ .saturating_sub(border_width) as u16,
+ }],
+ });
+
+ // Draw title text.
+ // Flush XCB buffer before Xft draws — both share the same
+ // connection but maintain separate output buffers, so pending
+ // XCB fills must reach the server before Xft renders text.
+ if !title.is_empty() {
+ let _ = self.conn.flush();
+
+ let avail = frame_w.saturating_sub(2 * border_width + 8);
+ let text = self.xft.truncate_to_width(title, avail);
+ let text_x = (border_width + 4) as i32;
+ let text_y =
+ (border_width + self.font_ascent + 2) as i32;
+
+ self.xft.draw_text(
+ frame.resource_id() as c_ulong,
+ text_x,
+ text_y,
+ text,
+ colors.text,
+ );
+ self.xft.flush();
+ }
+ }
+ }
+
+ /// Draw a split direction indicator on the focused window's frame.
+ ///
+ /// Draws a thin colored line on the edge where the next window
+ /// would appear (right edge for SplitH, bottom edge for SplitV).
+ pub fn draw_indicator(
+ &self,
+ frame: x::Window,
+ frame_w: u32,
+ frame_h: u32,
+ split_vertical: bool,
+ color: u32,
+ ) {
+ let drawable = x::Drawable::Window(frame);
+ let thickness: u16 = 2;
+
+ self.conn.send_request(&x::ChangeGc {
+ gc: self.gc,
+ value_list: &[x::Gc::Foreground(color)],
+ });
+
+ let rect = if split_vertical {
+ // SplitV: indicator on the bottom edge (next window goes below)
+ x::Rectangle {
+ x: 0,
+ y: frame_h.saturating_sub(thickness as u32) as i16,
+ width: frame_w as u16,
+ height: thickness,
+ }
+ } else {
+ // SplitH: indicator on the right edge (next window goes right)
+ x::Rectangle {
+ x: frame_w.saturating_sub(thickness as u32) as i16,
+ y: 0,
+ width: thickness,
+ height: frame_h as u16,
+ }
+ };
+
+ self.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc: self.gc,
+ rectangles: &[rect],
+ });
+ }
+
+ /// Draw a single tab/title bar at an arbitrary position within a frame.
+ ///
+ /// Used by tabbed/stacked layouts where the parent frame draws all
+ /// children's title bars. `tab_rect` is in frame-local coordinates.
+ /// Mimics i3's decoration style: border around each tab, background
+ /// fill, title text, and a 1px indicator line at the bottom for the
+ /// focused tab.
+ pub fn draw_tab(
+ &self,
+ frame: x::Window,
+ tab_rect: &Rect,
+ title: &str,
+ colors: &DecoColors,
+ focused: bool,
+ ) {
+ let drawable = x::Drawable::Window(frame);
+ let x = tab_rect.x as i16;
+ let y = tab_rect.y as i16;
+ let w = tab_rect.w as u16;
+ let h = tab_rect.h as u16;
+
+ // 1. Fill entire tab rect with border color (creates the border).
+ self.conn.send_request(&x::ChangeGc {
+ gc: self.gc,
+ value_list: &[x::Gc::Foreground(colors.border)],
+ });
+ self.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc: self.gc,
+ rectangles: &[x::Rectangle { x, y, width: w, height: h }],
+ });
+
+ // 2. Fill inner area with background (1px border on all sides).
+ if w > 2 && h > 2 {
+ let bg = colors.background;
+ self.conn.send_request(&x::ChangeGc {
+ gc: self.gc,
+ value_list: &[x::Gc::Foreground(bg)],
+ });
+ self.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc: self.gc,
+ rectangles: &[x::Rectangle {
+ x: x + 1,
+ y: y + 1,
+ width: w - 2,
+ height: h - 2,
+ }],
+ });
+ }
+
+ // 3. Focused tab: draw a 2px indicator line at the bottom
+ // in the border color (like i3's focused indicator).
+ if focused && h > 3 {
+ self.conn.send_request(&x::ChangeGc {
+ gc: self.gc,
+ value_list: &[x::Gc::Foreground(colors.border)],
+ });
+ self.conn.send_request(&x::PolyFillRectangle {
+ drawable,
+ gc: self.gc,
+ rectangles: &[x::Rectangle {
+ x: x + 1,
+ y: y + h as i16 - 3,
+ width: w - 2,
+ height: 2,
+ }],
+ });
+ }
+
+ // 4. Title text (flush XCB before Xft)
+ if !title.is_empty() && tab_rect.w > 8 && tab_rect.h > 0 {
+ let _ = self.conn.flush();
+
+ let avail = tab_rect.w.saturating_sub(8);
+ let text = self.xft.truncate_to_width(title, avail);
+ let text_x = x as i32 + 4;
+ let text_y = y as i32 + self.font_ascent as i32 + 2;
+
+ self.xft.draw_text(
+ frame.resource_id() as c_ulong,
+ text_x,
+ text_y,
+ text,
+ colors.text,
+ );
+ self.xft.flush();
+ }
+ }
+
+ // --- Window properties ---
+
+ /// Read _NET_WM_NAME (UTF-8) or fall back to WM_NAME (Latin-1).
+ pub fn get_wm_name(&self, win: x::Window) -> String {
+ // Try _NET_WM_NAME first (UTF-8)
+ let cookie = self.conn.send_request(&x::GetProperty {
+ delete: false,
+ window: win,
+ property: self.atoms.net_wm_name,
+ r#type: self.atoms.utf8_string,
+ long_offset: 0,
+ long_length: 256,
+ });
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ let data: &[u8] = reply.value();
+ if !data.is_empty() {
+ return String::from_utf8_lossy(data).into_owned();
+ }
+ }
+
+ // Fallback to WM_NAME (STRING encoding, typically Latin-1)
+ let cookie = self.conn.send_request(&x::GetProperty {
+ delete: false,
+ window: win,
+ property: self.atoms.wm_name,
+ r#type: x::ATOM_STRING,
+ long_offset: 0,
+ long_length: 256,
+ });
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ let data: &[u8] = reply.value();
+ if !data.is_empty() {
+ return String::from_utf8_lossy(data).into_owned();
+ }
+ }
+
+ String::new()
+ }
+
+ /// Read WM_CLASS property, returning (instance, class).
+ pub fn get_wm_class(&self, win: x::Window) -> (String, String) {
+ let cookie = self.conn.send_request(&x::GetProperty {
+ delete: false,
+ window: win,
+ property: self.atoms.wm_class,
+ r#type: x::ATOM_STRING,
+ long_offset: 0,
+ long_length: 256,
+ });
+ if let Ok(reply) = self.conn.wait_for_reply(cookie) {
+ let data: &[u8] = reply.value();
+ // WM_CLASS is two null-terminated strings: instance\0class\0
+ let mut parts = data.splitn(3, |&b| b == 0);
+ let instance = parts
+ .next()
+ .map(|b| String::from_utf8_lossy(b).into_owned())
+ .unwrap_or_default();
+ let class = parts
+ .next()
+ .map(|b| String::from_utf8_lossy(b).into_owned())
+ .unwrap_or_default();
+ return (instance, class);
+ }
+ (String::new(), String::new())
+ }
+
+ /// Flush the connection.
+ pub fn flush(&self) {
+ let _ = self.conn.flush();
+ }
+}
+
+use std::os::unix::io::AsRawFd;
+
+impl AsRawFd for XConn {
+ fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
+ self.conn.as_raw_fd()
+ }
+}
blob - /dev/null
blob + 83cf8594fe46832f7563f404026fe3e35b68e124 (mode 644)
--- /dev/null
+++ src/xft_font.rs
+use std::ffi::CString;
+use std::os::raw::{c_int, c_uchar, c_ulong};
+
+use x11::xft;
+use x11::xlib;
+use x11::xrender;
+
+/// Xft font context for anti-aliased text rendering.
+///
+/// Wraps an Xft font handle and provides methods for measuring
+/// and drawing UTF-8 text on X11 drawables.
+pub struct XftFont {
+ dpy: *mut xlib::Display,
+ visual: *mut xlib::Visual,
+ colormap: c_ulong,
+ font: *mut xft::XftFont,
+ /// Font ascent in pixels (baseline offset from top).
+ pub ascent: u32,
+ /// Font height in pixels (ascent + descent).
+ pub height: u32,
+}
+
+impl XftFont {
+ /// Open a font by fontconfig pattern (e.g. "monospace:size=11").
+ ///
+ /// # Safety
+ /// `dpy` must be a valid Xlib Display pointer for the lifetime of this struct.
+ pub unsafe fn open(
+ dpy: *mut xlib::Display,
+ screen: c_int,
+ pattern: &str,
+ ) -> Result<Self, String> {
+ let visual = unsafe { xlib::XDefaultVisual(dpy, screen) };
+ let colormap = unsafe { xlib::XDefaultColormap(dpy, screen) };
+
+ let c_pattern = CString::new(pattern)
+ .map_err(|_| "font pattern contains null byte".to_string())?;
+
+ let font =
+ unsafe { xft::XftFontOpenName(dpy, screen, c_pattern.as_ptr()) };
+ if font.is_null() {
+ return Err(format!("failed to open font '{}'", pattern));
+ }
+
+ let ascent = unsafe { (*font).ascent as u32 };
+ let height =
+ unsafe { ((*font).ascent + (*font).descent) as u32 };
+
+ Ok(XftFont {
+ dpy,
+ visual,
+ colormap,
+ font,
+ ascent,
+ height,
+ })
+ }
+
+ /// Measure the pixel width of a UTF-8 string.
+ pub fn text_width(&self, text: &str) -> u32 {
+ if text.is_empty() {
+ return 0;
+ }
+ let mut extents = xrender::XGlyphInfo {
+ width: 0,
+ height: 0,
+ x: 0,
+ y: 0,
+ xOff: 0,
+ yOff: 0,
+ };
+ unsafe {
+ xft::XftTextExtentsUtf8(
+ self.dpy,
+ self.font,
+ text.as_ptr() as *const c_uchar,
+ text.len() as c_int,
+ &mut extents,
+ );
+ }
+ extents.xOff as u32
+ }
+
+ /// Draw UTF-8 text on a drawable at (x, y) with given foreground color.
+ ///
+ /// `y` is the baseline position. `fg` is a 0xRRGGBB color value.
+ /// The background is not filled — caller should fill the area first.
+ pub fn draw_text(
+ &self,
+ drawable: c_ulong,
+ x: i32,
+ y: i32,
+ text: &str,
+ fg: u32,
+ ) {
+ if text.is_empty() {
+ return;
+ }
+ unsafe {
+ let draw = xft::XftDrawCreate(
+ self.dpy,
+ drawable,
+ self.visual,
+ self.colormap,
+ );
+ if draw.is_null() {
+ return;
+ }
+
+ let color = make_xft_color(fg);
+
+ xft::XftDrawStringUtf8(
+ draw,
+ &color,
+ self.font,
+ x as c_int,
+ y as c_int,
+ text.as_ptr() as *const c_uchar,
+ text.len() as c_int,
+ );
+
+ xft::XftDrawDestroy(draw);
+ }
+ }
+
+ /// Flush the Xlib output buffer.
+ ///
+ /// Must be called after a batch of `draw_text` calls to ensure
+ /// the rendered text reaches the X server. In the Xlib-XCB hybrid,
+ /// XCB flush only drains the XCB buffer; Xft operations go through
+ /// Xlib's separate buffer.
+ pub fn flush(&self) {
+ unsafe {
+ xlib::XFlush(self.dpy);
+ }
+ }
+
+ /// Truncate a UTF-8 string to fit within `max_width` pixels.
+ ///
+ /// Returns the longest prefix (at char boundaries) that fits.
+ pub fn truncate_to_width<'a>(
+ &self,
+ text: &'a str,
+ max_width: u32,
+ ) -> &'a str {
+ if self.text_width(text) <= max_width {
+ return text;
+ }
+ // Binary search on char indices for efficiency.
+ let indices: Vec<usize> =
+ text.char_indices().map(|(i, _)| i).collect();
+ let mut lo = 0usize;
+ let mut hi = indices.len();
+ while lo < hi {
+ let mid = (lo + hi + 1) / 2;
+ let byte_pos = if mid < indices.len() {
+ indices[mid]
+ } else {
+ text.len()
+ };
+ if self.text_width(&text[..byte_pos]) <= max_width {
+ lo = mid;
+ } else {
+ hi = mid - 1;
+ }
+ }
+ let end = if lo < indices.len() {
+ indices[lo]
+ } else {
+ text.len()
+ };
+ &text[..end]
+ }
+}
+
+/// Build an XftColor directly from a 0xRRGGBB value without
+/// server round-trip. Works on TrueColor visuals (the common case).
+fn make_xft_color(rgb: u32) -> xft::XftColor {
+ let r = ((rgb >> 16) & 0xff) as u16;
+ let g = ((rgb >> 8) & 0xff) as u16;
+ let b = (rgb & 0xff) as u16;
+ xft::XftColor {
+ pixel: rgb as c_ulong,
+ color: xrender::XRenderColor {
+ red: r | (r << 8),
+ green: g | (g << 8),
+ blue: b | (b << 8),
+ alpha: 0xffff,
+ },
+ }
+}
+
+impl Drop for XftFont {
+ fn drop(&mut self) {
+ unsafe {
+ xft::XftFontClose(self.dpy, self.font);
+ }
+ }
+}