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

commitd2bee463c3082cd5f4fdcc60b87454eb9c72513f
parent8219ce086b
authorLucas Galante <[email protected]>
date2026-09-22 08:23
feat(input): right-click on a window in overview opens its context menu

In overview the client never sees pointer buttons, so a right-click on a
window body now opens `cce-app-menu` the way a right-click on a handle disc
already did — the window's counterpart to cce-grid's desktop-item menu.
Overlay (chrome) and Utility (no handles, no mode) windows are excluded, and
normal-mode right-clicks still reach the client. The app_id is shell-quoted
for the `sh -c` spawn.

The menu itself is restructured around a "Window Mode" submenu — a second
cce-cloud JSON page reached through a `target_page` button — offering
Floating / Tiled / Fullscreen with the current mode marked, applied through
`ccectl set-mode <mode> <id>` rather than `mode <mode> <app_id>`, which
appended a persistent rule that re-classed every later window of the app.

cce-shadow now seeds `$SHADOW_HOME/.local/bin` as a symlink to the real one:
the compositor spawns both context menus by absolute path under $HOME, so
neither could open in a shadow before.

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

 CLAUDE.md            | 10 ++++++
 scripts/cce-app-menu | 91 ++++++++++++++++++++++++++++++++++------------------
 scripts/cce-shadow   | 11 +++++++
 src/server/cursor.rs | 22 ++++++++++++-
 4 files changed, 102 insertions(+), 32 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6a6d1ae..bf05305 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -479,6 +479,16 @@ sat outside the edges and the ring that followed hugged them.
   scanner cannot resolve; never sync the file over it wholesale.
 - The per-side foam clipping the outside band carried is gone: it split a gap
   SHARED with a neighbouring window, and an inside ring shares nothing.
+- **Right-click opens the window context menu** — `scripts/cce-app-menu`, a
+  `cce-cloud --json` popup like `cce-desktop-menu` and cce-grid's item menu.
+  It opens from a right-click on a handle disc in either adjust mode, and in
+  **overview from a right-click anywhere on the window**, since the client
+  never sees buttons there (`should_block_button`) and the press is the
+  compositor's to spend; Overlay (chrome) and Utility (no handles, no mode)
+  bodies are excluded. The menu's "Window Mode" page — a second JSON page
+  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.
 
 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-app-menu b/scripts/cce-app-menu
old mode 100644
new mode 100755
index 70d39b2..1139e35
--- a/scripts/cce-app-menu
+++ b/scripts/cce-app-menu
@@ -1,5 +1,18 @@
 #!/usr/bin/env bash
 # cce-app-menu — Context menu for CCE application windows
+#
+# Opened by the compositor (cursor.rs, the right-click border path): from a
+# right-click on a window's handle disc in adjust mode, and from a right-click
+# anywhere on a window in overview. It is a `cce-cloud --json` popup, the same
+# mechanism as cce-desktop-menu and cce-grid's desktop-item menu, so it looks
+# and dismisses like every other menu in the DE. The first page holds the
+# window verbs; "Window Mode" is a second page (a button with `target_page`
+# switches pages without closing the popup) listing the modes `ccectl
+# set-mode` accepts, the current one marked.
+#
+# The mode is set with `set-mode <mode> <id>` — one window, by id — NOT
+# `mode <mode> <app_id>`, which appends a persistent rule that also
+# re-classes every later window of that app.
 
 CCE_CTL="$HOME/.local/bin/ccectl"
 CLEAR_CLOUD="$HOME/.local/bin/cce-cloud"
@@ -37,23 +50,48 @@ if [[ -z "$window_index" || -z "$app_id" ]]; then
     exit 1
 fi
 
-# Define the JSON layout for the application context menu
-json_layout='{
-  "pages": [
-    {
-      "title": "'"${app_id}"' Window Options",
-      "justify": "left",
-      "widgets": [
-        { "type": "button", "text": "Maximize", "id": "maximize" },
-        { "type": "button", "text": "Tile (Grid)", "id": "tile_grid" },
-        { "type": "button", "text": "Float", "id": "float" },
-        { "type": "button", "text": "Fullscreen", "id": "fullscreen" },
-        { "type": "button", "text": "Minimize", "id": "minimize" },
-        { "type": "button", "text": "Close Window", "id": "close" }
-      ]
-    }
-  ]
-}'
+# The window's current mode, so the submenu can mark it. `windows --json` is
+# one object per line; a window that has gone since the click yields "".
+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")
+
+# Build the layout in python so the app_id (client-chosen text) is escaped
+# for JSON rather than spliced into it.
+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}
+layout = {"pages": [
+    {"title": app_id, "justify": "left", "widgets": [
+        {"type": "button", "text": "Window Mode >", "id": "mode_page", "target_page": 1},
+        {"type": "button", "text": "Minimize", "id": "minimize"},
+        {"type": "button", "text": "Close Window", "id": "close"},
+    ]},
+    {"title": "Window Mode", "justify": "left", "widgets": [
+        mode("Floating", "floating"),
+        mode("Tiled", "tiled"),
+        mode("Fullscreen", "fullscreen"),
+        {"type": "button", "text": "< Back", "id": "back", "target_page": 0},
+    ]},
+]}
+print(json.dumps(layout))
+PY
+)
 
 # Spawn cce-cloud with the json layout and coordinates
 selected=$(echo "$json_layout" | $CLEAR_CLOUD --json $x_arg $y_arg 2>/dev/null)
@@ -65,26 +103,17 @@ btn=$(echo "$selected" | python3 -c "import sys, json; print(json.load(sys.stdin
 
 [[ -z "$btn" ]] && exit 0
 
-# First focus the window so the commands apply to it
-$CCE_CTL focus-window "$window_index"
-
 case "$btn" in
-    "maximize")
-        $CCE_CTL mode maximized "$app_id"
-        ;;
-    "tile_grid")
-        $CCE_CTL mode grid "$app_id"
-        ;;
-    "float")
-        $CCE_CTL mode floating "$app_id"
-        ;;
-    "fullscreen")
-        $CCE_CTL mode fullscreen "$app_id"
+    "floating"|"tiled"|"fullscreen")
+        $CCE_CTL set-mode "$btn" "$window_index"
         ;;
     "minimize")
+        # minimize/close act on the focused window: focus this one first.
+        $CCE_CTL focus-window "$window_index"
         $CCE_CTL minimize
         ;;
     "close")
+        $CCE_CTL focus-window "$window_index"
         $CCE_CTL close
         ;;
 esac
diff --git a/scripts/cce-shadow b/scripts/cce-shadow
index 4229c4d..90c2321 100755
--- a/scripts/cce-shadow
+++ b/scripts/cce-shadow
@@ -448,6 +448,17 @@ seed_config() {
         cp "$HOME/.config/fontconfig/fonts.conf" "$SHADOW_HOME/.config/fontconfig/fonts.conf"
     fi
 
+    # The compositor spawns the desktop and window context menus by absolute
+    # path, "$HOME/.local/bin/cce-desktop-menu" / "cce-app-menu" (cursor.rs),
+    # and those scripts reach ccectl and cce-cloud the same way. With HOME
+    # isolated the directory is missing and a right-click silently opens
+    # nothing, so point it at the real one. A symlink, not copies: the
+    # binaries stay whatever is installed, and `--fresh`'s rm -rf removes
+    # the link, never what it points to.
+    if [ ! -e "$SHADOW_HOME/.local/bin" ] && [ -d "$HOME/.local/bin" ]; then
+        ln -s "$HOME/.local/bin" "$SHADOW_HOME/.local/bin"
+    fi
+
     # See the header: readable-but-key-absent means notifications default ON.
     if ! grep -q '^notifications' "$cfg/config.kdl" 2>/dev/null; then
         printf '\n// cce-shadow: keep captures off the real screen.\nnotifications {\n    screenshots (bool)false\n}\n' \
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index ffe5fe3..79b601b 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1806,12 +1806,32 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
         ) {
             let initial_mode = (*border_target_win).tiling_mode;
             let zone = get_border_zone(border_target_win, lx, ly);
-            if (*event).button == 0x111 && modifiers == 0 && !matches!(zone, BorderZone::None) {
+            // The window context menu (`scripts/cce-app-menu`, a cce-cloud
+            // popup like the desktop menu and cce-grid's item menu). A
+            // right-click on a handle disc opens it in either adjust mode;
+            // in OVERVIEW a right-click anywhere on the window does: the
+            // client never sees buttons there (`should_block_button`), so
+            // the press is the compositor's to spend, and the menu is how a
+            // window's mode is set from the overview. Overlay docks are
+            // chrome and keep their clicks (`overview_chrome` above);
+            // Utility windows take no handles and have no mode to set.
+            let menu_on_body = in_overview
+                && !matches!(
+                    initial_mode,
+                    crate::tiling::TilingMode::Overlay | crate::tiling::TilingMode::Utility
+                );
+            if (*event).button == 0x111
+                && modifiers == 0
+                && (!matches!(zone, BorderZone::None) || menu_on_body)
+            {
                 cursor.right_click_on_border = true;
                 let x = cursor.x() as i32;
                 let y = cursor.y() as i32;
                 let index = (*border_target_win).ref_key.index;
                 let app_id = (*border_target_win).get_app_id_string().unwrap_or_else(|| "unknown".to_string());
+                // The command runs under `sh -c`: quote the app_id, which
+                // is client-chosen text.
+                let app_id = format!("'{}'", app_id.replace('\'', "'\\''"));
                 let home = std::env::var("HOME").unwrap_or_default();
                 let cmd = format!("{}/.local/bin/cce-app-menu -x {} -y {} -i {} -a {}", home, x, y, index, app_id);
                 (*server).wm.execute_action(&crate::config::Action::Spawn, Some(&cmd));