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

commitce06ac21b6e2d31d77706cbadeb550078a61b617
parentf10f81a434
authorLucas Galante <[email protected]>
date2026-07-28 21:47
feat: volume/brightness media keys as first-class actions

Six new actions (volume_up/down/mute, mic_mute, brightness_up/down) with
default bindings on the XF86 media keysyms. The policy claims them and
spawns a stock wpctl/brightnessctl command (5% steps, volume capped at
100%); a binding's command= property overrides the command wholesale.

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

 src/actions.rs  | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/api.rs      | 21 ++++++++++++++++++++-
 src/bindings.rs | 16 ++++++++++++++--
 3 files changed, 83 insertions(+), 3 deletions(-)

diff --git a/src/actions.rs b/src/actions.rs
index c29dfae..202712c 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -41,6 +41,14 @@ impl Policy for DefaultPolicy {
             Action::OverlayRight => {
                 vec![Command::SetOverlayPosition(OverlaySide::Right), Command::Relayout]
             }
+            Action::VolumeUp
+            | Action::VolumeDown
+            | Action::VolumeMute
+            | Action::MicMute
+            | Action::BrightnessUp
+            | Action::BrightnessDown => {
+                vec![Command::Spawn(arg.unwrap_or(media_command(action)).to_string())]
+            }
             _ => Vec::new(),
         }
     }
@@ -354,6 +362,22 @@ fn mode_next_shared(ctx: &ActionCtx) -> Vec<Command> {
     cmds
 }
 
+/// Stock command line for a media-key action (PipeWire's wpctl for audio,
+/// brightnessctl for the backlight). A binding's `command="..."` property
+/// overrides this wholesale — that's where a custom step size or a different
+/// mixer goes.
+fn media_command(action: Action) -> &'static str {
+    match action {
+        Action::VolumeUp => "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+",
+        Action::VolumeDown => "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
+        Action::VolumeMute => "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
+        Action::MicMute => "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle",
+        Action::BrightnessUp => "brightnessctl set 5%+",
+        Action::BrightnessDown => "brightnessctl set 5%-",
+        _ => "",
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -647,4 +671,29 @@ mod tests {
         // Virtual point under (960, 540) at zoom 0.5: 100 + 960/0.5 = 2020.
         assert_eq!(camera.pan_x, 2020.0 - 960.0);
     }
+
+    #[test]
+    fn media_keys_spawn_stock_or_overridden_command() {
+        let c = ctx();
+        // Every media action is claimed and spawns its stock command.
+        for action in [
+            Action::VolumeUp, Action::VolumeDown, Action::VolumeMute,
+            Action::MicMute, Action::BrightnessUp, Action::BrightnessDown,
+        ] {
+            assert_eq!(
+                dispatch(&c, action),
+                vec![Command::Spawn(media_command(action).to_string())],
+                "{}", action.name()
+            );
+        }
+        assert_eq!(
+            dispatch(&c, Action::VolumeMute),
+            vec![Command::Spawn("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle".to_string())]
+        );
+        // A binding's command= property replaces the stock command.
+        assert_eq!(
+            DefaultPolicy.action(&c, Action::BrightnessUp, Some("brightnessctl set 10%+")),
+            vec![Command::Spawn("brightnessctl set 10%+".to_string())]
+        );
+    }
 }
diff --git a/src/api.rs b/src/api.rs
index 2c6e488..b5de195 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -89,6 +89,12 @@ pub enum Action {
     PanUp,
     PanDown,
     Screenshot,
+    VolumeUp,
+    VolumeDown,
+    VolumeMute,
+    MicMute,
+    BrightnessUp,
+    BrightnessDown,
 }
 
 impl Action {
@@ -136,6 +142,12 @@ impl Action {
             Action::PanUp => "pan_up",
             Action::PanDown => "pan_down",
             Action::Screenshot => "screenshot",
+            Action::VolumeUp => "volume_up",
+            Action::VolumeDown => "volume_down",
+            Action::VolumeMute => "volume_mute",
+            Action::MicMute => "mic_mute",
+            Action::BrightnessUp => "brightness_up",
+            Action::BrightnessDown => "brightness_down",
         }
     }
 
@@ -183,6 +195,12 @@ impl Action {
             "pan_up" => Action::PanUp,
             "pan_down" => Action::PanDown,
             "screenshot" => Action::Screenshot,
+            "volume_up" => Action::VolumeUp,
+            "volume_down" => Action::VolumeDown,
+            "volume_mute" | "mute" => Action::VolumeMute,
+            "mic_mute" => Action::MicMute,
+            "brightness_up" => Action::BrightnessUp,
+            "brightness_down" => Action::BrightnessDown,
             _ => return None,
         })
     }
@@ -378,7 +396,8 @@ pub enum Command {
 /// Decisions, policy-side. Implemented by `actions::DefaultPolicy`.
 pub trait Policy {
     /// Decide a user action against the snapshot; `arg` is the binding's
-    /// command string (Spawn/Toggle carry one). An empty vec means "not
+    /// command string (Spawn/Toggle carry one; media-key actions may carry
+    /// an override of their stock command). An empty vec means "not
     /// mine" — the mechanism falls through to its remaining legacy arms.
     fn action(&mut self, ctx: &ActionCtx, action: Action, arg: Option<&str>) -> Vec<Command>;
 }
diff --git a/src/bindings.rs b/src/bindings.rs
index 3dd81ec..c4ae278 100644
--- a/src/bindings.rs
+++ b/src/bindings.rs
@@ -56,7 +56,8 @@ pub fn parse_chord(s: &str) -> Option<Chord> {
 }
 
 /// One resolved binding: chord (mods + keysym code) → action, with the
-/// command argument for `Spawn`/`Toggle`.
+/// command argument for `Spawn`/`Toggle` (required) and the media-key
+/// actions (optional override of their stock command).
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Binding {
     pub mods: u32,
@@ -151,6 +152,15 @@ pub const DEFAULT_BINDINGS: &[DefaultBinding] = &[
     DefaultBinding { mods: mods::SUPER | mods::SHIFT, key: "r", action: Action::Reload },
     // Reverse companion to the (user-configured) super+tab window switcher.
     DefaultBinding { mods: mods::SUPER | mods::SHIFT, key: "Tab", action: Action::WindowSwitcherPrev },
+    // Media keys. Each action spawns a stock wpctl/brightnessctl command
+    // (see `actions::media_command`); an input.kdl binding can rebind the
+    // chord and/or override the command with a `command="..."` property.
+    DefaultBinding { mods: 0, key: "XF86AudioRaiseVolume", action: Action::VolumeUp },
+    DefaultBinding { mods: 0, key: "XF86AudioLowerVolume", action: Action::VolumeDown },
+    DefaultBinding { mods: 0, key: "XF86AudioMute", action: Action::VolumeMute },
+    DefaultBinding { mods: 0, key: "XF86AudioMicMute", action: Action::MicMute },
+    DefaultBinding { mods: 0, key: "XF86MonBrightnessUp", action: Action::BrightnessUp },
+    DefaultBinding { mods: 0, key: "XF86MonBrightnessDown", action: Action::BrightnessDown },
 ];
 
 #[cfg(test)]
@@ -194,7 +204,9 @@ mod tests {
             Action::Expose, Action::Minimize, Action::OverlayLeft,
             Action::OverlayRight, Action::ZoomIn, Action::ZoomOut,
             Action::ZoomReset, Action::PanLeft, Action::PanRight,
-            Action::PanUp, Action::PanDown,
+            Action::PanUp, Action::PanDown, Action::VolumeUp,
+            Action::VolumeDown, Action::VolumeMute, Action::MicMute,
+            Action::BrightnessUp, Action::BrightnessDown,
         ] {
             assert_eq!(Action::from_name(action.name()), Some(action), "{}", action.name());
         }