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

commita9cd6f0436e103698d845a8f1ebb5c8cbfe37393
parent6b52feb7ef
authorLucas Galante <[email protected]>
date2026-05-21 19:07
feat: add IPC server and title-based fallback matching for window rules

 .gitignore        |  3 ++
 src/borders.rs    | 13 ++++++++
 src/ipc.rs        |  1 +
 src/ipc_server.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs        |  1 +
 src/main.rs       | 29 +++++++++++++++++
 src/wayland.rs    |  9 +++++-
 src/wm.rs         | 12 ++++++-
 8 files changed, 159 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index ea8c4bf..95c7048 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
 /target
+.antigravitycli/
+.gemini/
+
diff --git a/src/borders.rs b/src/borders.rs
index 6647ada..be0d493 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -77,6 +77,14 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
 
         let is_focused = focused_id.map_or(false, |fid| win.id == fid);
 
+        eprintln!(
+            "[borders] win={} app_id={:?} is_focused={} border_r=#{:08x} border_g=#{:08x} border_b=#{:08x}",
+            win.id, win.app_id, is_focused,
+            state.layout.border_r,
+            state.layout.border_g,
+            state.layout.border_b,
+        );
+
         let (r, g, b, a) = if is_focused {
             // Focused window: pure border color
             (
@@ -108,6 +116,11 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
             TilingMode::Floating => state.layout.floating_border_width,
         };
 
+        eprintln!(
+            "[borders]   -> r=#{:08x} g=#{:08x} b=#{:08x} a=#{:08x} width={}",
+            r, g, b, a, width,
+        );
+
         results.push(WindowBorders {
             window_idx: idx,
             edges: all_edges,
diff --git a/src/ipc.rs b/src/ipc.rs
index d9d210c..fdd370c 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -280,6 +280,7 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
         }
         _ => {}
     }
+    state.needs_render = true;
 }
 
 /// Handle "mode <mode> <app_id_pattern> [--single] [--tag N] [title_pattern]" command
diff --git a/src/ipc_server.rs b/src/ipc_server.rs
new file mode 100644
index 0000000..4a07a07
--- /dev/null
+++ b/src/ipc_server.rs
@@ -0,0 +1,93 @@
+use std::io::Read;
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::mpsc;
+
+pub const IPC_SOCKET_PATH: &str = "/tmp/clearwm.sock";
+
+pub struct IpcReceiver {
+    pub rx: mpsc::Receiver<String>,
+}
+
+pub fn spawn_ipc_server() -> IpcReceiver {
+    let (tx, rx) = mpsc::channel::<String>();
+
+    std::thread::Builder::new()
+        .name("clearwm-ipc".into())
+        .spawn(move || {
+            ipc_server_main(tx);
+        })
+        .expect("failed to spawn IPC server thread");
+
+    IpcReceiver { rx }
+}
+
+fn ipc_server_main(tx: mpsc::Sender<String>) {
+    let _ = std::fs::remove_file(IPC_SOCKET_PATH);
+
+    let listener = match UnixListener::bind(IPC_SOCKET_PATH) {
+        Ok(l) => l,
+        Err(e) => {
+            eprintln!("[ipc] failed to bind {}: {}", IPC_SOCKET_PATH, e);
+            return;
+        }
+    };
+
+    if let Err(e) = listener.set_nonblocking(true) {
+        eprintln!("[ipc] failed to set non-blocking: {}", e);
+        return;
+    }
+
+    eprintln!("[ipc] listening on {}", IPC_SOCKET_PATH);
+
+    let mut streams: Vec<UnixStream> = Vec::new();
+
+    loop {
+        for _ in 0..5 {
+            match listener.accept() {
+                Ok((stream, _addr)) => {
+                    if let Err(e) = stream.set_nonblocking(true) {
+                        eprintln!("[ipc] failed to set non-blocking on client: {}", e);
+                        continue;
+                    }
+                    eprintln!("[ipc] new connection");
+                    streams.push(stream);
+                }
+                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
+                Err(e) => {
+                    eprintln!("[ipc] accept error: {}", e);
+                    break;
+                }
+            }
+        }
+
+        let mut dead = Vec::new();
+        for (i, stream) in streams.iter_mut().enumerate() {
+            let mut buf = [0u8; 4096];
+            match stream.read(&mut buf) {
+                Ok(0) => {
+                    dead.push(i);
+                }
+                Ok(n) => {
+                    let s = String::from_utf8_lossy(&buf[..n]);
+                    for line in s.lines() {
+                        let cmd = line.trim().to_string();
+                        if !cmd.is_empty() {
+                            let _ = tx.send(cmd);
+                        }
+                    }
+                }
+                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
+                Err(e) => {
+                    eprintln!("[ipc] read error: {}", e);
+                    dead.push(i);
+                }
+            }
+        }
+
+        for i in dead.into_iter().rev() {
+            streams.remove(i);
+        }
+
+        std::thread::sleep(std::time::Duration::from_millis(50));
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 6f49b62..84a0af0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,6 +3,7 @@
 pub mod borders;
 pub mod config;
 pub mod ipc;
+pub mod ipc_server;
 pub mod protocol;
 pub mod restart;
 pub mod state;
diff --git a/src/main.rs b/src/main.rs
index 2f667b9..429650e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,8 @@
 // clearwm — Wayland window manager for river
 
 use clearwm::config::parse_config;
+use clearwm::ipc;
+use clearwm::ipc_server;
 use clearwm::restart;
 use clearwm::status_server;
 use clearwm::wayland::wayland_init;
@@ -85,6 +87,9 @@ fn main() {
         }
     };
 
+    // Start the IPC server thread (for clearctl and clear-system-interface)
+    let ipc_rx = ipc_server::spawn_ipc_server().rx;
+
     // Store the status sender in the app state so RenderStart can push updates
     state.status_sender = Some(status_sender);
 
@@ -157,6 +162,30 @@ fn main() {
                     break;
                 }
                 eprintln!("[main] flush ok, looping");
+
+                // Process pending IPC commands from the socket
+                let mut ipc_commands = false;
+                loop {
+                    match ipc_rx.try_recv() {
+                        Ok(cmd) => {
+                            eprintln!("[main] IPC command: {}", cmd);
+                            ipc::handle_ipc_command(&cmd, &mut state.wm);
+                            ipc_commands = true;
+                        }
+                        Err(std::sync::mpsc::TryRecvError::Empty) => break,
+                        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
+                            eprintln!("[main] IPC server disconnected");
+                            break;
+                        }
+                    }
+                }
+                // Force a render sequence so the new rendering state (border
+                // colors, widths, etc.) is sent to River and displayed.
+                if ipc_commands {
+                    if let Some(ref wm) = state.window_manager {
+                        wm.manage_dirty();
+                    }
+                }
             }
             Err(e) => {
                 log_death(&format!(
diff --git a/src/wayland.rs b/src/wayland.rs
index 18e7628..4874ed1 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -1850,11 +1850,18 @@ fn enforce_single_instance(wm: &mut WindowManager) {
             if window.closed {
                 continue;
             }
+            let has_app_id = window.app_id.as_deref().map_or(false, |s| !s.is_empty());
             let match_app = rule.app_id_pattern == "*"
                 || window
                     .app_id
                     .as_deref()
-                    .map_or(false, |aid| aid.contains(&rule.app_id_pattern));
+                    .map_or(false, |aid| aid.contains(&rule.app_id_pattern))
+                || (!has_app_id && window.title.as_deref().map_or(false, |t| {
+                    let normalize = |s: &str| -> String {
+                        s.to_lowercase().replace(|c: char| c == '-' || c == '_', " ")
+                    };
+                    normalize(t).contains(&normalize(&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| {
diff --git a/src/wm.rs b/src/wm.rs
index f9c0fda..7d4241a 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -32,11 +32,21 @@ pub fn get_mode_for_window(wm: &WindowManager, win: &Window) -> Option<TilingMod
 
     // 1. Check mode_rules for a match on app_id/title
     for rule in &wm.mode_rules {
+        let has_app_id = win.app_id.as_deref().map_or(false, |s| !s.is_empty());
         let match_app = rule.app_id_pattern == "*"
             || win
                 .app_id
                 .as_deref()
-                .map_or(false, |aid| aid.contains(&rule.app_id_pattern));
+                .map_or(false, |aid| aid.contains(&rule.app_id_pattern))
+            // Fallback: if the window has no app_id (None or empty), try
+            // matching the app_id_pattern against the window title. This
+            // handles apps that never set a Wayland app_id (e.g. clear-colors).
+            || (!has_app_id && win.title.as_deref().map_or(false, |t| {
+                let normalize = |s: &str| -> String {
+                    s.to_lowercase().replace(|c: char| c == '-' || c == '_', " ")
+                };
+                normalize(t).contains(&normalize(&rule.app_id_pattern))
+            }));
         let match_title = rule.title_pattern.as_deref() == Some("*")
             || rule.title_pattern.is_none()
             || win.title.as_deref().map_or(false, |t| {