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.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-12 23:22:02 +01:00
1 parent 6b0d903ad0
commit 7d58eeb65d
28 files changed
+1258 -35

No files matched your search

+1
View File
@@ -376,6 +376,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, 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;
+5
View File
@@ -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<BindingConfig>,
#[serde(default)]
binding_modes: Vec<BindingModeConfig>,
#[serde(default)]
window_rules: Vec<WindowRuleConfig>,
#[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
+12 -1
View File
@@ -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<Vec<Binding>, ConfigError> {
fn binding_commands(&self) -> HashMap<String, Vec<String>> {
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<Vec<Binding>, 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<serde_json::Value> {
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") {
+18 -3
View File
@@ -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<Binding>,
keyboard: HashMap<BindingIdentity, usize>,
@@ -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)
}
}
+191
View File
@@ -0,0 +1,191 @@
use super::*;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct BindingModeConfig {
name: String,
#[serde(default)]
bindings: Vec<BindingConfig>,
}
/// 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<String, BindingSet>,
active: Option<String>,
}
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<BindingModes, ConfigError> {
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}"
);
}
}
}
+73 -9
View File
@@ -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<f64, Logical>,
@@ -110,6 +113,9 @@ impl Ferese {
}
pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) {
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::<Vec<_>>();
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::<Vec<_>>();
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(
+244
View File
@@ -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<TestInput> for TestKey {
fn time(&self) -> u64 {
1_000
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
}
}
impl KeyboardKeyEvent<TestInput> 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::<TestInput>(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);
}
+1
View File
@@ -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,
+2 -1
View File
@@ -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)
+3
View File
@@ -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();
+1 -1
View File
@@ -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);
+25
View File
@@ -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 {
+11 -2
View File
@@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities;
use crate::state::ClientState;
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseShellManagerV1, _>(8, ());
display.create_global::<Ferese, FereseShellManagerV1, _>(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<String>,
workspace_preview_revision: u64,
outputs: Vec<OutputSnapshot>,
workspaces: Vec<WorkspaceSnapshot>,
@@ -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),
+13 -2
View File
@@ -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<Keycode>,
pub(crate) binding_mode_repeats: HashMap<Keycode, crate::config::Binding>,
workspace_auto_back_and_forth: bool,
pub(crate) window_rules: Vec<WindowRule>,
pub(crate) theme_settings: ThemeSettings,
@@ -560,6 +563,7 @@ pub struct RuntimeConfig {
pub gap_config: GapConfig,
pub input_settings: InputSettings,
pub bindings: Vec<Binding>,
pub(crate) binding_modes: crate::config::BindingModes,
pub workspace_auto_back_and_forth: bool,
pub window_rules: Vec<WindowRule>,
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 {
@@ -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());
}