git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitf2ab384bf09a5311fa57981b98e3efc1386ca9d3
parent67c353ac31
authorLucas Galante <[email protected]>
date2026-05-11 14:35
add tiling, IPC, config, borders, status, restart, and Wayland protocol handling

 Cargo.lock      |    2 +
 Cargo.toml      |    4 +-
 src/borders.rs  |  151 +++++++++
 src/clearctl.rs |   82 ++++-
 src/config.rs   |  339 ++++++++++++++++++
 src/ipc.rs      |  460 +++++++++++++++++++++++++
 src/lib.rs      |   13 +
 src/main.rs     |  191 ++++++++++-
 src/protocol.rs |   44 +++
 src/restart.rs  |   99 ++++++
 src/status.rs   |   83 +++++
 src/tiling.rs   |  215 ++++++++++++
 src/types.rs    |  531 +++++++++++++++++++++++++++++
 src/wayland.rs  | 1018 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/wm.rs       |  266 +++++++++++++++
 start-river.sh  |   11 +
 16 files changed, 3506 insertions(+), 3 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index c2f0b75..bfd1f8f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -34,10 +34,12 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
 name = "clearwm"
 version = "0.1.0"
 dependencies = [
+ "bitflags",
  "libc",
  "nix",
  "serde",
  "toml",
+ "wayland-backend",
  "wayland-client",
  "wayland-protocols",
  "wayland-scanner",
diff --git a/Cargo.toml b/Cargo.toml
index 66543bc..971ab15 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,10 +13,12 @@ path = "src/clearctl.rs"
 
 [dependencies]
 wayland-client = "0.31"
+wayland-backend = "0.3"
 wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
 wayland-scanner = "0.31"
 xkbcommon = "0.7"
 toml = "0.8"
 serde = { version = "1", features = ["derive"] }
-nix = { version = "0.29", features = ["signal", "process", "fs"] }
+nix = { version = "0.29", features = ["signal", "process", "fs", "poll"] }
 libc = "0.2"
+bitflags = "2"
diff --git a/src/borders.rs b/src/borders.rs
new file mode 100644
index 0000000..d251912
--- /dev/null
+++ b/src/borders.rs
@@ -0,0 +1,151 @@
+// Border color computation for cascade depth gradient and normal windows
+
+use crate::types::{TilingMode, WindowManager};
+
+/// Interpolate a single 8-bit channel toward black by (factor ^ depth).
+/// fp_channel is in fixed-point format (0xRR000000).
+/// Returns the value in 32-bit fixed-point (channel in high byte).
+pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
+    let base = (fp_channel >> 24) as u8;
+    let mut f = 1.0_f64;
+    for _ in 0..depth {
+        f *= factor;
+    }
+    let val = (base as f64 * f) as u8;
+    (val as u32) << 24
+}
+
+/// Write "#RRGGBB" for a given depth into a String.
+pub fn cascade_hex_color(br: u32, bg: u32, bb: u32, depth: i32) -> String {
+    let depth_factor = 0.80_f64;
+    let r = interp_channel(br, depth_factor, depth) >> 24;
+    let g = interp_channel(bg, depth_factor, depth) >> 24;
+    let b = interp_channel(bb, depth_factor, depth) >> 24;
+    format!("#{:02x}{:02x}{:02x}", r, g, b)
+}
+
+/// Normal border color in fixed-point format: dark gray (#3E3E3E)
+pub const BORDER_COLOR_NORMAL_R: u32 = 0x3E000000;
+pub const BORDER_COLOR_NORMAL_G: u32 = 0x3E000000;
+pub const BORDER_COLOR_NORMAL_B: u32 = 0x3E000000;
+pub const BORDER_COLOR_NORMAL_A: u32 = 0x000000FF;
+
+/// Cascade alpha: full alpha in low byte
+pub const CASCADE_ALPHA: u32 = 0x000000FF;
+
+/// Cascade depth darkening factor
+pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
+
+/// Result of border color computation for a single window
+#[derive(Debug, Clone)]
+pub struct WindowBorders {
+    pub window_idx: usize,
+    pub edges: u32, // all edges = top | bottom | left | right
+    pub width: i32,
+    pub r: u32,
+    pub g: u32,
+    pub b: u32,
+    pub a: u32,
+}
+
+/// Compute border colors for all visible windows.
+/// Returns a list of WindowBorders and the background color string for swaybg.
+pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Option<String>) {
+    let mut results = Vec::new();
+    let all_edges = 0b1111u32; // all edges
+
+    // Count cascade windows
+    let mut n_cascade = 0usize;
+    for win in &state.windows {
+        if (win.tags & state.active_tags) != 0 && win.tiling_mode == TilingMode::Cascade {
+            n_cascade += 1;
+        }
+    }
+
+    let mut max_cascade_depth = 0i32;
+    let mut bg_color: Option<String> = None;
+
+    // Second pass: assign border colors
+    for (idx, win) in state.windows.iter().enumerate() {
+        if (win.tags & state.active_tags) == 0 {
+            continue;
+        }
+
+        if win.tiling_mode == TilingMode::Cascade && n_cascade > 0 {
+            // Compute cascade depth: count how many cascade windows come before this one
+            let mut cascade_idx = 0usize;
+            for (i, w) in state.windows.iter().enumerate() {
+                if i >= idx {
+                    break;
+                }
+                if (w.tags & state.active_tags) != 0 && w.tiling_mode == TilingMode::Cascade {
+                    cascade_idx += 1;
+                }
+            }
+            // depth: 0 for front (focused/last), n_cascade-1 for back
+            let depth = (n_cascade - 1 - cascade_idx) as i32;
+
+            let r = interp_channel(state.layout.border_r, CASCADE_DEPTH_FACTOR, depth);
+            let g = interp_channel(state.layout.border_g, CASCADE_DEPTH_FACTOR, depth);
+            let b = interp_channel(state.layout.border_b, CASCADE_DEPTH_FACTOR, depth);
+
+            results.push(WindowBorders {
+                window_idx: idx,
+                edges: all_edges,
+                width: state.layout.border_width,
+                r, g, b, a: CASCADE_ALPHA,
+            });
+
+            if depth > max_cascade_depth {
+                max_cascade_depth = depth;
+            }
+        } else {
+            results.push(WindowBorders {
+                window_idx: idx,
+                edges: all_edges,
+                width: state.layout.border_width,
+                r: BORDER_COLOR_NORMAL_R,
+                g: BORDER_COLOR_NORMAL_G,
+                b: BORDER_COLOR_NORMAL_B,
+                a: BORDER_COLOR_NORMAL_A,
+            });
+        }
+    }
+
+    // Set desktop background to the darkest cascade color
+    if n_cascade > 0 {
+        bg_color = Some(cascade_hex_color(
+            state.layout.border_r,
+            state.layout.border_g,
+            state.layout.border_b,
+            max_cascade_depth,
+        ));
+    }
+
+    (results, bg_color)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_interp_channel_depth0() {
+        // Depth 0 should return the base color unchanged
+        let result = interp_channel(0x5C000000, 0.80, 0);
+        assert_eq!(result >> 24, 0x5C);
+    }
+
+    #[test]
+    fn test_interp_channel_depth1() {
+        let result = interp_channel(0x90000000, 0.80, 1);
+        let val = result >> 24;
+        assert_eq!(val, ((0x90 as f64 * 0.80) as u8) as u32);
+    }
+
+    #[test]
+    fn test_cascade_hex_color() {
+        let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 0);
+        assert_eq!(color, "#5c9060");
+    }
+}
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 7dd073b..21732c3 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -1,3 +1,83 @@
+// clearctl — IPC client for clearwm
+
+use std::env;
+use std::fs;
+use std::io::{Read, Write};
+use std::os::unix::net::UnixStream;
+use std::process;
+
+const SOCKET_PATH: &str = "/tmp/clearwm.sock";
+
+fn usage(name: &str) {
+    eprintln!("usage: {} <command> [args...]", name);
+    eprintln!();
+    eprintln!("commands:");
+    eprintln!("  layout <gap|offset|bar_height|border_width|border_color> <value>");
+    eprintln!("  view <1-4>");
+    eprintln!("  toggle <1-4>");
+    eprintln!("  close");
+    eprintln!("  focus-next");
+    eprintln!("  windows");
+    eprintln!("  exit");
+    eprintln!("  restart");
+    eprintln!("  reload");
+    eprintln!("  repeat <rate> <delay>");
+    eprintln!("  config-done");
+    eprintln!("  spawn <command>");
+    eprintln!("  bind <mods> <keysym> <action> [args...]");
+    eprintln!("  pbind <mods> <button> <action>");
+    eprintln!("  retile");
+    eprintln!("  set-tag <1-4>");
+    eprintln!("  mode <cascade|grid|vsplit|hsplit|fullscreen|floating> <app_id> [title]");
+    eprintln!("  tag-layout <1-4> <cascade|grid|vsplit|hsplit|fullscreen|floating>");
+}
+
 fn main() {
-    println!("clearctl placeholder");
+    let args: Vec<String> = env::args().collect();
+    if args.len() < 2 {
+        usage(&args[0]);
+        process::exit(1);
+    }
+
+    // Special case: "windows" reads the status file directly
+    if args[1] == "windows" {
+        match fs::read_to_string("/tmp/clearwm-windows") {
+            Ok(content) => print!("{}", content),
+            Err(_) => eprintln!("No windows info (clearwm may not be running)"),
+        }
+        return;
+    }
+
+    // Connect to IPC socket
+    let stream = match UnixStream::connect(SOCKET_PATH) {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("connect: {}", e);
+            process::exit(1);
+        }
+    };
+
+    let mut stream = stream;
+    // Build command string from args
+    let cmd = args[1..].join(" ") + "\n";
+    if let Err(e) = stream.write_all(cmd.as_bytes()) {
+        eprintln!("write: {}", e);
+        process::exit(1);
+    }
+
+    // Read response
+    let mut buf = [0u8; 4096];
+    loop {
+        match stream.read(&mut buf) {
+            Ok(0) => break,
+            Ok(n) => {
+                let s = String::from_utf8_lossy(&buf[..n]);
+                print!("{}", s);
+            }
+            Err(e) => {
+                eprintln!("read: {}", e);
+                break;
+            }
+        }
+    }
 }
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..952331c
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,339 @@
+// TOML config parsing for clearwm
+
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::fs;
+
+use crate::types::{
+    parse_action, parse_button, parse_hex_color, parse_modifiers, parse_tiling_mode, ModeRule,
+    PendingPointerBinding, PendingXkbBinding, WindowManager, NUM_TAGS,
+};
+
+#[derive(Debug, Deserialize)]
+pub struct Config {
+    #[serde(default)]
+    pub layout: LayoutConfig,
+    #[serde(default)]
+    pub output: OutputConfig,
+    #[serde(default)]
+    pub repeat: RepeatConfig,
+    #[serde(default)]
+    pub startup: StartupConfig,
+    #[serde(default)]
+    pub keybind: Vec<KeybindConfig>,
+    #[serde(default)]
+    pub pointer_bind: Vec<PointerBindConfig>,
+    #[serde(default)]
+    pub mode_rule: Vec<ModeRuleConfig>,
+    #[serde(default)]
+    pub tag_layout: Vec<TagLayoutConfig>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct LayoutConfig {
+    #[serde(default = "default_gap")]
+    pub gap: i64,
+    #[serde(default = "default_offset")]
+    pub offset: i64,
+    #[serde(default = "default_bar_height")]
+    pub bar_height: i64,
+    #[serde(default = "default_border_width")]
+    pub border_width: i64,
+    #[serde(default = "default_border_color")]
+    pub border_color: String,
+}
+
+impl Default for LayoutConfig {
+    fn default() -> Self {
+        LayoutConfig {
+            gap: default_gap(),
+            offset: default_offset(),
+            bar_height: default_bar_height(),
+            border_width: default_border_width(),
+            border_color: default_border_color(),
+        }
+    }
+}
+
+fn default_gap() -> i64 {
+    48
+}
+fn default_offset() -> i64 {
+    20
+}
+fn default_bar_height() -> i64 {
+    24
+}
+fn default_border_width() -> i64 {
+    6
+}
+fn default_border_color() -> String {
+    "#3e3e3e".to_string()
+}
+
+#[derive(Debug, Deserialize, Default)]
+pub struct OutputConfig {
+    #[serde(default)]
+    pub scale: i64,
+}
+
+#[derive(Debug, Deserialize, Default)]
+pub struct RepeatConfig {
+    #[serde(default)]
+    pub rate: i64,
+    #[serde(default)]
+    pub delay: i64,
+}
+
+#[derive(Debug, Deserialize, Default)]
+pub struct StartupConfig {
+    #[serde(default)]
+    pub apps: Vec<String>,
+    #[serde(default)]
+    pub cold_start_only: Vec<String>,
+    #[serde(default)]
+    pub env: HashMap<String, String>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct KeybindConfig {
+    pub mods: String,
+    pub key: String,
+    pub action: String,
+    pub command: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct PointerBindConfig {
+    pub mods: String,
+    pub button: String,
+    pub action: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ModeRuleConfig {
+    pub mode: String,
+    pub app_id: String,
+    pub title: Option<String>,
+    pub single: Option<bool>,
+    pub tag: Option<i64>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TagLayoutConfig {
+    pub tag: i64,
+    pub mode: String,
+}
+
+/// Parse the TOML config file and apply it to the WindowManager state.
+/// `cold_start` controls whether cold_start_only apps are spawned.
+pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
+    let content = match fs::read_to_string(path) {
+        Ok(c) => c,
+        Err(e) => {
+            eprintln!("parse_config: cannot open {}: {}", path, e);
+            return;
+        }
+    };
+
+    let config: Config = match toml::from_str(&content) {
+        Ok(c) => c,
+        Err(e) => {
+            eprintln!("parse_config: TOML parse error: {}", e);
+            return;
+        }
+    };
+
+    // [layout] section
+    state.layout.gap = config.layout.gap as i32;
+    state.layout.offset = config.layout.offset as i32;
+    state.layout.bar_height = config.layout.bar_height as i32;
+    state.layout.border_width = config.layout.border_width as i32;
+    if let Some((r, g, b, a)) = parse_hex_color(&config.layout.border_color) {
+        state.layout.border_r = r;
+        state.layout.border_g = g;
+        state.layout.border_b = b;
+        state.layout.border_a = a;
+    }
+
+    // [[keybind]] array
+    for kb in &config.keybind {
+        let mods = parse_modifiers(&kb.mods);
+        let keysym = parse_keysym(&kb.key);
+        let action = parse_action(&kb.action);
+        let command = if action == crate::types::Action::Spawn {
+            kb.command.clone()
+        } else {
+            None
+        };
+
+        state.pending_bindings.push(PendingXkbBinding {
+            mods,
+            keysym,
+            action,
+            command,
+        });
+    }
+
+    // [[pointer_bind]] array
+    for pb in &config.pointer_bind {
+        let mods = parse_modifiers(&pb.mods);
+        let button = parse_button(&pb.button);
+        let action = parse_action(&pb.action);
+
+        state.pending_pointer_bindings.push(PendingPointerBinding {
+            mods,
+            button,
+            action,
+        });
+    }
+
+    // [[mode_rule]] array
+    for mr in &config.mode_rule {
+        let mode = parse_tiling_mode(&mr.mode);
+        let tag = mr.tag.unwrap_or(0) as i32;
+        state.mode_rules.push(ModeRule {
+            mode,
+            app_id_pattern: mr.app_id.clone(),
+            title_pattern: mr.title.clone(),
+            single_instance: mr.single.unwrap_or(false),
+            tag,
+        });
+    }
+
+    // [[tag_layout]] array
+    for tl in &config.tag_layout {
+        let tag = tl.tag as i32;
+        if tag >= 1 && tag <= NUM_TAGS as i32 {
+            let mode = parse_tiling_mode(&tl.mode);
+            state.tag_layouts[tag as usize - 1] = mode;
+            state.has_tag_layout[tag as usize - 1] = true;
+        }
+    }
+
+    // [startup] section — spawn apps
+    for app in &config.startup.apps {
+        // Extract program name for skip-if-running check
+        let name = extract_program_name(app);
+        if process_running(&name) {
+            continue;
+        }
+        // If waybar, kill existing before launching
+        // NOTE: pkill + sleep is too slow for nested mode where River's
+        // 3-second unresponsive timer is ticking. Just launch waybar
+        // directly — if an existing waybar is running, the new one will
+        // replace it (or the old one can be killed manually).
+        // if name == "waybar" {
+        //     let _ = std::process::Command::new("pkill")
+        //         .arg("waybar")
+        //         .output();
+        //     std::thread::sleep(std::time::Duration::from_millis(100));
+        // }
+        spawn_command_bg(app);
+    }
+
+    // Cold-start-only apps
+    if cold_start {
+        for app in &config.startup.cold_start_only {
+            spawn_command_bg(app);
+        }
+    }
+
+    // [startup.env] — set environment variables
+    for (key, value) in &config.startup.env {
+        std::env::set_var(key, value);
+        state.env_vars.insert(key.clone(), value.clone());
+    }
+
+    // [output] scale — handled at startup
+    if config.output.scale > 0 && cold_start {
+        // Scale is applied via wlr-randr; we just store the value
+        // The actual wlr-randr call would happen in the Wayland integration layer
+    }
+
+    // Signal config-done
+    state.config_done = true;
+}
+
+/// Extract the program name (first word, basename) from a command string
+fn extract_program_name(cmd: &str) -> String {
+    let cmd = cmd.trim_start();
+    let first_word: String = cmd
+        .chars()
+        .take_while(|c| !c.is_whitespace())
+        .collect();
+    if let Some(slash) = first_word.rfind('/') {
+        first_word[slash + 1..].to_string()
+    } else {
+        first_word
+    }
+}
+
+/// Parse a key name string into an xkb keysym value.
+/// Uses xkbcommon to resolve key names.
+pub fn parse_keysym(key_str: &str) -> u32 {
+    let name = if key_str.starts_with("XKB_KEY_") {
+        &key_str[8..]
+    } else {
+        key_str
+    };
+    // Use xkbcommon to parse the keysym
+    xkbcommon::xkb::keysym_from_name(name, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into()
+}
+
+/// Spawn a command in the background (double-fork style)
+pub fn spawn_command_bg(cmd: &str) {
+    use std::os::unix::process::CommandExt;
+    let cmd = cmd.to_string();
+    // Double-fork: first fork setsid, second fork execs
+    // Safety: pre_exec is unsafe because it runs between fork and exec.
+    // We only call setsid() which is async-signal-safe.
+    let _ = unsafe {
+        std::process::Command::new("sh")
+            .arg("-c")
+            .arg(&cmd)
+            .pre_exec(|| {
+                libc::setsid();
+                Ok(())
+            })
+            .spawn()
+    };
+}
+
+/// Check if a process with the given name is already running
+pub fn process_running(name: &str) -> bool {
+    match std::process::Command::new("pgrep")
+        .arg("-x")
+        .arg(name)
+        .output()
+    {
+        Ok(output) => output.status.success(),
+        Err(_) => false,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_extract_program_name() {
+        assert_eq!(extract_program_name("ghostty"), "ghostty");
+        assert_eq!(extract_program_name("ghostty -e something"), "ghostty");
+        assert_eq!(
+            extract_program_name("/home/lsgalante/.local/bin/tmux-startup --recreate main"),
+            "tmux-startup"
+        );
+        assert_eq!(extract_program_name("  waybar"), "waybar");
+    }
+
+    #[test]
+    fn test_config_layout_defaults() {
+        let lc = LayoutConfig::default();
+        assert_eq!(lc.gap, 48);
+        assert_eq!(lc.offset, 20);
+        assert_eq!(lc.bar_height, 24);
+        assert_eq!(lc.border_width, 6);
+        assert_eq!(lc.border_color, "#3e3e3e");
+    }
+}
diff --git a/src/ipc.rs b/src/ipc.rs
new file mode 100644
index 0000000..9833a6c
--- /dev/null
+++ b/src/ipc.rs
@@ -0,0 +1,460 @@
+// IPC command parser for clearwm
+// Ported from handle_ipc_command in clearwm.c
+
+use crate::config::{parse_keysym, spawn_command_bg};
+use crate::types::{
+    parse_action, parse_button, parse_hex_color, parse_modifiers, parse_tiling_mode, Action,
+    ModeRule, PendingPointerBinding, PendingXkbBinding, WindowManager, NUM_TAGS,
+};
+
+/// Handle an IPC command string, modifying the window manager state.
+/// This is called from the Unix socket listener when clearctl sends a command.
+pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
+    // Strip trailing newlines/spaces
+    let cmd = cmd.trim_end_matches(|c| c == '\n' || c == '\r' || c == ' ');
+    if cmd.is_empty() {
+        return;
+    }
+
+    // Split the command into tokens
+    let tokens: Vec<&str> = cmd.splitn(2, ' ').collect();
+    let tok = tokens[0];
+    let rest = if tokens.len() > 1 { tokens[1] } else { "" };
+
+    match tok {
+        "spawn" => {
+            if !rest.is_empty() {
+                spawn_command_bg(rest);
+            }
+        }
+        "close" => {
+            // Will be handled by the WM layer - mark that close was requested
+            // for the focused window
+            if let Some(seat) = state.seats.first() {
+                if seat.focused_window_id.is_some() {
+                    // The actual river_window_v1_close() call happens in wm.rs
+                }
+            }
+        }
+        "focus-next" => {
+            // Will be handled by the WM layer
+        }
+        "exit" => {
+            // Signal exit request
+        }
+        "restart" => {
+            crate::restart::wm_restart();
+        }
+        "reload" => {
+            crate::restart::wm_reload(state);
+        }
+        "view" | _ if tok.starts_with("view") => {
+            let tag = parse_tag_from_command(tok, "view", rest);
+            if let Some(tag) = tag {
+                if tag >= 1 && tag <= NUM_TAGS as i32 {
+                    state.active_tags = 1 << (tag - 1);
+                }
+            }
+        }
+        "toggle" | _ if tok.starts_with("toggle") => {
+            let tag = parse_tag_from_command(tok, "toggle", rest);
+            if let Some(tag) = tag {
+                if tag >= 1 && tag <= NUM_TAGS as i32 {
+                    state.active_tags ^= 1 << (tag - 1);
+                }
+            }
+        }
+        "config-done" => {
+            state.config_done = true;
+        }
+        "layout" => {
+            handle_layout_command(rest, state);
+        }
+        "mode" => {
+            handle_mode_command(rest, state);
+        }
+        "set-mode" => {
+            handle_set_mode_command(rest, state);
+        }
+        "bind" => {
+            handle_bind_command(rest, state);
+        }
+        "pbind" => {
+            handle_pbind_command(rest, state);
+        }
+        "retile" => {
+            // Retile is handled by the WM layer - just set a flag
+            // The actual tile_windows() call happens in wm.rs
+        }
+        "tag-layout" => {
+            handle_tag_layout_command(rest, state);
+        }
+        "set-tag" => {
+            handle_set_tag_command(rest, state);
+        }
+        "repeat" => {
+            handle_repeat_command(rest, state);
+        }
+        _ => {
+            // Unknown command, ignore
+        }
+    }
+}
+
+/// Parse a tag number from a command like "view-1" or "view 1"
+fn parse_tag_from_command(tok: &str, prefix: &str, rest: &str) -> Option<i32> {
+    let after_prefix = &tok[prefix.len()..];
+    if after_prefix.starts_with('-') {
+        after_prefix[1..].parse::<i32>().ok()
+    } else if !rest.is_empty() {
+        rest.trim()
+            .split_whitespace()
+            .next()
+            .and_then(|s| s.parse::<i32>().ok())
+    } else {
+        None
+    }
+}
+
+/// Handle "layout <param> <value>" command
+fn handle_layout_command(rest: &str, state: &mut WindowManager) {
+    let parts: Vec<&str> = rest.split_whitespace().collect();
+    if parts.len() < 2 {
+        return;
+    }
+    let param = parts[0];
+    let value_str = parts[1];
+
+    match param {
+        "gap" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.gap = value;
+            }
+        }
+        "offset" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.offset = value;
+            }
+        }
+        "bar_height" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.bar_height = value;
+            }
+        }
+        "border_width" => {
+            if let Ok(value) = value_str.parse::<i32>() {
+                state.layout.border_width = value;
+            }
+        }
+        "border_color" => {
+            if let Some((r, g, b, a)) = parse_hex_color(value_str) {
+                state.layout.border_r = r;
+                state.layout.border_g = g;
+                state.layout.border_b = b;
+                state.layout.border_a = a;
+            }
+        }
+        _ => {}
+    }
+}
+
+/// Handle "mode <mode> <app_id_pattern> [--single] [--tag N] [title_pattern]" command
+fn handle_mode_command(rest: &str, state: &mut WindowManager) {
+    let mut parts: Vec<&str> = rest.split_whitespace().collect();
+    if parts.len() < 2 {
+        return;
+    }
+
+    let mode_str = parts[0];
+    let app_id_pattern = parts[1].to_string();
+
+    // Remove the first two tokens
+    parts.drain(0..2);
+
+    let mut title_pattern: Option<String> = None;
+    let mut single_instance = false;
+    let mut tag = 0i32;
+
+    let mut i = 0;
+    while i < parts.len() {
+        if parts[i] == "--single" {
+            single_instance = true;
+        } else if parts[i] == "--tag" {
+            if i + 1 < parts.len() {
+                tag = parts[i + 1].parse::<i32>().unwrap_or(0);
+                i += 1;
+            }
+        } else if title_pattern.is_none() {
+            title_pattern = Some(parts[i].to_string());
+        }
+        i += 1;
+    }
+
+    let mode = parse_tiling_mode(mode_str);
+    state.mode_rules.push(ModeRule {
+        mode,
+        app_id_pattern,
+        title_pattern,
+        single_instance,
+        tag,
+    });
+}
+
+/// Handle "set-mode <mode>" command — set the focused window's tiling mode
+fn handle_set_mode_command(rest: &str, state: &mut WindowManager) {
+    let mode_str = rest.trim();
+    if mode_str.is_empty() {
+        return;
+    }
+    let mode = parse_tiling_mode(mode_str);
+    if let Some(window) = state.focused_window_mut() {
+        window.tiling_mode = mode;
+        window.mode_locked = true;
+    }
+}
+
+/// Handle "bind <mods> <key> <action> [command]" command
+fn handle_bind_command(rest: &str, state: &mut WindowManager) {
+    // Format: bind <mods> <key> <action> [command...]
+    let parts: Vec<&str> = rest.splitn(3, ' ').collect();
+    if parts.len() < 3 {
+        return;
+    }
+    let mod_str = parts[0];
+    let key_str = parts[1];
+    let action_and_cmd = parts[2];
+
+    let mods = parse_modifiers(mod_str);
+    let keysym = parse_keysym(key_str);
+
+    // Split action from command
+    let (action_str, command) = if let Some(space_pos) = action_and_cmd.find(' ') {
+        let (a, c) = action_and_cmd.split_at(space_pos);
+        (a, Some(c.trim_start().to_string()))
+    } else {
+        (action_and_cmd, None)
+    };
+
+    let action = parse_action(action_str);
+    let command = if action == Action::Spawn { command } else { None };
+
+    state.pending_bindings.push(PendingXkbBinding {
+        mods,
+        keysym,
+        action,
+        command,
+    });
+}
+
+/// Handle "pbind <mods> <button> <action>" command
+fn handle_pbind_command(rest: &str, state: &mut WindowManager) {
+    let parts: Vec<&str> = rest.split_whitespace().collect();
+    if parts.len() < 3 {
+        return;
+    }
+    let mod_str = parts[0];
+    let button_str = parts[1];
+    let action_str = parts[2];
+
+    let mods = parse_modifiers(mod_str);
+    let button = parse_button(button_str);
+    let action = parse_action(action_str);
+
+    state.pending_pointer_bindings.push(PendingPointerBinding {
+        mods,
+        button,
+        action,
+    });
+}
+
+/// Handle "tag-layout <tag> <mode>" command
+fn handle_tag_layout_command(rest: &str, state: &mut WindowManager) {
+    let parts: Vec<&str> = rest.split_whitespace().collect();
+    if parts.len() < 2 {
+        return;
+    }
+    if let Ok(tag) = parts[0].parse::<i32>() {
+        if tag >= 1 && tag <= NUM_TAGS as i32 {
+            let mode = parse_tiling_mode(parts[1]);
+            state.tag_layouts[tag as usize - 1] = mode;
+            state.has_tag_layout[tag as usize - 1] = true;
+        }
+    }
+}
+
+/// Handle "set-tag <tag>" command — set the focused window's tag
+fn handle_set_tag_command(rest: &str, state: &mut WindowManager) {
+    let tag_str = rest.trim();
+    if let Ok(tag) = tag_str.parse::<i32>() {
+        if tag >= 1 && tag <= NUM_TAGS as i32 {
+            if let Some(window) = state.focused_window_mut() {
+                window.tags = 1 << (tag - 1);
+            }
+        }
+    }
+}
+
+/// Handle "repeat <rate> <delay>" command
+fn handle_repeat_command(_rest: &str, _state: &mut WindowManager) {
+    // The actual repeat rate change is applied to input devices via the
+    // Wayland protocol; this is a placeholder for the pure-logic layer.
+    // The WM integration layer will read the rate/delay and call
+    // river_input_device_v1_set_repeat_info()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::types::{TilingMode, WindowManager};
+
+    #[test]
+    fn test_ipc_view() {
+        let mut state = WindowManager::default();
+        assert_eq!(state.active_tags, 1);
+
+        handle_ipc_command("view-2", &mut state);
+        assert_eq!(state.active_tags, 2);
+
+        handle_ipc_command("view 3", &mut state);
+        assert_eq!(state.active_tags, 4);
+    }
+
+    #[test]
+    fn test_ipc_toggle() {
+        let mut state = WindowManager::default();
+        assert_eq!(state.active_tags, 1);
+
+        handle_ipc_command("toggle-2", &mut state);
+        assert_eq!(state.active_tags, 3); // tag 1 + tag 2
+
+        handle_ipc_command("toggle-1", &mut state);
+        assert_eq!(state.active_tags, 2); // tag 2 only
+    }
+
+    #[test]
+    fn test_ipc_config_done() {
+        let mut state = WindowManager::default();
+        assert!(!state.config_done);
+
+        handle_ipc_command("config-done", &mut state);
+        assert!(state.config_done);
+    }
+
+    #[test]
+    fn test_ipc_layout_gap() {
+        let mut state = WindowManager::default();
+        assert_eq!(state.layout.gap, 48);
+
+        handle_ipc_command("layout gap 18", &mut state);
+        assert_eq!(state.layout.gap, 18);
+    }
+
+    #[test]
+    fn test_ipc_layout_border_color() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("layout border_color #5c9060", &mut state);
+        assert_eq!(state.layout.border_r, 0x5C000000);
+        assert_eq!(state.layout.border_g, 0x90000000);
+        assert_eq!(state.layout.border_b, 0x60000000);
+    }
+
+    #[test]
+    fn test_ipc_mode_rule() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("mode cascade ghostty", &mut state);
+        assert_eq!(state.mode_rules.len(), 1);
+        assert_eq!(state.mode_rules[0].mode, TilingMode::Cascade);
+        assert_eq!(state.mode_rules[0].app_id_pattern, "ghostty");
+    }
+
+    #[test]
+    fn test_ipc_mode_rule_with_single() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("mode cascade qutebrowser --single", &mut state);
+        assert!(state.mode_rules[0].single_instance);
+    }
+
+    #[test]
+    fn test_ipc_mode_rule_with_tag() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("mode cascade ghostty --tag 2", &mut state);
+        assert_eq!(state.mode_rules[0].tag, 2);
+    }
+
+    #[test]
+    fn test_ipc_bind() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("bind alt Return spawn ghostty", &mut state);
+        assert_eq!(state.pending_bindings.len(), 1);
+        assert_eq!(state.pending_bindings[0].action, Action::Spawn);
+        assert_eq!(
+            state.pending_bindings[0].command,
+            Some("ghostty".to_string())
+        );
+    }
+
+    #[test]
+    fn test_ipc_pbind() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("pbind alt left move", &mut state);
+        assert_eq!(state.pending_pointer_bindings.len(), 1);
+        assert_eq!(state.pending_pointer_bindings[0].action, Action::Move);
+        assert_eq!(state.pending_pointer_bindings[0].button, 0x110); // BTN_LEFT
+    }
+
+    #[test]
+    fn test_ipc_tag_layout() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("tag-layout 2 grid", &mut state);
+        assert!(state.has_tag_layout[1]);
+        assert_eq!(state.tag_layouts[1], TilingMode::Grid);
+    }
+
+    #[test]
+    fn test_ipc_set_tag() {
+        let mut state = WindowManager::default();
+        // Add a window and a seat with focus
+        state.windows.push(crate::types::Window {
+            id: 1,
+            tags: 1,
+            ..Default::default()
+        });
+        state.seats.push(crate::types::Seat {
+            id: 1,
+            focused_window_id: Some(1),
+            ..Default::default()
+        });
+
+        handle_ipc_command("set-tag 3", &mut state);
+        assert_eq!(state.windows[0].tags, 4); // 1 << 2
+    }
+
+    #[test]
+    fn test_ipc_empty_command() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("", &mut state);
+        handle_ipc_command("  \n", &mut state);
+        // Should not crash
+    }
+
+    #[test]
+    fn test_ipc_layout_offset() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("layout offset 32", &mut state);
+        assert_eq!(state.layout.offset, 32);
+    }
+
+    #[test]
+    fn test_ipc_layout_bar_height() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("layout bar_height 28", &mut state);
+        assert_eq!(state.layout.bar_height, 28);
+    }
+
+    #[test]
+    fn test_ipc_layout_border_width() {
+        let mut state = WindowManager::default();
+        handle_ipc_command("layout border_width 18", &mut state);
+        assert_eq!(state.layout.border_width, 18);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..d69dfc7
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,13 @@
+// clearwm — Wayland window manager for river, written in Rust
+
+pub mod protocol;
+pub mod types;
+pub mod config;
+pub mod tiling;
+pub mod ipc;
+pub mod borders;
+pub mod status;
+pub mod restart;
+pub mod wm;
+#[allow(unreachable_patterns)] // wayland event match arms use _ => {} for forward-compat
+pub mod wayland;
diff --git a/src/main.rs b/src/main.rs
index ec7d568..d7cd851 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,192 @@
+// clearwm — Wayland window manager for river
+
+use clearwm::config::parse_config;
+use clearwm::ipc::handle_ipc_command;
+use clearwm::restart;
+use clearwm::status::update_status_files;
+use clearwm::wayland::wayland_init;
+use std::env;
+use std::fs;
+use std::io::Read;
+use std::os::unix::io::AsRawFd;
+use std::os::unix::net::{UnixListener, UnixStream};
+
+const SOCKET_PATH: &str = "/tmp/clearwm.sock";
+
 fn main() {
-    println!("clearwm starting...");
+    eprintln!("clearwm starting...");
+
+    // Set up SIGCHLD handler to reap child processes
+    let sa = nix::sys::signal::SigAction::new(
+        nix::sys::signal::SigHandler::Handler(sigchld_handler),
+        nix::sys::signal::SaFlags::SA_RESTART,
+        nix::sys::signal::SigSet::empty(),
+    );
+    unsafe {
+        nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGCHLD, &sa)
+            .expect("failed to set SIGCHLD handler");
+    }
+
+    // Create IPC socket (non-blocking for polling)
+    let ipc_listener = create_ipc_socket();
+    if let Some(ref listener) = ipc_listener {
+        listener.set_nonblocking(true).ok();
+    }
+
+    // Connect to Wayland display and get initial state.
+    let (conn, mut event_queue, mut state) = match wayland_init() {
+        Ok(c) => c,
+        Err(e) => {
+            eprintln!("fatal: {}", e);
+            std::process::exit(1);
+        }
+    };
+
+    // Dispatch any events buffered during init
+    let _ = event_queue.dispatch_pending(&mut state);
+
+    // Flush and trigger manage cycle
+    let _ = conn.flush();
+    if let Some(ref wm) = state.window_manager {
+        wm.manage_dirty();
+    }
+
+    // Check if this is a restart
+    let cold_start = if env::var("CLEARWM_RESTARTING").as_deref() == Ok("1") {
+        env::remove_var("CLEARWM_RESTARTING");
+        false
+    } else {
+        true
+    };
+
+    let mut need_config_load = true;
+    env::remove_var("WAYLAND_DEBUG");
+
+    // Main event loop.
+    // Uses blocking_dispatch() which is the Rust equivalent of the C version's
+    // wl_display_dispatch() — it blocks until events are available, then reads
+    // and dispatches them. This is the simplest and most reliable pattern.
+
+    eprintln!("[DEBUG] entering main loop");
+
+    loop {
+        // Flush outgoing Wayland requests
+        if let Err(e) = conn.flush() {
+            eprintln!("wayland flush error: {:?}", e);
+            break;
+        }
+
+        eprintln!("[DEBUG] calling blocking_dispatch...");
+        match event_queue.blocking_dispatch(&mut state) {
+            Ok(n) => {
+                eprintln!("[DEBUG] blocking_dispatch returned Ok({})", n);
+            }
+            Err(e) => {
+                eprintln!("wayland dispatch error: {:?}", e);
+                if !state.wm.exit_requested {
+                    restart::wm_restart();
+                }
+                break;
+            }
+        }
+        update_status_files(&state.wm);
+
+        // Handle IPC connections (non-blocking)
+        if let Some(ref listener) = ipc_listener {
+            while let Ok((stream, _)) = listener.accept() {
+                handle_ipc_connection(stream, &mut state.wm);
+            }
+            update_status_files(&state.wm);
+        }
+
+        // Deferred config loading — must happen AFTER we've handled at least
+        // one render_start/render_finish cycle. But we also need to keep
+        // handling render cycles DURING config loading, because River's
+        // 3-second timer expects continuous responsiveness.
+        if need_config_load {
+            eprintln!("[DEBUG] loading config...");
+            if let Ok(home) = env::var("HOME") {
+                let config_path = format!("{}/.config/clearwm/config.toml", home);
+                if fs::metadata(&config_path).is_ok() {
+                    parse_config(&config_path, cold_start, &mut state.wm);
+                }
+            }
+
+            // After config loading, we MUST flush and dispatch before
+            // continuing — River may have sent render_start while we
+            // were busy. Do a non-blocking flush+read+dispatch cycle.
+            let _ = conn.flush();
+            if let Some(guard) = event_queue.prepare_read() {
+                // Non-blocking read using libc::recv with MSG_DONTWAIT
+                let fd = guard.connection_fd().as_raw_fd();
+                let mut buf = [0u8; 4096];
+                let _ = unsafe {
+                    libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), libc::MSG_DONTWAIT)
+                };
+                let _ = guard.read();
+            }
+            let pending = event_queue.dispatch_pending(&mut state).unwrap_or(0);
+            if pending > 0 {
+                eprintln!("[DEBUG] dispatched {} events after config", pending);
+                update_status_files(&state.wm);
+            }
+
+            // Trigger manage cycle for bindings
+            if state.wm.config_done {
+                if let Some(ref wm) = state.window_manager {
+                    wm.manage_dirty();
+                }
+                let _ = conn.flush();
+            }
+
+            need_config_load = false;
+            eprintln!("[DEBUG] config loaded, continuing loop");
+        }
+
+        if state.exit_requested || state.wm.exit_requested {
+            break;
+        }
+    }
+    let _ = fs::remove_file(SOCKET_PATH);
+    if !state.wm.exit_requested {
+        restart::wm_restart();
+    }
+    eprintln!("main loop exited");
+}
+
+fn create_ipc_socket() -> Option<UnixListener> {
+    let _ = fs::remove_file(SOCKET_PATH);
+    match UnixListener::bind(SOCKET_PATH) {
+        Ok(listener) => {
+            use std::os::unix::fs::PermissionsExt;
+            let _ = fs::set_permissions(SOCKET_PATH, fs::Permissions::from_mode(0o600));
+            Some(listener)
+        }
+        Err(e) => {
+            eprintln!("failed to create IPC socket: {}", e);
+            let _ = fs::remove_file(SOCKET_PATH);
+            UnixListener::bind(SOCKET_PATH).ok()
+        }
+    }
+}
+
+fn handle_ipc_connection(mut stream: UnixStream, state: &mut clearwm::types::WindowManager) {
+    let mut buf = [0u8; 4096];
+    match stream.read(&mut buf) {
+        Ok(n) if n > 0 => {
+            let cmd = String::from_utf8_lossy(&buf[..n]);
+            handle_ipc_command(cmd.trim(), state);
+            state.needs_render = true;
+        }
+        _ => {}
+    }
+}
+
+extern "C" fn sigchld_handler(_sig: nix::libc::c_int) {
+    while nix::sys::wait::waitpid(
+        nix::unistd::Pid::from_raw(-1),
+        Some(nix::sys::wait::WaitPidFlag::WNOHANG),
+    )
+    .is_ok()
+    {}
 }
diff --git a/src/protocol.rs b/src/protocol.rs
new file mode 100644
index 0000000..b8573aa
--- /dev/null
+++ b/src/protocol.rs
@@ -0,0 +1,44 @@
+// River Wayland protocol bindings generated from XML
+
+macro_rules! river_protocol {
+    ($path:expr, [$($imports:path),*]) => {
+        #[allow(dead_code, non_camel_case_types, unused_unsafe, unused_variables)]
+        #[allow(non_upper_case_globals, non_snake_case, unused_imports, missing_docs, clippy::all)]
+        pub mod generated {
+            pub mod client {
+                use wayland_client;
+                use wayland_client::protocol::*;
+                $(use $imports::{client::*};)*
+
+                pub mod __interfaces {
+                    use wayland_client::protocol::__interfaces::*;
+                    $(use $imports::{client::__interfaces::*};)*
+                    wayland_scanner::generate_interfaces!($path);
+                }
+                use self::__interfaces::*;
+
+                wayland_scanner::generate_client_code!($path);
+            }
+        }
+        pub use self::generated::client;
+    };
+}
+
+pub mod river_window_management {
+    river_protocol!("protocol/river-window-management-v1.xml", []);
+}
+
+pub mod river_xkb_bindings {
+    river_protocol!("protocol/river-xkb-bindings-v1.xml",
+        [crate::protocol::river_window_management::generated]);
+}
+
+pub mod river_layer_shell {
+    river_protocol!("protocol/river-layer-shell-v1.xml",
+        [crate::protocol::river_window_management::generated]);
+}
+
+pub mod river_input_management {
+    river_protocol!("protocol/river-input-management-v1.xml",
+        [crate::protocol::river_window_management::generated]);
+}
diff --git a/src/restart.rs b/src/restart.rs
new file mode 100644
index 0000000..d2284ca
--- /dev/null
+++ b/src/restart.rs
@@ -0,0 +1,99 @@
+// Restart and reload logic for clearwm
+
+use crate::config::parse_config;
+use crate::types::WindowManager;
+
+/// Restart the window manager process.
+///
+/// This uses execl() to replace the current process with a fresh instance.
+/// The CLEARWM_RESTARTING environment variable signals that this is a restart
+/// (not a cold start), so the new process skips cold_start_only apps.
+///
+/// Ported from C wm_restart() with throttle logic.
+pub fn wm_restart() {
+    use std::time::Instant;
+
+    static mut LAST_RESTART: Option<Instant> = None;
+
+    // Throttle restarts (minimum 2 seconds between restarts)
+    // Safety: single-threaded access; acceptable for this use case
+    let now = Instant::now();
+    let should_throttle = unsafe {
+        if let Some(last) = LAST_RESTART {
+            now.duration_since(last).as_secs_f64() < 2.0
+        } else {
+            false
+        }
+    };
+
+    if should_throttle {
+        std::thread::sleep(std::time::Duration::from_secs(2));
+    }
+
+    unsafe {
+        LAST_RESTART = Some(now);
+    }
+
+    // Signal that this is a restart, not a cold start
+    std::env::set_var("CLEARWM_RESTARTING", "1");
+
+    // Remove the IPC socket
+    let _ = std::fs::remove_file("/tmp/clearwm.sock");
+
+    // Get the current executable path
+    if let Ok(exe_path) = std::env::current_exe() {
+        let path_str = exe_path.to_string_lossy().to_string();
+        // Use execl via libc to replace the current process
+        let ret = unsafe {
+            libc::execl(
+                path_str.as_ptr() as *const i8,
+                path_str.as_ptr() as *const i8,
+                std::ptr::null::<i8>(),
+            )
+        };
+        if ret < 0 {
+            eprintln!("wm_restart: execl failed");
+        }
+    }
+
+    // If execl failed, exit
+    std::process::exit(1);
+}
+
+/// Reload the configuration file.
+///
+/// This clears mode rules, pending bindings, and seat bindings, then
+/// re-parses the config file.
+///
+/// Ported from C wm_reload().
+pub fn wm_reload(state: &mut WindowManager) {
+    // Unlock mode on all windows so the new rules apply to them
+    for window in &mut state.windows {
+        window.mode_locked = false;
+    }
+
+    // Clear per-tag layout defaults
+    state.has_tag_layout = [false; crate::types::NUM_TAGS];
+
+    // Clear mode rules
+    state.mode_rules.clear();
+
+    // Clear pending bindings
+    state.pending_bindings.clear();
+    state.pending_pointer_bindings.clear();
+
+    // Try TOML config first
+    let home = std::env::var("HOME").unwrap_or_default();
+    if !home.is_empty() {
+        let config_path = format!("{}/.config/clearwm/config.toml", home);
+        if std::path::Path::new(&config_path).exists() {
+            parse_config(&config_path, false, state);
+        }
+    }
+}
+
+/// Spawn a command in the background (re-export from config for convenience)
+pub use crate::config::spawn_command_bg;
+
+/// Check if a process with the given name is already running (re-export from config)
+pub use crate::config::process_running;
diff --git a/src/status.rs b/src/status.rs
new file mode 100644
index 0000000..e00ff61
--- /dev/null
+++ b/src/status.rs
@@ -0,0 +1,83 @@
+// Status file writing and waybar signaling
+
+use crate::types::{TilingMode, WindowManager};
+use std::fs;
+use std::process::Command;
+
+pub const NUM_TAGS: u32 = 4;
+
+/// Write all status files and signal waybar
+pub fn update_status_files(state: &WindowManager) {
+    // /tmp/clearwm-tags: active_tags focused_tags num_tags
+    if let Ok(mut f) = fs::File::create("/tmp/clearwm-tags") {
+        use std::io::Write;
+        let _ = writeln!(f, "{} {} {}", state.active_tags, state.focused_tags, NUM_TAGS);
+    }
+
+    // /tmp/clearwm-layout: focused window's tiling mode
+    let mode_str = state
+        .focused_window()
+        .map(|w| tiling_mode_str(w.tiling_mode))
+        .unwrap_or("none");
+
+    if let Ok(mut f) = fs::File::create("/tmp/clearwm-layout") {
+        use std::io::Write;
+        let _ = writeln!(f, "{}", mode_str);
+    }
+
+    // /tmp/clearwm-windows: one line per window
+    if let Ok(mut f) = fs::File::create("/tmp/clearwm-windows") {
+        use std::io::Write;
+        let focused_title = state.focused_window().map(|w| w.title.clone());
+
+        for win in &state.windows {
+            let mode_str = tiling_mode_str(win.tiling_mode);
+            let decoration_str = match win.decoration_hint {
+                0 => "only_csd",
+                1 => "prefers_csd",
+                2 => "prefers_ssd",
+                3 => "no_preference",
+                _ => "unknown",
+            };
+            let presentation_str = match win.presentation_hint {
+                0 => "vsync",
+                1 => "async",
+                _ => "unknown",
+            };
+            let _ = writeln!(
+                f,
+                "window app_id={} title={} mode={} decoration={} presentation={} tags={} x={} y={} w={} h={}",
+                win.app_id.as_deref().unwrap_or("(null)"),
+                win.title.as_deref().unwrap_or("(null)"),
+                mode_str,
+                decoration_str,
+                presentation_str,
+                win.tags, win.x, win.y, win.width, win.height,
+            );
+        }
+
+        // /tmp/clearwm-title
+        if let Some(title) = focused_title {
+            if let Ok(mut tf) = fs::File::create("/tmp/clearwm-title") {
+                use std::io::Write;
+                let _ = writeln!(tf, "{}", title.as_deref().unwrap_or("(null)"));
+            }
+        }
+    }
+
+    // Signal waybar
+    let _ = Command::new("pkill").args(["-RTMIN+8", "waybar"]).output();
+    let _ = Command::new("pkill").args(["-RTMIN+9", "waybar"]).output();
+    let _ = Command::new("pkill").args(["-RTMIN+10", "waybar"]).output();
+}
+
+fn tiling_mode_str(mode: TilingMode) -> &'static str {
+    match mode {
+        TilingMode::Floating => "Floating",
+        TilingMode::Cascade => "Cascade",
+        TilingMode::Grid => "Grid",
+        TilingMode::Vsplit => "Vsplit",
+        TilingMode::Hsplit => "Hsplit",
+        TilingMode::Fullscreen => "Fullscreen",
+    }
+}
diff --git a/src/tiling.rs b/src/tiling.rs
new file mode 100644
index 0000000..88f7af8
--- /dev/null
+++ b/src/tiling.rs
@@ -0,0 +1,215 @@
+// Tiling formulas ported from clearwm.c
+
+/// Cascade depth factor: each depth step multiplies channels by this
+pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
+
+/// Full alpha in high-byte-first fixed-point
+pub const CASCADE_ALPHA: u32 = 0x000000FFu32;
+
+/// Cascade base green (focused window) in fixed-point format
+/// These come from the layout.border_r/g/b values, not hardcoded constants.
+/// The C code uses #5c9060 as the default cascade base but it's actually
+/// read from config. We just define the factor here.
+
+/// Tile a window in cascade mode.
+///
+/// Returns (x, y, width, height) for the window at the given cascade index.
+///
+/// The cascade formula from C:
+///   width  = screen_w - (gap + bw)*2 - offset*(n_cascade - 1)
+///   height = screen_h - (gap + bw)*2 - offset*(n_cascade - 1)
+///   x = gap + bw + idx * offset
+///   y = bar_height + gap + bw + idx * offset
+pub fn tile_cascade(
+    screen_w: i32,
+    screen_h: i32,
+    gap: i32,
+    bw: i32,
+    offset: i32,
+    bar_height: i32,
+    n_cascade: i32,
+    idx: i32,
+) -> (i32, i32, i32, i32) {
+    let width = screen_w - (gap + bw) * 2 - offset * (n_cascade - 1);
+    let height = screen_h - (gap + bw) * 2 - offset * (n_cascade - 1);
+    let width = if width < 1 { 1 } else { width };
+    let height = if height < 1 { 1 } else { height };
+    let x = gap + bw + idx * offset;
+    let y = bar_height + gap + bw + idx * offset;
+    (x, y, width, height)
+}
+
+/// Tile a window in grid mode.
+///
+/// Uses a 2-column grid layout.
+///
+/// The grid formula from C:
+///   cols = 2
+///   rows = (n_grid + cols - 1) / cols
+///   width  = (screen_w - (cols + 1) * gap) / cols - 2 * bw
+///   height = (screen_h - (rows + 1) * gap) / rows - 2 * bw
+///   x = gap + bw + col * (width + 2 * bw + gap)
+///   y = bar_height + gap + bw + row * (height + 2 * bw + gap)
+pub fn tile_grid(
+    screen_w: i32,
+    screen_h: i32,
+    gap: i32,
+    bw: i32,
+    bar_height: i32,
+    n_grid: i32,
+    idx: i32,
+) -> (i32, i32, i32, i32) {
+    let cols = 2i32;
+    let row = idx / cols;
+    let col = idx % cols;
+    let rows = (n_grid + cols - 1) / cols;
+    let width = (screen_w - (cols + 1) * gap) / cols - 2 * bw;
+    let height = (screen_h - (rows + 1) * gap) / rows - 2 * bw;
+    let width = if width < 1 { 1 } else { width };
+    let height = if height < 1 { 1 } else { height };
+    let x = gap + bw + col * (width + 2 * bw + gap);
+    let y = bar_height + gap + bw + row * (height + 2 * bw + gap);
+    (x, y, width, height)
+}
+
+/// Interpolate a fixed-point channel (0xRR000000) by factor^depth.
+///
+/// depth 0 returns the base color unchanged; each step darkens by factor.
+/// Returns the value in 32-bit fixed-point (channel in high byte).
+///
+/// Ported from C: interp_channel()
+pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
+    let base = (fp_channel >> 24) as u8;
+    let f = factor.powi(depth);
+    let val = ((base as f64) * f) as u8;
+    (val as u32) << 24
+}
+
+/// Compute a "#RRGGBB" hex color string for a given cascade depth.
+///
+/// Takes the base border color channels in fixed-point format and
+/// interpolates them toward black by CASCADE_DEPTH_FACTOR^depth.
+///
+/// Ported from C: cascade_hex_color()
+pub fn cascade_hex_color(r: u32, g: u32, b: u32, depth: i32) -> String {
+    let ri = interp_channel(r, CASCADE_DEPTH_FACTOR, depth);
+    let gi = interp_channel(g, CASCADE_DEPTH_FACTOR, depth);
+    let bi = interp_channel(b, CASCADE_DEPTH_FACTOR, depth);
+    // Extract the high byte for display
+    let rv = ri >> 24;
+    let gv = gi >> 24;
+    let bv = bi >> 24;
+    format!("#{:02x}{:02x}{:02x}", rv, gv, bv)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_tile_cascade_single() {
+        // Single cascade window should fill the screen minus gaps/borders
+        let (x, y, w, h) = tile_cascade(1920, 1080, 18, 18, 32, 28, 1, 0);
+        assert_eq!(x, 36); // gap + bw
+        assert_eq!(y, 64); // bar_height + gap + bw
+        // w = 1920 - (18+18)*2 - 32*0 = 1920 - 72 = 1848
+        assert_eq!(w, 1848);
+        // h = 1080 - (18+18)*2 - 32*0 = 1080 - 72 = 1008
+        assert_eq!(h, 1008);
+    }
+
+    #[test]
+    fn test_tile_cascade_multiple() {
+        // 3 cascade windows, idx=2 (the back one)
+        let (x, y, _w, _h) = tile_cascade(1920, 1080, 18, 18, 32, 28, 3, 2);
+        assert_eq!(x, 36 + 2 * 32); // gap + bw + idx * offset
+        assert_eq!(y, 64 + 2 * 32); // bar + gap + bw + idx * offset
+    }
+
+    #[test]
+    fn test_tile_cascade_minimum_size() {
+        // Very small screen, many windows — should clamp to 1
+        let (_, _, w, h) = tile_cascade(100, 100, 18, 18, 32, 28, 10, 5);
+        assert!(w >= 1);
+        assert!(h >= 1);
+    }
+
+    #[test]
+    fn test_tile_grid_two_windows() {
+        let (x0, y0, w0, h0) = tile_grid(1920, 1080, 18, 18, 28, 2, 0);
+        let (x1, y1, w1, h1) = tile_grid(1920, 1080, 18, 18, 28, 2, 1);
+
+        // Both windows should have same dimensions
+        assert_eq!(w0, w1);
+        assert_eq!(h0, h1);
+
+        // Window 1 is to the right of window 0
+        assert!(x1 > x0);
+        assert_eq!(y0, y1); // Same row
+
+        // Width = (1920 - 3*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
+        assert_eq!(w0, 897);
+        // Height = (1080 - 2*18) / 1 - 2*18 = 1044 - 36 = 1008
+        // Wait: rows = (2+2-1)/2 = 1, so height = (1080 - 2*18)/1 - 36 = 1044 - 36 = 1008
+        assert_eq!(h0, 1008);
+    }
+
+    #[test]
+    fn test_tile_grid_four_windows() {
+        // 4 windows in 2x2 grid
+        let (x0, y0, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 0);
+        let (x1, y1, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 1);
+        let (x2, y2, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 2);
+        let (x3, y3, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 3);
+
+        // Row 0: windows 0,1
+        assert!(y0 == y1);
+        assert!(x0 < x1);
+        // Row 1: windows 2,3
+        assert!(y2 == y3);
+        assert!(x2 < x3);
+        // Row 1 below row 0
+        assert!(y2 > y0);
+    }
+
+    #[test]
+    fn test_interp_channel_depth_zero() {
+        // depth 0 should return the base color unchanged
+        let result = interp_channel(0x5C000000, CASCADE_DEPTH_FACTOR, 0);
+        assert_eq!(result >> 24, 0x5C);
+    }
+
+    #[test]
+    fn test_interp_channel_depth_one() {
+        let result = interp_channel(0x90000000, CASCADE_DEPTH_FACTOR, 1);
+        // 0x90 * 0.80 = 0x90 * 0.80 = 144 * 0.80 = 115.2 → 115 = 0x73
+        assert_eq!(result >> 24, 0x73);
+    }
+
+    #[test]
+    fn test_interp_channel_depth_two() {
+        let result = interp_channel(0x60000000, CASCADE_DEPTH_FACTOR, 2);
+        // 0x60 * 0.80^2 = 96 * 0.64 = 61.44 → 61 = 0x3D
+        assert_eq!(result >> 24, 0x3D);
+    }
+
+    #[test]
+    fn test_cascade_hex_color_depth_zero() {
+        let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 0);
+        assert_eq!(color, "#5c9060");
+    }
+
+    #[test]
+    fn test_cascade_hex_color_depth_one() {
+        let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 1);
+        // 0x5C*0.80=0x49, 0x90*0.80=0x73, 0x60*0.80=0x4C
+        assert_eq!(color, "#49734c");
+    }
+
+    #[test]
+    fn test_cascade_hex_color_dark_gray() {
+        // Using #3e3e3e as base (normal border color)
+        let color = cascade_hex_color(0x3E000000, 0x3E000000, 0x3E000000, 0);
+        assert_eq!(color, "#3e3e3e");
+    }
+}
diff --git a/src/types.rs b/src/types.rs
new file mode 100644
index 0000000..f668380
--- /dev/null
+++ b/src/types.rs
@@ -0,0 +1,531 @@
+// Core data structures for clearwm
+
+use std::collections::HashMap;
+
+pub const NUM_TAGS: usize = 4;
+
+/// Tiling mode for a window
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TilingMode {
+    Floating,
+    Cascade,
+    Grid,
+    Vsplit,
+    Hsplit,
+    Fullscreen,
+}
+
+impl TilingMode {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            TilingMode::Floating => "Floating",
+            TilingMode::Cascade => "Cascade",
+            TilingMode::Grid => "Grid",
+            TilingMode::Vsplit => "Vsplit",
+            TilingMode::Hsplit => "Hsplit",
+            TilingMode::Fullscreen => "Fullscreen",
+        }
+    }
+}
+
+/// Actions that can be triggered by keybindings or IPC
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Action {
+    None,
+    Spawn,
+    Close,
+    FocusNext,
+    Move,
+    Resize,
+    Exit,
+    Fullscreen,
+    LayoutNext,
+    Reload,
+    Restart,
+    View1,
+    View2,
+    View3,
+    View4,
+    Toggle1,
+    Toggle2,
+    Toggle3,
+    Toggle4,
+    SetTag1,
+    SetTag2,
+    SetTag3,
+    SetTag4,
+}
+
+/// Layout parameters
+#[derive(Debug, Clone)]
+pub struct Layout {
+    pub gap: i32,
+    pub offset: i32,
+    pub bar_height: i32,
+    pub border_width: i32,
+    pub border_r: u32,
+    pub border_g: u32,
+    pub border_b: u32,
+    pub border_a: u32,
+}
+
+impl Default for Layout {
+    fn default() -> Self {
+        Layout {
+            gap: 48,
+            offset: 20,
+            bar_height: 24,
+            border_width: 6,
+            border_r: 0x3E000000u32,
+            border_g: 0x3E000000u32,
+            border_b: 0x3E000000u32,
+            border_a: 0x000000FFu32,
+        }
+    }
+}
+
+/// A rule that matches windows by app_id/title and assigns a tiling mode
+#[derive(Debug, Clone)]
+pub struct ModeRule {
+    pub mode: TilingMode,
+    pub app_id_pattern: String,
+    pub title_pattern: Option<String>,
+    pub single_instance: bool,
+    pub tag: i32,
+}
+
+/// A pending keyboard binding waiting to be applied to seats
+#[derive(Debug, Clone)]
+pub struct PendingXkbBinding {
+    pub mods: u32,
+    pub keysym: u32,
+    pub action: Action,
+    pub command: Option<String>,
+}
+
+/// A pending pointer binding waiting to be applied to seats
+#[derive(Debug, Clone)]
+pub struct PendingPointerBinding {
+    pub mods: u32,
+    pub button: u32,
+    pub action: Action,
+}
+
+/// User data attached to XKB/pointer binding proxies, carrying the action
+/// to execute when the binding is triggered.
+#[derive(Debug, Clone)]
+pub struct BindingUserData {
+    pub action: Action,
+    pub command: Option<String>,
+}
+
+/// An input device (keyboard or other)
+#[derive(Debug, Clone)]
+pub struct InputDevice {
+    pub is_keyboard: bool,
+}
+
+/// A managed output (monitor)
+#[derive(Debug, Clone)]
+pub struct Output {
+    pub id: u64,
+    pub removed: bool,
+    pub x: i32,
+    pub y: i32,
+    pub width: i32,
+    pub height: i32,
+    pub usable_x: i32,
+    pub usable_y: i32,
+    pub usable_width: i32,
+    pub usable_height: i32,
+}
+
+impl Default for Output {
+    fn default() -> Self {
+        Output {
+            id: 0,
+            removed: false,
+            x: 0,
+            y: 0,
+            width: 0,
+            height: 0,
+            usable_x: 0,
+            usable_y: 0,
+            usable_width: 0,
+            usable_height: 0,
+        }
+    }
+}
+
+/// A managed window
+#[derive(Debug, Clone)]
+pub struct Window {
+    pub id: u64,
+    pub is_new: bool,
+    pub closed: bool,
+    pub tags: u32,
+    pub x: i32,
+    pub y: i32,
+    pub width: i32,
+    pub height: i32,
+    pub app_id: Option<String>,
+    pub title: Option<String>,
+    pub identifier: Option<String>,
+    pub parent_id: Option<u64>,
+    pub decoration_hint: u32,
+    pub presentation_hint: u32,
+    pub tiling_mode: TilingMode,
+    pub mode_locked: bool,
+}
+
+impl Default for Window {
+    fn default() -> Self {
+        Window {
+            id: 0,
+            is_new: true,
+            closed: false,
+            tags: 1,
+            x: 0,
+            y: 0,
+            width: 0,
+            height: 0,
+            app_id: None,
+            title: None,
+            identifier: None,
+            parent_id: None,
+            decoration_hint: 3, // no_preference
+            presentation_hint: 0, // vsync
+            tiling_mode: TilingMode::Floating,
+            mode_locked: false,
+        }
+    }
+}
+
+/// A seat (input device group)
+#[derive(Debug, Clone)]
+pub struct Seat {
+    pub id: u64,
+    pub is_new: bool,
+    pub removed: bool,
+    pub focused_window_id: Option<u64>,
+    pub hovered_window_id: Option<u64>,
+    pub interacted_window_id: Option<u64>,
+}
+
+impl Default for Seat {
+    fn default() -> Self {
+        Seat {
+            id: 0,
+            is_new: true,
+            removed: false,
+            focused_window_id: None,
+            hovered_window_id: None,
+            interacted_window_id: None,
+        }
+    }
+}
+
+/// The main window manager state
+#[derive(Debug, Clone)]
+pub struct WindowManager {
+    pub outputs: Vec<Output>,
+    pub windows: Vec<Window>,
+    pub seats: Vec<Seat>,
+    pub pending_bindings: Vec<PendingXkbBinding>,
+    pub pending_pointer_bindings: Vec<PendingPointerBinding>,
+    pub mode_rules: Vec<ModeRule>,
+    pub layout: Layout,
+    pub active_tags: u32,
+    pub focused_tags: u32,
+    pub config_done: bool,
+    pub in_manage_sequence: bool,
+    pub needs_render: bool,
+    pub exit_requested: bool,
+    pub global_layout: TilingMode,
+    pub tag_layouts: [TilingMode; NUM_TAGS],
+    pub has_tag_layout: [bool; NUM_TAGS],
+    pub input_devices: Vec<InputDevice>,
+    pub last_bg_color: String,
+    pub env_vars: HashMap<String, String>,
+}
+
+impl Default for WindowManager {
+    fn default() -> Self {
+        WindowManager {
+            outputs: Vec::new(),
+            windows: Vec::new(),
+            seats: Vec::new(),
+            pending_bindings: Vec::new(),
+            pending_pointer_bindings: Vec::new(),
+            mode_rules: Vec::new(),
+            layout: Layout::default(),
+            active_tags: 1,
+            focused_tags: 0,
+            config_done: false,
+            in_manage_sequence: false,
+            needs_render: true, // render on first frame
+            exit_requested: false,
+            global_layout: TilingMode::Cascade,
+            tag_layouts: [TilingMode::Cascade; NUM_TAGS],
+            has_tag_layout: [false; NUM_TAGS],
+            input_devices: Vec::new(),
+            last_bg_color: String::new(),
+            env_vars: HashMap::new(),
+        }
+    }
+}
+
+impl WindowManager {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Find a window by its ID
+    pub fn get_window(&self, id: u64) -> Option<&Window> {
+        self.windows.iter().find(|w| w.id == id)
+    }
+
+    /// Find a window by its ID (mutable)
+    pub fn get_window_mut(&mut self, id: u64) -> Option<&mut Window> {
+        self.windows.iter_mut().find(|w| w.id == id)
+    }
+
+    /// Find the first seat that has a focused window
+    pub fn first_seat_with_focus(&self) -> Option<&Seat> {
+        self.seats.iter().find(|s| s.focused_window_id.is_some())
+    }
+
+    /// Get the focused window for the first seat that has one
+    pub fn focused_window(&self) -> Option<&Window> {
+        let seat = self.first_seat_with_focus()?;
+        let wid = seat.focused_window_id?;
+        self.get_window(wid)
+    }
+
+    /// Get the focused window (mutable)
+    pub fn focused_window_mut(&mut self) -> Option<&mut Window> {
+        let (wid, _) = {
+            let seat = self.first_seat_with_focus()?;
+            (seat.focused_window_id?, seat.id)
+        };
+        self.get_window_mut(wid)
+    }
+}
+
+/// Parse a hex color string like "#RRGGBB" or "#RRGGBBAA" into
+/// fixed-point 32-bit channel values (0xRR000000 format)
+pub fn parse_hex_color(s: &str) -> Option<(u32, u32, u32, u32)> {
+    let s = s.strip_prefix('#')?;
+    if s.len() != 6 && s.len() != 8 {
+        return None;
+    }
+    let r = u8::from_str_radix(&s[0..2], 16).ok()? as u32;
+    let g = u8::from_str_radix(&s[2..4], 16).ok()? as u32;
+    let b = u8::from_str_radix(&s[4..6], 16).ok()? as u32;
+    let a = if s.len() == 8 {
+        u8::from_str_radix(&s[6..8], 16).ok()? as u32
+    } else {
+        0xFFu32
+    };
+    // Convert to high-byte-first fixed-point: 0xRR000000
+    Some((r << 24, g << 24, b << 24, a << 24))
+}
+
+/// Parse a tiling mode string
+pub fn parse_tiling_mode(s: &str) -> TilingMode {
+    match s {
+        "cascade" => TilingMode::Cascade,
+        "grid" => TilingMode::Grid,
+        "vsplit" => TilingMode::Vsplit,
+        "hsplit" => TilingMode::Hsplit,
+        "fullscreen" => TilingMode::Fullscreen,
+        "floating" => TilingMode::Floating,
+        _ => TilingMode::Floating,
+    }
+}
+
+/// Parse an action string
+pub fn parse_action(s: &str) -> Action {
+    if s == "close" {
+        Action::Close
+    } else if s == "exit" {
+        Action::Exit
+    } else if s == "focus-next" {
+        Action::FocusNext
+    } else if s == "move" {
+        Action::Move
+    } else if s == "resize" {
+        Action::Resize
+    } else if s == "layout-next" {
+        Action::LayoutNext
+    } else if s == "reload" {
+        Action::Reload
+    } else if s == "restart" {
+        Action::Restart
+    } else if s == "fullscreen" {
+        Action::Fullscreen
+    } else if s.starts_with("spawn") && (s.len() == 5 || s.as_bytes()[5] == b' ' || s.as_bytes()[5] == b'-') {
+        Action::Spawn
+    } else if s.starts_with("view") {
+        let rest = &s[4..];
+        let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+        if let Ok(tag) = tag_str.parse::<i32>() {
+            if tag >= 1 && tag <= NUM_TAGS as i32 {
+                return match tag {
+                    1 => Action::View1,
+                    2 => Action::View2,
+                    3 => Action::View3,
+                    4 => Action::View4,
+                    _ => Action::None,
+                };
+            }
+        }
+        Action::None
+    } else if s.starts_with("toggle") {
+        let rest = &s[6..];
+        let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+        if let Ok(tag) = tag_str.parse::<i32>() {
+            if tag >= 1 && tag <= NUM_TAGS as i32 {
+                return match tag {
+                    1 => Action::Toggle1,
+                    2 => Action::Toggle2,
+                    3 => Action::Toggle3,
+                    4 => Action::Toggle4,
+                    _ => Action::None,
+                };
+            }
+        }
+        Action::None
+    } else if s.starts_with("set-tag") {
+        let rest = &s[7..];
+        let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+        if let Ok(tag) = tag_str.parse::<i32>() {
+            if tag >= 1 && tag <= NUM_TAGS as i32 {
+                return match tag {
+                    1 => Action::SetTag1,
+                    2 => Action::SetTag2,
+                    3 => Action::SetTag3,
+                    4 => Action::SetTag4,
+                    _ => Action::None,
+                };
+            }
+        }
+        Action::None
+    } else {
+        Action::None
+    }
+}
+
+/// Parse modifier string like "alt+shift" into a bitmask
+pub fn parse_modifiers(mod_str: &str) -> u32 {
+    let mut mods = 0u32;
+    if mod_str.contains("super") || mod_str.contains("mod4") {
+        mods |= 0x40; // RIVER_SEAT_V1_MODIFIERS_MOD4
+    }
+    if mod_str.contains("shift") {
+        mods |= 0x01; // RIVER_SEAT_V1_MODIFIERS_SHIFT
+    }
+    if mod_str.contains("ctrl") {
+        mods |= 0x04; // RIVER_SEAT_V1_MODIFIERS_CTRL
+    }
+    if mod_str.contains("alt") || mod_str.contains("mod1") {
+        mods |= 0x08; // RIVER_SEAT_V1_MODIFIERS_MOD1
+    }
+    mods
+}
+
+/// Parse a button string ("left", "right", "middle", or numeric)
+pub fn parse_button(s: &str) -> u32 {
+    match s {
+        "left" => 0x110,   // BTN_LEFT
+        "right" => 0x111,  // BTN_RIGHT
+        "middle" => 0x112, // BTN_MIDDLE
+        _ => s.parse().unwrap_or(0),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_hex_color_rgb() {
+        let (r, g, b, a) = parse_hex_color("#3e3e3e").unwrap();
+        assert_eq!(r, 0x3E000000);
+        assert_eq!(g, 0x3E000000);
+        assert_eq!(b, 0x3E000000);
+        assert_eq!(a, 0xFF000000);
+    }
+
+    #[test]
+    fn test_parse_hex_color_rgba() {
+        let (r, g, b, a) = parse_hex_color("#5c9060ff").unwrap();
+        assert_eq!(r, 0x5C000000);
+        assert_eq!(g, 0x90000000);
+        assert_eq!(b, 0x60000000);
+        assert_eq!(a, 0xFF000000);
+    }
+
+    #[test]
+    fn test_parse_hex_color_invalid() {
+        assert!(parse_hex_color("3e3e3e").is_none());
+        assert!(parse_hex_color("#3e3e").is_none());
+        assert!(parse_hex_color("#3e3e3e3e3e").is_none());
+    }
+
+    #[test]
+    fn test_parse_tiling_mode() {
+        assert_eq!(parse_tiling_mode("cascade"), TilingMode::Cascade);
+        assert_eq!(parse_tiling_mode("grid"), TilingMode::Grid);
+        assert_eq!(parse_tiling_mode("vsplit"), TilingMode::Vsplit);
+        assert_eq!(parse_tiling_mode("hsplit"), TilingMode::Hsplit);
+        assert_eq!(parse_tiling_mode("fullscreen"), TilingMode::Fullscreen);
+        assert_eq!(parse_tiling_mode("floating"), TilingMode::Floating);
+        assert_eq!(parse_tiling_mode("unknown"), TilingMode::Floating);
+    }
+
+    #[test]
+    fn test_parse_action() {
+        assert_eq!(parse_action("close"), Action::Close);
+        assert_eq!(parse_action("exit"), Action::Exit);
+        assert_eq!(parse_action("focus-next"), Action::FocusNext);
+        assert_eq!(parse_action("move"), Action::Move);
+        assert_eq!(parse_action("resize"), Action::Resize);
+        assert_eq!(parse_action("layout-next"), Action::LayoutNext);
+        assert_eq!(parse_action("reload"), Action::Reload);
+        assert_eq!(parse_action("restart"), Action::Restart);
+        assert_eq!(parse_action("spawn"), Action::Spawn);
+        assert_eq!(parse_action("spawn something"), Action::Spawn);
+        assert_eq!(parse_action("view-1"), Action::View1);
+        assert_eq!(parse_action("view-4"), Action::View4);
+        assert_eq!(parse_action("toggle-2"), Action::Toggle2);
+        assert_eq!(parse_action("set-tag-3"), Action::SetTag3);
+        assert_eq!(parse_action("unknown"), Action::None);
+    }
+
+    #[test]
+    fn test_parse_modifiers() {
+        assert_eq!(parse_modifiers("alt"), 0x08);
+        assert_eq!(parse_modifiers("alt+shift"), 0x08 | 0x01);
+        assert_eq!(parse_modifiers("ctrl"), 0x04);
+        assert_eq!(parse_modifiers("super"), 0x40);
+        assert_eq!(parse_modifiers(""), 0x00);
+    }
+
+    #[test]
+    fn test_parse_button() {
+        assert_eq!(parse_button("left"), 0x110);
+        assert_eq!(parse_button("right"), 0x111);
+        assert_eq!(parse_button("middle"), 0x112);
+        assert_eq!(parse_button("272"), 272);
+    }
+
+    #[test]
+    fn test_wm_default() {
+        let wm = WindowManager::default();
+        assert_eq!(wm.active_tags, 1);
+        assert_eq!(wm.global_layout, TilingMode::Cascade);
+        assert!(!wm.config_done);
+        assert_eq!(wm.layout.gap, 48);
+    }
+}
diff --git a/src/wayland.rs b/src/wayland.rs
new file mode 100644
index 0000000..b8e114c
--- /dev/null
+++ b/src/wayland.rs
@@ -0,0 +1,1018 @@
+// Wayland display connection, registry, and event dispatch for clearwm
+
+use wayland_client::{
+    event_created_child, protocol::wl_registry, Connection, Dispatch, EventQueue, Proxy, QueueHandle,
+};
+
+use crate::protocol::river_input_management::client::{
+    river_input_device_v1::{self, RiverInputDeviceV1},
+    river_input_manager_v1::{self, RiverInputManagerV1},
+};
+use crate::protocol::river_layer_shell::client::{
+    river_layer_shell_output_v1::{self, RiverLayerShellOutputV1},
+    river_layer_shell_v1::{self, RiverLayerShellV1},
+};
+use crate::protocol::river_window_management::client::{
+    river_node_v1::{self, RiverNodeV1},
+    river_output_v1::{self, RiverOutputV1},
+    river_pointer_binding_v1::{self, RiverPointerBindingV1},
+    river_seat_v1::{self, Modifiers, RiverSeatV1},
+    river_window_manager_v1::{self, RiverWindowManagerV1},
+    river_window_v1::{self, RiverWindowV1},
+};
+use crate::protocol::river_xkb_bindings::client::{
+    river_xkb_binding_v1::{self, RiverXkbBindingV1},
+    river_xkb_bindings_seat_v1::{self, RiverXkbBindingsSeatV1},
+    river_xkb_bindings_v1::{self, RiverXkbBindingsV1},
+};
+
+use crate::types::{BindingUserData, Output, Seat, TilingMode, Window, WindowManager};
+
+// Interface name constants (from river protocol XML)
+const IFACE_WINDOW_MANAGER: &str = "river_window_manager_v1";
+const IFACE_XKB_BINDINGS: &str = "river_xkb_bindings_v1";
+const IFACE_LAYER_SHELL: &str = "river_layer_shell_v1";
+const IFACE_INPUT_MANAGER: &str = "river_input_manager_v1";
+
+/// Wayland proxy objects stored alongside each Window, so we can
+/// call protocol methods (set_position, propose_dimensions, etc.) on it.
+pub struct WindowProxy {
+    pub river_window: RiverWindowV1,
+}
+
+/// Wayland proxy objects stored alongside each Seat.
+pub struct SeatProxy {
+    pub river_seat: RiverSeatV1,
+    pub xkb_bindings_seat: Option<RiverXkbBindingsSeatV1>,
+}
+
+/// Wayland proxy objects stored alongside each Output.
+pub struct OutputProxy {
+    pub river_output: RiverOutputV1,
+    pub layer_shell_output: Option<RiverLayerShellOutputV1>,
+}
+
+/// The full app state combining logic state + protocol proxy storage.
+pub struct AppState {
+    pub wm: WindowManager,
+
+    // Protocol objects (None until bound via registry)
+    pub window_manager: Option<RiverWindowManagerV1>,
+    pub xkb_bindings: Option<RiverXkbBindingsV1>,
+    pub layer_shell: Option<RiverLayerShellV1>,
+    pub input_manager: Option<RiverInputManagerV1>,
+
+    // Whether we got all required globals
+    pub has_window_manager: bool,
+    pub has_xkb_bindings: bool,
+
+    // Proxy objects indexed by window/seat/output ID
+    pub window_proxies: Vec<(u64, WindowProxy)>,
+    pub seat_proxies: Vec<(u64, SeatProxy)>,
+    pub output_proxies: Vec<(u64, OutputProxy)>,
+
+    // River node proxies for window positioning (created via get_node request)
+    pub window_nodes: Vec<(u64, RiverNodeV1)>,
+
+    // Next ID counter for new windows/outputs/seats
+    pub next_id: u64,
+
+    // Exit flag
+    pub exit_requested: bool,
+}
+
+impl AppState {
+    pub fn new() -> Self {
+        AppState {
+            wm: WindowManager::new(),
+            window_manager: None,
+            xkb_bindings: None,
+            layer_shell: None,
+            input_manager: None,
+            has_window_manager: false,
+            has_xkb_bindings: false,
+            window_proxies: Vec::new(),
+            seat_proxies: Vec::new(),
+            output_proxies: Vec::new(),
+            window_nodes: Vec::new(),
+            next_id: 1,
+            exit_requested: false,
+        }
+    }
+
+    fn alloc_id(&mut self) -> u64 {
+        let id = self.next_id;
+        self.next_id += 1;
+        id
+    }
+
+    pub fn get_window_proxy(&self, id: u64) -> Option<&WindowProxy> {
+        self.window_proxies
+            .iter()
+            .find(|(wid, _)| *wid == id)
+            .map(|(_, p)| p)
+    }
+
+    pub fn get_seat_proxy(&self, id: u64) -> Option<&SeatProxy> {
+        self.seat_proxies
+            .iter()
+            .find(|(sid, _)| *sid == id)
+            .map(|(_, p)| p)
+    }
+
+    pub fn get_output_proxy(&self, id: u64) -> Option<&OutputProxy> {
+        self.output_proxies
+            .iter()
+            .find(|(oid, _)| *oid == id)
+            .map(|(_, p)| p)
+    }
+
+    /// Find the internal window ID for a given RiverWindowV1 proxy.
+    fn window_id_for_proxy(&self, proxy: &RiverWindowV1) -> Option<u64> {
+        let pid = proxy.id().protocol_id();
+        self.window_proxies
+            .iter()
+            .find(|(_, wp)| wp.river_window.id().protocol_id() == pid)
+            .map(|(id, _)| *id)
+    }
+
+    /// Find the internal seat ID for a given RiverSeatV1 proxy.
+    fn seat_id_for_proxy(&self, proxy: &RiverSeatV1) -> Option<u64> {
+        let pid = proxy.id().protocol_id();
+        self.seat_proxies
+            .iter()
+            .find(|(_, sp)| sp.river_seat.id().protocol_id() == pid)
+            .map(|(id, _)| *id)
+    }
+}
+
+// --- Dispatch implementations ---
+
+/// User data for the registry
+pub struct RegistryData;
+
+impl Dispatch<wl_registry::WlRegistry, RegistryData> for AppState {
+    fn event(
+        state: &mut Self,
+        registry: &wl_registry::WlRegistry,
+        event: wl_registry::Event,
+        _data: &RegistryData,
+        _conn: &Connection,
+        qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            wl_registry::Event::Global {
+                name,
+                interface,
+                version,
+            } => {
+                if interface == IFACE_WINDOW_MANAGER {
+                    if version >= 4 {
+                        eprintln!("registry: binding {} v{}", IFACE_WINDOW_MANAGER, version);
+                        // Use the Proxy trait's bind method via the interface
+                        let wm: RiverWindowManagerV1 =
+                            registry.bind::<RiverWindowManagerV1, _, _>(name, 4, qhandle, ());
+                        state.window_manager = Some(wm);
+                        state.has_window_manager = true;
+                    } else {
+                        eprintln!(
+                            "warning: {} version {} < 4, skipping",
+                            IFACE_WINDOW_MANAGER, version
+                        );
+                    }
+                } else if interface == IFACE_XKB_BINDINGS {
+                    let bind_ver = std::cmp::min(version, 2);
+                    eprintln!("registry: binding {} v{}", IFACE_XKB_BINDINGS, bind_ver);
+                    let xb: RiverXkbBindingsV1 =
+                        registry.bind::<RiverXkbBindingsV1, _, _>(name, bind_ver, qhandle, ());
+                    state.xkb_bindings = Some(xb);
+                    state.has_xkb_bindings = true;
+                } else if interface == IFACE_LAYER_SHELL {
+                    eprintln!("registry: binding {}", IFACE_LAYER_SHELL);
+                    let ls: RiverLayerShellV1 =
+                        registry.bind::<RiverLayerShellV1, _, _>(name, 1, qhandle, ());
+                    state.layer_shell = Some(ls);
+                } else if interface == IFACE_INPUT_MANAGER {
+                    eprintln!("registry: binding {}", IFACE_INPUT_MANAGER);
+                    let im: RiverInputManagerV1 =
+                        registry.bind::<RiverInputManagerV1, _, _>(name, 1, qhandle, ());
+                    state.input_manager = Some(im);
+                }
+            }
+            wl_registry::Event::GlobalRemove { name: _ } => {}
+            _ => {}
+        }
+    }
+}
+
+// --- RiverWindowManagerV1 events ---
+
+impl Dispatch<RiverWindowManagerV1, ()> for AppState {
+    event_created_child!(AppState, RiverWindowManagerV1, [
+        river_window_manager_v1::EVT_WINDOW_OPCODE => (RiverWindowV1, ()),
+        river_window_manager_v1::EVT_OUTPUT_OPCODE => (RiverOutputV1, ()),
+        river_window_manager_v1::EVT_SEAT_OPCODE => (RiverSeatV1, ()),
+    ]);
+
+    fn event(
+        state: &mut Self,
+        wm_proxy: &RiverWindowManagerV1,
+        event: river_window_manager_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_window_manager_v1::Event::Unavailable => {
+                eprintln!("error: another window manager is already running");
+                state.exit_requested = true;
+                state.wm.exit_requested = true;
+            }
+
+            river_window_manager_v1::Event::Finished => {
+                if state.wm.exit_requested {
+                    eprintln!("river sent finished, exiting");
+                    state.exit_requested = true;
+                } else {
+                    eprintln!("river sent finished unexpectedly, restarting");
+                    crate::restart::wm_restart();
+                }
+            }
+
+            river_window_manager_v1::Event::ManageStart => {
+                eprintln!("manage sequence start");
+                state.wm.in_manage_sequence = true;
+                state.wm.focused_tags = 0;
+                state.wm.needs_render = true;
+
+                // Remove closed windows
+                state.wm.windows.retain(|w| !w.closed);
+                // Remove removed outputs
+                state.wm.outputs.retain(|o| !o.removed);
+                // Remove removed seats
+                state.wm.seats.retain(|s| !s.removed);
+
+                // Set default layer shell on first output
+                if let Some(first_output) = state.wm.outputs.first() {
+                    if let Some(op) = state.get_output_proxy(first_output.id) {
+                        if let Some(ref lso) = op.layer_shell_output {
+                            lso.set_default();
+                        }
+                    }
+                }
+
+                // Enforce single_instance mode rules
+                enforce_single_instance(&mut state.wm);
+
+                // Apply pending bindings to seats
+                apply_pending_bindings(state, qhandle);
+
+                // Manage seats (set focused tags)
+                for seat in &state.wm.seats {
+                    if let Some(focused_id) = seat.focused_window_id {
+                        if let Some(fw) = state.wm.get_window(focused_id) {
+                            state.wm.focused_tags |= fw.tags;
+                        }
+                    }
+                }
+
+                wm_proxy.manage_finish();
+                state.wm.in_manage_sequence = false;
+                crate::status::update_status_files(&state.wm);
+            }
+
+            river_window_manager_v1::Event::RenderStart => {
+                eprintln!("EVENT: RenderStart (needs_render={})", state.wm.needs_render);
+                if state.wm.needs_render {
+                    crate::wm::render_windows(state, qhandle);
+                    state.wm.needs_render = false;
+                }
+
+                wm_proxy.render_finish();
+                eprintln!("EVENT: RenderFinish sent");
+            }
+
+            // Window event: field is `id` (the new RiverWindowV1 proxy)
+            river_window_manager_v1::Event::Window { id: river_window } => {
+                let already_tracked = state.window_proxies.iter().any(|(_, wp)| {
+                    wp.river_window.id().protocol_id() == river_window.id().protocol_id()
+                });
+                if already_tracked {
+                    eprintln!("wm_handle_window: duplicate skipped");
+                    return;
+                }
+
+                let id = state.alloc_id();
+                let mut window = Window::default();
+                window.id = id;
+                window.is_new = true;
+                window.tags = state.wm.active_tags;
+
+                state.wm.windows.push(window);
+                state
+                    .window_proxies
+                    .push((id, WindowProxy { river_window }));
+                eprintln!("wm_handle_window: new window id={}", id);
+            }
+
+            // Output event: field is `id` (the new RiverOutputV1 proxy)
+            river_window_manager_v1::Event::Output { id: river_output } => {
+                let already_tracked = state.output_proxies.iter().any(|(_, op)| {
+                    op.river_output.id().protocol_id() == river_output.id().protocol_id()
+                });
+                if already_tracked {
+                    eprintln!("wm_handle_output: duplicate skipped");
+                    return;
+                }
+
+                let id = state.alloc_id();
+                let mut output = Output::default();
+                output.id = id;
+
+                let lso = if let Some(ref ls) = state.layer_shell {
+                    Some(ls.get_output(&river_output, qhandle, ()))
+                } else {
+                    None
+                };
+
+                state.wm.outputs.push(output);
+                state.output_proxies.push((
+                    id,
+                    OutputProxy {
+                        river_output,
+                        layer_shell_output: lso,
+                    },
+                ));
+                eprintln!("wm_handle_output: new output id={}", id);
+            }
+
+            // Seat event: field is `id` (the new RiverSeatV1 proxy)
+            river_window_manager_v1::Event::Seat { id: river_seat } => {
+                let already_tracked = state.seat_proxies.iter().any(|(_, sp)| {
+                    sp.river_seat.id().protocol_id() == river_seat.id().protocol_id()
+                });
+                if already_tracked {
+                    return;
+                }
+
+                let id = state.alloc_id();
+                let mut seat = Seat::default();
+                seat.id = id;
+                seat.is_new = true;
+
+                state.wm.seats.push(seat);
+                state.seat_proxies.push((
+                    id,
+                    SeatProxy {
+                        river_seat,
+                        xkb_bindings_seat: None,
+                    },
+                ));
+                eprintln!("wm_handle_seat: new seat id={}", id);
+            }
+
+            river_window_manager_v1::Event::SessionLocked => {}
+            river_window_manager_v1::Event::SessionUnlocked => {}
+
+            _ => {}
+        }
+    }
+}
+
+// --- RiverWindowV1 events ---
+
+impl Dispatch<RiverWindowV1, ()> for AppState {
+    fn event(
+        state: &mut Self,
+        proxy: &RiverWindowV1,
+        event: river_window_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        let wid = match state.window_id_for_proxy(proxy) {
+            Some(id) => id,
+            None => return,
+        };
+
+        match event {
+            river_window_v1::Event::Closed => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.closed = true;
+                    eprintln!("window id={} closed", wid);
+                }
+            }
+
+            river_window_v1::Event::Dimensions { width, height } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.width = width;
+                    window.height = height;
+                }
+            }
+
+            river_window_v1::Event::AppId { app_id } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    if window.app_id != app_id {
+                        window.app_id = app_id;
+                        state.wm.needs_render = true;
+                    }
+                }
+            }
+
+            river_window_v1::Event::Title { title } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    if window.title != title {
+                        window.title = title;
+                        state.wm.needs_render = true;
+                    }
+                }
+            }
+
+            river_window_v1::Event::DecorationHint { hint } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    // Store the raw u32 value from the WEnum
+                    window.decoration_hint = hint.into();
+                }
+            }
+
+            river_window_v1::Event::PresentationHint { hint } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.presentation_hint = hint.into();
+                }
+            }
+
+            river_window_v1::Event::Identifier { identifier } => {
+                if let Some(window) = state.wm.get_window_mut(wid) {
+                    window.identifier = Some(identifier);
+                }
+            }
+
+            river_window_v1::Event::PointerMoveRequested { .. } => {
+                // Will handle pointer ops later
+            }
+
+            river_window_v1::Event::PointerResizeRequested { .. } => {
+                // Will handle pointer ops later
+            }
+
+            _ => {}
+        }
+    }
+}
+
+// --- RiverSeatV1 events ---
+
+impl Dispatch<RiverSeatV1, ()> for AppState {
+    fn event(
+        state: &mut Self,
+        proxy: &RiverSeatV1,
+        event: river_seat_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        let sid = match state.seat_id_for_proxy(proxy) {
+            Some(id) => id,
+            None => return,
+        };
+
+        match event {
+            river_seat_v1::Event::Removed => {
+                if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+                    seat.removed = true;
+                }
+            }
+
+            river_seat_v1::Event::WindowInteraction { window: river_window } => {
+                if let Some(wid) = state.window_id_for_proxy(&river_window) {
+                    if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+                        seat.focused_window_id = Some(wid);
+                        state.wm.needs_render = true;
+                    }
+                }
+            }
+
+            river_seat_v1::Event::PointerEnter { window: river_window } => {
+                if let Some(wid) = state.window_id_for_proxy(&river_window) {
+                    if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+                        seat.hovered_window_id = Some(wid);
+                    }
+                }
+            }
+
+            river_seat_v1::Event::PointerLeave => {
+                if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+                    seat.hovered_window_id = None;
+                }
+            }
+
+            river_seat_v1::Event::OpDelta { .. } => {}
+            river_seat_v1::Event::OpRelease => {}
+
+            _ => {}
+        }
+    }
+}
+
+// --- RiverOutputV1 events ---
+
+impl Dispatch<RiverOutputV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &RiverOutputV1,
+        event: river_output_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_output_v1::Event::WlOutput { .. } => {}
+            _ => {}
+        }
+    }
+}
+
+// --- RiverLayerShellV1 (no events) ---
+
+impl Dispatch<RiverLayerShellV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &RiverLayerShellV1,
+        _event: river_layer_shell_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+// --- RiverLayerShellOutputV1 events ---
+
+impl Dispatch<RiverLayerShellOutputV1, ()> for AppState {
+    fn event(
+        state: &mut Self,
+        proxy: &RiverLayerShellOutputV1,
+        event: river_layer_shell_output_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        let oid = state
+            .output_proxies
+            .iter()
+            .find(|(_, op)| {
+                op.layer_shell_output.as_ref().map_or(false, |lso| {
+                    lso.id().protocol_id() == proxy.id().protocol_id()
+                })
+            })
+            .map(|(id, _)| *id);
+
+        let Some(oid) = oid else { return };
+
+        match event {
+            river_layer_shell_output_v1::Event::NonExclusiveArea {
+                x,
+                y,
+                width,
+                height,
+            } => {
+                if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.id == oid) {
+                    if output.usable_width != width || output.usable_height != height
+                        || output.usable_x != x || output.usable_y != y
+                    {
+                        output.usable_x = x;
+                        output.usable_y = y;
+                        output.usable_width = width;
+                        output.usable_height = height;
+                        state.wm.needs_render = true;
+                        eprintln!(
+                            "output id={} usable area: {}x{} at ({},{})",
+                            oid, width, height, x, y
+                        );
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+}
+
+// --- RiverInputManagerV1 events ---
+
+impl Dispatch<RiverInputManagerV1, ()> for AppState {
+    event_created_child!(AppState, RiverInputManagerV1, [
+        river_input_manager_v1::EVT_INPUT_DEVICE_OPCODE => (RiverInputDeviceV1, ()),
+    ]);
+
+    fn event(
+        state: &mut Self,
+        _proxy: &RiverInputManagerV1,
+        event: river_input_manager_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_input_manager_v1::Event::InputDevice { id: _input_device } => {
+                use crate::types::InputDevice;
+                let dev = InputDevice { is_keyboard: false };
+                state.wm.input_devices.push(dev);
+                eprintln!("input_device discovered");
+            }
+            river_input_manager_v1::Event::Finished => {}
+            _ => {}
+        }
+    }
+}
+
+// --- RiverInputDeviceV1 events ---
+
+impl Dispatch<RiverInputDeviceV1, ()> for AppState {
+    fn event(
+        state: &mut Self,
+        _proxy: &RiverInputDeviceV1,
+        event: river_input_device_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_input_device_v1::Event::Type { _type: dev_type } => {
+                if let Some(dev) = state.wm.input_devices.last_mut() {
+                    // dev_type is WEnum<Type>; check for Keyboard variant
+                    dev.is_keyboard = matches!(dev_type, wayland_client::WEnum::Value(river_input_device_v1::Type::Keyboard));
+                }
+            }
+            river_input_device_v1::Event::Name { name } => {
+                eprintln!("input_device name: {}", name);
+            }
+            river_input_device_v1::Event::Removed => {}
+            _ => {}
+        }
+    }
+}
+
+// --- RiverPointerBindingV1 events ---
+
+impl Dispatch<RiverPointerBindingV1, BindingUserData> for AppState {
+    fn event(
+        state: &mut Self,
+        _proxy: &RiverPointerBindingV1,
+        event: river_pointer_binding_v1::Event,
+        data: &BindingUserData,
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_pointer_binding_v1::Event::Pressed => {
+                execute_action(state, &data.action, data.command.as_deref());
+            }
+            _ => {}
+        }
+    }
+}
+
+// --- XKB bindings ---
+
+impl Dispatch<RiverXkbBindingsV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &RiverXkbBindingsV1,
+        _event: river_xkb_bindings_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+impl Dispatch<RiverXkbBindingV1, BindingUserData> for AppState {
+    fn event(
+        state: &mut Self,
+        _proxy: &RiverXkbBindingV1,
+        event: river_xkb_binding_v1::Event,
+        data: &BindingUserData,
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            river_xkb_binding_v1::Event::Pressed => {
+                execute_action(state, &data.action, data.command.as_deref());
+            }
+            river_xkb_binding_v1::Event::Released => {}
+            river_xkb_binding_v1::Event::StopRepeat => {}
+            _ => {}
+        }
+    }
+}
+
+impl Dispatch<RiverXkbBindingsSeatV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &RiverXkbBindingsSeatV1,
+        _event: river_xkb_bindings_seat_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+// --- RiverNodeV1 (no events, used for set_position) ---
+
+impl Dispatch<RiverNodeV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &RiverNodeV1,
+        _event: river_node_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        // river_node_v1 has no events
+    }
+}
+
+// --- Helper functions ---
+
+/// Execute an action triggered by a keybinding or pointer binding.
+fn execute_action(state: &mut AppState, action: &crate::types::Action, command: Option<&str>) {
+    use crate::types::Action;
+    match action {
+        Action::None => {}
+        Action::Spawn => {
+            if let Some(cmd) = command {
+                eprintln!("spawn: {}", cmd);
+                unsafe {
+                    match nix::unistd::fork() {
+                        Ok(nix::unistd::ForkResult::Child) => {
+                            nix::unistd::setsid().ok();
+                            let cmd_c = std::ffi::CString::new(cmd).unwrap();
+                            nix::unistd::execvp(
+                                &std::ffi::CString::new("/bin/sh").unwrap(),
+                                &[
+                                    std::ffi::CString::new("sh").unwrap(),
+                                    std::ffi::CString::new("-c").unwrap(),
+                                    cmd_c,
+                                ],
+                            )
+                            .ok();
+                            libc::_exit(127);
+                        }
+                        Ok(nix::unistd::ForkResult::Parent { .. }) => {}
+                        Err(_) => {}
+                    }
+                }
+            }
+        }
+        Action::Close => {
+            // Close the focused window
+            if let Some(seat) = state.wm.seats.first() {
+                if let Some(focused_id) = seat.focused_window_id {
+                    if let Some(wp) = state.get_window_proxy(focused_id) {
+                        wp.river_window.close();
+                    }
+                }
+            }
+        }
+        Action::FocusNext => {
+            // Focus the next visible window (wrapping)
+            if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+                let focused_id = seat.focused_window_id;
+                let visible_ids: Vec<u64> = state
+                    .wm
+                    .windows
+                    .iter()
+                    .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+                    .map(|w| w.id)
+                    .collect();
+                if let Some(fid) = focused_id {
+                    if let Some(idx) = visible_ids.iter().position(|id| *id == fid) {
+                        let next_idx = (idx + 1) % visible_ids.len();
+                        seat.focused_window_id = Some(visible_ids[next_idx]);
+                        state.wm.needs_render = true;
+                    }
+                }
+            }
+        }
+        Action::Move => {
+            // TODO: pointer move
+        }
+        Action::Resize => {
+            // TODO: pointer resize
+        }
+        Action::Exit => {
+            state.wm.exit_requested = true;
+            state.exit_requested = true;
+            if let Some(ref wm) = state.window_manager {
+                wm.exit_session();
+            }
+        }
+        Action::Fullscreen => {
+            if let Some(seat) = state.wm.seats.first() {
+                if let Some(focused_id) = seat.focused_window_id {
+                    if let Some(window) = state.wm.get_window_mut(focused_id) {
+                        if window.tiling_mode == TilingMode::Fullscreen {
+                            window.tiling_mode = TilingMode::Cascade; // TODO: get_mode_for_window
+                        } else {
+                            window.tiling_mode = TilingMode::Fullscreen;
+                        }
+                        window.mode_locked = true;
+                        state.wm.needs_render = true;
+                    }
+                }
+            }
+            if let Some(ref wm) = state.window_manager {
+                wm.manage_dirty();
+            }
+        }
+        Action::LayoutNext => {
+            let cycle = [TilingMode::Cascade, TilingMode::Grid, TilingMode::Vsplit, TilingMode::Hsplit];
+            let current = state.wm.global_layout;
+            let next = cycle
+                .iter()
+                .position(|m| *m == current)
+                .map(|i| cycle[(i + 1) % cycle.len()])
+                .unwrap_or(TilingMode::Cascade);
+            state.wm.global_layout = next;
+            eprintln!("layout-next: global layout is now {}", next.as_str());
+            state.wm.needs_render = true;
+            if let Some(ref wm) = state.window_manager {
+                wm.manage_dirty();
+            }
+        }
+        Action::Reload => {
+            // TODO: implement reload (re-run config)
+            eprintln!("reload: not yet implemented");
+        }
+        Action::Restart => {
+            crate::restart::wm_restart();
+        }
+        Action::View1 | Action::View2 | Action::View3 | Action::View4 => {
+            let tag = match action {
+                Action::View1 => 1,
+                Action::View2 => 2,
+                Action::View3 => 3,
+                Action::View4 => 4,
+                _ => return,
+            };
+            state.wm.active_tags = 1 << (tag - 1);
+            state.wm.needs_render = true;
+            if let Some(ref wm) = state.window_manager {
+                wm.manage_dirty();
+            }
+        }
+        Action::Toggle1 | Action::Toggle2 | Action::Toggle3 | Action::Toggle4 => {
+            let tag = match action {
+                Action::Toggle1 => 1,
+                Action::Toggle2 => 2,
+                Action::Toggle3 => 3,
+                Action::Toggle4 => 4,
+                _ => return,
+            };
+            state.wm.active_tags ^= 1 << (tag - 1);
+            state.wm.needs_render = true;
+            if let Some(ref wm) = state.window_manager {
+                wm.manage_dirty();
+            }
+        }
+        Action::SetTag1 | Action::SetTag2 | Action::SetTag3 | Action::SetTag4 => {
+            let tag = match action {
+                Action::SetTag1 => 1,
+                Action::SetTag2 => 2,
+                Action::SetTag3 => 3,
+                Action::SetTag4 => 4,
+                _ => return,
+            };
+            if let Some(seat) = state.wm.seats.first() {
+                if let Some(focused_id) = seat.focused_window_id {
+                    if let Some(window) = state.wm.get_window_mut(focused_id) {
+                        window.tags = 1 << (tag - 1);
+                        state.wm.needs_render = true;
+                    }
+                }
+            }
+            if let Some(ref wm) = state.window_manager {
+                wm.manage_dirty();
+            }
+        }
+    }
+}
+
+fn enforce_single_instance(wm: &mut WindowManager) {
+    for rule in &wm.mode_rules {
+        if !rule.single_instance {
+            continue;
+        }
+        let mut first_matched = false;
+        for window in &mut wm.windows {
+            if window.closed {
+                continue;
+            }
+            let match_app = rule.app_id_pattern == "*"
+                || window
+                    .app_id
+                    .as_deref()
+                    .map_or(false, |aid| aid.contains(&rule.app_id_pattern));
+            let match_title = rule.title_pattern.as_deref() == Some("*")
+                || rule.title_pattern.is_none()
+                || window.title.as_deref().map_or(false, |t| {
+                    t.contains(rule.title_pattern.as_deref().unwrap_or(""))
+                });
+            if match_app && match_title {
+                if first_matched {
+                    window.tiling_mode = TilingMode::Floating;
+                    window.mode_locked = true;
+                }
+                first_matched = true;
+            }
+        }
+    }
+}
+
+/// Convert our u32 modifier bitmask to the generated Modifiers bitflags.
+fn u32_to_modifiers(mods: u32) -> Modifiers {
+    Modifiers::from_bits_truncate(mods)
+}
+
+fn apply_pending_bindings(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+    // Create xkb binding seats for any seats that don't have one yet
+    if state.xkb_bindings.is_some() {
+        for (_sid, sp) in &mut state.seat_proxies {
+            if sp.xkb_bindings_seat.is_none() {
+                if let Some(ref xb) = state.xkb_bindings {
+                    sp.xkb_bindings_seat = Some(xb.get_seat(&sp.river_seat, qhandle, ()));
+                }
+            }
+        }
+    }
+
+    // Apply xkb bindings to each seat
+    let bindings: Vec<_> = state.wm.pending_bindings.drain(..).collect();
+    for pb in &bindings {
+        for (_sid, sp) in &state.seat_proxies {
+            if let Some(ref xb) = state.xkb_bindings {
+                if let Some(ref _xbs) = sp.xkb_bindings_seat {
+                    let modifiers = u32_to_modifiers(pb.mods);
+                    let binding_data = BindingUserData {
+                        action: pb.action.clone(),
+                        command: pb.command.clone(),
+                    };
+                    let binding =
+                        xb.get_xkb_binding(&sp.river_seat, pb.keysym, modifiers, qhandle, binding_data);
+                    binding.enable();
+                }
+            }
+        }
+    }
+
+    // Apply pointer bindings to each seat
+    let ptr_bindings: Vec<_> = state.wm.pending_pointer_bindings.drain(..).collect();
+    for ppb in &ptr_bindings {
+        for (_sid, sp) in &state.seat_proxies {
+            let modifiers = u32_to_modifiers(ppb.mods);
+            let binding_data = BindingUserData {
+                action: ppb.action.clone(),
+                command: None,
+            };
+            let _pb = sp
+                .river_seat
+                .get_pointer_binding(ppb.button, modifiers, qhandle, binding_data);
+        }
+    }
+}
+
+/// Connect to the Wayland display and set up the event queue.
+/// Returns the Connection, EventQueue, and AppState after the initial registry roundtrip.
+pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), String> {
+    let conn = Connection::connect_to_env()
+        .map_err(|e| format!("failed to connect to Wayland: {:?}", e))?;
+
+    let mut event_queue = conn.new_event_queue::<AppState>();
+    let qh = event_queue.handle();
+
+    let _registry = conn.display().get_registry(&qh, RegistryData);
+
+    // Do initial roundtrip to receive global events and bind protocols
+    let mut state = AppState::new();
+    event_queue
+        .roundtrip(&mut state)
+        .map_err(|e| format!("initial roundtrip failed: {:?}", e))?;
+
+    // Check we got the required protocols
+    if !state.has_window_manager {
+        return Err("river_window_manager_v1 not available".to_string());
+    }
+    if !state.has_xkb_bindings {
+        return Err("river_xkb_bindings_v1 not available".to_string());
+    }
+
+    // Second roundtrip for input device events
+    event_queue
+        .roundtrip(&mut state)
+        .map_err(|e| format!("second roundtrip failed: {:?}", e))?;
+
+    eprintln!("clearwm: Wayland connection established");
+
+    Ok((conn, event_queue, state))
+}
diff --git a/src/wm.rs b/src/wm.rs
new file mode 100644
index 0000000..436437b
--- /dev/null
+++ b/src/wm.rs
@@ -0,0 +1,266 @@
+// WM rendering logic — tiling, borders, and positioning
+//
+// Called from wayland.rs render_start handler. This module performs
+// the same work as the C version's wm_handle_render_start():
+//   1. Tile visible windows (propose dimensions + set position)
+//   2. Set border colors (cascade depth gradient or normal gray)
+//   3. Update desktop background via swaybg
+
+use crate::borders::compute_border_colors;
+use crate::protocol::river_window_management::client::river_node_v1::RiverNodeV1;
+use crate::protocol::river_window_management::client::river_window_v1::Edges;
+use crate::tiling;
+use crate::types::{TilingMode, WindowManager};
+use crate::wayland::AppState;
+use wayland_client::QueueHandle;
+
+/// A tiling result for a single window
+struct TileResult {
+    wid: u64,
+    x: i32,
+    y: i32,
+    w: i32,
+    h: i32,
+}
+
+/// Perform a full render cycle: tile windows and set borders.
+///
+/// This is called from the `render_start` handler only when `needs_render` is true.
+/// After this, the caller always calls `render_finish()`.
+pub fn render_windows(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+    let screen_dims = get_screen_dimensions(&state.wm);
+    let (screen_w, screen_h) = screen_dims;
+
+    // Ensure each window that needs positioning has a river_node_v1
+    ensure_window_nodes(state, qhandle);
+
+    // Compute tiling (read-only pass over windows)
+    let tile_results = compute_tiling(&state.wm, screen_w, screen_h);
+
+    // Apply tiling results (mutations)
+    apply_tiling(state, &tile_results);
+
+    // Set border colors
+    set_borders(state);
+}
+
+/// Get screen dimensions from the first output, with fallbacks.
+fn get_screen_dimensions(wm: &WindowManager) -> (i32, i32) {
+    let output = match wm.outputs.first() {
+        Some(o) if !o.removed => o,
+        _ => return (800, 600),
+    };
+
+    let mut w = output.usable_width;
+    let mut h = output.usable_height;
+
+    // Fall back to raw output dimensions if usable area not yet set
+    if w <= 0 && output.width > 0 {
+        w = output.width;
+    }
+    if h <= 0 && output.height > 0 {
+        h = output.height;
+    }
+
+    if w <= 0 {
+        w = 800;
+    }
+    if h <= 0 {
+        h = 600;
+    }
+
+    (w, h)
+}
+
+/// Ensure each window has a river_node_v1 proxy for positioning.
+/// The node is created via river_window_v1.get_node() — can only be called once.
+fn ensure_window_nodes(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+    let windows_needing_nodes: Vec<u64> = state
+        .wm
+        .windows
+        .iter()
+        .filter(|w| !w.closed)
+        .filter(|w| !state.window_nodes.iter().any(|(id, _)| *id == w.id))
+        .map(|w| w.id)
+        .collect();
+
+    if windows_needing_nodes.is_empty() {
+        return;
+    }
+
+    for wid in windows_needing_nodes {
+        if let Some(wp) = state.get_window_proxy(wid) {
+            let node: RiverNodeV1 = wp.river_window.get_node(qhandle, ());
+            state.window_nodes.push((wid, node));
+        }
+    }
+}
+
+/// Compute tiling for all visible windows (read-only, returns results).
+fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileResult> {
+    let gap = wm.layout.gap;
+    let bw = wm.layout.border_width;
+    let offset = wm.layout.offset;
+    let bar_height = wm.layout.bar_height;
+
+    // Count windows per tiling mode
+    let mut n_cascade = 0i32;
+    let mut n_grid = 0i32;
+    for win in &wm.windows {
+        if (win.tags & wm.active_tags) == 0 || win.closed {
+            continue;
+        }
+        match win.tiling_mode {
+            TilingMode::Cascade => n_cascade += 1,
+            TilingMode::Grid => n_grid += 1,
+            _ => {}
+        }
+    }
+
+    // Check for fullscreen window
+    let fullscreen_id = wm
+        .windows
+        .iter()
+        .find(|w| {
+            (w.tags & wm.active_tags) != 0
+                && !w.closed
+                && w.tiling_mode == TilingMode::Fullscreen
+        })
+        .map(|w| w.id);
+
+    // Compute tiling
+    let mut results = Vec::new();
+    let mut idx_cascade = 0i32;
+    let mut idx_grid = 0i32;
+
+    for win in &wm.windows {
+        if (win.tags & wm.active_tags) == 0 || win.closed {
+            continue;
+        }
+
+        let wid = win.id;
+        let mode = win.tiling_mode;
+
+        let (x, y, w, h) = match mode {
+            TilingMode::Fullscreen => {
+                if fullscreen_id == Some(wid) {
+                    (0, 0, screen_w, screen_h)
+                } else {
+                    continue;
+                }
+            }
+            TilingMode::Cascade => {
+                let (x, y, w, h) = tiling::tile_cascade(
+                    screen_w,
+                    screen_h,
+                    gap,
+                    bw,
+                    offset,
+                    bar_height,
+                    n_cascade,
+                    idx_cascade,
+                );
+                idx_cascade += 1;
+                (x, y, w, h)
+            }
+            TilingMode::Grid => {
+                let (x, y, w, h) =
+                    tiling::tile_grid(screen_w, screen_h, gap, bw, bar_height, n_grid, idx_grid);
+                idx_grid += 1;
+                (x, y, w, h)
+            }
+            TilingMode::Vsplit | TilingMode::Hsplit => {
+                // TODO: implement vsplit/hsplit
+                continue;
+            }
+            TilingMode::Floating => {
+                continue;
+            }
+        };
+
+        results.push(TileResult { wid, x, y, w, h });
+    }
+
+    results
+}
+
+/// Apply computed tiling results: set position and propose dimensions.
+fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
+    for tr in results {
+        // Set position via river_node_v1
+        if let Some(node) = state
+            .window_nodes
+            .iter()
+            .find(|(id, _)| *id == tr.wid)
+            .map(|(_, n)| n)
+        {
+            node.set_position(tr.x, tr.y);
+        }
+
+        // Propose dimensions via river_window_v1
+        if let Some(wp) = state.get_window_proxy(tr.wid) {
+            wp.river_window.propose_dimensions(tr.w, tr.h);
+        }
+
+        // Update internal state
+        if let Some(win) = state.wm.get_window_mut(tr.wid) {
+            win.x = tr.x;
+            win.y = tr.y;
+            win.width = tr.w;
+            win.height = tr.h;
+        }
+    }
+}
+
+/// Set border colors on all visible windows.
+fn set_borders(state: &mut AppState) {
+    let (border_colors, bg_color) = compute_border_colors(&state.wm);
+
+    for bc in &border_colors {
+        let wid = match state.wm.windows.get(bc.window_idx) {
+            Some(w) => w.id,
+            None => continue,
+        };
+
+        if let Some(wp) = state.get_window_proxy(wid) {
+            let edges = Edges::from_bits_truncate(bc.edges);
+            wp.river_window
+                .set_borders(edges, bc.width, bc.r, bc.g, bc.b, bc.a);
+        }
+    }
+
+    // Update desktop background if cascade color changed
+    if let Some(ref color) = bg_color {
+        if *color != state.wm.last_bg_color {
+            state.wm.last_bg_color = color.clone();
+            spawn_swaybg(color);
+        }
+    }
+}
+
+/// Spawn swaybg with the given color (fire-and-forget).
+fn spawn_swaybg(color: &str) {
+    let cmd = format!("pkill -f swaybg 2>/dev/null; swaybg -c '{}'", color);
+    unsafe {
+        match nix::unistd::fork() {
+            Ok(nix::unistd::ForkResult::Child) => {
+                nix::unistd::close(nix::libc::STDIN_FILENO).ok();
+                nix::unistd::close(nix::libc::STDOUT_FILENO).ok();
+                nix::unistd::close(nix::libc::STDERR_FILENO).ok();
+                nix::unistd::execvp(
+                    &std::ffi::CString::new("/bin/sh").unwrap(),
+                    &[
+                        std::ffi::CString::new("sh").unwrap(),
+                        std::ffi::CString::new("-c").unwrap(),
+                        std::ffi::CString::new(cmd).unwrap(),
+                    ],
+                )
+                .ok();
+                // If exec fails, exit child
+                libc::_exit(127);
+            }
+            Ok(nix::unistd::ForkResult::Parent { .. }) => {}
+            Err(_) => {}
+        }
+    }
+}
diff --git a/start-river.sh b/start-river.sh
new file mode 100755
index 0000000..a2cf546
--- /dev/null
+++ b/start-river.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# Launch river with clearwm-rs on this TTY
+# Usage: Switch to a free TTY, log in, and run this script
+
+export XDG_RUNTIME_DIR=/run/user/$(id -u)
+export WAYLAND_DISPLAY=wayland-1
+
+echo "Starting river with clearwm-rs..."
+echo "Log will be at /tmp/river-clearwm.log"
+
+exec river -c /tmp/clearwm-rs-launch.sh 2>/tmp/river-clearwm.log