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:
1 parent
6b0d903ad0
commit
7d58eeb65d
28 files changed
+1258
-35
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -76,6 +76,7 @@ fn field(name: &str, parent: &str) -> String {
|
||||
|
||||
match name {
|
||||
"binding" => "bindings".into(),
|
||||
"binding-mode" => "binding_modes".into(),
|
||||
"window-rule" => "window_rules".into(),
|
||||
"output-profile" => "output_profiles".into(),
|
||||
"output" => "outputs".into(),
|
||||
@@ -91,8 +92,9 @@ fn is_records(key: &str, parent: &str) -> bool {
|
||||
(parent, key),
|
||||
(
|
||||
"",
|
||||
"bindings" | "window_rules" | "output_profiles" | "autostart" | "panels"
|
||||
"bindings" | "binding_modes" | "window_rules" | "output_profiles" | "autostart" | "panels"
|
||||
) | ("output_profiles", "outputs")
|
||||
| ("binding_modes", "bindings")
|
||||
| ("start" | "center" | "end", "groups")
|
||||
| ("groups", "items")
|
||||
)
|
||||
@@ -185,6 +187,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
|
||||
}
|
||||
let positional: &[&str] = match key {
|
||||
"bindings" => &["keys", "action", "argument"],
|
||||
"binding_modes" => &["name"],
|
||||
"output_profiles" => &["name"],
|
||||
"outputs" => &["match"],
|
||||
"panels" | "groups" | "items" => &["id"],
|
||||
@@ -704,6 +707,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
|
||||
if rest.len() == 1 {
|
||||
let positions: &[&str] = match key {
|
||||
"bindings" => &["keys", "action", "argument"],
|
||||
"binding_modes" => &["name"],
|
||||
"outputs" => &["match"],
|
||||
"output_profiles" => &["name"],
|
||||
"panels" | "groups" | "items" => &["id"],
|
||||
@@ -795,6 +799,7 @@ fn node_name(key: &str, parent: &str) -> String {
|
||||
|
||||
match key {
|
||||
"bindings" => "binding".into(),
|
||||
"binding_modes" => "binding-mode".into(),
|
||||
"window_rules" => "window-rule".into(),
|
||||
"output_profiles" => "output-profile".into(),
|
||||
"outputs" => "output".into(),
|
||||
@@ -812,6 +817,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
|
||||
Value::Object(map) => {
|
||||
let positions: &[&str] = match key {
|
||||
"bindings" => &["keys", "action", "argument"],
|
||||
"binding_modes" => &["name"],
|
||||
"output_profiles" => &["name"],
|
||||
"outputs" => &["match"],
|
||||
"panels" | "groups" | "items" => &["id"],
|
||||
@@ -880,6 +886,21 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() {
|
||||
let mut document = super::Document::parse(
|
||||
"binding-mode resize { binding h resize left; binding l resize right; }\nbinding-mode launch { binding t spawn terminal; }\n",
|
||||
).unwrap();
|
||||
assert_eq!(document.get("binding_modes.0.name").unwrap(), "resize");
|
||||
assert_eq!(document.get("binding_modes.1.bindings.0.argument").unwrap(), "terminal");
|
||||
document
|
||||
.set("binding_modes.0.bindings.0.argument", "up".into())
|
||||
.unwrap();
|
||||
let reparsed = super::Document::parse(&document.to_string()).unwrap();
|
||||
assert_eq!(reparsed.get("binding_modes.0.bindings.0.argument").unwrap(), "up");
|
||||
assert_eq!(reparsed.get("binding_modes.0.bindings.1.argument").unwrap(), "right");
|
||||
}
|
||||
#[test]
|
||||
fn adopting_appearance_modes_preserves_legacy_colors_and_comments() {
|
||||
let mut document = Document::parse(
|
||||
|
||||
@@ -525,6 +525,7 @@ invalid. A disabled binding must omit `action` and `argument`.
|
||||
| Actions | Argument |
|
||||
| --- | --- |
|
||||
| `spawn` | Name in `commands` |
|
||||
| `binding-mode` | Named mode, or `"default"` to leave it |
|
||||
| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` |
|
||||
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
|
||||
| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor |
|
||||
@@ -547,6 +548,65 @@ binding keys="Super+V" action="focus-floating"
|
||||
changing placement. It stays on the active workspace and does nothing when the
|
||||
opposite layer has no candidate. `toggle-floating` changes window placement.
|
||||
|
||||
### Binding modes
|
||||
|
||||
Named modes provide a separate keyboard map for resizing, launching, or other
|
||||
sets of actions. No modes are enabled by default. For example:
|
||||
|
||||
```kdl
|
||||
binding "Super+Shift+r" "binding-mode" "resize"
|
||||
binding "Super+Shift+Return" "binding-mode" "launch"
|
||||
binding-mode "resize" {
|
||||
binding "h" "resize" "left"
|
||||
binding "j" "resize" "down"
|
||||
binding "k" "resize" "up"
|
||||
binding "l" "resize" "right"
|
||||
binding "Return" "binding-mode" "default"
|
||||
}
|
||||
binding-mode "launch" {
|
||||
binding "t" "spawn" "terminal"
|
||||
binding "r" "binding-mode" "resize"
|
||||
binding "Return" "binding-mode" "default"
|
||||
}
|
||||
```
|
||||
|
||||
Release the entry chord's modifiers before using an unmodified mode binding.
|
||||
Modifiers keep their usual exact matching semantics; entering a mode never
|
||||
changes the application's modifier state. Escape exits with any modifiers held
|
||||
and cannot be overridden inside a mode. The bar shows the active name and Escape
|
||||
hint, including over fullscreen windows, and restores its ordinary contents on
|
||||
exit. Long names truncate to keep the exit hint visible. Mode changes reach the
|
||||
shell through its existing snapshot protocol.
|
||||
|
||||
Entering a mode closes panel popovers and workspace tooltips. An expanded Morph
|
||||
panel collapses and releases keyboard focus. Hover, activity updates, and queued
|
||||
menu requests cannot reopen it while the mode indicator is displayed.
|
||||
Live Activities continue updating their data, but leave the hidden Morph panel's
|
||||
geometry unchanged until the mode exits.
|
||||
|
||||
Mode bindings replace the default keyboard map while active. Unmatched keys
|
||||
reach the focused application; default compositor bindings and portal shortcuts
|
||||
do not run as fallbacks. Gestures keep the default configuration. Normal shortcut
|
||||
inhibition still applies, with Escape available to leave the mode. A mode remains
|
||||
active across actions and focus/workspace changes until explicitly changed or
|
||||
exited; launching an application does not implicitly leave it.
|
||||
|
||||
Each mode must have a unique name of 1–48 ASCII letters, digits, underscores, or
|
||||
hyphens. `default` is reserved. Mode bindings accept the same actions and physical
|
||||
key matching as ordinary bindings; swipes, disabled records, duplicate chords,
|
||||
and Escape bindings are rejected, including physical keys remapped to Escape.
|
||||
References to undefined modes reject the configuration before it is applied.
|
||||
|
||||
An accepted change to bindings, modes, commands, or input settings returns to
|
||||
default and closes any focus preview. Unrelated reloads preserve the mode;
|
||||
rejected reloads keep the previous configuration and mode. Locking, input capture,
|
||||
and session suspension also exit.
|
||||
The first press owns its matching release across these transitions. Held keys
|
||||
are never reinterpreted by a new mode; consumed releases stay consumed. Repeated
|
||||
presses retain their original action and repeat only while its chord still
|
||||
matches. A mode/configuration transition stops that action's repeats.
|
||||
`feresectl keybindings` lists the active keyboard map.
|
||||
|
||||
Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus;
|
||||
Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace;
|
||||
Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# feresectl and IPC events
|
||||
|
||||
`feresectl keybindings` lists the active binding mode's shortcuts. Outside a
|
||||
mode it lists the configured default shortcuts. Escape is always available to
|
||||
leave a mode; see [Binding modes](configuration.md#binding-modes).
|
||||
|
||||
`feresectl` controls a running Ferese session. Run `feresectl --help` for commands
|
||||
and `feresectl COMMAND --help` for arguments.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</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">
|
||||
This private global is visible only to clients granted the shell-control
|
||||
capability. It does not assign a Wayland surface role.
|
||||
@@ -35,7 +35,7 @@
|
||||
<request name="destroy" type="destructor"/>
|
||||
</interface>
|
||||
|
||||
<interface name="ferese_shell_v1" version="8">
|
||||
<interface name="ferese_shell_v1" version="9">
|
||||
<description summary="managed-window metadata and shell control">
|
||||
Window, workspace, and output identifiers are unsigned 64-bit values
|
||||
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
||||
@@ -205,5 +205,13 @@
|
||||
<arg name="workspace_hi" type="uint"/>
|
||||
<arg name="workspace_lo" type="uint"/>
|
||||
</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>
|
||||
</protocol>
|
||||
@@ -381,15 +381,17 @@ impl FereseShell {
|
||||
}
|
||||
// Compact ranking changes must not replay the expanded panel's
|
||||
// crossfade: its live rows update in place, preserving focus/scroll.
|
||||
if before != runtime.activities.selection.visible
|
||||
&& matches!(
|
||||
runtime.hub.mode(),
|
||||
ferese_panel::morph::Mode::Idle | ferese_panel::morph::Mode::Peek
|
||||
)
|
||||
{
|
||||
runtime.hub.replace_content();
|
||||
if self.snapshot.binding_mode.is_none() {
|
||||
if before != runtime.activities.selection.visible
|
||||
&& matches!(
|
||||
runtime.hub.mode(),
|
||||
ferese_panel::morph::Mode::Idle | ferese_panel::morph::Mode::Peek
|
||||
)
|
||||
{
|
||||
runtime.hub.replace_content();
|
||||
}
|
||||
runtime.refresh_activity_size();
|
||||
}
|
||||
runtime.refresh_activity_size();
|
||||
}
|
||||
}
|
||||
pub fn activity_tick_subscription(&self) -> Subscription<()> {
|
||||
|
||||
@@ -14,7 +14,32 @@ pub(super) mod presentation;
|
||||
use crate::panel::Representation;
|
||||
use crate::panel_layout::Placement;
|
||||
|
||||
#[cfg(test)]
|
||||
mod binding_mode_tests;
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn binding_mode_transition(&mut self, was_active: bool) -> cosmic::app::Task<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> {
|
||||
let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?;
|
||||
self.snapshot.outputs.iter().find(|output| output.name == name)
|
||||
@@ -45,6 +70,9 @@ impl FereseShell {
|
||||
.into();
|
||||
}
|
||||
let panel = self.panel_for_bar(id);
|
||||
if let Some(name) = &self.snapshot.binding_mode {
|
||||
return self.view_binding_mode(id, name);
|
||||
}
|
||||
if panel.style == ferese_panel::PanelStyle::Morph {
|
||||
return self.view_hub(id);
|
||||
}
|
||||
@@ -250,6 +278,72 @@ impl FereseShell {
|
||||
self.with_workspace_hover(id, content)
|
||||
}
|
||||
|
||||
fn view_binding_mode<'a>(&'a self, id: window::Id, name: &'a str) -> Element<'a, cosmic::Action<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>(
|
||||
&'a self,
|
||||
id: window::Id,
|
||||
@@ -706,6 +800,9 @@ pub(super) fn bar_hidden(snapshot: &ShellSnapshot) -> bool {
|
||||
}
|
||||
|
||||
pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -> bool {
|
||||
if snapshot.binding_mode.is_some() {
|
||||
return false;
|
||||
}
|
||||
let active_workspace = snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
@@ -832,6 +929,17 @@ pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style {
|
||||
#[cfg(test)]
|
||||
mod island_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mode_indicator_stays_visible_on_fullscreen_outputs() {
|
||||
let mut snapshot = super::super::tests::snapshot_with_fullscreen_window(7);
|
||||
assert!(output_bar_hidden(&snapshot, Some(1)));
|
||||
snapshot.binding_mode = Some("resize".into());
|
||||
assert!(!output_bar_hidden(&snapshot, Some(1)));
|
||||
snapshot.binding_mode = None;
|
||||
assert!(output_bar_hidden(&snapshot, Some(1)));
|
||||
}
|
||||
|
||||
use crate::panel::{ItemId, Panel};
|
||||
use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget};
|
||||
use cosmic::iced::{Font, Pixels, Rectangle, Size};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ShellSnapshot {
|
||||
pub(crate) binding_mode: Option<String>,
|
||||
pub(crate) outputs: Vec<OutputSnapshot>,
|
||||
pub(crate) workspaces: Vec<WorkspaceSnapshot>,
|
||||
pub(crate) windows: Vec<WindowSnapshot>,
|
||||
@@ -136,7 +137,7 @@ impl ShellControl {
|
||||
let connection = control_connection()?;
|
||||
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
||||
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 (sender, updates) = mpsc::channel();
|
||||
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
|
||||
@@ -330,6 +331,9 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
state.serial = Some(serial);
|
||||
state.pending = ShellSnapshot::default();
|
||||
}
|
||||
ferese_shell_v1::Event::BindingMode { name } => {
|
||||
state.pending.binding_mode = (!name.is_empty()).then_some(name);
|
||||
}
|
||||
ferese_shell_v1::Event::Output {
|
||||
output_hi,
|
||||
output_lo,
|
||||
@@ -558,6 +562,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_mode_is_published_atomically_and_cleared_by_the_next_snapshot() {
|
||||
let (control, mut state, queue, _peer) = control_fixture();
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::SnapshotBegin { serial: 1 },
|
||||
);
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::BindingMode { name: "resize".into() },
|
||||
);
|
||||
assert!(control.poll().snapshot.is_none());
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial: 1 },
|
||||
);
|
||||
assert_eq!(control.poll().snapshot.unwrap().binding_mode.as_deref(), Some("resize"));
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::SnapshotBegin { serial: 2 },
|
||||
);
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::BindingMode { name: String::new() },
|
||||
);
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial: 2 },
|
||||
);
|
||||
assert!(control.poll().snapshot.unwrap().binding_mode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_retains_identity_and_floating_state_for_identical_window_metadata() {
|
||||
let (control, mut state, queue, _peer) = control_fixture();
|
||||
|
||||
@@ -830,10 +830,13 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
|
||||
if let Some(snapshot) = poll.snapshot {
|
||||
let mode_was_active = self.snapshot.binding_mode.is_some();
|
||||
self.snapshot = snapshot;
|
||||
let mode = self.binding_mode_transition(mode_was_active);
|
||||
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
|
||||
let mut tasks = vec![
|
||||
reload_task,
|
||||
mode,
|
||||
self.update_workspace_ui(),
|
||||
feedback,
|
||||
self.refresh_adaptive_panels(),
|
||||
@@ -1506,8 +1509,9 @@ mod tests {
|
||||
assert_eq!(focused_bar_title(&snapshot, None), "");
|
||||
}
|
||||
|
||||
fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
|
||||
pub(super) fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
|
||||
ShellSnapshot {
|
||||
binding_mode: None,
|
||||
outputs: vec![OutputSnapshot {
|
||||
id: 1,
|
||||
name: "eDP-1".to_owned(),
|
||||
|
||||
@@ -251,7 +251,7 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
if kind == status_ui::Menu::Notifications {
|
||||
@@ -296,7 +296,8 @@ impl FereseShell {
|
||||
let Some(id) = self.active_bar() else {
|
||||
return Task::none();
|
||||
};
|
||||
if self.bar_hidden(id)
|
||||
if self.snapshot.binding_mode.is_some()
|
||||
|| self.bar_hidden(id)
|
||||
|| self.hub_priority().is_some()
|
||||
|| !matches!(self.hub_mode(id), Some(Mode::Idle | Mode::Peek | Mode::Toast))
|
||||
|| !self
|
||||
@@ -361,6 +362,9 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
fn transition_hub(&mut self, id: window::Id, event: Event) -> Task<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 panel = self.panel_for_bar(id);
|
||||
let geometry = panel.resolved_geometry();
|
||||
@@ -1883,6 +1887,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_updates_keep_the_binding_indicator_as_the_only_geometry_owner() {
|
||||
let mut app = shell();
|
||||
let bar = app.outputs[0].bar;
|
||||
app.snapshot.binding_mode = Some("resize".into());
|
||||
drop(app.binding_mode_transition(false));
|
||||
let (allocation, target) = {
|
||||
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
||||
(runtime.allocation, runtime.hub.target_size())
|
||||
};
|
||||
seed_activities(&mut app, 3);
|
||||
drop(app.sync_hub_activity());
|
||||
{
|
||||
let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow();
|
||||
assert_eq!(
|
||||
runtime.activities.selection.visible.len(),
|
||||
2,
|
||||
"activity data must still update"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.hub.target_size(),
|
||||
target,
|
||||
"hidden activity content cannot retarget the hub"
|
||||
);
|
||||
assert_eq!(runtime.allocation, allocation);
|
||||
assert!(!runtime.hub.active());
|
||||
}
|
||||
app.snapshot.binding_mode = None;
|
||||
drop(app.binding_mode_transition(true));
|
||||
assert_ne!(
|
||||
app.outputs[0].runtime.hub.as_ref().unwrap().borrow().hub.target_size(),
|
||||
target
|
||||
);
|
||||
drop(app.open_hub(bar, status_ui::Menu::Activities));
|
||||
assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_updates_do_not_start_a_second_hub_animation() {
|
||||
let mut app = shell();
|
||||
|
||||
@@ -80,6 +80,9 @@ impl FereseShell {
|
||||
|
||||
pub(crate) fn sync_hub_activity(&mut self) -> Task<Message> {
|
||||
self.sync_activity_presentations();
|
||||
if self.snapshot.binding_mode.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let priority = self.hub_priority();
|
||||
let changes: Vec<_> = self
|
||||
.outputs
|
||||
|
||||
@@ -39,7 +39,8 @@ impl OpenMenu {
|
||||
|
||||
impl FereseShell {
|
||||
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.bar_hidden(anchor.parent)
|
||||
|| !kind.available(&self.status)
|
||||
|
||||
@@ -472,6 +472,7 @@ mod tests {
|
||||
|
||||
fn snapshot(active: u64) -> ShellSnapshot {
|
||||
ShellSnapshot {
|
||||
binding_mode: None,
|
||||
outputs: vec![OutputSnapshot {
|
||||
id: 4,
|
||||
name: "test".into(),
|
||||
|
||||
Reference in new issue
Block a user