git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

commit5d4f7c96f107a25e19a39f202dd00694aeb1a4b6
parent58bffc7cc5
authorLucas Galante <[email protected]>
date2026-07-28 12:47
feat: Toggle joins DefaultPolicy — match-existing-vs-spawn decision

Policy::action gains the binding's command-string arg. Toggle's
decision half moves in: program_name() (first token, basename) matched
case-insensitively against mapped windows — app_id equal to or
containing the program name (either direction), with a title-contains
fallback for windows that have no app_id; first match in window order
wins. A match closes the window (plus FocusNextVisible when it was the
focused one); no match emits the new Command::Spawn(cmdline), which the
mechanism forks. ActionWindow grows app_id/title/mapped, dropping Copy;
Command drops Copy for the Spawn string.

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

 src/actions.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/api.rs     | 15 ++++++---
 2 files changed, 106 insertions(+), 6 deletions(-)

diff --git a/src/actions.rs b/src/actions.rs
index af41271..0fec670 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -13,8 +13,9 @@ use crate::tiling::TilingMode;
 pub struct DefaultPolicy;
 
 impl Policy for DefaultPolicy {
-    fn action(&mut self, ctx: &ActionCtx, action: Action) -> Vec<Command> {
+    fn action(&mut self, ctx: &ActionCtx, action: Action, arg: Option<&str>) -> Vec<Command> {
         match action {
+            Action::Toggle => toggle(ctx, arg),
             Action::ZoomIn | Action::ZoomOut | Action::ZoomReset => zoom(ctx, action),
             Action::PanLeft | Action::PanRight | Action::PanUp | Action::PanDown => {
                 pan_step(ctx, action)
@@ -178,6 +179,49 @@ fn expose(ctx: &ActionCtx) -> Vec<Command> {
     }
 }
 
+/// The bare program name of a command line: first token, basename only.
+pub fn program_name(cmd: &str) -> String {
+    let first_token = cmd.trim().split_whitespace().next().unwrap_or("");
+    match first_token.rfind('/') {
+        Some(pos) => first_token[pos + 1..].to_string(),
+        None => first_token.to_string(),
+    }
+}
+
+/// Toggle a program: if a mapped window matches its name, close it (and
+/// refocus if it was the focused one); otherwise spawn the command. Matching
+/// is case-insensitive — app_id equal to or containing (either way) the
+/// program name; a window with NO app_id falls back to a title-contains
+/// match. First match in window order wins.
+fn toggle(ctx: &ActionCtx, arg: Option<&str>) -> Vec<Command> {
+    let Some(cmd) = arg else { return Vec::new() };
+    let prog = program_name(cmd).to_lowercase();
+    let matched = ctx.windows.iter().find(|w| {
+        if !w.mapped {
+            return false;
+        }
+        if let Some(aid) = &w.app_id {
+            let aid = aid.to_lowercase();
+            aid == prog || aid.contains(&prog) || prog.contains(&aid)
+        } else if let Some(title) = &w.title {
+            title.to_lowercase().contains(&prog)
+        } else {
+            false
+        }
+    });
+    match matched {
+        Some(w) => {
+            let mut cmds = vec![Command::CloseWindow(w.id)];
+            if ctx.focused == Some(w.id) {
+                cmds.push(Command::FocusNextVisible);
+            }
+            cmds.push(Command::Relayout);
+            cmds
+        }
+        None => vec![Command::Spawn(cmd.to_string())],
+    }
+}
+
 /// Close the focused window, then refocus by the mechanism's next-visible
 /// rule.
 fn close(ctx: &ActionCtx) -> Vec<Command> {
@@ -318,6 +362,9 @@ mod tests {
     fn win(index: u32, x: f64, y: f64, w: f64, h: f64) -> ActionWindow {
         ActionWindow {
             id: wid(index),
+            app_id: None,
+            title: None,
+            mapped: true,
             x,
             y,
             w,
@@ -351,7 +398,53 @@ mod tests {
     }
 
     fn dispatch(ctx: &ActionCtx, action: Action) -> Vec<Command> {
-        DefaultPolicy.action(ctx, action)
+        DefaultPolicy.action(ctx, action, None)
+    }
+
+    #[test]
+    fn toggle_spawns_or_closes_by_program_match() {
+        let mut c = ctx();
+        // No arg: not claimed. No match: spawn.
+        assert!(dispatch(&c, Action::Toggle).is_empty());
+        assert_eq!(
+            DefaultPolicy.action(&c, Action::Toggle, Some("firefox --new-window")),
+            vec![Command::Spawn("firefox --new-window".to_string())]
+        );
+        // App-id containment match (either direction, case-insensitive)
+        // closes; the focused match also refocuses.
+        let mut w = win(1, 0.0, 0.0, 100.0, 100.0);
+        w.app_id = Some("org.mozilla.Firefox".to_string());
+        c.windows.push(w);
+        assert_eq!(
+            DefaultPolicy.action(&c, Action::Toggle, Some("/usr/bin/firefox -P")),
+            vec![Command::CloseWindow(wid(1)), Command::Relayout]
+        );
+        c.focused = Some(wid(1));
+        assert_eq!(
+            DefaultPolicy.action(&c, Action::Toggle, Some("firefox")),
+            vec![Command::CloseWindow(wid(1)), Command::FocusNextVisible, Command::Relayout]
+        );
+        // A window without an app_id falls back to title-contains; an
+        // unmapped window never matches.
+        let mut t = win(2, 0.0, 0.0, 100.0, 100.0);
+        t.title = Some("Alacritty scratchpad".to_string());
+        c.windows.push(t);
+        assert_eq!(
+            DefaultPolicy.action(&c, Action::Toggle, Some("alacritty")),
+            vec![Command::CloseWindow(wid(2)), Command::Relayout]
+        );
+        c.windows[1].mapped = false;
+        assert_eq!(
+            DefaultPolicy.action(&c, Action::Toggle, Some("alacritty")),
+            vec![Command::Spawn("alacritty".to_string())]
+        );
+    }
+
+    #[test]
+    fn program_name_takes_first_token_basename() {
+        assert_eq!(program_name("/usr/bin/firefox --new-window"), "firefox");
+        assert_eq!(program_name("  alacritty -e htop "), "alacritty");
+        assert_eq!(program_name(""), "");
     }
 
     #[test]
diff --git a/src/api.rs b/src/api.rs
index 1ca50d1..2416b25 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -269,9 +269,13 @@ pub struct ActionCtx {
 }
 
 /// A window as `Policy::action` sees it.
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone)]
 pub struct ActionWindow {
     pub id: WindowId,
+    pub app_id: Option<String>,
+    pub title: Option<String>,
+    /// state == Mapped (narrower than `visible`, which also spans teardown).
+    pub mapped: bool,
     /// Virtual-space position. `w`/`h` are the mechanism's working extent in
     /// output px (box_geom, defaulted to 800x600 while unmapped).
     pub x: f64,
@@ -298,8 +302,10 @@ pub struct ActionWindow {
 
 /// One mechanism write, returned by policy decisions and applied in order —
 /// the command-stream counterpart of the arrange plan.
-#[derive(Debug, Clone, Copy, PartialEq)]
+#[derive(Debug, Clone, PartialEq)]
 pub enum Command {
+    /// Spawn a command line (the mechanism forks `sh -c`).
+    Spawn(String),
     /// Write the camera. `overview: None` leaves the mode untouched.
     SetCamera { camera: Camera, overview: Option<bool> },
     /// Set pan-animation targets (a `None` axis is left alone) and start
@@ -328,9 +334,10 @@ pub enum Command {
 
 /// Decisions, policy-side. Implemented by `actions::DefaultPolicy`.
 pub trait Policy {
-    /// Decide a user action against the snapshot. An empty vec means "not
+    /// Decide a user action against the snapshot; `arg` is the binding's
+    /// command string (Spawn/Toggle carry one). An empty vec means "not
     /// mine" — the mechanism falls through to its remaining legacy arms.
-    fn action(&mut self, ctx: &ActionCtx, action: Action) -> Vec<Command>;
+    fn action(&mut self, ctx: &ActionCtx, action: Action, arg: Option<&str>) -> Vec<Command>;
 }
 
 /// Execution, mechanism-side. Implemented by the compositor's WindowManager.