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

commit3f3d05c1c9de5487bd2fdd8d6eb5009412b6c7d1
parenta9cd6f0436
authorLucas Galante <[email protected]>
date2026-05-23 12:20
feat: add system notifications support and notify IPC command

 Cargo.toml      |   2 +-
 src/clearctl.rs |   1 +
 src/config.rs   |  56 ++++++++++--
 src/ipc.rs      |  78 +++++++++++++++++
 src/main.rs     |   4 +-
 src/restart.rs  |  13 ++-
 src/tiling.rs   |  57 +++++-------
 src/types.rs    |   5 ++
 src/wayland.rs  | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/wm.rs       |  67 ++++++++------
 start-river.sh  |   3 +
 11 files changed, 476 insertions(+), 78 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 971ab15..b453b40 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ path = "src/clearctl.rs"
 [dependencies]
 wayland-client = "0.31"
 wayland-backend = "0.3"
-wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
+wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
 wayland-scanner = "0.31"
 xkbcommon = "0.7"
 toml = "0.8"
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 3af57e1..eb58a30 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -24,6 +24,7 @@ fn usage(name: &str) {
     eprintln!("  repeat <rate> <delay>");
     eprintln!("  config-done");
     eprintln!("  spawn <command>");
+    eprintln!("  notify <title> [body]");
     eprintln!("  bind <mods> <keysym> <action> [args...]");
     eprintln!("  pbind <mods> <button> <action>");
     eprintln!("  retile");
diff --git a/src/config.rs b/src/config.rs
index 77f8494..a060828 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -31,6 +31,8 @@ pub struct Config {
     pub mode_rule: Vec<ModeRuleConfig>,
     #[serde(default)]
     pub tag_layout: Vec<TagLayoutConfig>,
+    #[serde(default)]
+    pub notifications: NotificationsConfig,
 }
 
 #[derive(Debug, Deserialize)]
@@ -161,6 +163,22 @@ pub struct InputConfig {
     pub tap_to_click: bool,
 }
 
+#[derive(Debug, Deserialize)]
+pub struct NotificationsConfig {
+    #[serde(default = "default_true")]
+    pub enable: bool,
+}
+
+impl Default for NotificationsConfig {
+    fn default() -> Self {
+        Self { enable: true }
+    }
+}
+
+fn default_true() -> bool {
+    true
+}
+
 #[derive(Debug, Deserialize)]
 pub struct StartupEntryConfig {
     pub exec: String,
@@ -200,20 +218,22 @@ pub struct TagLayoutConfig {
 
 /// Parse the TOML config file and apply it to the WindowManager state.
 /// `cold_start` controls whether `once = true` startup entries are spawned.
-pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
+pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) -> Result<(), String> {
     let content = match fs::read_to_string(path) {
         Ok(c) => c,
         Err(e) => {
-            eprintln!("parse_config: cannot open {}: {}", path, e);
-            return;
+            let err_msg = format!("cannot open {}: {}", path, e);
+            eprintln!("parse_config: {}", err_msg);
+            return Err(err_msg);
         }
     };
 
     let config: Config = match toml::from_str(&content) {
         Ok(c) => c,
         Err(e) => {
-            eprintln!("parse_config: TOML parse error: {}", e);
-            return;
+            let err_msg = format!("TOML parse error: {}", e);
+            eprintln!("parse_config: {}", err_msg);
+            return Err(err_msg);
         }
     };
 
@@ -357,8 +377,12 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
     // (in the RiverLibinputDeviceV1 TapSupport event handler).
     state.tap_to_click = config.input.tap_to_click;
 
+    // [notifications]
+    state.notifications_enable = config.notifications.enable;
+
     // Signal config-done
     state.config_done = true;
+    Ok(())
 }
 
 /// Expand $VAR and ${VAR} references in a string using the current environment.
@@ -470,6 +494,14 @@ pub fn process_running(name: &str) -> bool {
     }
 }
 
+/// Show a system desktop notification via notify-send.
+pub fn show_notification(title: &str, body: &str) {
+    let title_escaped = title.replace('\'', "'\\''");
+    let body_escaped = body.replace('\'', "'\\''");
+    let cmd = format!("notify-send -a clearwm '{}' '{}'", title_escaped, body_escaped);
+    spawn_command_bg(&cmd);
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -550,6 +582,20 @@ mod tests {
         assert!(expanded.starts_with(&format!("{}/.local/bin:", home)));
         assert!(expanded.contains(&orig_path));
     }
+
+    #[test]
+    fn test_notifications_config_parse() {
+        let toml_str = r#"
+[notifications]
+enable = false
+"#;
+        let config: Config = toml::from_str(toml_str).unwrap();
+        assert!(!config.notifications.enable);
+
+        let toml_str_empty = "";
+        let config_empty: Config = toml::from_str(toml_str_empty).unwrap();
+        assert!(config_empty.notifications.enable);
+    }
 }
 
 #[cfg(test)]
diff --git a/src/ipc.rs b/src/ipc.rs
index fdd370c..76f2771 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -161,6 +161,18 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
         "input" => {
             handle_input_command(rest, state);
         }
+        "notify" => {
+            let parts: Vec<&str> = rest.splitn(2, ' ').collect();
+            if parts.len() == 2 {
+                if state.notifications_enable {
+                    crate::config::show_notification(parts[0], parts[1]);
+                }
+            } else if parts.len() == 1 && !parts[0].is_empty() {
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", parts[0]);
+                }
+            }
+        }
         _ => {
             // Unknown command, ignore
         }
@@ -195,71 +207,113 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
         "gap" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.gap = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Gap set to {}px", value));
+                }
             }
         }
         "gap_top" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.gap_top = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Top gap set to {}px", value));
+                }
             }
         }
         "gap_left" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.gap_left = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Left gap set to {}px", value));
+                }
             }
         }
         "gap_right" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.gap_right = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Right gap set to {}px", value));
+                }
             }
         }
         "gap_bottom" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.gap_bottom = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Bottom gap set to {}px", value));
+                }
             }
         }
         "cascade_offset" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.cascade_offset = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Cascade offset set to {}px", value));
+                }
             }
         }
         "bar_height" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.bar_height = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Bar height set to {}px", value));
+                }
             }
         }
         "border_width" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.border_width = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Border width set to {}px", value));
+                }
             }
         }
         "fullscreen_border_width" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.fullscreen_border_width = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Fullscreen border width set to {}px", value));
+                }
             }
         }
         "cascade_border_width" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.cascade_border_width = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Cascade border width set to {}px", value));
+                }
             }
         }
         "grid_border_width" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.grid_border_width = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Grid border width set to {}px", value));
+                }
             }
         }
         "vsplit_border_width" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.vsplit_border_width = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Vsplit border width set to {}px", value));
+                }
             }
         }
         "hsplit_border_width" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.hsplit_border_width = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Hsplit border width set to {}px", value));
+                }
             }
         }
         "floating_border_width" => {
             if let Ok(value) = value_str.parse::<i32>() {
                 state.layout.floating_border_width = value;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Floating border width set to {}px", value));
+                }
             }
         }
         "border_color" => {
@@ -268,6 +322,9 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
                 state.layout.border_g = g;
                 state.layout.border_b = b;
                 state.layout.border_a = a;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Border color set to {}", value_str));
+                }
             }
         }
         "background_color" => {
@@ -276,6 +333,9 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
                 state.layout.background_g = g;
                 state.layout.background_b = b;
                 state.layout.background_a = a;
+                if state.notifications_enable {
+                    crate::config::show_notification("clearwm", &format!("Background color set to {}", value_str));
+                }
             }
         }
         _ => {}
@@ -332,9 +392,14 @@ fn handle_set_mode_command(rest: &str, state: &mut WindowManager) {
         return;
     }
     let mode = parse_tiling_mode(mode_str);
+    let notifications_enable = state.notifications_enable;
     if let Some(window) = state.focused_window_mut() {
         window.tiling_mode = mode;
         window.mode_locked = true;
+        if notifications_enable {
+            let win_title = window.title.as_deref().unwrap_or("Window");
+            crate::config::show_notification("clearwm", &format!("Tiling mode set to {} for: {}", mode.as_str(), win_title));
+        }
     }
 }
 
@@ -407,6 +472,9 @@ fn handle_tag_layout_command(rest: &str, state: &mut WindowManager) {
             let mode = parse_tiling_mode(parts[1]);
             state.tag_layouts[tag as usize - 1] = mode;
             state.has_tag_layout[tag as usize - 1] = true;
+            if state.notifications_enable {
+                crate::config::show_notification("clearwm", &format!("Tag {} layout set to {}", tag, mode.as_str()));
+            }
         }
     }
 }
@@ -464,6 +532,7 @@ fn handle_input_command(rest: &str, state: &mut WindowManager) {
 
     match param {
         "tap-to-click" | "tap_to_click" => {
+            let old_val = state.tap_to_click;
             match value_str {
                 "true" | "1" | "enabled" => {
                     state.tap_to_click = true;
@@ -479,6 +548,15 @@ fn handle_input_command(rest: &str, state: &mut WindowManager) {
                 }
                 _ => {}
             }
+            if state.tap_to_click != old_val && state.notifications_enable {
+                crate::config::show_notification(
+                    "clearwm",
+                    &format!(
+                        "Tap-to-click {}",
+                        if state.tap_to_click { "enabled" } else { "disabled" }
+                    ),
+                );
+            }
         }
         _ => {}
     }
diff --git a/src/main.rs b/src/main.rs
index 429650e..e655e8d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -115,7 +115,9 @@ fn main() {
     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);
+            if let Err(e) = parse_config(&config_path, cold_start, &mut state.wm) {
+                eprintln!("[init] failed to load config: {}", e);
+            }
         }
     }
     eprintln!(
diff --git a/src/restart.rs b/src/restart.rs
index dba8c56..dd3a542 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -215,7 +215,18 @@ pub fn wm_reload(state: &mut WindowManager) {
     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);
+            match parse_config(&config_path, false, state) {
+                Ok(_) => {
+                    if state.notifications_enable {
+                        crate::config::show_notification("clearwm", "Configuration reloaded successfully");
+                    }
+                }
+                Err(e) => {
+                    if state.notifications_enable {
+                        crate::config::show_notification("clearwm", &format!("Config reload failed:\n{}", e));
+                    }
+                }
+            }
         }
     }
 }
diff --git a/src/tiling.rs b/src/tiling.rs
index df4cfee..baf262c 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -149,27 +149,18 @@ pub fn tile_hsplit(
 /// Tile a window in fullscreen mode — fills the screen minus gaps, bar, and borders.
 ///
 /// Only the focused window is visible; other fullscreen windows are skipped.
-///   width  = screen_w - gap_left - gap_right - bw * 2
-///   height = screen_h - bar_height - gap_top - gap_bottom - bw * 2
-///   x = gap_left + bw
-///   y = bar_height + gap_top + bw
+/// A fullscreen window occupies the entire screen (0, 0, screen_w, screen_h).
 pub fn tile_fullscreen(
     screen_w: i32,
     screen_h: i32,
-    gap_top: i32,
-    gap_left: i32,
-    gap_right: i32,
-    gap_bottom: i32,
-    bw: i32,
-    bar_height: i32,
+    _gap_top: i32,
+    _gap_left: i32,
+    _gap_right: i32,
+    _gap_bottom: i32,
+    _bw: i32,
+    _bar_height: i32,
 ) -> (i32, i32, i32, i32) {
-    let width = screen_w - gap_left - gap_right - bw * 2;
-    let height = screen_h - bar_height - gap_top - gap_bottom - bw * 2;
-    let width = if width < 1 { 1 } else { width };
-    let height = if height < 1 { 1 } else { height };
-    let x = gap_left + bw;
-    let y = bar_height + gap_top + bw;
-    (x, y, width, height)
+    (0, 0, screen_w, screen_h)
 }
 
 /// Interpolate a byte-replicated 32-bit channel (0xVVVVVVVV) by factor^depth.
@@ -466,33 +457,27 @@ mod tests {
     #[test]
     fn test_tile_fullscreen_basic() {
         let (x, y, w, h) = tile_fullscreen(1920, 1080, 18, 18, 18, 18, 6, 28);
-        // x = gap_left + bw = 18 + 6 = 24
-        assert_eq!(x, 24);
-        // y = bar_height + gap_top + bw = 28 + 18 + 6 = 52
-        assert_eq!(y, 52);
-        // w = 1920 - 18 - 18 - 6*2 = 1920 - 48 = 1872
-        assert_eq!(w, 1872);
-        // h = 1080 - 28 - 18 - 18 - 6*2 = 1080 - 76 = 1004
-        assert_eq!(h, 1004);
+        assert_eq!(x, 0);
+        assert_eq!(y, 0);
+        assert_eq!(w, 1920);
+        assert_eq!(h, 1080);
     }
 
     #[test]
     fn test_tile_fullscreen_asymmetric_gaps() {
         let (x, y, w, h) = tile_fullscreen(1920, 1080, 10, 20, 30, 40, 6, 28);
-        // x = gap_left + bw = 20 + 6 = 26
-        assert_eq!(x, 26);
-        // y = bar_height + gap_top + bw = 28 + 10 + 6 = 44
-        assert_eq!(y, 44);
-        // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
-        assert_eq!(w, 1858);
-        // h = 1080 - 28 - 10 - 40 - 6*2 = 1080 - 90 = 990
-        assert_eq!(h, 990);
+        assert_eq!(x, 0);
+        assert_eq!(y, 0);
+        assert_eq!(w, 1920);
+        assert_eq!(h, 1080);
     }
 
     #[test]
     fn test_tile_fullscreen_minimum_size() {
-        let (_, _, w, h) = tile_fullscreen(50, 50, 18, 18, 18, 18, 6, 28);
-        assert!(w >= 1);
-        assert!(h >= 1);
+        let (x, y, w, h) = tile_fullscreen(50, 50, 18, 18, 18, 18, 6, 28);
+        assert_eq!(x, 0);
+        assert_eq!(y, 0);
+        assert_eq!(w, 50);
+        assert_eq!(h, 50);
     }
 }
diff --git a/src/types.rs b/src/types.rs
index bdcea97..8ca8617 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -168,6 +168,7 @@ pub struct Output {
     pub usable_y: i32,
     pub usable_width: i32,
     pub usable_height: i32,
+    pub wl_output_name: Option<u32>,
 }
 
 impl Default for Output {
@@ -183,6 +184,7 @@ impl Default for Output {
             usable_y: 0,
             usable_width: 0,
             usable_height: 0,
+            wl_output_name: None,
         }
     }
 }
@@ -326,6 +328,8 @@ pub struct WindowManager {
     pub tap_to_click: bool,
     /// Whether tap-to-click config has been applied to libinput devices yet
     pub tap_config_applied: bool,
+    /// Whether system notifications are enabled
+    pub notifications_enable: bool,
 }
 
 impl Default for WindowManager {
@@ -359,6 +363,7 @@ impl Default for WindowManager {
             state_restore_attempts: 0,
             tap_to_click: false,
             tap_config_applied: false,
+            notifications_enable: true,
         }
     }
 }
diff --git a/src/wayland.rs b/src/wayland.rs
index 4874ed1..dc16c5d 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -40,9 +40,15 @@ use crate::protocol::wlr_output_management::client::{
 };
 
 use crate::types::{Action, BindingUserData, Output, Seat, TilingMode, Window, WindowManager};
+use wayland_client::protocol::{wl_pointer, wl_seat, wl_output};
+use wayland_protocols::wp::cursor_shape::v1::client::{
+    wp_cursor_shape_device_v1::{self, WpCursorShapeDeviceV1, Shape},
+    wp_cursor_shape_manager_v1::{self, WpCursorShapeManagerV1},
+};
 
 // Interface name constants (from river protocol XML)
 const IFACE_WINDOW_MANAGER: &str = "river_window_manager_v1";
+const IFACE_CURSOR_SHAPE_MANAGER: &str = "wp_cursor_shape_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";
@@ -59,6 +65,9 @@ pub struct WindowProxy {
 pub struct SeatProxy {
     pub river_seat: RiverSeatV1,
     pub xkb_bindings_seat: Option<RiverXkbBindingsSeatV1>,
+    pub wl_seat: Option<wl_seat::WlSeat>,
+    pub wl_pointer: Option<wl_pointer::WlPointer>,
+    pub cursor_shape_device: Option<WpCursorShapeDeviceV1>,
 }
 
 /// Wayland proxy objects stored alongside each Output.
@@ -67,15 +76,28 @@ pub struct OutputProxy {
     pub layer_shell_output: Option<RiverLayerShellOutputV1>,
 }
 
+/// Tracked wl_output proxy and its physical properties.
+pub struct WlOutputInfo {
+    pub name: u32,
+    pub wl_output: wl_output::WlOutput,
+    pub width: i32,
+    pub height: i32,
+    pub x: i32,
+    pub y: i32,
+}
+
 /// The full app state combining logic state + protocol proxy storage.
 pub struct AppState {
     pub wm: WindowManager,
+    pub wl_outputs: Vec<WlOutputInfo>,
 
     // Protocol objects (None until bound via registry)
+    pub registry: Option<wl_registry::WlRegistry>,
     pub window_manager: Option<RiverWindowManagerV1>,
     pub xkb_bindings: Option<RiverXkbBindingsV1>,
     pub layer_shell: Option<RiverLayerShellV1>,
     pub input_manager: Option<RiverInputManagerV1>,
+    pub cursor_shape_manager: Option<WpCursorShapeManagerV1>,
 
     // Whether we got all required globals
     pub has_window_manager: bool,
@@ -147,10 +169,13 @@ impl AppState {
     pub fn new() -> Self {
         AppState {
             wm: WindowManager::new(),
+            wl_outputs: Vec::new(),
+            registry: None,
             window_manager: None,
             xkb_bindings: None,
             layer_shell: None,
             input_manager: None,
+            cursor_shape_manager: None,
             has_window_manager: false,
             has_xkb_bindings: false,
             window_proxies: Vec::new(),
@@ -279,6 +304,23 @@ impl Dispatch<wl_registry::WlRegistry, RegistryData> for AppState {
                     let om: ZwlrOutputManagerV1 =
                         registry.bind::<ZwlrOutputManagerV1, _, _>(name, 4, qhandle, ());
                     state.output_manager = Some(om);
+                } else if interface == IFACE_CURSOR_SHAPE_MANAGER {
+                    eprintln!("registry: binding {}", IFACE_CURSOR_SHAPE_MANAGER);
+                    let csm: WpCursorShapeManagerV1 =
+                        registry.bind::<WpCursorShapeManagerV1, _, _>(name, 1, qhandle, ());
+                    state.cursor_shape_manager = Some(csm);
+                } else if interface == "wl_output" {
+                    eprintln!("registry: binding wl_output name={}", name);
+                    let wl_out: wl_output::WlOutput =
+                        registry.bind::<wl_output::WlOutput, _, _>(name, 4, qhandle, ());
+                    state.wl_outputs.push(WlOutputInfo {
+                        name,
+                        wl_output: wl_out,
+                        width: 0,
+                        height: 0,
+                        x: 0,
+                        y: 0,
+                    });
                 }
             }
             wl_registry::Event::GlobalRemove { name: _ } => {}
@@ -892,6 +934,9 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                     SeatProxy {
                         river_seat,
                         xkb_bindings_seat: None,
+                        wl_seat: None,
+                        wl_pointer: None,
+                        cursor_shape_device: None,
                     },
                 ));
                 eprintln!("wm_handle_seat: new seat id={}", id);
@@ -1146,7 +1191,7 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
         event: river_seat_v1::Event,
         _data: &(),
         _conn: &Connection,
-        _qhandle: &QueueHandle<Self>,
+        qhandle: &QueueHandle<Self>,
     ) {
         let sid = match state.seat_id_for_proxy(proxy) {
             Some(id) => id,
@@ -1199,6 +1244,17 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
                 }
             }
 
+            river_seat_v1::Event::WlSeat { name } => {
+                if let Some(ref registry) = state.registry {
+                    if let Some((_, seat_proxy)) = state.seat_proxies.iter_mut().find(|(_, sp)| sp.river_seat == *proxy) {
+                        if seat_proxy.wl_seat.is_none() {
+                            let wl_seat = registry.bind::<wl_seat::WlSeat, _, _>(name, 2, qhandle, ());
+                            seat_proxy.wl_seat = Some(wl_seat);
+                        }
+                    }
+                }
+            }
+
             river_seat_v1::Event::OpDelta { .. } => {}
             river_seat_v1::Event::OpRelease => {}
 
@@ -1211,15 +1267,115 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
 
 impl Dispatch<RiverOutputV1, ()> for AppState {
     fn event(
-        _state: &mut Self,
-        _proxy: &RiverOutputV1,
+        state: &mut Self,
+        proxy: &RiverOutputV1,
         event: river_output_v1::Event,
         _data: &(),
         _conn: &Connection,
         _qhandle: &QueueHandle<Self>,
     ) {
+        let oid = state
+            .output_proxies
+            .iter()
+            .find(|(_, op)| op.river_output.id() == proxy.id())
+            .map(|(id, _)| *id);
+
         match event {
-            river_output_v1::Event::WlOutput { .. } => {}
+            river_output_v1::Event::WlOutput { name } => {
+                if let Some(oid) = oid {
+                    if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.id == oid) {
+                        output.wl_output_name = Some(name);
+                        // Also try to copy dimensions from WlOutputInfo if already populated
+                        if let Some(info) = state.wl_outputs.iter().find(|info| info.name == name) {
+                            if info.width > 0 && info.height > 0 {
+                                output.width = info.width;
+                                output.height = info.height;
+                                output.x = info.x;
+                                output.y = info.y;
+                                state.wm.needs_render = true;
+                                eprintln!(
+                                    "river_output linked to wl_output name={} dimensions (copied): {}x{} at ({},{})",
+                                    name, info.width, info.height, info.x, info.y
+                                );
+                            }
+                        }
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+}
+
+// --- wl_output::WlOutput events ---
+
+impl Dispatch<wl_output::WlOutput, ()> for AppState {
+    fn event(
+        state: &mut Self,
+        proxy: &wl_output::WlOutput,
+        event: wl_output::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+        // Find registry name
+        let name = state
+            .wl_outputs
+            .iter()
+            .find(|info| info.wl_output.id() == proxy.id())
+            .map(|info| info.name);
+
+        let Some(name) = name else { return };
+
+        match event {
+            wl_output::Event::Geometry {
+                x,
+                y,
+                ..
+            } => {
+                if let Some(info) = state.wl_outputs.iter_mut().find(|info| info.name == name) {
+                    info.x = x;
+                    info.y = y;
+                }
+                // Also update matched Output in WindowManager
+                if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.wl_output_name == Some(name)) {
+                    if output.x != x || output.y != y {
+                        output.x = x;
+                        output.y = y;
+                        state.wm.needs_render = true;
+                    }
+                }
+            }
+            wl_output::Event::Mode {
+                flags,
+                width,
+                height,
+                ..
+            } => {
+                let is_current = match flags {
+                    wayland_client::WEnum::Value(mode) => mode.contains(wl_output::Mode::Current),
+                    _ => false,
+                };
+
+                if is_current {
+                    if let Some(info) = state.wl_outputs.iter_mut().find(|info| info.name == name) {
+                        info.width = width;
+                        info.height = height;
+                    }
+                    // Also update matched Output in WindowManager
+                    if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.wl_output_name == Some(name)) {
+                        if output.width != width || output.height != height {
+                            output.width = width;
+                            output.height = height;
+                            state.wm.needs_render = true;
+                            eprintln!(
+                                "wl_output name={} updated current mode dimensions: {}x{}",
+                                name, width, height
+                            );
+                        }
+                    }
+                }
+            }
             _ => {}
         }
     }
@@ -1676,6 +1832,10 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 active_tags
             );
 
+            if state.wm.notifications_enable {
+                crate::config::show_notification("clearwm", &format!("Layout set to {} for active tags", next.as_str()));
+            }
+
             // Unlock windows that got their mode from the layout (not from mode_rules
             // or manual set-mode) so assign_window_modes will reassign them.
             // Windows with mode_locked=true were explicitly set by the user and stay.
@@ -1701,6 +1861,7 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                 .find(|s| !s.removed)
                 .and_then(|s| s.focused_window_id);
             if let Some(fid) = focused_id {
+                let notifications_enable = state.wm.notifications_enable;
                 if let Some(win) = state.wm.get_window_mut(fid) {
                     let next = cycle
                         .iter()
@@ -1716,14 +1877,17 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                     );
                     win.tiling_mode = next;
                     win.mode_locked = true;
+                    if notifications_enable {
+                        let win_title = win.title.as_deref().unwrap_or("Window");
+                        crate::config::show_notification("clearwm", &format!("Tiling mode set to {} for: {}", next.as_str(), win_title));
+                    }
                     state.wm.needs_render = true;
                     state.wm.needs_status_update = true;
                 }
             }
         }
         Action::Reload => {
-            // TODO: implement reload (re-run config)
-            eprintln!("reload: not yet implemented");
+            crate::restart::wm_reload(&mut state.wm);
         }
         Action::Restart => {
             crate::restart::wm_restart();
@@ -2021,7 +2185,7 @@ impl Dispatch<ZwlrOutputHeadV1, ()> for AppState {
         event: zwlr_output_head_v1::Event,
         _data: &(),
         _conn: &Connection,
-        qhandle: &QueueHandle<Self>,
+        _qhandle: &QueueHandle<Self>,
     ) {
         // Find or create the head entry by proxy ID
         let pid = proxy.id().protocol_id();
@@ -2291,10 +2455,11 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
     let mut event_queue = conn.new_event_queue::<AppState>();
     let qh = event_queue.handle();
 
-    let _registry = conn.display().get_registry(&qh, RegistryData);
+    let registry = conn.display().get_registry(&qh, RegistryData);
 
     // Do initial roundtrip to receive global events and bind protocols
     let mut state = AppState::new();
+    state.registry = Some(registry);
     eprintln!("[init] first roundtrip starting...");
     let rt1 = std::time::Instant::now();
     event_queue
@@ -2371,7 +2536,7 @@ impl Dispatch<RiverLibinputDeviceV1, ()> for AppState {
         event: river_libinput_device_v1::Event,
         _data: &(),
         _conn: &Connection,
-        qhandle: &QueueHandle<Self>,
+        _qhandle: &QueueHandle<Self>,
     ) {
         match event {
             river_libinput_device_v1::Event::TapSupport { finger_count } => {
@@ -2476,3 +2641,88 @@ pub fn apply_tap_config(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
         eprintln!("[libinput] tap config applied to all devices");
     }
 }
+
+// --- wl_seat events ---
+
+impl Dispatch<wl_seat::WlSeat, ()> for AppState {
+    fn event(
+        state: &mut Self,
+        proxy: &wl_seat::WlSeat,
+        event: wl_seat::Event,
+        _data: &(),
+        _conn: &Connection,
+        qhandle: &QueueHandle<Self>,
+    ) {
+        match event {
+            wl_seat::Event::Capabilities { capabilities } => {
+                let has_pointer = match capabilities {
+                    wayland_client::WEnum::Value(caps) => caps.contains(wl_seat::Capability::Pointer),
+                    _ => false,
+                };
+                if let Some((_, seat_proxy)) = state.seat_proxies.iter_mut().find(|(_, sp)| {
+                    sp.wl_seat.as_ref() == Some(proxy)
+                }) {
+                    if has_pointer && seat_proxy.wl_pointer.is_none() {
+                        let wl_pointer = proxy.get_pointer(qhandle, ());
+                        
+                        if let Some(ref csm) = state.cursor_shape_manager {
+                            let device = csm.get_pointer(&wl_pointer, qhandle, ());
+                            device.set_shape(0, Shape::Crosshair);
+                            seat_proxy.cursor_shape_device = Some(device);
+                        }
+                        
+                        seat_proxy.wl_pointer = Some(wl_pointer);
+                    } else if !has_pointer && seat_proxy.wl_pointer.is_some() {
+                        seat_proxy.cursor_shape_device = None;
+                        if let Some(pointer) = seat_proxy.wl_pointer.take() {
+                            pointer.release();
+                        }
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+}
+
+// --- wl_pointer events ---
+
+impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &wl_pointer::WlPointer,
+        _event: wl_pointer::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+// --- wp_cursor_shape_manager_v1 events ---
+
+impl Dispatch<WpCursorShapeManagerV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &WpCursorShapeManagerV1,
+        _event: wp_cursor_shape_manager_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+// --- wp_cursor_shape_device_v1 events ---
+
+impl Dispatch<WpCursorShapeDeviceV1, ()> for AppState {
+    fn event(
+        _state: &mut Self,
+        _proxy: &WpCursorShapeDeviceV1,
+        _event: wp_cursor_shape_device_v1::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+    }
+}
diff --git a/src/wm.rs b/src/wm.rs
index 7d4241a..2f97f50 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -108,22 +108,25 @@ struct TileResult {
 /// These modify window management state and can ONLY be called during
 /// a manage sequence (between ManageStart and ManageFinish).
 pub fn manage_windows(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
-    let screen_dims = get_screen_dimensions(&state.wm);
-    let (screen_w, screen_h) = screen_dims;
+    let (screen_w, screen_h, phys_w, phys_h, phys_x, phys_y) = get_screen_geometry(&state.wm);
 
     eprintln!(
-        "[manage] windows={} outputs={} screen={}x{}",
+        "[manage] windows={} outputs={} screen={}x{} (phys={}x{} at {},{})",
         state.wm.windows.len(),
         state.wm.outputs.len(),
         screen_w,
-        screen_h
+        screen_h,
+        phys_w,
+        phys_h,
+        phys_x,
+        phys_y
     );
 
     // Ensure each window has a river_node_v1 proxy for positioning
     ensure_window_nodes(state, qhandle);
 
     // Compute tiling
-    let tile_results = compute_tiling(&state.wm, screen_w, screen_h);
+    let tile_results = compute_tiling(&state.wm, screen_w, screen_h, phys_w, phys_h, phys_x, phys_y);
 
     // Apply: set_position + propose_dimensions + update internal state
     apply_tiling(state, &tile_results);
@@ -136,32 +139,37 @@ pub fn render_borders(state: &mut AppState) {
     set_borders(state);
 }
 
-/// Get screen dimensions from the first output, with fallbacks.
-fn get_screen_dimensions(wm: &WindowManager) -> (i32, i32) {
+/// Get screen geometry (usable_w, usable_h, phys_w, phys_h, phys_x, phys_y) from the first output, with fallbacks.
+fn get_screen_geometry(wm: &WindowManager) -> (i32, i32, i32, i32, i32, i32) {
     let output = match wm.outputs.first() {
         Some(o) if !o.removed => o,
-        _ => return (800, 600),
+        _ => return (800, 600, 800, 600, 0, 0),
     };
 
-    let mut w = output.usable_width;
-    let mut h = output.usable_height;
+    let mut uw = output.usable_width;
+    let mut uh = output.usable_height;
 
-    // Fall back to raw output dimensions if usable area not yet set
-    if w <= 0 && output.width > 0 {
-        w = output.width;
+    // Fall back to physical output dimensions if usable area not yet set
+    if uw <= 0 && output.width > 0 {
+        uw = output.width;
     }
-    if h <= 0 && output.height > 0 {
-        h = output.height;
+    if uh <= 0 && output.height > 0 {
+        uh = output.height;
     }
 
-    if w <= 0 {
-        w = 800;
+    if uw <= 0 {
+        uw = 800;
     }
-    if h <= 0 {
-        h = 600;
+    if uh <= 0 {
+        uh = 600;
     }
 
-    (w, h)
+    let pw = if output.width > 0 { output.width } else { uw };
+    let ph = if output.height > 0 { output.height } else { uh };
+    let px = output.x;
+    let py = output.y;
+
+    (uw, uh, pw, ph, px, py)
 }
 
 /// Ensure each window has a river_node_v1 proxy for positioning.
@@ -189,7 +197,15 @@ fn ensure_window_nodes(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
 }
 
 /// Compute tiling for all visible windows (read-only, returns results).
-fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileResult> {
+fn compute_tiling(
+    wm: &WindowManager,
+    screen_w: i32,
+    screen_h: i32,
+    phys_w: i32,
+    phys_h: i32,
+    phys_x: i32,
+    phys_y: i32,
+) -> Vec<TileResult> {
     let gap = wm.layout.gap;
     let gap_top = wm.layout.gap_top;
     let gap_left = wm.layout.gap_left;
@@ -253,16 +269,17 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
         let (x, y, w, h) = match mode {
             TilingMode::Fullscreen => {
                 if fullscreen_id == Some(wid) {
-                    tiling::tile_fullscreen(
-                        screen_w,
-                        screen_h,
+                    let (tx, ty, tw, th) = tiling::tile_fullscreen(
+                        phys_w,
+                        phys_h,
                         gap_top,
                         gap_left,
                         gap_right,
                         gap_bottom,
                         wm.layout.fullscreen_border_width,
                         bar_height,
-                    )
+                    );
+                    (tx + phys_x, ty + phys_y, tw, th)
                 } else {
                     continue;
                 }
diff --git a/start-river.sh b/start-river.sh
index f21b21f..1e725ea 100755
--- a/start-river.sh
+++ b/start-river.sh
@@ -11,6 +11,9 @@ done
 
 export XDG_RUNTIME_DIR=/run/user/$(id -u)
 export WAYLAND_DISPLAY=wayland-1
+export XCURSOR_THEME="crosshair-theme"
+export XCURSOR_SIZE=24
+export XCURSOR_PATH="/home/lsgalante/.local/share/icons:/home/lsgalante/.icons:/usr/share/icons"
 
 # Create the River init executable (clearwm launch script)
 # This must exist before River starts, and /tmp is cleared on reboot.