Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
Eat portal-bound global shortcuts for the GlobalShortcuts backend
A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
GlobalShortcuts interface (1Password's Quick Access, Ctrl+Shift+Space),
and the new cce-shortcuts-portal backend forwards that here. This is the
compositor half:
- global_shortcuts.rs: a table of (session, id, mods, keysym) on the
window manager and the 'shortcut bind|unbind|clear|list' control-socket
command. bind parses a shortcuts-spec trigger (CTRL+SHIFT+space) and
replies with the trigger_description the app renders; a chord the
user's keybinds already use is refused rather than shadowed.
- keyboard_group.rs: the chord is matched after the builtins and config
keybinds through the same two-level keysym lookup, now shared as
match_chord, as KeyConsumer::PortalShortcut. Press and release are
both eaten.
- status_server.rs: a 'shortcuts' topic carries one-shot
'activated|deactivated <session> <id> <time_msec>' lines, which the
backend re-emits as the portal signals.
- ccebuild installs <crate>/portals/*.portal declarations, and
file_crate_dir learns the portals/ subdirectory so the package filter
does not drop them.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 39 +++++++
scripts/ccebuild | 31 +++++-
src/cce_ctl.rs | 2 +
src/lib.rs | 2 +
src/server/global_shortcuts.rs | 234 +++++++++++++++++++++++++++++++++++++++++
src/server/keyboard_group.rs | 78 +++++++++++---
src/server/status_server.rs | 59 ++++++++++-
src/server/window_manager.rs | 7 ++
8 files changed, 432 insertions(+), 20 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2e56a6a..2ebc4cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,6 +71,13 @@ It also installs the **`.desktop` entries** crates ship at their own root into
in `~/.local/share/applications` until 2026-08-16; see `./WORKSPACE.md` for the
`Exec=`/`MimeType=` rules that go with them.
+**Portal declarations** (`<crate>/portals/*.portal`, the file that tells
+xdg-desktop-portal a backend's bus name and interfaces) install to
+`$XDG_DATA_HOME/xdg-desktop-portal/portals/` the same filtered way
+(`portal_files()`); `cce-shortcuts-portal` ships the first. `file_crate_dir()`
+must know every such subdirectory name (`scripts`, `dbus`, `portals`) or the
+package filter reads the subdirectory as the crate and drops the file.
+
**App icons** install from any crate's `hicolor/` tree (`app_icons()`), mirrored
verbatim into `$XDG_DATA_HOME/icons/hicolor/` — so an icon's size and context are
its directory, not a rule in the script, and `48x48/apps` would need no edit here.
@@ -703,6 +710,38 @@ headless seat has no keyboard and Chromium crashes in
no committed buffer, an unsupported read format, an implausibly large strip —
falls back to `backdrop::UNKNOWN`.
+### Portal global shortcuts
+
+A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
+`GlobalShortcuts` interface for one (1Password's Quick Access does), and the
+portal frontend hands that to a backend. `../cce-shortcuts-portal` is that
+backend and **`src/server/global_shortcuts.rs` is this side of it** — a
+table of `(session, id, mods, keysym)` on the window manager
+(`portal_shortcuts`) with a control-socket command to fill it and a status
+topic to report it:
+
+- `shortcut bind <session> <id> <trigger>` parses a shortcuts-spec trigger
+ (`CTRL+SHIFT+space`; modifiers `CTRL`/`ALT`/`SHIFT`/`LOGO`, key an xkb
+ keysym name) and replies `ok <trigger_description>` (`Ctrl+Shift+Space`)
+ or `error: …`. A chord in `keybinds` is refused — the user's config owns
+ it — as is one another session already holds. `unbind <session> [<id>]`,
+ `clear` and `list` are the rest. Nothing is persisted; the backend sends
+ `clear` when it starts.
+- The chord is matched in `handle_group_key` after the builtins and the
+ config keybinds, through the same two-level keysym lookup
+ (`keyboard_group::match_chord`, which `match_cce_keybind` now wraps), as
+ `KeyConsumer::PortalShortcut`. Press AND release are pushed as one-shot
+ lines on the status socket's `shortcuts` topic —
+ `activated|deactivated <session> <id> <time_msec>` — since the portal has
+ a `Deactivated` signal; neither edge reaches the client.
+
+The compositor never learns which app asked: the session object path is
+the only identity it carries, and it is one whitespace-free token, which is
+why ids come percent-encoded (`Quick%20Access`) and stay that way here.
+Drive it in a shadow with `ccectl shortcut bind /s/1 x CTRL+SHIFT+space`
+and `verify/clients`' `vkey mod:5 57` — not `ccectl keypress`, which goes
+straight to the focused client and never meets the chord matcher.
+
## Conventions
- This is systems FFI code: raw pointers, `unsafe`, and manual wlroots listener wiring
diff --git a/scripts/ccebuild b/scripts/ccebuild
index 4b9306f..8930104 100755
--- a/scripts/ccebuild
+++ b/scripts/ccebuild
@@ -32,6 +32,7 @@ UNITDIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
DESKTOPDIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICONDIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
DBUSDIR="${XDG_DATA_HOME:-$HOME/.local/share}/dbus-1/services"
+PORTALDIR="${XDG_DATA_HOME:-$HOME/.local/share}/xdg-desktop-portal/portals"
die() { printf 'ccebuild: %s\n' "$*" >&2; exit 1; }
@@ -225,6 +226,18 @@ dbus_services() {
-not -path "$WS/target/*" 2>/dev/null
}
+# xdg-desktop-portal backend declarations a crate ships in its portals/ dir
+# (`<name>.portal`: the backend's bus name and the impl.portal interfaces it
+# serves), installed to $XDG_DATA_HOME/xdg-desktop-portal/portals where the
+# portal frontend reads them beside /usr/share's. The file only announces the
+# backend; the bus name it names must be activatable, which is the crate's
+# dbus/ service file. Which backend a desktop USES for an interface is
+# ~/.config/xdg-desktop-portal/cce-portals.conf, not installed from here.
+portal_files() {
+ find "$WS" -mindepth 3 -maxdepth 3 -path '*/portals/*.portal' -type f \
+ -not -path "$WS/target/*" 2>/dev/null
+}
+
# XDG .desktop entries shipped by crates, at the crate root next to Cargo.toml.
#
# These used to be hand-written straight into ~/.local/share/applications, which
@@ -278,8 +291,12 @@ app_icons() {
file_crate_dir() {
local dir
dir=$(basename "$(dirname "$1")")
+ # The subdirectories a crate ships installable files in; a file one level
+ # down reports the crate above. A new kind of shipped file needs its dir
+ # here, or its crate reads as e.g. "portals" and the package filter drops
+ # it silently (which is how the first portal declaration went uninstalled).
case "$dir" in
- scripts|dbus) dir=$(basename "$(dirname "$(dirname "$1")")") ;;
+ scripts|dbus|portals) dir=$(basename "$(dirname "$(dirname "$1")")") ;;
esac
printf '%s\n' "$dir"
}
@@ -374,6 +391,18 @@ cmd_install() {
dbus=$((dbus + 1))
done < <(dbus_services)
+ local pf portals=0
+ while read -r pf; do
+ [ -n "$pf" ] || continue
+ # Same filtered install as the units above.
+ if [ ${#pkgs[@]} -gt 0 ]; then
+ crate_selected "$(file_crate_dir "$pf")" "${pkgs[@]}" || continue
+ fi
+ [ "$portals" -eq 0 ] && { printf '==> installing portal declarations -> %s\n' "$PORTALDIR"; mkdir -p "$PORTALDIR"; }
+ install -m 644 "$pf" "$PORTALDIR/$(basename "$pf")"
+ portals=$((portals + 1))
+ done < <(portal_files)
+
local d desktops=0
while read -r d; do
[ -n "$d" ] || continue
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 8a2fb57..36781b1 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -111,6 +111,8 @@ fn usage(name: &str, to_stderr: bool) {
print(" key-down <keycode> (modifier codes — ctrl 29/97, shift 42/54,");
print(" key-up <keycode> alt 56/100, super 125/126 — update client");
print(" xkb state, so e.g. 29+36 lands as ctrl+j)");
+ print(" shortcut bind <session> <id> <trigger> (portal GlobalShortcuts backend: CTRL+SHIFT+space)");
+ print(" shortcut unbind <session> [<id>] | clear | list");
}
pub fn run_cce_ctl(args: Vec<String>) {
diff --git a/src/lib.rs b/src/lib.rs
index 903d158..a6510d7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -37,6 +37,8 @@ pub mod ipc_server;
pub mod screenshot;
#[path = "server/status_server.rs"]
pub mod status_server;
+#[path = "server/global_shortcuts.rs"]
+pub mod global_shortcuts;
#[path = "server/stream_server.rs"]
pub mod stream_server;
#[path = "server/scene_node_data.rs"]
diff --git a/src/server/global_shortcuts.rs b/src/server/global_shortcuts.rs
new file mode 100644
index 0000000..04d0713
--- /dev/null
+++ b/src/server/global_shortcuts.rs
@@ -0,0 +1,234 @@
+// SPDX-License-Identifier: GPL-3.0-only
+//! Portal global shortcuts — chords the compositor eats on behalf of the
+//! `org.freedesktop.impl.portal.GlobalShortcuts` backend (`cce-shortcuts-portal`).
+//!
+//! A native Wayland client cannot grab keys; what it can do is ask the
+//! desktop portal to bind a trigger for it (1Password's Quick Access does
+//! exactly this). The portal frontend forwards that to a backend, and the
+//! backend forwards it here over the control socket:
+//!
+//! ```text
+//! shortcut bind <session> <id> <trigger> -> ok <trigger_description> | error: …
+//! shortcut unbind <session> [<id>] -> ok
+//! shortcut clear -> ok
+//! shortcut list -> <session> <id> <trigger_description> per line
+//! ```
+//!
+//! `<session>` is the portal's session object path (no whitespace, so it is
+//! one token) and `<trigger>` is the shortcuts-spec string the app supplied
+//! (`CTRL+SHIFT+space`). A bound chord is matched in `handle_group_key`
+//! AFTER the builtins and the user's own keybinds — the user's config always
+//! wins, and a bind for a chord the config already uses is refused rather
+//! than silently shadowed, so the app is told it did not get it. Press and
+//! release are reported as one-shot lines on the status socket's
+//! `shortcuts` topic (`activated|deactivated <session> <id> <time_msec>`),
+//! which is where the backend turns them into the portal's `Activated` /
+//! `Deactivated` signals. The compositor never learns which app asked; the
+//! session path is the only identity it carries.
+//!
+//! The table is process state, not config: nothing here is persisted, and a
+//! backend that starts fresh sends `clear` first so a bind left by a dead
+//! predecessor cannot keep eating a chord nobody listens for.
+
+use crate::window_manager::WindowManager;
+
+/// One bound chord. `mods` is the wlr modifier mask (`config::parse_modifiers`
+/// values) and `keysym` an xkb keysym, exactly what `Keybind` carries, so the
+/// same matcher serves both tables.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PortalShortcut {
+ pub session: String,
+ pub id: String,
+ pub mods: u32,
+ pub keysym: u32,
+ /// The `trigger_description` handed back to the app: `Ctrl+Shift+Space`.
+ pub description: String,
+}
+
+/// A parsed shortcuts-spec trigger.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Trigger {
+ pub mods: u32,
+ pub keysym: u32,
+ pub description: String,
+}
+
+const MOD_SHIFT: u32 = 0x01;
+const MOD_CTRL: u32 = 0x04;
+const MOD_ALT: u32 = 0x08;
+const MOD_LOGO: u32 = 0x40;
+
+/// Parse a shortcuts-spec trigger: modifiers and one key joined by `+`, the
+/// modifiers being `CTRL`, `ALT`, `SHIFT` and `LOGO` (case-insensitive;
+/// `SUPER` and `META` are taken as `LOGO` since apps do write them) and the
+/// key an xkb keysym name (`space`, `F5`, `q`). A trailing `+` is the plus
+/// key itself, as in `CTRL++`.
+pub fn parse_trigger(s: &str) -> Result<Trigger, String> {
+ let s = s.trim();
+ if s.is_empty() {
+ return Err("empty trigger".into());
+ }
+ // `CTRL++` splits as ["CTRL", "", ""]: an empty last part after a `+`
+ // means the key is `+` itself.
+ let mut parts: Vec<&str> = s.split('+').collect();
+ let key = match parts.pop() {
+ Some("") if s.ends_with('+') => {
+ // Drop the empty part before it too (the one between the two
+ // plus signs), leaving just the modifiers.
+ parts.pop();
+ "plus"
+ }
+ Some(k) => k,
+ None => return Err("empty trigger".into()),
+ };
+ let mut mods = 0u32;
+ for m in parts {
+ let bit = match m.to_ascii_uppercase().as_str() {
+ "CTRL" | "CONTROL" => MOD_CTRL,
+ "ALT" => MOD_ALT,
+ "SHIFT" => MOD_SHIFT,
+ "LOGO" | "SUPER" | "META" => MOD_LOGO,
+ "" => return Err(format!("empty modifier in {s:?}")),
+ other => return Err(format!("unknown modifier {other:?}")),
+ };
+ mods |= bit;
+ }
+ let keysym: u32 = xkbcommon::xkb::keysym_from_name(key, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into();
+ if keysym == 0 {
+ return Err(format!("unknown key {key:?}"));
+ }
+ if unsafe { crate::keyboard::keysym_is_modifier(keysym) } {
+ return Err(format!("{key:?} is a modifier, not a key"));
+ }
+ Ok(Trigger { mods, keysym, description: describe(mods, keysym) })
+}
+
+/// Human form for the app to render: `Ctrl+Shift+Space`. Modifier order is
+/// fixed regardless of how the trigger was written.
+fn describe(mods: u32, keysym: u32) -> String {
+ let mut out = Vec::new();
+ if mods & MOD_CTRL != 0 {
+ out.push("Ctrl".to_string());
+ }
+ if mods & MOD_ALT != 0 {
+ out.push("Alt".to_string());
+ }
+ if mods & MOD_SHIFT != 0 {
+ out.push("Shift".to_string());
+ }
+ if mods & MOD_LOGO != 0 {
+ out.push("Super".to_string());
+ }
+ let name = xkbcommon::xkb::keysym_get_name(xkbcommon::xkb::Keysym::new(keysym));
+ // Single letters read better upper-case; multi-letter names (`space`,
+ // `Return`, `F5`) get an initial capital and are otherwise left alone.
+ let mut chars = name.chars();
+ let pretty = match chars.next() {
+ Some(c) if name.chars().count() == 1 => c.to_uppercase().collect::<String>(),
+ Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
+ None => name.clone(),
+ };
+ out.push(pretty);
+ out.join("+")
+}
+
+/// The `shortcut …` control-socket command. `args` is everything after the
+/// word `shortcut`.
+pub fn ipc(wm: &mut WindowManager, args: &[&str]) -> String {
+ match args.first().copied() {
+ Some("bind") => {
+ let [_, session, id, trigger] = args else {
+ return "error: usage: shortcut bind <session> <id> <trigger>\n".to_string();
+ };
+ let t = match parse_trigger(trigger) {
+ Ok(t) => t,
+ Err(e) => return format!("error: {e}\n"),
+ };
+ // The user's config owns its chords: a portal bind never
+ // shadows one, and the app hears that it was refused.
+ if wm.keybinds.iter().any(|kb| kb.mods == t.mods && kb.keysym == t.keysym) {
+ return format!("error: {} is a compositor keybind\n", t.description);
+ }
+ if let Some(other) = wm
+ .portal_shortcuts
+ .iter()
+ .find(|s| s.mods == t.mods && s.keysym == t.keysym && !(s.session == *session && s.id == *id))
+ {
+ return format!("error: {} is already bound by {} {}\n", t.description, other.session, other.id);
+ }
+ // Re-binding the same (session, id) replaces its chord.
+ wm.portal_shortcuts.retain(|s| !(s.session == *session && s.id == *id));
+ log::info!("[shortcut] bind {} {} -> {}", session, id, t.description);
+ wm.portal_shortcuts.push(PortalShortcut {
+ session: session.to_string(),
+ id: id.to_string(),
+ mods: t.mods,
+ keysym: t.keysym,
+ description: t.description.clone(),
+ });
+ format!("ok {}\n", t.description)
+ }
+ Some("unbind") => {
+ let (session, id) = match args {
+ [_, session] => (*session, None),
+ [_, session, id] => (*session, Some(*id)),
+ _ => return "error: usage: shortcut unbind <session> [<id>]\n".to_string(),
+ };
+ let before = wm.portal_shortcuts.len();
+ wm.portal_shortcuts.retain(|s| !(s.session == session && id.map_or(true, |id| s.id == id)));
+ log::info!("[shortcut] unbind {} {}: {} removed", session, id.unwrap_or("*"), before - wm.portal_shortcuts.len());
+ "ok\n".to_string()
+ }
+ Some("clear") => {
+ log::info!("[shortcut] clear: {} removed", wm.portal_shortcuts.len());
+ wm.portal_shortcuts.clear();
+ "ok\n".to_string()
+ }
+ Some("list") => {
+ let mut out = String::new();
+ for s in &wm.portal_shortcuts {
+ out.push_str(&format!("{} {} {}\n", s.session, s.id, s.description));
+ }
+ out
+ }
+ _ => "error: usage: shortcut bind|unbind|clear|list\n".to_string(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_spec_triggers() {
+ let t = parse_trigger("CTRL+SHIFT+space").unwrap();
+ assert_eq!(t.mods, MOD_CTRL | MOD_SHIFT);
+ assert_eq!(t.keysym, u32::from(xkbcommon::xkb::keysyms::KEY_space));
+ assert_eq!(t.description, "Ctrl+Shift+Space");
+ }
+
+ #[test]
+ fn modifier_spelling_is_lenient_and_order_fixed() {
+ let a = parse_trigger("shift+logo+q").unwrap();
+ let b = parse_trigger("SUPER+SHIFT+Q").unwrap();
+ assert_eq!(a.mods, b.mods);
+ assert_eq!(a.keysym, b.keysym, "keysym lookup is case-insensitive");
+ assert_eq!(a.description, "Shift+Super+Q");
+ }
+
+ #[test]
+ fn plus_key_and_bare_key() {
+ assert_eq!(parse_trigger("CTRL++").unwrap().description, "Ctrl+Plus");
+ let f5 = parse_trigger("F5").unwrap();
+ assert_eq!(f5.mods, 0);
+ assert_eq!(f5.description, "F5");
+ }
+
+ #[test]
+ fn rejects_garbage() {
+ assert!(parse_trigger("").is_err());
+ assert!(parse_trigger("HYPER+a").is_err());
+ assert!(parse_trigger("CTRL+nosuchkey").is_err());
+ assert!(parse_trigger("CTRL+Shift_L").is_err(), "a lone modifier key is not a chord");
+ }
+}
diff --git a/src/server/keyboard_group.rs b/src/server/keyboard_group.rs
index a3eed0f..ec6395e 100644
--- a/src/server/keyboard_group.rs
+++ b/src/server/keyboard_group.rs
@@ -13,6 +13,10 @@ pub enum KeyConsumer {
Builtin,
Binding(*mut XkbBinding),
CceBinding(crate::config::Keybind),
+ /// A chord bound through the GlobalShortcuts portal backend (see
+ /// `global_shortcuts`): the press and the release are both reported on
+ /// the status socket's `shortcuts` topic and neither reaches the client.
+ PortalShortcut { session: String, id: String },
EnsureEaten,
ImGrab,
Focus,
@@ -415,6 +419,9 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
} else if let Some(kb) = match_cce_keybind(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state) {
log::debug!("matched CCE monolithic keybind: {:?}", kb);
KeyConsumer::CceBinding(kb)
+ } else if let Some((session, id)) = match_portal_shortcut(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state) {
+ log::debug!("matched portal shortcut {} {}", session, id);
+ KeyConsumer::PortalShortcut { session, id }
} else if let Some(binding) = (*group.seat).match_xkb_binding(xkb_keycode, &mut group.wlr_keyboard) {
log::debug!("matched xkb binding");
(*group.seat).xkb_bindings_seat.ensure_next_key_eaten = false;
@@ -477,6 +484,21 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
wm.execute_action(&action, kb.command.as_deref());
}
}
+ KeyConsumer::PortalShortcut { session, id } => {
+ // Both edges go out: the portal has a Deactivated signal, and
+ // the release comes back here through the consumer map with the
+ // same variant the press recorded.
+ let pressed = (*event).state == ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED;
+ if let Some(ref sender) = (*(*group.seat).server).wm.status_sender {
+ sender.send_shortcut_event(&format!(
+ "{} {} {} {}",
+ if pressed { "activated" } else { "deactivated" },
+ session,
+ id,
+ (*event).time_msec
+ ));
+ }
+ }
KeyConsumer::Binding(binding) => {
if !binding.is_null() {
if (*event).state == ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED {
@@ -524,6 +546,42 @@ pub unsafe fn match_cce_keybind(
modifiers: u32,
xkb_state: *mut ffi::xkb_state,
) -> Option<crate::config::Keybind> {
+ match_chord(keycode, modifiers, xkb_state, |mods, sym| {
+ wm.keybinds.iter().find(|kb| kb.mods == mods && kb.keysym == sym).cloned()
+ })
+}
+
+/// The portal-bound chords (`global_shortcuts`), matched exactly like the
+/// config keybinds but consulted after them. Returns `(session, id)`.
+pub unsafe fn match_portal_shortcut(
+ wm: &crate::window_manager::WindowManager,
+ keycode: u32,
+ modifiers: u32,
+ xkb_state: *mut ffi::xkb_state,
+) -> Option<(String, String)> {
+ if wm.portal_shortcuts.is_empty() {
+ return None;
+ }
+ match_chord(keycode, modifiers, xkb_state, |mods, sym| {
+ wm.portal_shortcuts
+ .iter()
+ .find(|s| s.mods == mods && s.keysym == sym)
+ .map(|s| (s.session.clone(), s.id.clone()))
+ })
+}
+
+/// Chord lookup shared by every (mods, keysym) table. `probe` is asked
+/// twice over: first with the keycode's level-0 keysyms against the raw
+/// modifier mask (so `super+shift+h` matches on `h`, not `H`), then with the
+/// keysyms of the level the modifiers actually select against the mask with
+/// the consumed modifiers removed (so a bind on a shifted symbol like
+/// `plus` still fires). The first hit wins.
+unsafe fn match_chord<T>(
+ keycode: u32,
+ modifiers: u32,
+ xkb_state: *mut ffi::xkb_state,
+ probe: impl Fn(u32, u32) -> Option<T>,
+) -> Option<T> {
if xkb_state.is_null() {
return None;
}
@@ -537,13 +595,9 @@ pub unsafe fn match_cce_keybind(
let num_syms = ffi::xkb_keymap_key_get_syms_by_level(keymap, keycode, layout, 0, &mut syms_ptr);
if num_syms > 0 && !syms_ptr.is_null() {
let syms = std::slice::from_raw_parts(syms_ptr, num_syms as usize);
- for kb in &wm.keybinds {
- if kb.mods == modifiers {
- for &sym in syms {
- if sym == kb.keysym {
- return Some(kb.clone());
- }
- }
+ for &sym in syms {
+ if let Some(hit) = probe(modifiers, sym) {
+ return Some(hit);
}
}
}
@@ -555,13 +609,9 @@ pub unsafe fn match_cce_keybind(
let syms = std::slice::from_raw_parts(syms_ptr_level, num_syms_level as usize);
let consumed = ffi::xkb_state_key_get_consumed_mods2(xkb_state, keycode, ffi::xkb_consumed_mode_XKB_CONSUMED_MODE_XKB);
let modifiers_translated = modifiers & !consumed;
- for kb in &wm.keybinds {
- if kb.mods == modifiers_translated {
- for &sym in syms {
- if sym == kb.keysym {
- return Some(kb.clone());
- }
- }
+ for &sym in syms {
+ if let Some(hit) = probe(modifiers_translated, sym) {
+ return Some(hit);
}
}
}
diff --git a/src/server/status_server.rs b/src/server/status_server.rs
index f122c14..fe020bf 100644
--- a/src/server/status_server.rs
+++ b/src/server/status_server.rs
@@ -2,7 +2,7 @@
//
// Runs in a dedicated thread. cce-status connects to
// /tmp/cce-status-{WAYLAND_DISPLAY}.sock, sends a subscription line
-// ("layout", "title", "modifiers", "adjust", or "dismiss") and receives lines whenever the status changes.
+// ("layout", "title", "modifiers", "adjust", "dismiss", or "shortcuts") and receives lines whenever the status changes.
//
// The main loop sends updates through an mpsc channel. The server thread
// owns the socket and handles all I/O independently of the Wayland event loop.
@@ -49,6 +49,10 @@ pub enum StatusMsg {
/// subscriber EXCEPT the segment whose app_id is carried here should
/// close its open menu (the exempt segment saw the press itself).
MenuDismiss { except_app_id: String },
+ /// A portal-bound chord went down or up (`global_shortcuts`): one line,
+ /// `activated|deactivated <session> <id> <time_msec>`, for every
+ /// `shortcuts` subscriber — in practice the one portal backend.
+ Shortcut(String),
}
/// Subscription types that the status bar script can request.
@@ -64,6 +68,9 @@ enum Subscription {
Adjust,
/// One-shot menu-dismiss events only — never receives state pushes.
Dismiss,
+ /// `shortcuts` — one-shot portal shortcut press/release lines only
+ /// (see `StatusMsg::Shortcut`); never receives state pushes.
+ Shortcuts,
/// `backdrop <app_id>` — what THIS segment is composited over, so it can
/// adapt its own text contrast. Lines are `<luma> <spread>`, both 0-100.
Backdrop(String),
@@ -85,6 +92,7 @@ impl Subscription {
"modifiers" => Subscription::Modifiers,
"adjust" => Subscription::Adjust,
"dismiss" => Subscription::Dismiss,
+ "shortcuts" => Subscription::Shortcuts,
_ => Subscription::Unknown,
}
}
@@ -131,6 +139,15 @@ impl StatusSender {
}
}
+impl StatusSender {
+ /// Report a portal shortcut edge to every `shortcuts` subscriber.
+ pub fn send_shortcut_event(&self, line: &str) {
+ if self.tx.send(StatusMsg::Shortcut(line.to_string())).is_ok() {
+ wake_fd(&self.wake);
+ }
+ }
+}
+
impl Drop for StatusSender {
/// Dropping the last handle disconnects the channel; the thread only
/// notices when it next wakes, so give it one.
@@ -309,6 +326,7 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
// Process incoming updates from the main loop
let mut dismiss_events: Vec<String> = Vec::new();
+ let mut shortcut_events: Vec<String> = Vec::new();
loop {
match rx.try_recv() {
Ok(StatusMsg::State(update)) => {
@@ -318,6 +336,9 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
Ok(StatusMsg::MenuDismiss { except_app_id }) => {
dismiss_events.push(except_app_id);
}
+ Ok(StatusMsg::Shortcut(line)) => {
+ shortcut_events.push(line);
+ }
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
log::info!("[status] channel disconnected, exiting");
@@ -356,15 +377,43 @@ fn status_server_main(rx: mpsc::Receiver<StatusMsg>, wake: Arc<OwnedFd>, display
}
}
+ // Portal shortcut edges go only to `shortcuts` subscribers, in order.
+ if !shortcut_events.is_empty() {
+ let mut dead_clients = Vec::new();
+ for (i, client) in clients.iter_mut().enumerate() {
+ if client.subscription != Subscription::Shortcuts {
+ continue;
+ }
+ for line in &shortcut_events {
+ match client
+ .stream
+ .write_all(line.as_bytes())
+ .and_then(|_| client.stream.write_all(b"\n"))
+ {
+ Ok(_) => {}
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
+ Err(_) => {
+ dead_clients.push(i);
+ break;
+ }
+ }
+ }
+ }
+ dead_clients.dedup();
+ for i in dead_clients.into_iter().rev() {
+ clients.remove(i);
+ }
+ }
+
// If we got a new update, push it to all clients
if has_new_update {
if let Some(ref update) = latest {
let mut dead_clients = Vec::new();
for (i, client) in clients.iter_mut().enumerate() {
- // Dismiss subscribers get one-shot events only, never
- // state pushes.
- if client.subscription == Subscription::Dismiss {
+ // Dismiss and shortcuts subscribers get one-shot events
+ // only, never state pushes.
+ if matches!(client.subscription, Subscription::Dismiss | Subscription::Shortcuts) {
continue;
}
let msg = format_for_subscription(&client.subscription, update);
@@ -444,7 +493,7 @@ fn format_for_subscription(sub: &Subscription, update: &StatusUpdate) -> String
None => "unknown".to_string(),
}
}
- Subscription::Dismiss | Subscription::Unknown => String::new(),
+ Subscription::Dismiss | Subscription::Shortcuts | Subscription::Unknown => String::new(),
}
}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 3ced192..4c9a0d9 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -158,6 +158,9 @@ pub struct WindowManager {
pub keybinds: Vec<crate::config::Keybind>,
pub pointer_binds: Vec<crate::config::PointerBind>,
pub gesture_binds: Vec<crate::config::GestureBind>,
+ /// Chords bound through the GlobalShortcuts portal backend — see
+ /// `global_shortcuts`. Matched after `keybinds`, never persisted.
+ pub portal_shortcuts: Vec<crate::global_shortcuts::PortalShortcut>,
pub ipc_rx: Option<std::sync::mpsc::Receiver<crate::ipc_server::IpcRequest>>,
/// The IPC thread's wake eventfd as a wl_event_loop fd source: fires once
/// per queued request, so the drain runs only when there is something to
@@ -624,6 +627,7 @@ impl WindowManager {
self.keybinds = Vec::new();
self.pointer_binds = Vec::new();
self.gesture_binds = Vec::new();
+ self.portal_shortcuts = Vec::new();
self.ipc_rx = None;
self.ipc_source = std::ptr::null_mut();
self.ipc_wake = None;
@@ -6220,6 +6224,9 @@ impl WindowManager {
"error: invalid keycode\n".to_string()
}
}
+ // Portal global shortcuts (the `cce-shortcuts-portal` backend's
+ // half of the contract lives in `global_shortcuts`).
+ "shortcut" => crate::global_shortcuts::ipc(self, &parts[1..]),
_ => format!("error: unknown command: {}\n", action),
}
}