use super::*; impl Config { pub fn bindings(&self, input: &InputSettings) -> Result, ConfigError> { let mut commands = HashMap::from([ ("terminal".to_owned(), vec!["foot".to_owned()]), ("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]), ( "screenshot-full".to_owned(), vec!["ferese-screenshot".to_owned(), "--full".to_owned()], ), ]); commands.extend(self.commands.clone()); validate_commands(&commands)?; let keymap = physical_keymap(input)?; let mut bindings = default_bindings() .into_iter() .map(|binding| parse_binding(&binding, &commands, &keymap)) .collect::, _>>()?; let mut supplied = HashSet::new(); for configured in &self.bindings { let identity = binding_identity(configured, &keymap)?; if !supplied.insert(identity.clone()) { return Err(ConfigError::Binding(format!("duplicate binding {:?}", configured.keys))); } bindings.retain(|binding| binding_identity_for_runtime(binding) != identity); if configured.disabled { if configured.action.is_some() || configured.argument.is_some() { return Err(ConfigError::Binding(format!( "disabled binding {:?} cannot have an action or argument", configured.keys ))); } } else { bindings.push(parse_binding(configured, &commands, &keymap)?); } } Ok(bindings) } } pub(super) fn validate_commands(commands: &HashMap>) -> Result<(), ConfigError> { for (name, argv) in commands { if name.trim().is_empty() { return Err(ConfigError::Binding("command names cannot be empty".to_owned())); } if argv.is_empty() || argv[0].is_empty() { return Err(ConfigError::Binding(format!("command {name:?} must contain a program"))); } } Ok(()) } pub(crate) fn physical_keymap(input: &InputSettings) -> Result { let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(",")); xkb::Keymap::new_from_names( &context, "", "", &input.xkb_layout, &input.xkb_variant, options, xkb::KEYMAP_COMPILE_NO_FLAGS, ) .ok_or_else(|| ConfigError::Binding("failed to compile the XKB keymap".to_owned())) } pub(super) fn parse_binding( configured: &BindingConfig, commands: &HashMap>, keymap: &xkb::Keymap, ) -> Result { if configured.disabled { return Err(ConfigError::Binding(format!( "disabled binding {:?} cannot be executed", configured.keys ))); } let identity = binding_identity(configured, keymap)?; let action = configured .action .as_deref() .ok_or_else(|| ConfigError::Binding(format!("binding {:?} has no action", configured.keys)))?; let action = parse_action(action, configured.argument.as_deref(), commands)?; let trigger = match identity.match_mode { BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)), BindingMatch::Swipe => BindingTrigger::Swipe { fingers: identity.key / 4, direction: [ crate::gestures::SwipeDirection::Up, crate::gestures::SwipeDirection::Down, crate::gestures::SwipeDirection::Left, crate::gestures::SwipeDirection::Right, ][(identity.key % 4) as usize], }, }; Ok(Binding { modifiers: identity.modifiers, trigger, action, }) } pub(super) fn binding_identity( configured: &BindingConfig, keymap: &xkb::Keymap, ) -> Result { let (modifiers, key) = parse_chord(&configured.keys)?; if let Some((fingers, direction)) = parse_swipe(&key) { if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() { return Err(ConfigError::Binding( "swipes cannot use keyboard modifiers or physical key matching".into(), )); } return Ok(BindingIdentity { modifiers, match_mode: BindingMatch::Swipe, key: fingers * 4 + direction as u32, }); } let key = match configured.match_mode { BindingMatch::Keysym => parse_keysym(&key)?, BindingMatch::Physical => keymap .key_by_name(&key.to_ascii_uppercase()) .map(Keycode::raw) .ok_or_else(|| { ConfigError::Binding(format!( "unknown XKB physical key name {key:?} in {:?}", configured.keys )) })?, BindingMatch::Swipe => { return Err(ConfigError::Binding( "gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(), )); } }; Ok(BindingIdentity { modifiers, match_mode: configured.match_mode, key, }) } pub(super) fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity { let (match_mode, key) = match binding.trigger { BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key), BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()), BindingTrigger::Swipe { fingers, direction } => (BindingMatch::Swipe, fingers * 4 + direction as u32), }; BindingIdentity { modifiers: binding.modifiers, match_mode, key, } } pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> { let mut modifiers = BindingModifiers::default(); let mut key = None; for component in chord.split('+').map(str::trim) { if component.is_empty() { return Err(ConfigError::Binding(format!("invalid key chord {chord:?}"))); } let slot = match component.to_ascii_lowercase().as_str() { "super" | "logo" | "mod4" => Some(&mut modifiers.logo), "ctrl" | "control" => Some(&mut modifiers.ctrl), "alt" => Some(&mut modifiers.alt), "shift" => Some(&mut modifiers.shift), _ => None, }; if let Some(slot) = slot { if *slot { return Err(ConfigError::Binding(format!("duplicate modifier in {chord:?}"))); } *slot = true; } else if key.replace(component.to_owned()).is_some() { return Err(ConfigError::Binding(format!( "key chord {chord:?} contains more than one key" ))); } } let key = key.ok_or_else(|| ConfigError::Binding(format!("key chord {chord:?} does not contain a key")))?; Ok((modifiers, key)) } pub(super) fn parse_swipe(key: &str) -> Option<(u32, crate::gestures::SwipeDirection)> { use crate::gestures::SwipeDirection; let lower = key.to_ascii_lowercase(); let rest = lower.strip_prefix("swipe")?; let fingers = rest.get(..1)?.parse::().ok()?; if !(3..=5).contains(&fingers) { return None; } let direction = match rest.get(1..)? { "up" => SwipeDirection::Up, "down" => SwipeDirection::Down, "left" => SwipeDirection::Left, "right" => SwipeDirection::Right, _ => return None, }; Some((fingers, direction)) } pub(super) fn parse_keysym(name: &str) -> Result { let normalized = match name { "Enter" | "enter" => "Return".to_owned(), "Space" | "space" => "space".to_owned(), "[" => "bracketleft".to_owned(), "]" => "bracketright".to_owned(), name if name.len() == 1 => name.to_ascii_lowercase(), name => name.to_owned(), }; let mut symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_NO_FLAGS); if symbol.raw() == keysyms::KEY_NoSymbol { symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE); } if symbol.raw() == keysyms::KEY_NoSymbol { Err(ConfigError::Binding(format!("unknown keysym {name:?}"))) } else { Ok(symbol.raw()) } } pub(super) fn parse_action( action: &str, argument: Option<&str>, commands: &HashMap>, ) -> Result { let no_argument = || { if argument.is_some() { Err(ConfigError::Binding(format!( "action {action:?} does not accept an argument" ))) } else { Ok(()) } }; let required_argument = || argument.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument"))); match action { "none" => { no_argument()?; Ok(BindingAction::None) } "workspace-next" | "workspace-previous" => { no_argument()?; Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next")) } "workspace-back-and-forth" => { no_argument()?; Ok(BindingAction::WorkspaceBackAndForth) } "focus-last-window" => { no_argument()?; Ok(BindingAction::FocusLastWindow) } "focus-mru-next" | "focus-mru-previous" => { no_argument()?; Ok(BindingAction::FocusMru(action == "focus-mru-previous")) } "spawn" => { let command = required_argument()?; let argv = commands .get(command) .ok_or_else(|| ConfigError::Binding(format!("unknown command {command:?}")))?; Ok(BindingAction::Spawn(argv.clone())) } "close" => { no_argument()?; Ok(BindingAction::Close) } "exit" => { no_argument()?; Ok(BindingAction::Exit) } "focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)), "move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)), "resize" => Ok(BindingAction::Resize(parse_direction(required_argument()?)?)), "workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(required_argument()?)?)), "move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(required_argument()?)?)), "toggle-maximized" => { no_argument()?; Ok(BindingAction::ToggleMaximized) } "toggle-fullscreen" => { no_argument()?; Ok(BindingAction::ToggleFullscreen) } "toggle-layout" => { no_argument()?; Ok(BindingAction::ToggleLayout) } "cycle-column-width" => { no_argument()?; Ok(BindingAction::CycleColumnWidth) } "center-column" => { no_argument()?; Ok(BindingAction::CenterColumn) } "consume" => { no_argument()?; Ok(BindingAction::Consume) } "expel" => { no_argument()?; Ok(BindingAction::Expel) } "toggle-floating" => { no_argument()?; Ok(BindingAction::ToggleFloating) } "toggle-overview" => { no_argument()?; Ok(BindingAction::ToggleOverview) } "toggle-keybinding-guide" => { no_argument()?; Ok(BindingAction::ToggleKeybindingGuide) } _ => Err(ConfigError::Binding(format!("unknown action {action:?}"))), } } pub(super) fn parse_direction(argument: &str) -> Result { match argument { "left" => Ok(Direction::Left), "right" => Ok(Direction::Right), "up" => Ok(Direction::Up), "down" => Ok(Direction::Down), _ => Err(ConfigError::Binding(format!("invalid direction {argument:?}"))), } } pub(super) fn parse_workspace(argument: &str) -> Result { match argument.parse() { Ok(workspace) if workspace > 0 => Ok(workspace), _ => Err(ConfigError::Binding(format!("invalid workspace {argument:?}"))), } } pub(super) fn default_bindings() -> Vec { let mut bindings = vec![ binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Shift+S", "spawn", Some("screenshot")), binding("Print", "spawn", Some("screenshot-full")), binding("Swipe3Up", "workspace-next", None), binding("Swipe3Down", "workspace-previous", None), binding("Swipe3Left", "focus", Some("right")), binding("Swipe3Right", "focus", Some("left")), binding("Super+Q", "close", None), binding("Super+F", "toggle-maximized", None), binding("Super+Shift+F", "toggle-fullscreen", None), binding("Super+M", "toggle-layout", None), binding("Super+R", "cycle-column-width", None), binding("Super+C", "center-column", None), binding("Super+[", "consume", None), binding("Super+]", "expel", None), binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Tab", "toggle-overview", None), binding("Super+F1", "toggle-keybinding-guide", None), binding("Super+Escape", "workspace-back-and-forth", None), binding("Super+BackSpace", "focus-last-window", None), binding("Alt+Tab", "focus-mru-next", None), binding("Alt+Shift+Tab", "focus-mru-previous", None), binding("Super+Shift+E", "exit", None), ]; for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] { bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction))); bindings.push(binding("Super+Shift+".to_owned() + key, "move", Some(direction))); bindings.push(binding("Super+Ctrl+".to_owned() + key, "resize", Some(direction))); } for workspace in 1..=9 { let workspace = workspace.to_string(); bindings.push(binding(format!("Super+{workspace}"), "workspace", Some(&workspace))); bindings.push(binding( format!("Super+Shift+{workspace}"), "move-to-workspace", Some(&workspace), )); } bindings } pub(super) fn binding(keys: impl Into, action: impl Into, argument: Option<&str>) -> BindingConfig { BindingConfig { keys: keys.into(), match_mode: BindingMatch::Keysym, action: Some(action.into()), argument: argument.map(str::to_owned), disabled: false, } } #[derive(Clone, Debug, PartialEq)] pub struct Binding { pub(super) modifiers: BindingModifiers, pub(super) trigger: BindingTrigger, pub action: BindingAction, } #[derive(Clone, Debug, PartialEq)] pub(super) enum BindingTrigger { Keysym(u32), Physical(Keycode), Swipe { fingers: u32, direction: crate::gestures::SwipeDirection, }, } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] pub(super) struct BindingModifiers { pub(super) logo: bool, pub(super) ctrl: bool, pub(super) alt: bool, pub(super) shift: bool, } #[derive(Clone, Debug, PartialEq)] pub enum BindingAction { None, Spawn(Vec), Close, Exit, Focus(ferese_layout::Direction), FocusLastWindow, FocusMru(bool), Move(ferese_layout::Direction), Resize(ferese_layout::Direction), SwitchWorkspace(u8), SwitchRelativeWorkspace(bool), WorkspaceBackAndForth, MoveToWorkspace(u8), ToggleFullscreen, ToggleMaximized, ToggleLayout, CycleColumnWidth, CenterColumn, Consume, Expel, ToggleFloating, ToggleOverview, ToggleKeybindingGuide, } impl Binding { pub(crate) fn portal_trigger(chord: &str) -> Result { if chord.len() > 256 { return Err(ConfigError::Binding("Shortcut is too long".into())); } let parts = chord.split('+').collect::>(); if parts .iter() .filter(|part| part.trim().eq_ignore_ascii_case("num")) .count() > 1 { return Err(ConfigError::Binding("Duplicate NUM modifier".into())); } let chord = parts .into_iter() .filter(|part| !part.trim().eq_ignore_ascii_case("num")) .collect::>() .join("+"); let (modifiers, key) = parse_chord(&chord)?; let symbol = parse_keysym(&key)?; if (modifiers.ctrl && modifiers.alt && matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12)) || (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol) { return Err(ConfigError::Binding("Reserved system shortcut".into())); } Ok(Self { modifiers, trigger: BindingTrigger::Keysym(symbol), action: BindingAction::None, }) } pub(crate) fn conflicts(&self, other: &Self, map: &xkb::Keymap) -> bool { if self.modifiers != other.modifiers { return false; } match (&self.trigger, &other.trigger) { (BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right, (BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code)) | (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => (0..map.num_layouts_for_key(*code)) .any(|layout| { map.key_get_syms_by_level(*code, layout, 0) .iter() .any(|key| key.raw() == *symbol) }), (BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right, _ => false, } } pub fn matches(&self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool) -> bool { self.modifiers == BindingModifiers { logo, ctrl, alt, shift } && match self.trigger { BindingTrigger::Keysym(expected) => keysyms.contains(&expected), BindingTrigger::Physical(expected) => expected == keycode, BindingTrigger::Swipe { .. } => false, } } pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option { let description = match &self.action { BindingAction::None => return None, BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => { if argv.iter().any(|arg| arg == "--full") { "Capture whole screen" } else { "Capture selected area" } .into() } BindingAction::Spawn(argv) => format!( "Launch {}", argv.first() .and_then(|program| std::path::Path::new(program).file_name()) .and_then(|name| name.to_str()) .unwrap_or("application") ), BindingAction::Close => "Close window".into(), BindingAction::Exit => "Log out (with confirmation)".into(), BindingAction::Focus(direction) => format!("Focus window {direction:?}"), BindingAction::FocusLastWindow => "Return to last focused window".into(), BindingAction::FocusMru(reverse) => if *reverse { "Previous window in focus history" } else { "Next window in focus history" } .into(), BindingAction::Move(direction) => format!("Move window {direction:?}"), BindingAction::Resize(direction) => format!("Resize window {direction:?}"), BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), BindingAction::WorkspaceBackAndForth => "Return to last workspace on this monitor".into(), BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"), BindingAction::SwitchRelativeWorkspace(next) => { if *next { "Next workspace" } else { "Previous workspace" }.into() } BindingAction::ToggleFullscreen => "Toggle fullscreen".into(), BindingAction::ToggleMaximized => "Toggle full-width window".into(), BindingAction::ToggleLayout => "Switch workspace layout".into(), BindingAction::CycleColumnWidth => "Cycle column width".into(), BindingAction::CenterColumn => "Center focused column".into(), BindingAction::Consume => "Join window into column".into(), BindingAction::Expel => "Move window out of column".into(), BindingAction::ToggleFloating => "Toggle floating window".into(), BindingAction::ToggleOverview => "Open or close overview".into(), BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), }; let mut parts = Vec::new(); for (enabled, label) in [ (self.modifiers.logo, "Super"), (self.modifiers.ctrl, "Ctrl"), (self.modifiers.alt, "Alt"), (self.modifiers.shift, "Shift"), ] { if enabled { parts.push(label.to_owned()); } } parts.push(match self.trigger { BindingTrigger::Keysym(symbol) => { let name = xkb::keysym_get_name(xkb::Keysym::new(symbol)); match name.as_str() { "Return" => "Enter".into(), "Print" => "Print Screen".into(), "space" => "Space".into(), _ if name.len() == 1 => name.to_uppercase(), _ => name, } } BindingTrigger::Physical(code) => keymap .and_then(|map| map.key_get_name(code)) .map(|name| format!("Physical {name}")) .unwrap_or_else(|| format!("Keycode {}", u32::from(code))), BindingTrigger::Swipe { fingers, direction } => { format!("{fingers}-finger swipe {direction:?}") } }); Some(serde_json::json!({"keys": parts.join(" + "), "description": description})) } pub(crate) fn swipe_fingers(&self, count: u32) -> bool { matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count) } pub(crate) fn matches_swipe(&self, count: u32, target: crate::gestures::SwipeDirection) -> bool { matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target) } } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)] #[serde(rename_all = "lowercase")] pub(super) enum BindingMatch { #[default] Keysym, Physical, Swipe, } #[derive(Clone, Debug, Deserialize)] pub(super) struct BindingConfig { pub(super) keys: String, #[serde(default, rename = "match")] pub(super) match_mode: BindingMatch, pub(super) action: Option, pub(super) argument: Option, #[serde(default)] pub(super) disabled: bool, }