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
+1250 -27

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() .handle()
.insert_source(notifier, move |event, _, state| match event { .insert_source(notifier, move |event, _, state| match event {
SessionEvent::PauseSession => { SessionEvent::PauseSession => {
state.set_binding_mode("default");
let handle = state.loop_handle.clone(); let handle = state.loop_handle.clone();
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.active = false; 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}; pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup};
use appearance::*; use appearance::*;
pub use appearance::{BorderGradient, FocusEffectSettings, MaterialStyle, ThemeSettings}; pub use appearance::{BorderGradient, FocusEffectSettings, MaterialStyle, ThemeSettings};
pub(crate) use bindings::BindingModes;
use bindings::*; use bindings::*;
pub use bindings::{Binding, BindingAction}; pub use bindings::{Binding, BindingAction};
pub(crate) use bindings::{BindingSet, physical_keymap}; pub(crate) use bindings::{BindingSet, physical_keymap};
@@ -62,6 +63,8 @@ pub struct Config {
#[serde(default)] #[serde(default)]
bindings: Vec<BindingConfig>, bindings: Vec<BindingConfig>,
#[serde(default)] #[serde(default)]
binding_modes: Vec<BindingModeConfig>,
#[serde(default)]
window_rules: Vec<WindowRuleConfig>, window_rules: Vec<WindowRuleConfig>,
#[serde(default)] #[serde(default)]
scrolling: ScrollingConfig, scrolling: ScrollingConfig,
@@ -130,6 +133,7 @@ impl Config {
} }
let input_settings = self.input_settings()?; let input_settings = self.input_settings()?;
let bindings = self.bindings(&input_settings)?; let bindings = self.bindings(&input_settings)?;
let binding_modes = self.binding_modes(&input_settings, &bindings)?;
Ok(crate::RuntimeConfig { Ok(crate::RuntimeConfig {
activity_settings: self.activities.clone(), activity_settings: self.activities.clone(),
activity_matcher: ferese_config::activities::Matcher::new(&self.activities) activity_matcher: ferese_config::activities::Matcher::new(&self.activities)
@@ -142,6 +146,7 @@ impl Config {
gap_config: self.gap_config()?, gap_config: self.gap_config()?,
input_settings, input_settings,
bindings, bindings,
binding_modes,
window_rules: self.window_rules()?, window_rules: self.window_rules()?,
theme_settings: self.theme_settings()?, theme_settings: self.theme_settings()?,
panel_style: self panel_style: self
+12 -1
View File
@@ -3,10 +3,13 @@ use std::sync::Arc;
use super::*; use super::*;
mod index; mod index;
mod modes;
pub(crate) use index::BindingSet; pub(crate) use index::BindingSet;
pub(super) use modes::BindingModeConfig;
pub(crate) use modes::BindingModes;
impl Config { 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([ let mut commands = HashMap::from([
("terminal".to_owned(), vec!["foot".to_owned()]), ("terminal".to_owned(), vec!["foot".to_owned()]),
("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]), ("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]),
@@ -16,6 +19,11 @@ impl Config {
), ),
]); ]);
commands.extend(self.commands.clone()); commands.extend(self.commands.clone());
commands
}
pub fn bindings(&self, input: &InputSettings) -> Result<Vec<Binding>, ConfigError> {
let commands = self.binding_commands();
validate_commands(&commands)?; validate_commands(&commands)?;
let keymap = physical_keymap(input)?; 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"))); || argument.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument")));
match action { match action {
"binding-mode" => Ok(BindingAction::BindingMode(required_argument()?.to_owned())),
"none" => { "none" => {
no_argument()?; no_argument()?;
Ok(BindingAction::None) Ok(BindingAction::None)
@@ -467,6 +476,7 @@ pub(super) struct BindingModifiers {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum BindingAction { pub enum BindingAction {
None, None,
BindingMode(String),
Spawn(Arc<[String]>), Spawn(Arc<[String]>),
Close, Close,
Exit, Exit,
@@ -558,6 +568,7 @@ impl Binding {
pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option<serde_json::Value> { pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option<serde_json::Value> {
let description = match &self.action { let description = match &self.action {
BindingAction::BindingMode(name) => format!("Switch to binding mode {name}"),
BindingAction::None => return None, BindingAction::None => return None,
BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => { BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => {
if argv.iter().any(|arg| arg == "--full") { 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. /// Immutable bindings and their keyboard index are replaced together on reload.
/// The ordered slice remains available for gestures and the shortcut guide. /// The ordered slice remains available for gestures and the shortcut guide.
#[derive(Clone)]
pub(crate) struct BindingSet { pub(crate) struct BindingSet {
ordered: Vec<Binding>, ordered: Vec<Binding>,
keyboard: HashMap<BindingIdentity, usize>, keyboard: HashMap<BindingIdentity, usize>,
@@ -32,7 +33,7 @@ impl std::ops::Deref for BindingSet {
} }
impl BindingSet { impl BindingSet {
pub(crate) fn action_for_key( pub(crate) fn binding_for_key(
&self, &self,
keycode: Keycode, keycode: Keycode,
symbols: &[u32], symbols: &[u32],
@@ -40,7 +41,7 @@ impl BindingSet {
ctrl: bool, ctrl: bool,
alt: bool, alt: bool,
shift: bool, shift: bool,
) -> Option<&BindingAction> { ) -> Option<&Binding> {
let modifiers = BindingModifiers { logo, ctrl, alt, shift }; let modifiers = BindingModifiers { logo, ctrl, alt, shift };
let physical = self.keyboard.get(&BindingIdentity { let physical = self.keyboard.get(&BindingIdentity {
modifiers, modifiers,
@@ -60,7 +61,21 @@ impl BindingSet {
.into_iter() .into_iter()
.chain(symbolic) .chain(symbolic)
.min() .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_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111; const BTN_RIGHT: u32 = 0x111;
#[cfg(test)]
pub(crate) mod binding_mode_tests;
pub(crate) struct LockedPointerHint { pub(crate) struct LockedPointerHint {
pub(crate) surface: WlSurface, pub(crate) surface: WlSurface,
pub(crate) local: Point<f64, Logical>, pub(crate) local: Point<f64, Logical>,
@@ -110,6 +113,9 @@ impl Ferese {
} }
pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) { 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(); let seat = self.seat.clone();
self.idle_notifier_state.notify_activity(&seat); self.idle_notifier_state.notify_activity(&seat);
if !matches!( if !matches!(
@@ -286,15 +292,59 @@ impl Ferese {
} }
} }
if state == KeyState::Released { if state == KeyState::Released {
data.binding_mode_pressed.remove(&keycode);
data.binding_mode_repeats.remove(&keycode);
data.portal_shortcuts.release(keycode, Event::time(&event)); data.portal_shortcuts.release(keycode, Event::time(&event));
if data.intercepted_keys.remove(&keycode) { if data.intercepted_keys.remove(&keycode) {
return FilterResult::Intercept(()); 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() { if data.session_lock.active() {
return FilterResult::Forward; return FilterResult::Forward;
} }
let symbol = keysym.modified_sym().raw(); 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 data.focus_cycle.is_some() && symbol == keysyms::KEY_Escape {
if state == KeyState::Pressed { if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode); data.intercepted_keys.insert(keycode);
@@ -362,12 +412,7 @@ impl Ferese {
return FilterResult::Intercept(()); return FilterResult::Intercept(());
} }
let raw_symbols = keysym let binding = data.binding_modes.bindings(&data.bindings).binding_for_key(
.raw_syms()
.into_iter()
.map(|symbol| symbol.raw())
.collect::<Vec<_>>();
let action = data.bindings.action_for_key(
keycode, keycode,
&raw_symbols, &raw_symbols,
modifiers.logo, modifiers.logo,
@@ -377,7 +422,7 @@ impl Ferese {
); );
if data.focus_cycle.is_some() if data.focus_cycle.is_some()
&& state == KeyState::Pressed && state == KeyState::Pressed
&& !matches!(action, Some(BindingAction::FocusMru(_))) && !matches!(binding.map(|binding| &binding.action), Some(BindingAction::FocusMru(_)))
&& !matches!(symbol, && !matches!(symbol,
keysyms::KEY_Alt_L | keysyms::KEY_Alt_R keysyms::KEY_Alt_L | keysyms::KEY_Alt_R
| keysyms::KEY_Shift_L | keysyms::KEY_Shift_R | keysyms::KEY_Shift_L | keysyms::KEY_Shift_R
@@ -387,8 +432,9 @@ impl Ferese {
data.intercepted_keys.insert(keycode); data.intercepted_keys.insert(keycode);
return FilterResult::Intercept(()); return FilterResult::Intercept(());
} }
let Some(action) = action else { let Some(binding) = binding else {
if state == KeyState::Pressed if state == KeyState::Pressed
&& data.binding_modes.active().is_none()
&& data.portal_shortcuts.press( && data.portal_shortcuts.press(
keycode, keycode,
&raw_symbols, &raw_symbols,
@@ -404,7 +450,10 @@ impl Ferese {
if state == KeyState::Pressed { if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode); 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 { if let BindingAction::FocusMru(reverse) = action {
data.cycle_focus(reverse, modifiers.alt); data.cycle_focus(reverse, modifiers.alt);
@@ -1083,6 +1132,7 @@ impl Ferese {
fn execute_binding(&mut self, action: BindingAction) { fn execute_binding(&mut self, action: BindingAction) {
match action { match action {
BindingAction::None => {} BindingAction::None => {}
BindingAction::BindingMode(name) => self.set_binding_mode(&name),
BindingAction::Spawn(argv) => { BindingAction::Spawn(argv) => {
if let Some(child) = if let Some(child) =
spawn_client(self, argv.iter(), crate::private_client::ClientCapabilities::default()) 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( 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) (to.x - from.x, to.y - from.y)
}; };
if !active { if !active {
self.set_binding_mode("default");
self.cursor_redraw_pending = true; self.cursor_redraw_pending = true;
pointer.motion( pointer.motion(
self, self,
+2 -1
View File
@@ -1104,7 +1104,8 @@ impl Ferese {
"get-keybindings" => { "get-keybindings" => {
let map = crate::config::physical_keymap(&self.input_settings).ok(); let map = crate::config::physical_keymap(&self.input_settings).ok();
return Ok(json!( return Ok(json!(
self.bindings self.binding_modes
.bindings(&self.bindings)
.iter() .iter()
.filter_map(|binding| binding.guide_entry(map.as_ref())) .filter_map(|binding| binding.guide_entry(map.as_ref()))
.take(256) .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.process_pending_dmabuf_imports();
state.update_named_cursor(std::time::Instant::now()); state.update_named_cursor(std::time::Instant::now());
state.poll_theme(); 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 // The decoder wakes the loop after publishing its result, including
// when no output has a pending frame. // when no output has a pending frame.
let wallpaper_changed = state.wallpaper.poll(); 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 // Replacing an orphan never passes through Unlocked. Existing protected
// presentation facts remain valid, but old surfaces must not own input. // presentation facts remain valid, but old surfaces must not own input.
self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation); self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation);
self.set_binding_mode("default");
self.capture_epoch = self.capture_epoch.wrapping_add(1); self.capture_epoch = self.capture_epoch.wrapping_add(1);
self.session_lock.surfaces.clear(); self.session_lock.surfaces.clear();
self.cancel_logout_confirmation(); self.cancel_logout_confirmation();
@@ -472,7 +473,6 @@ impl SessionLockHandler for Ferese {
let keyboard = self.seat.get_keyboard().unwrap(); let keyboard = self.seat.get_keyboard().unwrap();
keyboard.unset_grab(self); keyboard.unset_grab(self);
keyboard.set_focus(self, None, serial); keyboard.set_focus(self, None, serial);
self.intercepted_keys.clear();
self.lock_input_activity(); self.lock_input_activity();
self.confirm_lock_if_ready(); self.confirm_lock_if_ready();
crate::backends::direct::render_all(self); 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] #[test]
fn lock_confirms_without_outputs() { fn lock_confirms_without_outputs() {
let Some(mut fixture) = Fixture::new("session_lock::tests::lock_confirms_without_outputs") else { 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; use crate::state::ClientState;
pub(crate) fn init_global(display: &DisplayHandle) { 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> { pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
@@ -339,7 +339,9 @@ impl Ferese {
} }
fn shell_snapshot_matches(&self, snapshot: &ShellSnapshot) -> bool { 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; return false;
} }
let mut outputs = self.output_workspaces.connected_outputs(); let mut outputs = self.output_workspaces.connected_outputs();
@@ -409,6 +411,7 @@ impl Ferese {
fn shell_snapshot(&self) -> ShellSnapshot { fn shell_snapshot(&self) -> ShellSnapshot {
ShellSnapshot { ShellSnapshot {
binding_mode: self.binding_modes.active().map(str::to_owned),
workspace_preview_revision: self.workspace_preview_revision, workspace_preview_revision: self.workspace_preview_revision,
outputs: self.output_snapshots(), outputs: self.output_snapshots(),
workspaces: self.workspace_snapshots(), workspaces: self.workspace_snapshots(),
@@ -553,6 +556,7 @@ enum ShellWindowAction {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub(crate) struct ShellSnapshot { pub(crate) struct ShellSnapshot {
binding_mode: Option<String>,
workspace_preview_revision: u64, workspace_preview_revision: u64,
outputs: Vec<OutputSnapshot>, outputs: Vec<OutputSnapshot>,
workspaces: Vec<WorkspaceSnapshot>, workspaces: Vec<WorkspaceSnapshot>,
@@ -562,6 +566,9 @@ pub(crate) struct ShellSnapshot {
impl ShellSnapshot { impl ShellSnapshot {
fn send(&self, shell: &FereseShellV1, serial: u32) { fn send(&self, shell: &FereseShellV1, serial: u32) {
shell.snapshot_begin(serial); shell.snapshot_begin(serial);
if shell.version() >= 9 {
shell.binding_mode(self.binding_mode.clone().unwrap_or_default());
}
for output in &self.outputs { for output in &self.outputs {
let (output_hi, output_lo) = split_id(output.id.0); let (output_hi, output_lo) = split_id(output.id.0);
let (workspace_hi, workspace_lo) = split_id(output.active_workspace.0); let (workspace_hi, workspace_lo) = split_id(output.active_workspace.0);
@@ -809,6 +816,7 @@ mod tests {
#[test] #[test]
fn snapshot_deduplication_preserves_shell_visible_changes() { fn snapshot_deduplication_preserves_shell_visible_changes() {
let original = ShellSnapshot { let original = ShellSnapshot {
binding_mode: None,
workspace_preview_revision: 0, workspace_preview_revision: 0,
outputs: vec![OutputSnapshot { outputs: vec![OutputSnapshot {
bounds: None, bounds: None,
@@ -838,6 +846,7 @@ mod tests {
}; };
assert_eq!(original, original.clone()); assert_eq!(original, original.clone());
let changes: &[fn(&mut ShellSnapshot)] = &[ let changes: &[fn(&mut ShellSnapshot)] = &[
|s| s.binding_mode = Some("resize".into()),
|s| s.workspace_preview_revision += 1, |s| s.workspace_preview_revision += 1,
|s| s.outputs[0].focused = false, |s| s.outputs[0].focused = false,
|s| s.outputs[0].active_workspace = WorkspaceId(2), |s| s.outputs[0].active_workspace = WorkspaceId(2),
+13 -2
View File
@@ -461,6 +461,9 @@ pub struct Ferese {
gap_config: GapConfig, gap_config: GapConfig,
pub(crate) input_settings: InputSettings, pub(crate) input_settings: InputSettings,
pub(crate) bindings: BindingSet, 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, workspace_auto_back_and_forth: bool,
pub(crate) window_rules: Vec<WindowRule>, pub(crate) window_rules: Vec<WindowRule>,
pub(crate) theme_settings: ThemeSettings, pub(crate) theme_settings: ThemeSettings,
@@ -560,6 +563,7 @@ pub struct RuntimeConfig {
pub gap_config: GapConfig, pub gap_config: GapConfig,
pub input_settings: InputSettings, pub input_settings: InputSettings,
pub bindings: Vec<Binding>, pub bindings: Vec<Binding>,
pub(crate) binding_modes: crate::config::BindingModes,
pub workspace_auto_back_and_forth: bool, pub workspace_auto_back_and_forth: bool,
pub window_rules: Vec<WindowRule>, pub window_rules: Vec<WindowRule>,
pub theme_settings: ThemeSettings, pub theme_settings: ThemeSettings,
@@ -750,6 +754,9 @@ impl Ferese {
gap_config: config.gap_config, gap_config: config.gap_config,
input_settings: config.input_settings, input_settings: config.input_settings,
bindings: config.bindings.into(), 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, workspace_auto_back_and_forth: config.workspace_auto_back_and_forth,
window_rules: config.window_rules, window_rules: config.window_rules,
theme_settings: config.theme_settings, theme_settings: config.theme_settings,
@@ -862,7 +869,7 @@ impl Ferese {
} }
let layout_changed = changed(&["layout", "scrolling", "workspaces"]); 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 input_changed = changed(&["input"]);
let daemons_changed = changed(&["autostart"]); let daemons_changed = changed(&["autostart"]);
let lock_changed = changed(&["lock_screen"]); let lock_changed = changed(&["lock_screen"]);
@@ -946,8 +953,12 @@ impl Ferese {
} }
self.gap_config = config.gap_config; self.gap_config = config.gap_config;
self.input_settings = config.input_settings; 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.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; self.workspace_auto_back_and_forth = config.workspace_auto_back_and_forth;
if bindings_changed || input_changed { if bindings_changed || input_changed {
@@ -844,3 +844,135 @@ fn window_rule_minimum_size_replaces_the_client_advertised_minimum() {
(800, 600) (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());
}
+22 -1
View File
@@ -76,6 +76,7 @@ fn field(name: &str, parent: &str) -> String {
match name { match name {
"binding" => "bindings".into(), "binding" => "bindings".into(),
"binding-mode" => "binding_modes".into(),
"window-rule" => "window_rules".into(), "window-rule" => "window_rules".into(),
"output-profile" => "output_profiles".into(), "output-profile" => "output_profiles".into(),
"output" => "outputs".into(), "output" => "outputs".into(),
@@ -91,8 +92,9 @@ fn is_records(key: &str, parent: &str) -> bool {
(parent, key), (parent, key),
( (
"", "",
"bindings" | "window_rules" | "output_profiles" | "autostart" | "panels" "bindings" | "binding_modes" | "window_rules" | "output_profiles" | "autostart" | "panels"
) | ("output_profiles", "outputs") ) | ("output_profiles", "outputs")
| ("binding_modes", "bindings")
| ("start" | "center" | "end", "groups") | ("start" | "center" | "end", "groups")
| ("groups", "items") | ("groups", "items")
) )
@@ -185,6 +187,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
} }
let positional: &[&str] = match key { let positional: &[&str] = match key {
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"binding_modes" => &["name"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"panels" | "groups" | "items" => &["id"], "panels" | "groups" | "items" => &["id"],
@@ -704,6 +707,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
if rest.len() == 1 { if rest.len() == 1 {
let positions: &[&str] = match key { let positions: &[&str] = match key {
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"binding_modes" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"panels" | "groups" | "items" => &["id"], "panels" | "groups" | "items" => &["id"],
@@ -795,6 +799,7 @@ fn node_name(key: &str, parent: &str) -> String {
match key { match key {
"bindings" => "binding".into(), "bindings" => "binding".into(),
"binding_modes" => "binding-mode".into(),
"window_rules" => "window-rule".into(), "window_rules" => "window-rule".into(),
"output_profiles" => "output-profile".into(), "output_profiles" => "output-profile".into(),
"outputs" => "output".into(), "outputs" => "output".into(),
@@ -812,6 +817,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
Value::Object(map) => { Value::Object(map) => {
let positions: &[&str] = match key { let positions: &[&str] = match key {
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"binding_modes" => &["name"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"panels" | "groups" | "items" => &["id"], "panels" | "groups" | "items" => &["id"],
@@ -880,6 +886,21 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn adopting_appearance_modes_preserves_legacy_colors_and_comments() { fn adopting_appearance_modes_preserves_legacy_colors_and_comments() {
let mut document = Document::parse( let mut document = Document::parse(
+60
View File
@@ -525,6 +525,7 @@ invalid. A disabled binding must omit `action` and `argument`.
| Actions | Argument | | Actions | Argument |
| --- | --- | | --- | --- |
| `spawn` | Name in `commands` | | `spawn` | Name in `commands` |
| `binding-mode` | Named mode, or `"default"` to leave it |
| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` |
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | | `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 changing placement. It stays on the active workspace and does nothing when the
opposite layer has no candidate. `toggle-floating` changes window placement. 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; 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+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; Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center;
+4
View File
@@ -1,5 +1,9 @@
# feresectl and IPC events # 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 `feresectl` controls a running Ferese session. Run `feresectl --help` for commands
and `feresectl COMMAND --help` for arguments. and `feresectl COMMAND --help` for arguments.
+10 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
</copyright> </copyright>
<interface name="ferese_shell_manager_v1" version="8"> <interface name="ferese_shell_manager_v1" version="9">
<description summary="create a capability-scoped Ferese shell controller"> <description summary="create a capability-scoped Ferese shell controller">
This private global is visible only to clients granted the shell-control This private global is visible only to clients granted the shell-control
capability. It does not assign a Wayland surface role. capability. It does not assign a Wayland surface role.
@@ -35,7 +35,7 @@
<request name="destroy" type="destructor"/> <request name="destroy" type="destructor"/>
</interface> </interface>
<interface name="ferese_shell_v1" version="8"> <interface name="ferese_shell_v1" version="9">
<description summary="managed-window metadata and shell control"> <description summary="managed-window metadata and shell control">
Window, workspace, and output identifiers are unsigned 64-bit values Window, workspace, and output identifiers are unsigned 64-bit values
encoded as high and low 32-bit words. Each snapshot atomically replaces encoded as high and low 32-bit words. Each snapshot atomically replaces
@@ -205,5 +205,13 @@
<arg name="workspace_hi" type="uint"/> <arg name="workspace_hi" type="uint"/>
<arg name="workspace_lo" type="uint"/> <arg name="workspace_lo" type="uint"/>
</request> </request>
<event name="binding_mode" since="9">
<description summary="active keyboard binding mode inside a snapshot">
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.
</description>
<arg name="name" type="string"/>
</event>
</interface> </interface>
</protocol> </protocol>
+2
View File
@@ -381,6 +381,7 @@ impl FereseShell {
} }
// Compact ranking changes must not replay the expanded panel's // Compact ranking changes must not replay the expanded panel's
// crossfade: its live rows update in place, preserving focus/scroll. // crossfade: its live rows update in place, preserving focus/scroll.
if self.snapshot.binding_mode.is_none() {
if before != runtime.activities.selection.visible if before != runtime.activities.selection.visible
&& matches!( && matches!(
runtime.hub.mode(), runtime.hub.mode(),
@@ -392,6 +393,7 @@ impl FereseShell {
runtime.refresh_activity_size(); runtime.refresh_activity_size();
} }
} }
}
pub fn activity_tick_subscription(&self) -> Subscription<()> { pub fn activity_tick_subscription(&self) -> Subscription<()> {
let visible = self.activities.snapshot.activities.iter().any(|activity| { let visible = self.activities.snapshot.activities.iter().any(|activity| {
if !matches!(activity.data.value, Value::Countdown { .. }) if !matches!(activity.data.value, Value::Countdown { .. })
+108
View File
@@ -14,7 +14,32 @@ pub(super) mod presentation;
use crate::panel::Representation; use crate::panel::Representation;
use crate::panel_layout::Placement; use crate::panel_layout::Placement;
#[cfg(test)]
mod binding_mode_tests;
impl FereseShell { impl FereseShell {
pub(super) fn binding_mode_transition(&mut self, was_active: bool) -> cosmic::app::Task<Message> {
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> { 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()?; let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?;
self.snapshot.outputs.iter().find(|output| output.name == name) self.snapshot.outputs.iter().find(|output| output.name == name)
@@ -45,6 +70,9 @@ impl FereseShell {
.into(); .into();
} }
let panel = self.panel_for_bar(id); 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 { if panel.style == ferese_panel::PanelStyle::Morph {
return self.view_hub(id); return self.view_hub(id);
} }
@@ -250,6 +278,72 @@ impl FereseShell {
self.with_workspace_hover(id, content) self.with_workspace_hover(id, content)
} }
fn view_binding_mode<'a>(&'a self, id: window::Id, name: &'a str) -> Element<'a, cosmic::Action<Message>> {
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>( pub(super) fn with_workspace_hover<'a>(
&'a self, &'a self,
id: window::Id, 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<u64>) -> bool { pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -> bool {
if snapshot.binding_mode.is_some() {
return false;
}
let active_workspace = snapshot let active_workspace = snapshot
.outputs .outputs
.iter() .iter()
@@ -832,6 +929,17 @@ pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
#[cfg(test)] #[cfg(test)]
mod island_tests { mod island_tests {
use super::*; 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 crate::panel::{ItemId, Panel};
use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget}; use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget};
use cosmic::iced::{Font, Pixels, Rectangle, Size}; use cosmic::iced::{Font, Pixels, Rectangle, Size};
@@ -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();
}
}
}
}
+49 -1
View File
@@ -14,6 +14,7 @@ use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub(crate) struct ShellSnapshot { pub(crate) struct ShellSnapshot {
pub(crate) binding_mode: Option<String>,
pub(crate) outputs: Vec<OutputSnapshot>, pub(crate) outputs: Vec<OutputSnapshot>,
pub(crate) workspaces: Vec<WorkspaceSnapshot>, pub(crate) workspaces: Vec<WorkspaceSnapshot>,
pub(crate) windows: Vec<WindowSnapshot>, pub(crate) windows: Vec<WindowSnapshot>,
@@ -136,7 +137,7 @@ impl ShellControl {
let connection = control_connection()?; let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?; let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle(); let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=8, ())?; let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=9, ())?;
let shell = manager.get_shell(&qh, ()); let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel(); let (sender, updates) = mpsc::channel();
let wake = Wake(Arc::new(tokio::sync::Notify::new())); let wake = Wake(Arc::new(tokio::sync::Notify::new()));
@@ -330,6 +331,9 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
state.serial = Some(serial); state.serial = Some(serial);
state.pending = ShellSnapshot::default(); 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 { ferese_shell_v1::Event::Output {
output_hi, output_hi,
output_lo, 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] #[test]
fn snapshot_retains_identity_and_floating_state_for_identical_window_metadata() { fn snapshot_retains_identity_and_floating_state_for_identical_window_metadata() {
let (control, mut state, queue, _peer) = control_fixture(); let (control, mut state, queue, _peer) = control_fixture();
+5 -1
View File
@@ -830,10 +830,13 @@ impl cosmic::Application for FereseShell {
} }
if let Some(snapshot) = poll.snapshot { if let Some(snapshot) = poll.snapshot {
let mode_was_active = self.snapshot.binding_mode.is_some();
self.snapshot = snapshot; 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 feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
let mut tasks = vec![ let mut tasks = vec![
reload_task, reload_task,
mode,
self.update_workspace_ui(), self.update_workspace_ui(),
feedback, feedback,
self.refresh_adaptive_panels(), self.refresh_adaptive_panels(),
@@ -1506,8 +1509,9 @@ mod tests {
assert_eq!(focused_bar_title(&snapshot, None), ""); 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 { ShellSnapshot {
binding_mode: None,
outputs: vec![OutputSnapshot { outputs: vec![OutputSnapshot {
id: 1, id: 1,
name: "eDP-1".to_owned(), name: "eDP-1".to_owned(),
+43 -2
View File
@@ -251,7 +251,7 @@ impl FereseShell {
} }
pub(super) fn open_hub(&mut self, id: window::Id, kind: status_ui::Menu) -> Task<Message> { pub(super) fn open_hub(&mut self, id: window::Id, kind: status_ui::Menu) -> Task<Message> {
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(); return Task::none();
} }
if kind == status_ui::Menu::Notifications { if kind == status_ui::Menu::Notifications {
@@ -296,7 +296,8 @@ impl FereseShell {
let Some(id) = self.active_bar() else { let Some(id) = self.active_bar() else {
return Task::none(); return Task::none();
}; };
if self.bar_hidden(id) if self.snapshot.binding_mode.is_some()
|| self.bar_hidden(id)
|| self.hub_priority().is_some() || self.hub_priority().is_some()
|| !matches!(self.hub_mode(id), Some(Mode::Idle | Mode::Peek | Mode::Toast)) || !matches!(self.hub_mode(id), Some(Mode::Idle | Mode::Peek | Mode::Toast))
|| !self || !self
@@ -361,6 +362,9 @@ impl FereseShell {
} }
fn transition_hub(&mut self, id: window::Id, event: Event) -> Task<Message> { fn transition_hub(&mut self, id: window::Id, event: Event) -> Task<Message> {
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 workspace_width = self.hub_workspace_width(id);
let panel = self.panel_for_bar(id); let panel = self.panel_for_bar(id);
let geometry = panel.resolved_geometry(); 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] #[test]
fn progress_updates_do_not_start_a_second_hub_animation() { fn progress_updates_do_not_start_a_second_hub_animation() {
let mut app = shell(); let mut app = shell();
@@ -80,6 +80,9 @@ impl FereseShell {
pub(crate) fn sync_hub_activity(&mut self) -> Task<Message> { pub(crate) fn sync_hub_activity(&mut self) -> Task<Message> {
self.sync_activity_presentations(); self.sync_activity_presentations();
if self.snapshot.binding_mode.is_some() {
return Task::none();
}
let priority = self.hub_priority(); let priority = self.hub_priority();
let changes: Vec<_> = self let changes: Vec<_> = self
.outputs .outputs
@@ -39,7 +39,8 @@ impl OpenMenu {
impl FereseShell { impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> { pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> {
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.outputs.iter().any(|output| output.bar == anchor.parent)
|| self.bar_hidden(anchor.parent) || self.bar_hidden(anchor.parent)
|| !kind.available(&self.status) || !kind.available(&self.status)
+1
View File
@@ -472,6 +472,7 @@ mod tests {
fn snapshot(active: u64) -> ShellSnapshot { fn snapshot(active: u64) -> ShellSnapshot {
ShellSnapshot { ShellSnapshot {
binding_mode: None,
outputs: vec![OutputSnapshot { outputs: vec![OutputSnapshot {
id: 4, id: 4,
name: "test".into(), name: "test".into(),