Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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));