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

commitf082902012480cf23874a8addf0032e7113d9efb
parentd21524d373
authorLucas Galante <[email protected]>
date2026-07-07 15:05
feat(switcher): add super+tab window switcher driven by config key

Add a `window_manager.window_switcher` keybind (super+tab) that opens an
alt-tab window switcher. On the new Action::WindowSwitcher the compositor
builds the window list in most-recently-used order (focus_history), spawns
a `cce-cloud --switcher` overlay, and focuses the committed selection over
the control socket. Also exposed as the `window-switcher` IPC command.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

 src/cce_ctl.rs               |   1 +
 src/server/config.rs         |  20 ++++-
 src/server/window_manager.rs | 176 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 196 insertions(+), 1 deletion(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 9c483a3..f77d54f 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -37,6 +37,7 @@ fn usage(name: &str, to_stderr: bool) {
     print("  minimize");
     print("  focus-next");
     print("  focus-prev");
+    print("  window-switcher            # open the alt-tab window switcher (cce-cloud overlay)");
     print("  fullscreen");
     print("  mode-next");
     print("  mode-next-shared");
diff --git a/src/server/config.rs b/src/server/config.rs
index 443e21b..e5c3a53 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -131,6 +131,7 @@ pub enum Action {
     Close,
     FocusNext,
     FocusPrev,
+    WindowSwitcher,
     Move,
     Resize,
     Exit,
@@ -242,6 +243,7 @@ pub struct WindowManagerConfig {
     pub close_window: Option<String>,
     pub toggle_fullscreen: Option<String>,
     pub toggle_overview: Option<String>,
+    pub window_switcher: Option<String>,
 }
 
 #[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
@@ -1477,7 +1479,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let close_window = get_child_arg_string_opt(node, "close_window");
         let toggle_fullscreen = get_child_arg_string_opt(node, "toggle_fullscreen");
         let toggle_overview = get_child_arg_string_opt(node, "toggle_overview");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview });
+        let window_switcher = get_child_arg_string_opt(node, "window_switcher");
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher });
     }
 
     Ok(Config {
@@ -1680,6 +1683,21 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
                 command: None,
             });
         }
+        if let Some(ref switcher_str) = wm_config.window_switcher {
+            let (mods_str, key_str) = if let Some(last_plus) = switcher_str.rfind('+') {
+                (switcher_str[..last_plus].to_string(), switcher_str[last_plus+1..].to_string())
+            } else {
+                ("".to_string(), switcher_str.clone())
+            };
+            let mods = parse_modifiers(&mods_str);
+            let keysym = parse_keysym(&key_str);
+            state.keybinds.push(Keybind {
+                mods,
+                keysym,
+                action: Action::WindowSwitcher,
+                command: None,
+            });
+        }
     }
 
     let super_mod = parse_modifiers("super");
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index b0cae0d..8643a2b 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2129,6 +2129,95 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
         }
     }
 
+    /// True for windows that should appear in the window switcher: mapped,
+    /// non-closed, and not one of the desktop-shell surfaces (status bar,
+    /// wallpaper, or the switcher's own cce-cloud overlay).
+    unsafe fn is_switchable_window(&self, w: *mut Window) -> bool {
+        if w.is_null() || (*w).closed {
+            return false;
+        }
+        if !matches!((*w).state, crate::window::WindowState::Mapped) {
+            return false;
+        }
+        match (*w).get_app_id_string().as_deref() {
+            Some(id) if id.starts_with("cce-status") => false,
+            Some("cce-wallpaper") | Some("cce-cloud") => false,
+            _ => true,
+        }
+    }
+
+    /// Open the alt-tab window switcher: spawn a `cce-cloud --switcher` overlay,
+    /// feed it the currently switchable windows in most-recently-used order, and
+    /// hand off to a background thread that focuses whatever the user commits to.
+    ///
+    /// cce-cloud is a `Layer::Overlay` surface with exclusive keyboard focus, so
+    /// once it is up it drives the interaction itself (Tab cycles, Super release
+    /// commits, Escape cancels) and prints the chosen entry to stdout. We only
+    /// build the list and map the committed entry back to a window id.
+    pub unsafe fn launch_window_switcher(&mut self) {
+        // Most-recently-used order (focus_history is MRU-front). The focused
+        // window lands first, so cce-cloud auto-selects index 1 — the previously
+        // focused window — which is the classic alt-tab default.
+        let mut ordered: Vec<*mut Window> = Vec::new();
+        for &w in self.focus_history.iter() {
+            if self.is_switchable_window(w) && !ordered.contains(&w) {
+                ordered.push(w);
+            }
+        }
+        for &w in self.windows.iter() {
+            if self.is_switchable_window(w) && !ordered.contains(&w) {
+                ordered.push(w);
+            }
+        }
+        if ordered.is_empty() {
+            return;
+        }
+
+        // cce-cloud echoes the committed entry back verbatim, so keep the mapping
+        // from display string to window id to resolve the selection.
+        let mut items: Vec<(String, String)> = Vec::new();
+        let mut input = String::new();
+        for &w in &ordered {
+            let id = (*w).ref_key.index.to_string();
+            let app_id = (*w).get_app_id_string().unwrap_or_default();
+            let title = (*w).get_title_string().unwrap_or_default();
+            let display = if title.is_empty() {
+                app_id.clone()
+            } else {
+                format!("{} ({})", title, app_id)
+            };
+            input.push_str(&display);
+            input.push('\n');
+            items.push((id, display));
+        }
+
+        // Position near the top-centre of the enabled output. Passing explicit
+        // -x/-y keeps cce-cloud a layer-shell overlay (omitting both would make
+        // it an XDG toplevel, which would not grab keyboard the same way).
+        let (mut vp_w, mut origin_x, mut origin_y) = (1920.0_f64, 0i32, 0i32);
+        let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
+        let mut curr_out = (*outputs_list).next;
+        while curr_out != outputs_list {
+            let output = crate::container_of!(curr_out, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                let wlr_box = (*output).sent.box_layout();
+                vp_w = wlr_box.width as f64;
+                origin_x = wlr_box.x;
+                origin_y = wlr_box.y;
+                break;
+            }
+            curr_out = (*curr_out).next;
+        }
+        // cce-cloud's default logical width is 600; centre it horizontally.
+        let x_pos = origin_x + (((vp_w - 600.0) / 2.0).max(0.0)) as i32;
+        let y_pos = origin_y + 80;
+
+        let display_env = std::env::var("WAYLAND_DISPLAY").ok();
+        std::thread::spawn(move || {
+            run_window_switcher(input, items, x_pos, y_pos, display_env);
+        });
+    }
+
     pub unsafe fn keep_status_bar_on_top(&mut self) {
         let mut status_bar_windows = Vec::new();
         for &win_ptr in self.windows.iter() {
@@ -2331,6 +2420,9 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
                     }
                 }
             }
+            Action::WindowSwitcher => {
+                self.launch_window_switcher();
+            }
             Action::Reload => {
                 log::info!("monolithic execute_action: Reload requested");
                 match self.reload_config() {
@@ -2994,6 +3086,10 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
                 self.execute_action(&crate::config::Action::FocusPrev, None);
                 "ok\n".to_string()
             }
+            "window-switcher" => {
+                self.execute_action(&crate::config::Action::WindowSwitcher, None);
+                "ok\n".to_string()
+            }
             "fullscreen" => {
                 self.execute_action(&crate::config::Action::Fullscreen, None);
                 "ok\n".to_string()
@@ -3499,6 +3595,86 @@ unsafe fn rendered_fullscreen(window: *mut Window) -> bool {
     (*window).is_fullscreen() && !(*window).rendering_requested.hidden
 }
 
+/// Prefer the installed `~/.local/bin/cce-cloud`, falling back to PATH lookup.
+fn cce_cloud_cmd() -> String {
+    if let Ok(home) = std::env::var("HOME") {
+        let path = format!("{}/.local/bin/cce-cloud", home);
+        if std::path::Path::new(&path).exists() {
+            return path;
+        }
+    }
+    "cce-cloud".to_string()
+}
+
+/// Body of the window switcher, run on a detached thread. Spawns cce-cloud in
+/// switcher mode, pipes it the item list, waits for the committed selection on
+/// stdout, and asks the compositor to focus it via the control socket (so the
+/// actual focus change happens on the main thread through the IPC dispatcher).
+fn run_window_switcher(
+    input: String,
+    items: Vec<(String, String)>,
+    x_pos: i32,
+    y_pos: i32,
+    display_env: Option<String>,
+) {
+    use std::io::{Read, Write};
+
+    let mut child = match std::process::Command::new(cce_cloud_cmd())
+        .args([
+            "--switcher",
+            "-p",
+            "Windows:",
+            "-x",
+            &x_pos.to_string(),
+            "-y",
+            &y_pos.to_string(),
+        ])
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::inherit())
+        .spawn()
+    {
+        Ok(c) => c,
+        Err(e) => {
+            log::error!("window switcher: failed to spawn cce-cloud: {}", e);
+            return;
+        }
+    };
+
+    // Write the item list, then drop stdin so cce-cloud sees EOF on the feed.
+    if let Some(mut stdin) = child.stdin.take() {
+        let _ = stdin.write_all(input.as_bytes());
+        let _ = stdin.flush();
+    }
+
+    let mut selected = String::new();
+    if let Some(mut stdout) = child.stdout.take() {
+        let _ = stdout.read_to_string(&mut selected);
+    }
+    let _ = child.wait();
+
+    let selected = selected.trim();
+    if selected.is_empty() {
+        return; // cancelled (Escape) or empty selection
+    }
+
+    let id = match items.iter().find(|(_, display)| display == selected) {
+        Some((id, _)) => id.clone(),
+        None => return,
+    };
+
+    let sock = match display_env {
+        Some(d) => format!("/tmp/cce-{}.sock", d),
+        None => "/tmp/cce.sock".to_string(),
+    };
+    if let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&sock) {
+        let _ = stream.write_all(format!("focus-window {}\n", id).as_bytes());
+        let _ = stream.flush();
+        let mut resp = String::new();
+        let _ = stream.read_to_string(&mut resp);
+    }
+}
+
 unsafe extern "C" fn dirty_idle_callback(data: *mut std::ffi::c_void) {
     let wm = data as *mut WindowManager;
     if wm.is_null() {