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

commit99946ad53999c2a6c61471eedacf49d2617c3fdc
parent7054266c7a
authorLucas Galante <[email protected]>
date2026-07-20 22:45
feat: cycle the window switcher with repeat super+tab / super+shift+tab

Super+tab is a compositor keybinding, so while super is held Tab never
reaches the cce-cloud overlay - every press re-entered the WindowSwitcher
action and tried to spawn a second switcher. Track the running switcher's
stdin (held open instead of closed after the item feed) and forward repeat
presses as cce-cloud's __cce_switcher_next__ / __cce_switcher_prev__ magic
lines, moving the highlight.

New WindowSwitcherPrev action (policy crate) cycles backwards; a fresh
backwards open lands on the least-recently-used window. Also parsed from
the legacy window_manager.window_switcher_prev config key.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/server/config.rs         |   5 +-
 src/server/window_manager.rs | 148 +++++++++++++++++++++++++++++++------------
 2 files changed, 110 insertions(+), 43 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 2575f30..456e72f 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -245,6 +245,7 @@ pub struct WindowManagerConfig {
     pub toggle_fullscreen: Option<String>,
     pub toggle_overview: Option<String>,
     pub window_switcher: Option<String>,
+    pub window_switcher_prev: Option<String>,
     /// Whether a newly spawned window pulls the viewport over to it. `None` = the default,
     /// which is to centre (what the compositor has always done).
     pub center_on_spawn: Option<bool>,
@@ -1712,9 +1713,10 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let toggle_fullscreen = get_child_arg_string_opt(node, "toggle_fullscreen");
         let toggle_overview = get_child_arg_string_opt(node, "toggle_overview");
         let window_switcher = get_child_arg_string_opt(node, "window_switcher");
+        let window_switcher_prev = get_child_arg_string_opt(node, "window_switcher_prev");
         let center_on_spawn = get_child_arg_bool_opt(node, "center_on_spawn");
         let corner_shape = get_child_arg_f64_opt(node, "corner_shape");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, center_on_spawn, corner_shape });
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, corner_shape });
     }
 
     Ok(Config {
@@ -1942,6 +1944,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
             (&wm_config.close_window, Action::Close),
             (&wm_config.toggle_fullscreen, Action::Fullscreen),
             (&wm_config.window_switcher, Action::WindowSwitcher),
+            (&wm_config.window_switcher_prev, Action::WindowSwitcherPrev),
         ];
         for (chord_str, action) in wm_section_binds {
             let Some(chord_str) = chord_str else { continue };
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 79c5b8b..2e298fc 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1642,11 +1642,43 @@ impl WindowManager {
     /// 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) {
+    /// cce-cloud is a `Layer::Overlay` surface with exclusive keyboard focus,
+    /// and it commits on Super release / cancels on Escape by itself. Tab while
+    /// Super is held, however, never reaches it — that chord matches this very
+    /// keybinding — so a repeat press lands back here and is forwarded as a
+    /// `__cce_switcher_next__` / `__cce_switcher_prev__` line down the held-open
+    /// stdin pipe, moving the highlight. The committed entry is printed to
+    /// stdout; we only build the list and map the selection back to a window id.
+    ///
+    /// `backwards` is the super+shift+tab direction: it cycles the highlight
+    /// the other way, and opening with it lands on the least-recently-used
+    /// window instead of the previously focused one.
+    pub unsafe fn launch_window_switcher(&mut self, backwards: bool) {
+        let cycle_line: &[u8] = if backwards {
+            b"__cce_switcher_prev__\n"
+        } else {
+            b"__cce_switcher_next__\n"
+        };
+
+        // A repeat super+(shift+)tab while the switcher is already up moves its
+        // highlight instead of spawning a second switcher.
+        {
+            let mut active = ACTIVE_SWITCHER.lock().unwrap_or_else(|e| e.into_inner());
+            if let Some(handle) = active.as_mut() {
+                use std::io::Write;
+                if handle
+                    .stdin
+                    .write_all(cycle_line)
+                    .and_then(|_| handle.stdin.flush())
+                    .is_ok()
+                {
+                    return;
+                }
+                // Dead pipe: the child exited and we raced its cleanup thread.
+                // Drop the stale handle and open a fresh switcher below.
+                *active = None;
+            }
+        }
         // 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.
@@ -1682,6 +1714,12 @@ impl WindowManager {
             input.push('\n');
             items.push((id, display));
         }
+        if backwards {
+            // cce-cloud auto-highlights index 1 once the items land; two prev
+            // steps from there wrap to the last entry, so a backwards open
+            // starts on the least-recently-used window (classic alt+shift+tab).
+            input.push_str("__cce_switcher_prev__\n__cce_switcher_prev__\n");
+        }
 
         // Position near the top-centre of the enabled output. Passing explicit
         // -x/-y keeps cce-cloud a layer-shell overlay (omitting both would make
@@ -1705,8 +1743,49 @@ impl WindowManager {
         let y_pos = origin_y + 80;
 
         let display_env = std::env::var("WAYLAND_DISPLAY").ok();
+
+        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;
+            }
+        };
+
+        // Feed the item list but keep stdin open: repeat super+(shift+)tab
+        // presses write cycle lines down the same pipe. cce-cloud streams
+        // items in as they arrive and does not wait for EOF.
+        let generation = SWITCHER_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+        if let Some(mut stdin) = child.stdin.take() {
+            use std::io::Write;
+            let _ = stdin.write_all(input.as_bytes());
+            let _ = stdin.flush();
+            *ACTIVE_SWITCHER.lock().unwrap_or_else(|e| e.into_inner()) =
+                Some(SwitcherHandle { generation, stdin });
+        }
+
         std::thread::spawn(move || {
-            run_window_switcher(input, items, x_pos, y_pos, display_env);
+            run_window_switcher(child, items, display_env);
+            // The switcher is gone; release its stdin unless a newer switcher
+            // already replaced it.
+            let mut active = ACTIVE_SWITCHER.lock().unwrap_or_else(|e| e.into_inner());
+            if active.as_ref().map_or(false, |h| h.generation == generation) {
+                *active = None;
+            }
         });
     }
 
@@ -1961,7 +2040,10 @@ impl WindowManager {
                 }
             }
             Action::WindowSwitcher => {
-                self.launch_window_switcher();
+                self.launch_window_switcher(false);
+            }
+            Action::WindowSwitcherPrev => {
+                self.launch_window_switcher(true);
             }
             Action::Reload => {
                 log::info!("monolithic execute_action: Reload requested");
@@ -3379,47 +3461,29 @@ fn cce_cloud_cmd() -> String {
     "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).
+/// Stdin of the currently open `cce-cloud --switcher` child. Held open (in
+/// `ACTIVE_SWITCHER`) so repeat super+tab presses can advance the highlight via
+/// cce-cloud's magic `__cce_switcher_next__` stdin line; the generation lets the
+/// per-switcher cleanup thread avoid clearing a newer switcher's handle.
+struct SwitcherHandle {
+    generation: u64,
+    stdin: std::process::ChildStdin,
+}
+
+static ACTIVE_SWITCHER: std::sync::Mutex<Option<SwitcherHandle>> = std::sync::Mutex::new(None);
+static SWITCHER_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+
+/// Tail of the window switcher, run on a detached thread: waits for the
+/// committed selection on the already-spawned child's 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,
+    mut child: std::process::Child,
     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);