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

commitff3856b0bbf272d6eb5bff0038db94ecfd9b2b38
parentd2bee463c3
authorLucas Galante <[email protected]>
date2026-09-22 10:19
feat(menu): the desktop menu gets a Window Mode page for the focused window

The same Floating / Tiled / Fullscreen page cce-app-menu shows, current mode
marked, applied with `ccectl set-mode <mode> <id>` and the window refocused
after. The background right-click drops focus right after spawning the menu
(so the popup takes the keyboard and a click-away dismisses it), so the
script cannot ask which window was focused: the compositor passes it as
`-i <id> -a <app_id>`, only when its mode is one set-mode accepts, and the
page is omitted otherwise.

The script is also made executable in the repo; the installer chmods, so
only a shadow pointed at the tree's copy ever noticed.

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

 CLAUDE.md                |  4 ++
 scripts/cce-desktop-menu | 97 ++++++++++++++++++++++++++++++++++++++----------
 src/server/cursor.rs     | 25 ++++++++++++-
 3 files changed, 106 insertions(+), 20 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bf05305..701fb5b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -489,6 +489,10 @@ sat outside the edges and the ring that followed hugged them.
   reached through a `target_page` button, which switches pages without
   closing the popup — sets the mode with `ccectl set-mode <mode> <id>`, one
   window by id. Not `ccectl mode`, which appends a persistent app_id rule.
+  `cce-desktop-menu` carries the same page for the FOCUSED window: the
+  background right-click passes it as `-i <id> -a <app_id>` (settable modes
+  only) because it drops focus right after the spawn, so the script could
+  not ask for it.
 
 None of this is policy — `cce-window-manager` was untouched. The mode is
 already in `ActionCtx`, but what a *pointer* may grab is mechanism.
diff --git a/scripts/cce-desktop-menu b/scripts/cce-desktop-menu
old mode 100644
new mode 100755
index fb3be1d..a362957
--- a/scripts/cce-desktop-menu
+++ b/scripts/cce-desktop-menu
@@ -1,5 +1,13 @@
 #!/usr/bin/env bash
 # cce-desktop-menu — Context menu for CCE desktop background
+#
+# A `cce-cloud --json` popup, like cce-app-menu and cce-grid's item menu. The
+# compositor (cursor.rs, the background right-click) passes the FOCUSED
+# window as `-i <id> -a <app_id>` when there is one whose mode can be set:
+# it drops focus right after the spawn so the popup can take the keyboard,
+# so the script cannot ask for it. With a window named, the menu gains a
+# "Window Mode" page — the same page cce-app-menu shows — whose picks go
+# through `ccectl set-mode <mode> <id>`.
 
 CCE_CTL="$HOME/.local/bin/ccectl"
 CLEAR_CLOUD="$HOME/.local/bin/cce-cloud"
@@ -9,6 +17,8 @@ x_arg=""
 y_arg=""
 x_val=""
 y_val=""
+window_index=""
+app_id=""
 
 # Open the app WHERE THE MENU WAS OPENED: the next window matching $1 covers
 # the grid square under the click, keeping the size it had last time and
@@ -31,31 +41,72 @@ while [[ $# -gt 0 ]]; do
         -y|--y-pos)
             if [[ $# -ge 2 ]]; then y_arg="-y $2"; y_val="$2"; shift 2; else shift; fi
             ;;
+        -i|--window-id)
+            if [[ $# -ge 2 ]]; then window_index="$2"; shift 2; else shift; fi
+            ;;
+        -a|--app-id)
+            if [[ $# -ge 2 ]]; then app_id="$2"; shift 2; else shift; fi
+            ;;
         *)
             shift
             ;;
     esac
 done
 
-# Define the JSON layout for the desktop context menu
-json_layout='{
-  "pages": [
-    {
-      "title": "Desktop Context Menu",
-      "justify": "left",
-      "widgets": [
-        { "type": "button", "text": "Terminal", "id": "terminal" },
-        { "type": "button", "text": "Files", "id": "files" },
-        { "type": "button", "text": "Data Editor", "id": "data_editor" },
-        { "type": "button", "text": "Applications", "id": "apps" },
-        { "type": "button", "text": "System Settings", "id": "settings" },
-        { "type": "button", "text": "Overview", "id": "overview" },
-        { "type": "button", "text": "Reload Config", "id": "reload" },
-        { "type": "button", "text": "Logout", "id": "exit" }
-      ]
-    }
-  ]
-}'
+# The named window's current mode, so the submenu can mark it. One JSON
+# object per line; a window gone since the click yields "" and the page is
+# left out.
+current_mode=""
+if [[ -n "$window_index" ]]; then
+    current_mode=$("$CCE_CTL" windows --json 2>/dev/null | python3 -c '
+import sys, json
+want = sys.argv[1]
+for line in sys.stdin:
+    line = line.strip()
+    if not line:
+        continue
+    try:
+        w = json.loads(line)
+    except ValueError:
+        continue
+    if str(w.get("id")) == want:
+        print(str(w.get("mode", "")).lower())
+        break
+' "$window_index")
+fi
+
+# Build the layout in python so the app_id (client-chosen text) is escaped
+# for JSON rather than spliced into it. The "Window Mode" entry sits with
+# Overview, the other window-manager verb; a `target_page` button switches
+# pages without closing the popup.
+json_layout=$(python3 - "$app_id" "$current_mode" <<'PY'
+import sys, json
+app_id, current = sys.argv[1], sys.argv[2]
+def mode(text, ident):
+    mark = "● " if ident == current else "   "
+    return {"type": "button", "text": mark + text, "id": ident}
+main = [
+    {"type": "button", "text": "Terminal", "id": "terminal"},
+    {"type": "button", "text": "Files", "id": "files"},
+    {"type": "button", "text": "Data Editor", "id": "data_editor"},
+    {"type": "button", "text": "Applications", "id": "apps"},
+    {"type": "button", "text": "System Settings", "id": "settings"},
+    {"type": "button", "text": "Overview", "id": "overview"},
+]
+pages = [{"title": "Desktop Context Menu", "justify": "left", "widgets": main}]
+if current:
+    main.append({"type": "button", "text": "Window Mode >", "id": "mode_page", "target_page": 1})
+    pages.append({"title": app_id, "justify": "left", "widgets": [
+        mode("Floating", "floating"),
+        mode("Tiled", "tiled"),
+        mode("Fullscreen", "fullscreen"),
+        {"type": "button", "text": "< Back", "id": "back", "target_page": 0},
+    ]})
+main.append({"type": "button", "text": "Reload Config", "id": "reload"})
+main.append({"type": "button", "text": "Logout", "id": "exit"})
+print(json.dumps({"pages": pages}))
+PY
+)
 
 # Spawn cce-cloud with the json layout and coordinates
 selected=$(echo "$json_layout" | $CLEAR_CLOUD --json $x_arg $y_arg 2>/dev/null)
@@ -104,6 +155,14 @@ case "$btn" in
     "overview")
         $CCE_CTL overview
         ;;
+    "floating"|"tiled"|"fullscreen")
+        # The background right-click dropped focus for the popup's sake;
+        # the window just acted on is the one to hand it back to.
+        if [[ -n "$window_index" ]]; then
+            $CCE_CTL set-mode "$btn" "$window_index"
+            $CCE_CTL focus-window "$window_index"
+        fi
+        ;;
     "reload")
         $CCE_CTL reload
         ;;
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 79b601b..250c422 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1679,7 +1679,30 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 let x = cursor.x() as i32;
                 let y = cursor.y() as i32;
                 let home = std::env::var("HOME").unwrap_or_default();
-                let cmd = format!("{}/.local/bin/cce-desktop-menu -x {} -y {}", home, x, y);
+                let mut cmd = format!("{}/.local/bin/cce-desktop-menu -x {} -y {}", home, x, y);
+                // The menu's "Window Mode" page acts on the FOCUSED window,
+                // and focus is dropped right below (so the popup takes the
+                // keyboard and a click-away dismisses it) — by the time the
+                // script asks `ccectl windows` nothing is focused. Hand it
+                // the window here instead. Only a window whose mode
+                // `set-mode` accepts is worth naming.
+                if let Focus::Window(w) = seat.focused {
+                    if !w.is_null()
+                        && matches!(
+                            (*w).tiling_mode,
+                            crate::tiling::TilingMode::Floating
+                                | crate::tiling::TilingMode::Tiled
+                                | crate::tiling::TilingMode::Fullscreen
+                        )
+                    {
+                        let app_id = (*w).get_app_id_string().unwrap_or_else(|| "unknown".to_string());
+                        cmd.push_str(&format!(
+                            " -i {} -a '{}'",
+                            (*w).ref_key.index,
+                            app_id.replace('\'', "'\\''")
+                        ));
+                    }
+                }
                 (*server).wm.execute_action(&crate::config::Action::Spawn, Some(&cmd));
 
                 seat.focus(Focus::None);