Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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