From 7d58eeb65dd7f212554e68249dc859ebd21f05be Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 12 Oct 2026 23:22:02 +0100 Subject: [PATCH] Add named keyboard binding modes (#32) Add named keyboard shortcut maps with an Escape exit and a panel indicator. Keep press/release ownership across transitions, validate remapped Escape keys, and cancel stale repeats and focus previews. Give the indicator ownership of the panel while a mode is active. Close popovers, collapse Morph, and keep Live Activities data current without resizing the hidden panel. Document configuration and transition behavior. Validation: 1,519 workspace Rust tests passed; 70 Python tests passed. Workspace build, formatting, Clippy, and Settings configuration validation passed. Opt-in native desktop checks were not rerun because Weston is absent. Related: #5. --- compositor/src/backends/direct.rs | 1 + compositor/src/config.rs | 5 + compositor/src/config/bindings.rs | 13 +- compositor/src/config/bindings/index.rs | 21 +- compositor/src/config/bindings/modes.rs | 191 ++++++++++++++ compositor/src/input.rs | 82 +++++- compositor/src/input/binding_mode_tests.rs | 244 ++++++++++++++++++ compositor/src/input_capture.rs | 1 + compositor/src/ipc.rs | 3 +- compositor/src/main.rs | 3 + compositor/src/session_lock.rs | 2 +- compositor/src/session_lock/tests.rs | 25 ++ compositor/src/shell_control.rs | 13 +- compositor/src/state.rs | 15 +- .../src/state/window_lifecycle_tests.rs | 132 ++++++++++ crates/ferese-config/src/lib.rs | 23 +- docs/configuration.md | 60 +++++ docs/ipc.md | 4 + protocols/ferese-shell-v1.xml | 12 +- shell/ferese-shell/src/activities.rs | 18 +- shell/ferese-shell/src/bar.rs | 108 ++++++++ .../src/bar/binding_mode_tests.rs | 209 +++++++++++++++ shell/ferese-shell/src/control.rs | 50 +++- shell/ferese-shell/src/main.rs | 6 +- shell/ferese-shell/src/panel_hub.rs | 45 +++- shell/ferese-shell/src/panel_hub/activity.rs | 3 + shell/ferese-shell/src/status_ui/lifecycle.rs | 3 +- shell/ferese-shell/src/workspace_ui.rs | 1 + 28 files changed, 1258 insertions(+), 35 deletions(-) create mode 100644 compositor/src/config/bindings/modes.rs create mode 100644 compositor/src/input/binding_mode_tests.rs create mode 100644 shell/ferese-shell/src/bar/binding_mode_tests.rs diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 2d1f80d..13a8f8a 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -376,6 +376,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() .handle() .insert_source(notifier, move |event, _, state| match event { SessionEvent::PauseSession => { + state.set_binding_mode("default"); let handle = state.loop_handle.clone(); if let Some(backend) = state.direct_backend.as_mut() { backend.active = false; diff --git a/compositor/src/config.rs b/compositor/src/config.rs index f664345..8a01219 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -15,6 +15,7 @@ pub(crate) use self::loading::{prepare, prepare_document, read_source, startup}; pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup}; use appearance::*; pub use appearance::{BorderGradient, FocusEffectSettings, MaterialStyle, ThemeSettings}; +pub(crate) use bindings::BindingModes; use bindings::*; pub use bindings::{Binding, BindingAction}; pub(crate) use bindings::{BindingSet, physical_keymap}; @@ -62,6 +63,8 @@ pub struct Config { #[serde(default)] bindings: Vec, #[serde(default)] + binding_modes: Vec, + #[serde(default)] window_rules: Vec, #[serde(default)] scrolling: ScrollingConfig, @@ -130,6 +133,7 @@ impl Config { } let input_settings = self.input_settings()?; let bindings = self.bindings(&input_settings)?; + let binding_modes = self.binding_modes(&input_settings, &bindings)?; Ok(crate::RuntimeConfig { activity_settings: self.activities.clone(), activity_matcher: ferese_config::activities::Matcher::new(&self.activities) @@ -142,6 +146,7 @@ impl Config { gap_config: self.gap_config()?, input_settings, bindings, + binding_modes, window_rules: self.window_rules()?, theme_settings: self.theme_settings()?, panel_style: self diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index c237bdf..3e4298f 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -3,10 +3,13 @@ use std::sync::Arc; use super::*; mod index; +mod modes; pub(crate) use index::BindingSet; +pub(super) use modes::BindingModeConfig; +pub(crate) use modes::BindingModes; impl Config { - pub fn bindings(&self, input: &InputSettings) -> Result, ConfigError> { + fn binding_commands(&self) -> HashMap> { let mut commands = HashMap::from([ ("terminal".to_owned(), vec!["foot".to_owned()]), ("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]), @@ -16,6 +19,11 @@ impl Config { ), ]); commands.extend(self.commands.clone()); + commands + } + + pub fn bindings(&self, input: &InputSettings) -> Result, ConfigError> { + let commands = self.binding_commands(); validate_commands(&commands)?; let keymap = physical_keymap(input)?; @@ -260,6 +268,7 @@ pub(super) fn parse_action( || argument.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument"))); match action { + "binding-mode" => Ok(BindingAction::BindingMode(required_argument()?.to_owned())), "none" => { no_argument()?; Ok(BindingAction::None) @@ -467,6 +476,7 @@ pub(super) struct BindingModifiers { #[derive(Clone, Debug, PartialEq)] pub enum BindingAction { None, + BindingMode(String), Spawn(Arc<[String]>), Close, Exit, @@ -558,6 +568,7 @@ impl Binding { pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option { let description = match &self.action { + BindingAction::BindingMode(name) => format!("Switch to binding mode {name}"), BindingAction::None => return None, BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => { if argv.iter().any(|arg| arg == "--full") { diff --git a/compositor/src/config/bindings/index.rs b/compositor/src/config/bindings/index.rs index a298bf6..603c9e8 100644 --- a/compositor/src/config/bindings/index.rs +++ b/compositor/src/config/bindings/index.rs @@ -2,6 +2,7 @@ use super::*; /// Immutable bindings and their keyboard index are replaced together on reload. /// The ordered slice remains available for gestures and the shortcut guide. +#[derive(Clone)] pub(crate) struct BindingSet { ordered: Vec, keyboard: HashMap, @@ -32,7 +33,7 @@ impl std::ops::Deref for BindingSet { } impl BindingSet { - pub(crate) fn action_for_key( + pub(crate) fn binding_for_key( &self, keycode: Keycode, symbols: &[u32], @@ -40,7 +41,7 @@ impl BindingSet { ctrl: bool, alt: bool, shift: bool, - ) -> Option<&BindingAction> { + ) -> Option<&Binding> { let modifiers = BindingModifiers { logo, ctrl, alt, shift }; let physical = self.keyboard.get(&BindingIdentity { modifiers, @@ -60,7 +61,21 @@ impl BindingSet { .into_iter() .chain(symbolic) .min() - .map(|&index| &self.ordered[index].action) + .map(|&index| &self.ordered[index]) + } + + #[cfg(test)] + pub(crate) fn action_for_key( + &self, + keycode: Keycode, + symbols: &[u32], + logo: bool, + ctrl: bool, + alt: bool, + shift: bool, + ) -> Option<&BindingAction> { + self.binding_for_key(keycode, symbols, logo, ctrl, alt, shift) + .map(|binding| &binding.action) } } diff --git a/compositor/src/config/bindings/modes.rs b/compositor/src/config/bindings/modes.rs new file mode 100644 index 0000000..648032a --- /dev/null +++ b/compositor/src/config/bindings/modes.rs @@ -0,0 +1,191 @@ +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct BindingModeConfig { + name: String, + #[serde(default)] + bindings: Vec, +} + +/// Named keyboard maps share the ordinary binding index. The default map and +/// gestures remain owned by the compositor; a mode never mutates either. +#[derive(Clone, Default)] +pub(crate) struct BindingModes { + maps: HashMap, + active: Option, +} + +impl BindingModes { + pub(crate) fn active(&self) -> Option<&str> { + self.active.as_deref() + } + + pub(crate) fn set(&mut self, name: &str) -> bool { + let next = if name == "default" { + None + } else if self.maps.contains_key(name) { + Some(name.to_owned()) + } else { + return false; + }; + if next == self.active { + return false; + } + self.active = next; + true + } + + pub(crate) fn bindings<'a>(&'a self, default: &'a BindingSet) -> &'a BindingSet { + self.active + .as_ref() + .and_then(|name| self.maps.get(name)) + .unwrap_or(default) + } +} + +impl Config { + pub(crate) fn binding_modes( + &self, + input: &InputSettings, + default: &[Binding], + ) -> Result { + let keymap = physical_keymap(input)?; + let commands = self.binding_commands(); + let mut modes = BindingModes::default(); + for mode in &self.binding_modes { + if mode.name.is_empty() + || mode.name.len() > 48 + || mode.name == "default" + || !mode + .name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + || modes.maps.contains_key(&mode.name) + { + return Err(ConfigError::Binding(format!( + "invalid or duplicate binding mode {:?}", + mode.name + ))); + } + let mut identities = HashSet::new(); + let mut bindings = Vec::new(); + for configured in &mode.bindings { + let identity = binding_identity(configured, &keymap)?; + if configured.disabled + || identity.match_mode == BindingMatch::Swipe + || !identities.insert(identity.clone()) + || (identity.match_mode == BindingMatch::Keysym && identity.key == keysyms::KEY_Escape) + || (identity.match_mode == BindingMatch::Physical + && (0..keymap.num_layouts_for_key(Keycode::new(identity.key))).any(|layout| { + (0..keymap.num_levels_for_key(Keycode::new(identity.key), layout)).any(|level| { + keymap + .key_get_syms_by_level(Keycode::new(identity.key), layout, level) + .iter() + .any(|symbol| symbol.raw() == keysyms::KEY_Escape) + }) + })) + { + return Err(ConfigError::Binding(format!( + "invalid, duplicate or reserved mode binding {:?}", + configured.keys + ))); + } + bindings.push(parse_binding(configured, &commands, &keymap)?); + } + modes.maps.insert(mode.name.clone(), bindings.into()); + } + for binding in default.iter().chain(modes.maps.values().flat_map(|map| map.iter())) { + if let BindingAction::BindingMode(name) = &binding.action + && name != "default" + && !modes.maps.contains_key(name) + { + return Err(ConfigError::Binding(format!("unknown binding mode {name:?}"))); + } + } + Ok(modes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modes_use_the_binding_index_without_inheriting_default_shortcuts() { + let config = Config::parse_source( + r#" + binding "Super+r" binding-mode resize + binding-mode resize { + binding h resize left + binding t binding-mode launch + binding AD02 resize up match="physical" + } + binding-mode launch { binding Return spawn terminal; } + "#, + ) + .unwrap(); + let runtime = config.runtime_config().unwrap(); + let default = BindingSet::from(runtime.bindings); + let mut modes = runtime.binding_modes; + assert!(modes.set("resize")); + let selected = modes.bindings(&default); + assert_eq!( + selected.action_for_key(Keycode::new(43), &[keysyms::KEY_h], false, false, false, false), + Some(&BindingAction::Resize(Direction::Left)) + ); + assert_eq!( + selected.action_for_key(Keycode::new(25), &[], false, false, false, false), + Some(&BindingAction::Resize(Direction::Up)) + ); + assert_eq!( + selected.action_for_key(Keycode::new(43), &[keysyms::KEY_h], true, false, false, false), + None + ); + assert_eq!( + selected.action_for_key(Keycode::new(36), &[keysyms::KEY_Return], true, false, false, false), + None + ); + assert!(!modes.set("missing")); + assert_eq!(modes.active(), Some("resize")); + assert!(modes.set("default")); + assert!(matches!( + modes.bindings(&default).action_for_key( + Keycode::new(36), + &[keysyms::KEY_Return], + true, + false, + false, + false + ), + Some(BindingAction::Spawn(_)) + )); + } + + #[test] + fn invalid_modes_and_targets_are_rejected_before_runtime_publication() { + for source in [ + "binding-mode default {}", + "binding-mode \"\" {}", + "binding-mode \"two words\" {}", + "binding-mode resize {}\nbinding-mode resize {}", + "binding x binding-mode missing", + "binding-mode resize { binding x binding-mode missing; }", + "binding-mode resize { binding x close; binding x none; }", + "binding-mode resize { binding Escape none; }", + "binding-mode resize { binding Super+Escape none; }", + "binding-mode resize { binding ESC none match=\"physical\"; }", + "input { xkb-options \"caps:escape\"; }\nbinding-mode resize { binding CAPS none match=\"physical\"; }", + "binding-mode resize { binding Swipe3Left none; }", + "binding-mode resize { binding x disabled=#true; }", + "binding-mode resize fallback=#true {}", + ] { + assert!( + Config::parse_source(source) + .and_then(|config| config.runtime_config()) + .is_err(), + "{source}" + ); + } + } +} diff --git a/compositor/src/input.rs b/compositor/src/input.rs index b0b3f3c..f77734d 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -25,6 +25,9 @@ use crate::process::spawn_client; const BTN_LEFT: u32 = 0x110; const BTN_RIGHT: u32 = 0x111; +#[cfg(test)] +pub(crate) mod binding_mode_tests; + pub(crate) struct LockedPointerHint { pub(crate) surface: WlSurface, pub(crate) local: Point, @@ -110,6 +113,9 @@ impl Ferese { } pub fn process_input_event(&mut self, event: InputEvent) { + if self.input_capture.active() { + self.set_binding_mode("default"); + } let seat = self.seat.clone(); self.idle_notifier_state.notify_activity(&seat); if !matches!( @@ -286,15 +292,59 @@ impl Ferese { } } if state == KeyState::Released { + data.binding_mode_pressed.remove(&keycode); + data.binding_mode_repeats.remove(&keycode); data.portal_shortcuts.release(keycode, Event::time(&event)); if data.intercepted_keys.remove(&keycode) { return FilterResult::Intercept(()); } + if data.input_capture.captures(1) { + data.input_capture.push(serde_json::json!({"type":"key", "key":keycode.raw().saturating_sub(8), "pressed":false})); + } + // Ownership comes from the press, never from the + // current mode or modifiers at release time. + return FilterResult::Forward; + } + let raw_symbols = keysym.raw_syms().into_iter().map(|symbol| symbol.raw()).collect::>(); + if !data.binding_mode_pressed.insert(keycode) { + // A held key belongs to the recipient of its first + // press, even when a mode or configuration changes. + return if data.intercepted_keys.contains(&keycode) { + if !data.session_lock.active() && !data.input_capture.active() + && !data.seat.keyboard_shortcuts_inhibited() + && let Some(binding) = data.binding_mode_repeats.get(&keycode) + .filter(|binding| binding.matches(keycode, &raw_symbols, + modifiers.logo, modifiers.ctrl, modifiers.alt, modifiers.shift)) + .cloned() + { + let action = binding.action; + if let BindingAction::FocusMru(reverse) = action { + data.cycle_focus(reverse, modifiers.alt); + } else { + data.execute_binding(action); + } + } + FilterResult::Intercept(()) + } else { + FilterResult::Forward + }; } if data.session_lock.active() { return FilterResult::Forward; } let symbol = keysym.modified_sym().raw(); + if data.binding_modes.active().is_some() && symbol == keysyms::KEY_Escape { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + if emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) + && let Some(inhibitor) = data.active_shortcuts_inhibitor.take() + { + inhibitor.inactivate(); + } + data.set_binding_mode("default"); + } + return FilterResult::Intercept(()); + } if data.focus_cycle.is_some() && symbol == keysyms::KEY_Escape { if state == KeyState::Pressed { data.intercepted_keys.insert(keycode); @@ -362,12 +412,7 @@ impl Ferese { return FilterResult::Intercept(()); } - let raw_symbols = keysym - .raw_syms() - .into_iter() - .map(|symbol| symbol.raw()) - .collect::>(); - let action = data.bindings.action_for_key( + let binding = data.binding_modes.bindings(&data.bindings).binding_for_key( keycode, &raw_symbols, modifiers.logo, @@ -377,7 +422,7 @@ impl Ferese { ); if data.focus_cycle.is_some() && state == KeyState::Pressed - && !matches!(action, Some(BindingAction::FocusMru(_))) + && !matches!(binding.map(|binding| &binding.action), Some(BindingAction::FocusMru(_))) && !matches!(symbol, keysyms::KEY_Alt_L | keysyms::KEY_Alt_R | keysyms::KEY_Shift_L | keysyms::KEY_Shift_R @@ -387,8 +432,9 @@ impl Ferese { data.intercepted_keys.insert(keycode); return FilterResult::Intercept(()); } - let Some(action) = action else { + let Some(binding) = binding else { if state == KeyState::Pressed + && data.binding_modes.active().is_none() && data.portal_shortcuts.press( keycode, &raw_symbols, @@ -404,7 +450,10 @@ impl Ferese { if state == KeyState::Pressed { data.intercepted_keys.insert(keycode); - let action = action.clone(); + let action = binding.action.clone(); + if !matches!(action, BindingAction::BindingMode(_)) { + data.binding_mode_repeats.insert(keycode, binding.clone()); + } if let BindingAction::FocusMru(reverse) = action { data.cycle_focus(reverse, modifiers.alt); @@ -1083,6 +1132,7 @@ impl Ferese { fn execute_binding(&mut self, action: BindingAction) { match action { BindingAction::None => {} + BindingAction::BindingMode(name) => self.set_binding_mode(&name), BindingAction::Spawn(argv) => { if let Some(child) = spawn_client(self, argv.iter(), crate::private_client::ClientCapabilities::default()) @@ -1128,6 +1178,20 @@ impl Ferese { } } } + + pub(crate) fn set_binding_mode(&mut self, name: &str) { + if name != "default" && (self.session_lock.active() || self.input_capture.active()) { + return; + } + if name == "default" { + self.binding_mode_repeats.clear(); + } + if self.binding_modes.set(name) { + self.binding_mode_repeats.clear(); + self.cancel_focus_cycle(); + self.send_shell_snapshots(); + } + } } fn surface_tree_position( diff --git a/compositor/src/input/binding_mode_tests.rs b/compositor/src/input/binding_mode_tests.rs new file mode 100644 index 0000000..1643287 --- /dev/null +++ b/compositor/src/input/binding_mode_tests.rs @@ -0,0 +1,244 @@ +use super::*; +use smithay::backend::input::UnusedEvent; +use smithay::backend::winit::WinitVirtualDevice; +use smithay::input::keyboard::Keycode; +use smithay::reexports::{calloop::EventLoop, wayland_server::Display}; + +#[derive(Debug)] +struct TestInput; + +impl InputBackend for TestInput { + type Device = WinitVirtualDevice; + type PointerButtonEvent = UnusedEvent; + type KeyboardKeyEvent = TestKey; + type PointerAxisEvent = UnusedEvent; + type PointerMotionEvent = UnusedEvent; + type PointerMotionAbsoluteEvent = UnusedEvent; + type GestureSwipeBeginEvent = UnusedEvent; + type GestureSwipeUpdateEvent = UnusedEvent; + type GestureSwipeEndEvent = UnusedEvent; + type GesturePinchBeginEvent = UnusedEvent; + type GesturePinchUpdateEvent = UnusedEvent; + type GesturePinchEndEvent = UnusedEvent; + type GestureHoldBeginEvent = UnusedEvent; + type GestureHoldEndEvent = UnusedEvent; + type TouchDownEvent = UnusedEvent; + type TouchUpEvent = UnusedEvent; + type TouchMotionEvent = UnusedEvent; + type TouchCancelEvent = UnusedEvent; + type TouchFrameEvent = UnusedEvent; + type TabletToolAxisEvent = UnusedEvent; + type TabletToolProximityEvent = UnusedEvent; + type TabletToolTipEvent = UnusedEvent; + type TabletToolButtonEvent = UnusedEvent; + type SwitchToggleEvent = UnusedEvent; + type SpecialEvent = UnusedEvent; +} + +#[derive(Debug)] +struct TestKey(Keycode, KeyState); +impl Event for TestKey { + fn time(&self) -> u64 { + 1_000 + } + fn device(&self) -> WinitVirtualDevice { + WinitVirtualDevice + } +} +impl KeyboardKeyEvent for TestKey { + fn key_code(&self) -> Keycode { + self.0 + } + fn state(&self) -> KeyState { + self.1 + } + fn count(&self) -> u32 { + 1 + } +} +pub(crate) fn key(state: &mut Ferese, code: u32, pressed: bool) { + state.process_input_event::(InputEvent::Keyboard { + event: TestKey( + Keycode::new(code), + if pressed { KeyState::Pressed } else { KeyState::Released }, + ), + }); +} +fn tap(state: &mut Ferese, code: u32) { + key(state, code, true); + key(state, code, false); +} +const SOURCE: &str = r#" +binding "Super+r" binding-mode resize +binding-mode resize { binding h resize left; binding l binding-mode launch; } +binding-mode launch { binding h binding-mode default; } +"#; +fn state(events: &mut EventLoop<'static, Ferese>) -> Ferese { + let (_, mut config, _) = crate::config::prepare(SOURCE, std::path::Path::new(".")).unwrap(); + config.wallpaper.path = None; + let mut state = Ferese::new(events, Display::new().unwrap(), config).unwrap(); + state.config_source = Some(SOURCE.into()); + let mut sections = ferese_config::Document::parse(SOURCE).unwrap().value().clone(); + sections.as_object_mut().unwrap().remove("theme"); + state.config_sections = Some(sections); + state +} +#[test] +fn keyboard_modes_pair_releases_and_handle_held_modifiers_and_keys() { + if !crate::startup_tests::private_runtime( + "input::binding_mode_tests::keyboard_modes_pair_releases_and_handle_held_modifiers_and_keys", + ) { + return; + } + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + // Enter with Super held. A new unmodified mode shortcut must not match + // until the modifier is released; Escape remains usable throughout. + key(&mut state, 133, true); + key(&mut state, 27, true); + assert_eq!(state.binding_modes.active(), Some("resize")); + assert!(state.intercepted_keys.contains(&Keycode::new(27))); + tap(&mut state, 46); // Super+l does not switch to launch. + assert_eq!(state.binding_modes.active(), Some("resize")); + tap(&mut state, 9); // Super+Escape always leaves. + assert_eq!(state.binding_modes.active(), None); + key(&mut state, 27, false); + key(&mut state, 133, false); + assert!(state.intercepted_keys.is_empty()); + + // An application-owned h stays application-owned after entering a mode. + key(&mut state, 43, true); + state.set_binding_mode("launch"); + key(&mut state, 43, true); + assert_eq!(state.binding_modes.active(), Some("launch")); + assert!(!state.intercepted_keys.contains(&Keycode::new(43))); + key(&mut state, 43, false); + assert_eq!(state.binding_modes.active(), Some("launch")); + tap(&mut state, 43); + assert_eq!(state.binding_modes.active(), None); + + // A consumed resize key retains release ownership across an explicit exit. + state.set_binding_mode("resize"); + key(&mut state, 43, true); + assert!(state.intercepted_keys.contains(&Keycode::new(43))); + tap(&mut state, 9); + assert!(state.intercepted_keys.contains(&Keycode::new(43))); + key(&mut state, 43, false); + assert!(state.intercepted_keys.is_empty()); + assert!(state.binding_mode_pressed.is_empty()); + key(&mut state, 9, true); + state.set_binding_mode("launch"); + key(&mut state, 9, false); + assert_eq!( + state.binding_modes.active(), + Some("launch"), + "an application-owned Escape release cannot exit a new mode" + ); + tap(&mut state, 9); + assert_eq!(state.binding_modes.active(), None); +} + +#[test] +fn reload_transitions_cancel_modes_without_losing_key_ownership() { + if !crate::startup_tests::private_runtime( + "input::binding_mode_tests::reload_transitions_cancel_modes_without_losing_key_ownership", + ) { + return; + } + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + state.set_binding_mode("resize"); + key(&mut state, 43, true); + assert!(state.reload_config_source("binding-mode default {}".into()).is_err()); + assert_eq!(state.binding_modes.active(), Some("resize")); + assert!(state.intercepted_keys.contains(&Keycode::new(43))); + state + .reload_config_source(format!("{SOURCE}\nanimations {{ speed 0.75; }}")) + .unwrap(); + assert_eq!( + state.binding_modes.active(), + Some("resize"), + "unrelated reload preserves mode" + ); + state + .reload_config_source(format!("{SOURCE}\ninput {{ repeat-rate 30; }}")) + .unwrap(); + assert_eq!(state.binding_modes.active(), None, "input reload exits mode"); + state.set_binding_mode("resize"); + state + .reload_config_source("binding-mode launch { binding t spawn terminal; }".into()) + .unwrap(); + assert_eq!(state.binding_modes.active(), None); + assert!(!state.binding_modes.set("resize")); + key(&mut state, 43, false); + assert!(state.intercepted_keys.is_empty()); +} + +#[test] +fn escape_exits_with_every_modifier_combination() { + if !crate::startup_tests::private_runtime("input::binding_mode_tests::escape_exits_with_every_modifier_combination") + { + return; + } + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + let modifiers = [133, 37, 64, 50]; // Super, Ctrl, Alt, Shift. + for bits in 0..16 { + state.set_binding_mode("resize"); + for (bit, code) in modifiers.iter().enumerate() { + if bits & (1 << bit) != 0 { + key(&mut state, *code, true); + } + } + tap(&mut state, 9); + assert_eq!(state.binding_modes.active(), None, "modifiers={bits:04b}"); + for (bit, code) in modifiers.iter().enumerate() { + if bits & (1 << bit) != 0 { + key(&mut state, *code, false); + } + } + assert!(state.intercepted_keys.is_empty()); + assert!(state.binding_mode_pressed.is_empty()); + } +} + +#[test] +fn input_capture_exits_modes_and_prevents_reentry() { + if !crate::startup_tests::private_runtime( + "input::binding_mode_tests::input_capture_exits_modes_and_prevents_reentry", + ) { + return; + } + use crate::input_capture::{Barrier, Zone}; + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + state.set_binding_mode("resize"); + state.input_capture.update_zones(vec![Zone { + width: 800, + height: 600, + x: 0, + y: 0, + }]); + let id = state.input_capture.register(1, 3).unwrap(); + let zone_set = state.input_capture.zones(id).unwrap()["zone_set"].as_u64().unwrap() as u32; + state + .input_capture + .set_barriers( + id, + zone_set, + vec![Barrier { + id: 1, + position: [800, 0, 800, 599], + }], + ) + .unwrap(); + state.input_capture.enable(id).unwrap(); + assert!(state.input_capture.motion((799., 50.), (810., 50.), true)); + crate::after_dispatch_with_redraw(&mut state, |_, _| {}); + assert_eq!(state.binding_modes.active(), None); + state.set_binding_mode("resize"); + assert_eq!(state.binding_modes.active(), None); + state.input_capture.disable(id).unwrap(); + crate::after_dispatch_with_redraw(&mut state, |_, _| {}); + assert_eq!(state.binding_modes.active(), None); +} diff --git a/compositor/src/input_capture.rs b/compositor/src/input_capture.rs index 726ac95..f8106f5 100644 --- a/compositor/src/input_capture.rs +++ b/compositor/src/input_capture.rs @@ -491,6 +491,7 @@ impl crate::Ferese { (to.x - from.x, to.y - from.y) }; if !active { + self.set_binding_mode("default"); self.cursor_redraw_pending = true; pointer.motion( self, diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index c4bcb4d..eb6509c 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -1104,7 +1104,8 @@ impl Ferese { "get-keybindings" => { let map = crate::config::physical_keymap(&self.input_settings).ok(); return Ok(json!( - self.bindings + self.binding_modes + .bindings(&self.bindings) .iter() .filter_map(|binding| binding.guide_entry(map.as_ref())) .take(256) diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 4d49148..a730079 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -203,6 +203,9 @@ fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Fe state.process_pending_dmabuf_imports(); state.update_named_cursor(std::time::Instant::now()); state.poll_theme(); + if state.session_lock.active() || state.input_capture.active() { + state.set_binding_mode("default"); + } // The decoder wakes the loop after publishing its result, including // when no output has a pending frame. let wallpaper_changed = state.wallpaper.poll(); diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 774b82d..db5724f 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -434,6 +434,7 @@ impl SessionLockHandler for Ferese { // Replacing an orphan never passes through Unlocked. Existing protected // presentation facts remain valid, but old surfaces must not own input. self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation); + self.set_binding_mode("default"); self.capture_epoch = self.capture_epoch.wrapping_add(1); self.session_lock.surfaces.clear(); self.cancel_logout_confirmation(); @@ -472,7 +473,6 @@ impl SessionLockHandler for Ferese { let keyboard = self.seat.get_keyboard().unwrap(); keyboard.unset_grab(self); keyboard.set_focus(self, None, serial); - self.intercepted_keys.clear(); self.lock_input_activity(); self.confirm_lock_if_ready(); crate::backends::direct::render_all(self); diff --git a/compositor/src/session_lock/tests.rs b/compositor/src/session_lock/tests.rs index e5172f5..fb6b3ca 100644 --- a/compositor/src/session_lock/tests.rs +++ b/compositor/src/session_lock/tests.rs @@ -344,6 +344,31 @@ fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]) { } } +#[test] +fn locking_exits_binding_modes_and_retains_consumed_release_ownership() { + let Some(mut fixture) = + Fixture::new("session_lock::tests::locking_exits_binding_modes_and_retains_consumed_release_ownership") + else { + return; + }; + fixture + .state + .reload_config_source("binding-mode resize { binding h resize left; }".into()) + .unwrap(); + fixture.state.set_binding_mode("resize"); + crate::input::binding_mode_tests::key(&mut fixture.state, 43, true); + let code = smithay::input::keyboard::Keycode::new(43); + assert!(fixture.state.intercepted_keys.contains(&code)); + let mut locker = fixture.acquire(); + locker.expect_locked(); + assert_eq!(fixture.state.binding_modes.active(), None); + assert!(fixture.state.intercepted_keys.contains(&code)); + crate::input::binding_mode_tests::key(&mut fixture.state, 43, false); + assert!(!fixture.state.intercepted_keys.contains(&code)); + fixture.state.unlock(); + assert_eq!(fixture.state.binding_modes.active(), None); +} + #[test] fn lock_confirms_without_outputs() { let Some(mut fixture) = Fixture::new("session_lock::tests::lock_confirms_without_outputs") else { diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 1910620..402e81b 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities; use crate::state::ClientState; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(8, ()); + display.create_global::(9, ()); } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -339,7 +339,9 @@ impl Ferese { } fn shell_snapshot_matches(&self, snapshot: &ShellSnapshot) -> bool { - if snapshot.workspace_preview_revision != self.workspace_preview_revision { + if snapshot.workspace_preview_revision != self.workspace_preview_revision + || snapshot.binding_mode.as_deref() != self.binding_modes.active() + { return false; } let mut outputs = self.output_workspaces.connected_outputs(); @@ -409,6 +411,7 @@ impl Ferese { fn shell_snapshot(&self) -> ShellSnapshot { ShellSnapshot { + binding_mode: self.binding_modes.active().map(str::to_owned), workspace_preview_revision: self.workspace_preview_revision, outputs: self.output_snapshots(), workspaces: self.workspace_snapshots(), @@ -553,6 +556,7 @@ enum ShellWindowAction { #[derive(Clone, Debug, PartialEq)] pub(crate) struct ShellSnapshot { + binding_mode: Option, workspace_preview_revision: u64, outputs: Vec, workspaces: Vec, @@ -562,6 +566,9 @@ pub(crate) struct ShellSnapshot { impl ShellSnapshot { fn send(&self, shell: &FereseShellV1, serial: u32) { shell.snapshot_begin(serial); + if shell.version() >= 9 { + shell.binding_mode(self.binding_mode.clone().unwrap_or_default()); + } for output in &self.outputs { let (output_hi, output_lo) = split_id(output.id.0); let (workspace_hi, workspace_lo) = split_id(output.active_workspace.0); @@ -809,6 +816,7 @@ mod tests { #[test] fn snapshot_deduplication_preserves_shell_visible_changes() { let original = ShellSnapshot { + binding_mode: None, workspace_preview_revision: 0, outputs: vec![OutputSnapshot { bounds: None, @@ -838,6 +846,7 @@ mod tests { }; assert_eq!(original, original.clone()); let changes: &[fn(&mut ShellSnapshot)] = &[ + |s| s.binding_mode = Some("resize".into()), |s| s.workspace_preview_revision += 1, |s| s.outputs[0].focused = false, |s| s.outputs[0].active_workspace = WorkspaceId(2), diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 66ffd6a..781ef4a 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -461,6 +461,9 @@ pub struct Ferese { gap_config: GapConfig, pub(crate) input_settings: InputSettings, pub(crate) bindings: BindingSet, + pub(crate) binding_modes: crate::config::BindingModes, + pub(crate) binding_mode_pressed: HashSet, + pub(crate) binding_mode_repeats: HashMap, workspace_auto_back_and_forth: bool, pub(crate) window_rules: Vec, pub(crate) theme_settings: ThemeSettings, @@ -560,6 +563,7 @@ pub struct RuntimeConfig { pub gap_config: GapConfig, pub input_settings: InputSettings, pub bindings: Vec, + pub(crate) binding_modes: crate::config::BindingModes, pub workspace_auto_back_and_forth: bool, pub window_rules: Vec, pub theme_settings: ThemeSettings, @@ -750,6 +754,9 @@ impl Ferese { gap_config: config.gap_config, input_settings: config.input_settings, bindings: config.bindings.into(), + binding_modes: config.binding_modes, + binding_mode_pressed: HashSet::new(), + binding_mode_repeats: HashMap::new(), workspace_auto_back_and_forth: config.workspace_auto_back_and_forth, window_rules: config.window_rules, theme_settings: config.theme_settings, @@ -862,7 +869,7 @@ impl Ferese { } let layout_changed = changed(&["layout", "scrolling", "workspaces"]); - let bindings_changed = changed(&["bindings", "commands"]); + let bindings_changed = changed(&["bindings", "binding_modes", "commands"]); let input_changed = changed(&["input"]); let daemons_changed = changed(&["autostart"]); let lock_changed = changed(&["lock_screen"]); @@ -946,8 +953,12 @@ impl Ferese { } self.gap_config = config.gap_config; self.input_settings = config.input_settings; - if bindings_changed { + if bindings_changed || input_changed { + self.binding_mode_repeats.clear(); self.bindings = config.bindings.into(); + self.binding_modes = config.binding_modes; + self.cancel_focus_cycle(); + self.send_shell_snapshots(); } self.workspace_auto_back_and_forth = config.workspace_auto_back_and_forth; if bindings_changed || input_changed { diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index b7e51b3..cca2501 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -844,3 +844,135 @@ fn window_rule_minimum_size_replaces_the_client_advertised_minimum() { (800, 600) ); } + +#[test] +fn resize_binding_mode_repeats_resize_a_mapped_window_and_stop_on_escape() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::resize_binding_mode_repeats_resize_a_mapped_window_and_stop_on_escape", + ) { + return; + } + use crate::input::binding_mode_tests::key; + let (mut events, mut state, _) = fixture(); + state + .reload_config_source("binding-mode resize { binding h resize left; }".into()) + .unwrap(); + let (window, _wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.toggle_focused_floating(); + state + .workspaces + .set_floating_rect(id, Rect::new(120., 100., 224., 128.)) + .unwrap(); + state.relayout_window(id); + state.set_binding_mode("resize"); + key(&mut state, 43, true); + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { + rect: Rect::new(120., 100., 192., 128.) + }) + ); + key(&mut state, 43, true); + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { + rect: Rect::new(120., 100., 160., 128.) + }) + ); + key(&mut state, 9, true); + key(&mut state, 9, false); + key(&mut state, 43, true); + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { + rect: Rect::new(120., 100., 160., 128.) + }) + ); + key(&mut state, 43, false); + assert!(state.intercepted_keys.is_empty()); +} + +#[test] +fn resize_binding_mode_repeats_require_the_original_modifiers() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::resize_binding_mode_repeats_require_the_original_modifiers", + ) { + return; + } + use crate::input::binding_mode_tests::key; + let (mut events, mut state, _) = fixture(); + state + .reload_config_source("binding-mode resize { binding h resize left; }".into()) + .unwrap(); + let (window, _wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.toggle_focused_floating(); + state + .workspaces + .set_floating_rect(id, Rect::new(120., 100., 320., 128.)) + .unwrap(); + state.relayout_window(id); + state.set_binding_mode("resize"); + key(&mut state, 43, true); + let resized = state.workspaces.placement(id); + key(&mut state, 133, true); + key(&mut state, 43, true); + assert_eq!( + state.workspaces.placement(id), + resized, + "Super+h is not a resize-mode chord" + ); + assert!( + state + .intercepted_keys + .contains(&smithay::input::keyboard::Keycode::new(43)) + ); + key(&mut state, 133, false); + key(&mut state, 43, true); + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { + rect: Rect::new(120., 100., 256., 128.), + }) + ); + key(&mut state, 43, false); + assert!(state.intercepted_keys.is_empty()); +} + +#[test] +fn reloading_mode_bindings_cancels_the_modes_focus_preview() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::reloading_mode_bindings_cancels_the_modes_focus_preview", + ) { + return; + } + use crate::input::binding_mode_tests::key; + let (mut events, mut state, _) = fixture(); + let source = "binding-mode cycle { binding Alt+Tab focus-mru-next; }"; + state.reload_config_source(source.into()).unwrap(); + let (_first, _first_wire) = window(&mut state, &mut events, 0xff112233); + let (_second, _second_wire) = window(&mut state, &mut events, 0xff223344); + let origin = state.focused_window; + state.set_binding_mode("cycle"); + key(&mut state, 64, true); + key(&mut state, 23, true); + assert!(state.focus_cycle.is_some()); + state + .reload_config_source(format!("{source}\ninput {{ repeat-rate 30; }}")) + .unwrap(); + assert_eq!(state.binding_modes.active(), None); + assert!( + state.focus_cycle.is_none(), + "the exited mode cannot keep its focus preview" + ); + assert_eq!(state.focused_window, origin); + assert!( + state + .intercepted_keys + .contains(&smithay::input::keyboard::Keycode::new(23)) + ); + key(&mut state, 23, false); + key(&mut state, 64, false); + assert!(state.intercepted_keys.is_empty()); +} diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index adbd356..ca13445 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -76,6 +76,7 @@ fn field(name: &str, parent: &str) -> String { match name { "binding" => "bindings".into(), + "binding-mode" => "binding_modes".into(), "window-rule" => "window_rules".into(), "output-profile" => "output_profiles".into(), "output" => "outputs".into(), @@ -91,8 +92,9 @@ fn is_records(key: &str, parent: &str) -> bool { (parent, key), ( "", - "bindings" | "window_rules" | "output_profiles" | "autostart" | "panels" + "bindings" | "binding_modes" | "window_rules" | "output_profiles" | "autostart" | "panels" ) | ("output_profiles", "outputs") + | ("binding_modes", "bindings") | ("start" | "center" | "end", "groups") | ("groups", "items") ) @@ -185,6 +187,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result { } let positional: &[&str] = match key { "bindings" => &["keys", "action", "argument"], + "binding_modes" => &["name"], "output_profiles" => &["name"], "outputs" => &["match"], "panels" | "groups" | "items" => &["id"], @@ -704,6 +707,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) -> if rest.len() == 1 { let positions: &[&str] = match key { "bindings" => &["keys", "action", "argument"], + "binding_modes" => &["name"], "outputs" => &["match"], "output_profiles" => &["name"], "panels" | "groups" | "items" => &["id"], @@ -795,6 +799,7 @@ fn node_name(key: &str, parent: &str) -> String { match key { "bindings" => "binding".into(), + "binding_modes" => "binding-mode".into(), "window_rules" => "window-rule".into(), "output_profiles" => "output-profile".into(), "outputs" => "output".into(), @@ -812,6 +817,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result Value::Object(map) => { let positions: &[&str] = match key { "bindings" => &["keys", "action", "argument"], + "binding_modes" => &["name"], "output_profiles" => &["name"], "outputs" => &["match"], "panels" | "groups" | "items" => &["id"], @@ -880,6 +886,21 @@ pub fn from_str(source: &str) -> Result { #[cfg(test)] mod tests { use super::*; + + #[test] + fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() { + let mut document = super::Document::parse( + "binding-mode resize { binding h resize left; binding l resize right; }\nbinding-mode launch { binding t spawn terminal; }\n", + ).unwrap(); + assert_eq!(document.get("binding_modes.0.name").unwrap(), "resize"); + assert_eq!(document.get("binding_modes.1.bindings.0.argument").unwrap(), "terminal"); + document + .set("binding_modes.0.bindings.0.argument", "up".into()) + .unwrap(); + let reparsed = super::Document::parse(&document.to_string()).unwrap(); + assert_eq!(reparsed.get("binding_modes.0.bindings.0.argument").unwrap(), "up"); + assert_eq!(reparsed.get("binding_modes.0.bindings.1.argument").unwrap(), "right"); + } #[test] fn adopting_appearance_modes_preserves_legacy_colors_and_comments() { let mut document = Document::parse( diff --git a/docs/configuration.md b/docs/configuration.md index adf5aed..152e8d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -525,6 +525,7 @@ invalid. A disabled binding must omit `action` and `argument`. | Actions | Argument | | --- | --- | | `spawn` | Name in `commands` | +| `binding-mode` | Named mode, or `"default"` to leave it | | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | @@ -547,6 +548,65 @@ binding keys="Super+V" action="focus-floating" changing placement. It stays on the active workspace and does nothing when the opposite layer has no candidate. `toggle-floating` changes window placement. +### Binding modes + +Named modes provide a separate keyboard map for resizing, launching, or other +sets of actions. No modes are enabled by default. For example: + +```kdl +binding "Super+Shift+r" "binding-mode" "resize" +binding "Super+Shift+Return" "binding-mode" "launch" +binding-mode "resize" { + binding "h" "resize" "left" + binding "j" "resize" "down" + binding "k" "resize" "up" + binding "l" "resize" "right" + binding "Return" "binding-mode" "default" +} +binding-mode "launch" { + binding "t" "spawn" "terminal" + binding "r" "binding-mode" "resize" + binding "Return" "binding-mode" "default" +} +``` + +Release the entry chord's modifiers before using an unmodified mode binding. +Modifiers keep their usual exact matching semantics; entering a mode never +changes the application's modifier state. Escape exits with any modifiers held +and cannot be overridden inside a mode. The bar shows the active name and Escape +hint, including over fullscreen windows, and restores its ordinary contents on +exit. Long names truncate to keep the exit hint visible. Mode changes reach the +shell through its existing snapshot protocol. + +Entering a mode closes panel popovers and workspace tooltips. An expanded Morph +panel collapses and releases keyboard focus. Hover, activity updates, and queued +menu requests cannot reopen it while the mode indicator is displayed. +Live Activities continue updating their data, but leave the hidden Morph panel's +geometry unchanged until the mode exits. + +Mode bindings replace the default keyboard map while active. Unmatched keys +reach the focused application; default compositor bindings and portal shortcuts +do not run as fallbacks. Gestures keep the default configuration. Normal shortcut +inhibition still applies, with Escape available to leave the mode. A mode remains +active across actions and focus/workspace changes until explicitly changed or +exited; launching an application does not implicitly leave it. + +Each mode must have a unique name of 1–48 ASCII letters, digits, underscores, or +hyphens. `default` is reserved. Mode bindings accept the same actions and physical +key matching as ordinary bindings; swipes, disabled records, duplicate chords, +and Escape bindings are rejected, including physical keys remapped to Escape. +References to undefined modes reject the configuration before it is applied. + +An accepted change to bindings, modes, commands, or input settings returns to +default and closes any focus preview. Unrelated reloads preserve the mode; +rejected reloads keep the previous configuration and mode. Locking, input capture, +and session suspension also exit. +The first press owns its matching release across these transitions. Held keys +are never reinterpreted by a new mode; consumed releases stay consumed. Repeated +presses retain their original action and repeat only while its chord still +matches. A mode/configuration transition stops that action's repeats. +`feresectl keybindings` lists the active keyboard map. + Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus; Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace; Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center; diff --git a/docs/ipc.md b/docs/ipc.md index 731eb38..df8042a 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -1,5 +1,9 @@ # feresectl and IPC events +`feresectl keybindings` lists the active binding mode's shortcuts. Outside a +mode it lists the configured default shortcuts. Escape is always available to +leave a mode; see [Binding modes](configuration.md#binding-modes). + `feresectl` controls a running Ferese session. Run `feresectl --help` for commands and `feresectl COMMAND --help` for arguments. diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index af8cad4..d0f1b77 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -205,5 +205,13 @@ + + + Sent between snapshot_begin and snapshot_end. An empty name denotes + the default bindings. Other names must be displayed with an Escape exit + hint until the next snapshot replaces them. + + + diff --git a/shell/ferese-shell/src/activities.rs b/shell/ferese-shell/src/activities.rs index c8a1b2a..a0e0299 100644 --- a/shell/ferese-shell/src/activities.rs +++ b/shell/ferese-shell/src/activities.rs @@ -381,15 +381,17 @@ impl FereseShell { } // Compact ranking changes must not replay the expanded panel's // crossfade: its live rows update in place, preserving focus/scroll. - if before != runtime.activities.selection.visible - && matches!( - runtime.hub.mode(), - ferese_panel::morph::Mode::Idle | ferese_panel::morph::Mode::Peek - ) - { - runtime.hub.replace_content(); + if self.snapshot.binding_mode.is_none() { + if before != runtime.activities.selection.visible + && matches!( + runtime.hub.mode(), + ferese_panel::morph::Mode::Idle | ferese_panel::morph::Mode::Peek + ) + { + runtime.hub.replace_content(); + } + runtime.refresh_activity_size(); } - runtime.refresh_activity_size(); } } pub fn activity_tick_subscription(&self) -> Subscription<()> { diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index 0e1700d..dae2e26 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -14,7 +14,32 @@ pub(super) mod presentation; use crate::panel::Representation; use crate::panel_layout::Placement; +#[cfg(test)] +mod binding_mode_tests; + impl FereseShell { + pub(super) fn binding_mode_transition(&mut self, was_active: bool) -> cosmic::app::Task { + match (was_active, self.snapshot.binding_mode.is_some()) { + (false, true) => { + let mut tasks = vec![self.destroy_menu(), self.dismiss_workspace_tooltip()]; + let hubs: Vec<_> = self + .outputs + .iter() + .filter(|entry| entry.runtime.hub.is_some()) + .map(|entry| entry.bar) + .collect(); + for id in hubs { + // Release keyboard ownership and return to the collapsed + // backing allocation before displaying the mode indicator. + tasks.push(self.suspend_hub(id)); + } + cosmic::app::Task::batch(tasks) + } + (true, false) => self.sync_hub_activity(), + _ => cosmic::app::Task::none(), + } + } + pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?; self.snapshot.outputs.iter().find(|output| output.name == name) @@ -45,6 +70,9 @@ impl FereseShell { .into(); } let panel = self.panel_for_bar(id); + if let Some(name) = &self.snapshot.binding_mode { + return self.view_binding_mode(id, name); + } if panel.style == ferese_panel::PanelStyle::Morph { return self.view_hub(id); } @@ -250,6 +278,72 @@ impl FereseShell { self.with_workspace_hover(id, content) } + fn view_binding_mode<'a>(&'a self, id: window::Id, name: &'a str) -> Element<'a, cosmic::Action> { + let panel = self.panel_for_bar(id); + let output = self.outputs.iter().find(|entry| entry.bar == id).expect("panel output"); + let effects = output.effects.as_ref(); + let width = output + .runtime + .hub + .as_ref() + .map(|hub| hub.borrow().allocation[0]) + .unwrap_or_else(|| output.size.map_or(320, |size| size.0 as u32)) + .min(320); + let badge_id = cosmic::iced::advanced::widget::Id::new("ferese-binding-mode"); + let title = self + .render + .text(format!("Mode: {name}")) + .size(14) + .width(Length::Fill) + .ellipsize(cosmic::iced::widget::text::Ellipsize::End( + cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), + )); + let badge = container( + row![title, self.render.text("Esc to exit").size(12)] + .spacing(12) + .align_y(cosmic::iced::Alignment::Center), + ) + .id(badge_id.clone()) + .padding([0, 12]) + .width(width as f32) + .height(panel.resolved_geometry().height) + .align_y(alignment::Vertical::Center); + let content = container(badge).center_x(Length::Fill).center_y(Length::Fill); + let palette = self.config.theme.for_bar(); + let mut background = color(palette.bar_background); + background.a = palette.panel_opacity(panel.background_opacity); + // Replace the native paint/input scene as well as the text. Retaining + // the previous bar or Morph scene can clip a newly displayed mode. + ferese_theme::panel_surface::frame( + content.into(), + ferese_theme::panel_surface::Options { + parameters: panel.shape_parameters(), + bounds: Some(badge_id.clone()), + groups: vec![(badge_id, ferese_panel::shape::ZonePosition::Center)], + scale: output.scale, + high_contrast: palette.high_contrast, + background, + border: if panel.border { + color(palette.border) + } else { + Color::TRANSPARENT + }, + divider: Color::TRANSPARENT, + native: effects.is_some(), + }, + move |scene| { + if let Some(effects) = effects + && let Err(error) = effects.set_panel_presentation(scene) + { + eprintln!("ferese-shell: could not update binding mode material: {error}"); + } + }, + Some(Box::new(move |scene| { + cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone()))) + })), + ) + } + pub(super) fn with_workspace_hover<'a>( &'a self, id: window::Id, @@ -706,6 +800,9 @@ pub(super) fn bar_hidden(snapshot: &ShellSnapshot) -> bool { } pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option) -> bool { + if snapshot.binding_mode.is_some() { + return false; + } let active_workspace = snapshot .outputs .iter() @@ -832,6 +929,17 @@ pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style { #[cfg(test)] mod island_tests { use super::*; + + #[test] + fn mode_indicator_stays_visible_on_fullscreen_outputs() { + let mut snapshot = super::super::tests::snapshot_with_fullscreen_window(7); + assert!(output_bar_hidden(&snapshot, Some(1))); + snapshot.binding_mode = Some("resize".into()); + assert!(!output_bar_hidden(&snapshot, Some(1))); + snapshot.binding_mode = None; + assert!(output_bar_hidden(&snapshot, Some(1))); + } + use crate::panel::{ItemId, Panel}; use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget}; use cosmic::iced::{Font, Pixels, Rectangle, Size}; diff --git a/shell/ferese-shell/src/bar/binding_mode_tests.rs b/shell/ferese-shell/src/bar/binding_mode_tests.rs new file mode 100644 index 0000000..7d0eabd --- /dev/null +++ b/shell/ferese-shell/src/bar/binding_mode_tests.rs @@ -0,0 +1,209 @@ +use super::*; +use cosmic::iced::advanced::{Layout, Renderer, Shell, clipboard, layout, mouse, renderer, widget}; +use cosmic::iced::{Event, Rectangle, Size}; + +#[derive(Default)] +struct TextBounds(Vec<(String, Rectangle)>); + +impl widget::Operation for TextBounds { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { + self.0.push((text.to_owned(), bounds)); + } +} + +#[test] +fn a_binding_mode_owns_the_panel_instead_of_hidden_morph_hover_or_menus() { + let mut shell = crate::tests::shell_with_measured_panel(); + shell.config.panels[0].style = ferese_panel::PanelStyle::Morph; + shell.outputs[0] + .runtime + .reset(&shell.config.panels[0], &shell.snapshot, Some("test")); + let id = shell.outputs[0].bar; + shell.snapshot.binding_mode = Some("resize".into()); + let allocation = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation; + drop(shell.hub_hover(id, true)); + assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle)); + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle)); + assert_eq!( + shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation, + allocation + ); +} + +#[test] +fn mode_entry_dismisses_existing_popovers_and_collapses_expanded_morph_content() { + for style in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Morph] { + let mut shell = crate::tests::shell_with_measured_panel(); + shell.config.panels[0].style = style; + shell.outputs[0] + .runtime + .reset(&shell.config.panels[0], &shell.snapshot, Some("test")); + let id = shell.outputs[0].bar; + let collapsed = shell.outputs[0].runtime.hub.as_ref().map(|hub| hub.borrow().allocation); + if collapsed.is_some() { + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + assert!(shell.hub_mode(id).unwrap().expanded()); + } + let anchor = status_ui::PopoverAnchor { + parent: id, + panel: shell.config.panels[0].id.clone(), + item: Some(crate::panel::ItemId("clock".into())), + rectangle: Default::default(), + }; + shell.menu = Some(status_ui::OpenMenu { + id: window::Id::unique(), + anchor: anchor.clone(), + kind: status_ui::Menu::Calendar, + motion: crate::motion::PopupMotion::new(Default::default()), + effects: None, + regions: Default::default(), + }); + shell.workspace_ui.tooltip = Some(window::Id::unique()); + shell.snapshot.binding_mode = Some("resize".into()); + drop(shell.binding_mode_transition(false)); + assert!(shell.menu.is_none()); + assert!(shell.workspace_ui.tooltip.is_none()); + drop(shell.open_menu(status_ui::Menu::Calendar, anchor)); + assert!( + shell.menu.is_none(), + "a queued click on a displaced control must not reopen its popup" + ); + if let Some(collapsed) = collapsed { + assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle)); + let hub = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow(); + assert_eq!(hub.allocation, collapsed); + assert!(!hub.hub.active()); + } + // Another snapshot while the mode is active does not restart the handoff. + drop(shell.binding_mode_transition(true)); + shell.snapshot.binding_mode = None; + drop(shell.binding_mode_transition(true)); + if collapsed.is_some() { + drop(shell.open_hub(id, status_ui::Menu::Calendar)); + assert!(shell.hub_mode(id).unwrap().expanded()); + } + } +} + +#[test] +fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() { + use cosmic::iced::advanced::renderer::Headless; + let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let previews = std::env::var_os("FERESE_TEST_MODE_PREVIEWS").map(std::path::PathBuf::from); + if let Some(path) = &previews { + std::fs::create_dir_all(path).unwrap(); + } + for style in ferese_panel::PanelStyle::AVAILABLE { + for name in ["resize", "resize_mode_with_a_long_name_and_a_required_exit"] { + let mut shell = crate::tests::shell_with_measured_panel(); + shell.config.panels[0].style = style; + let style = style.key(); + shell.snapshot.binding_mode = Some(name.into()); + shell.outputs[0] + .runtime + .reset(&shell.config.panels[0], &shell.snapshot, Some("test")); + let id = shell.outputs[0].bar; + let allocation = shell.outputs[0] + .runtime + .hub + .as_ref() + .map(|hub| hub.borrow().allocation) + .unwrap_or([1200, shell.config.panels[0].resolved_geometry().height as u32]); + let viewport = Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32)); + let mut view = shell.view_layer(id); + let mut tree = widget::Tree::new(&view); + let node = + view.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size())); + let mut messages = Vec::new(); + view.as_widget_mut().update( + &mut tree, + &Event::Window(window::Event::RedrawRequested(std::time::Instant::now())), + Layout::new(&node), + mouse::Cursor::Unavailable, + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + let scene = messages + .iter() + .find_map(|message| match message { + cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene), + _ => None, + }) + .unwrap_or_else(|| panic!("{style}: mode must replace the previous native clipping and input shape")); + let mut labels = TextBounds::default(); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut labels); + assert!( + labels.0.iter().any(|(text, _)| text.contains("resize")), + "{style}: mode name missing" + ); + assert!( + labels.0.iter().any(|(text, _)| text.contains("Esc to exit")), + "{style}: exit hint missing" + ); + for (text, bounds) in &labels.0 { + assert!( + bounds.x >= 0. + && bounds.y >= 0. + && bounds.x + bounds.width <= viewport.width + 0.1 + && bounds.y + bounds.height <= viewport.height + 0.1, + "{style}: {text} exceeds its backing surface: {bounds:?}" + ); + assert!( + scene.paint.iter().any(|paint| { + let rect = paint.bounds; + f64::from(bounds.x) >= rect.x - 0.1 + && f64::from(bounds.y) >= rect.y - 0.1 + && f64::from(bounds.x + bounds.width) <= rect.x + rect.width + 0.1 + && f64::from(bounds.y + bounds.height) <= rect.y + rect.height + 0.1 + }), + "{style}: {text} is outside the native paint shape" + ); + } + renderer.reset(viewport); + view.as_widget().draw( + &tree, + &mut renderer, + &shell.config.theme.palette().native_theme(), + &renderer::Style { + text_color: color(shell.config.theme.for_bar().text_primary), + icon_color: Color::WHITE, + scale_factor: 1., + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &viewport, + ); + let pixels = renderer.screenshot(Size::new(allocation[0], allocation[1]), 1., Color::TRANSPARENT); + assert!( + pixels.chunks_exact(4).any(|pixel| pixel[3] > 0), + "{style}: no visible pixels" + ); + if let Some(path) = &previews { + image_codec::save_buffer( + path.join(format!( + "mode-{style}{}.png", + if name == "resize" { "" } else { "-long" } + )), + &pixels, + allocation[0], + allocation[1], + image_codec::ColorType::Rgba8, + ) + .unwrap(); + } + } + } +} diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 7a609ff..599a954 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -14,6 +14,7 @@ use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop}; #[derive(Clone, Debug, Default)] pub(crate) struct ShellSnapshot { + pub(crate) binding_mode: Option, pub(crate) outputs: Vec, pub(crate) workspaces: Vec, pub(crate) windows: Vec, @@ -136,7 +137,7 @@ impl ShellControl { let connection = control_connection()?; let (globals, mut queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 7..=8, ())?; + let manager = globals.bind::(&qh, 7..=9, ())?; let shell = manager.get_shell(&qh, ()); let (sender, updates) = mpsc::channel(); let wake = Wake(Arc::new(tokio::sync::Notify::new())); @@ -330,6 +331,9 @@ impl Dispatch for ControlState { state.serial = Some(serial); state.pending = ShellSnapshot::default(); } + ferese_shell_v1::Event::BindingMode { name } => { + state.pending.binding_mode = (!name.is_empty()).then_some(name); + } ferese_shell_v1::Event::Output { output_hi, output_lo, @@ -558,6 +562,50 @@ mod tests { } } + #[test] + fn binding_mode_is_published_atomically_and_cleared_by_the_next_snapshot() { + let (control, mut state, queue, _peer) = control_fixture(); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotBegin { serial: 1 }, + ); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::BindingMode { name: "resize".into() }, + ); + assert!(control.poll().snapshot.is_none()); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotEnd { serial: 1 }, + ); + assert_eq!(control.poll().snapshot.unwrap().binding_mode.as_deref(), Some("resize")); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotBegin { serial: 2 }, + ); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::BindingMode { name: String::new() }, + ); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotEnd { serial: 2 }, + ); + assert!(control.poll().snapshot.unwrap().binding_mode.is_none()); + } + #[test] fn snapshot_retains_identity_and_floating_state_for_identical_window_metadata() { let (control, mut state, queue, _peer) = control_fixture(); diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 6343d94..953ead8 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -830,10 +830,13 @@ impl cosmic::Application for FereseShell { } if let Some(snapshot) = poll.snapshot { + let mode_was_active = self.snapshot.binding_mode.is_some(); self.snapshot = snapshot; + let mode = self.binding_mode_transition(mode_was_active); let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active); let mut tasks = vec![ reload_task, + mode, self.update_workspace_ui(), feedback, self.refresh_adaptive_panels(), @@ -1506,8 +1509,9 @@ mod tests { assert_eq!(focused_bar_title(&snapshot, None), ""); } - fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot { + pub(super) fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot { ShellSnapshot { + binding_mode: None, outputs: vec![OutputSnapshot { id: 1, name: "eDP-1".to_owned(), diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs index 56de7ff..7c99fc0 100644 --- a/shell/ferese-shell/src/panel_hub.rs +++ b/shell/ferese-shell/src/panel_hub.rs @@ -251,7 +251,7 @@ impl FereseShell { } pub(super) fn open_hub(&mut self, id: window::Id, kind: status_ui::Menu) -> Task { - if self.bar_hidden(id) || self.hub_mode(id).is_none() { + if self.snapshot.binding_mode.is_some() || self.bar_hidden(id) || self.hub_mode(id).is_none() { return Task::none(); } if kind == status_ui::Menu::Notifications { @@ -296,7 +296,8 @@ impl FereseShell { let Some(id) = self.active_bar() else { return Task::none(); }; - if self.bar_hidden(id) + if self.snapshot.binding_mode.is_some() + || self.bar_hidden(id) || self.hub_priority().is_some() || !matches!(self.hub_mode(id), Some(Mode::Idle | Mode::Peek | Mode::Toast)) || !self @@ -361,6 +362,9 @@ impl FereseShell { } fn transition_hub(&mut self, id: window::Id, event: Event) -> Task { + if self.snapshot.binding_mode.is_some() && !matches!(event, Event::Close | Event::Resize) { + return Task::none(); + } let workspace_width = self.hub_workspace_width(id); let panel = self.panel_for_bar(id); let geometry = panel.resolved_geometry(); @@ -1883,6 +1887,43 @@ mod tests { } } + #[test] + fn activity_updates_keep_the_binding_indicator_as_the_only_geometry_owner() { + let mut app = shell(); + let bar = app.outputs[0].bar; + app.snapshot.binding_mode = Some("resize".into()); + drop(app.binding_mode_transition(false)); + let (allocation, target) = { + let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow(); + (runtime.allocation, runtime.hub.target_size()) + }; + seed_activities(&mut app, 3); + drop(app.sync_hub_activity()); + { + let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow(); + assert_eq!( + runtime.activities.selection.visible.len(), + 2, + "activity data must still update" + ); + assert_eq!( + runtime.hub.target_size(), + target, + "hidden activity content cannot retarget the hub" + ); + assert_eq!(runtime.allocation, allocation); + assert!(!runtime.hub.active()); + } + app.snapshot.binding_mode = None; + drop(app.binding_mode_transition(true)); + assert_ne!( + app.outputs[0].runtime.hub.as_ref().unwrap().borrow().hub.target_size(), + target + ); + drop(app.open_hub(bar, status_ui::Menu::Activities)); + assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities))); + } + #[test] fn progress_updates_do_not_start_a_second_hub_animation() { let mut app = shell(); diff --git a/shell/ferese-shell/src/panel_hub/activity.rs b/shell/ferese-shell/src/panel_hub/activity.rs index c97bdf8..2a53ca5 100644 --- a/shell/ferese-shell/src/panel_hub/activity.rs +++ b/shell/ferese-shell/src/panel_hub/activity.rs @@ -80,6 +80,9 @@ impl FereseShell { pub(crate) fn sync_hub_activity(&mut self) -> Task { self.sync_activity_presentations(); + if self.snapshot.binding_mode.is_some() { + return Task::none(); + } let priority = self.hub_priority(); let changes: Vec<_> = self .outputs diff --git a/shell/ferese-shell/src/status_ui/lifecycle.rs b/shell/ferese-shell/src/status_ui/lifecycle.rs index 6844158..4dae4cc 100644 --- a/shell/ferese-shell/src/status_ui/lifecycle.rs +++ b/shell/ferese-shell/src/status_ui/lifecycle.rs @@ -39,7 +39,8 @@ impl OpenMenu { impl FereseShell { pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task { - if !anchor.belongs_to(&self.config.panels[0]) + if (self.snapshot.binding_mode.is_some() && anchor.item.is_some()) + || !anchor.belongs_to(&self.config.panels[0]) || !self.outputs.iter().any(|output| output.bar == anchor.parent) || self.bar_hidden(anchor.parent) || !kind.available(&self.status) diff --git a/shell/ferese-shell/src/workspace_ui.rs b/shell/ferese-shell/src/workspace_ui.rs index ebb7092..d94c078 100644 --- a/shell/ferese-shell/src/workspace_ui.rs +++ b/shell/ferese-shell/src/workspace_ui.rs @@ -472,6 +472,7 @@ mod tests { fn snapshot(active: u64) -> ShellSnapshot { ShellSnapshot { + binding_mode: None, outputs: vec![OutputSnapshot { id: 4, name: "test".into(),