use super::{Key, Modifiers, PlatformShortcutStyle, Shortcut, ShortcutEntry};
use crate::compat::{format, HashMap, String, Vec};
use crate::event::Event;
use crate::signal::Signal1;
pub struct ShortcutManager {
shortcuts: HashMap<Shortcut, String>,
scoped_shortcuts: HashMap<(String, Shortcut), String>,
entries: HashMap<String, ShortcutEntry>,
scopes: Vec<ActiveScope>,
pub shortcut_triggered: Signal1<String>,
pub conflict_detected: Signal1<(Shortcut, String, String)>,
}
#[derive(Debug, Clone)]
struct ActiveScope {
id: String,
modal: bool,
}
impl ShortcutManager {
pub fn new() -> Self {
Self {
shortcuts: HashMap::new(),
scoped_shortcuts: HashMap::new(),
entries: HashMap::new(),
scopes: Vec::new(),
shortcut_triggered: Signal1::new(),
conflict_detected: Signal1::new(),
}
}
pub fn register(
&mut self,
action_id: impl Into<String>,
shortcut: Shortcut,
description: impl Into<String>,
) -> bool {
self.register_scoped(None, action_id, shortcut, description)
}
pub fn register_in(
&mut self,
scope: impl Into<String>,
action_id: impl Into<String>,
shortcut: Shortcut,
description: impl Into<String>,
) -> bool {
self.register_scoped(Some(scope.into()), action_id, shortcut, description)
}
fn register_scoped(
&mut self,
scope: Option<String>,
action_id: impl Into<String>,
shortcut: Shortcut,
description: impl Into<String>,
) -> bool {
let action_id = action_id.into();
let description = description.into();
let conflict = match &scope {
None => {
self.shortcuts.get(&shortcut).filter(|existing| **existing != action_id).cloned()
}
Some(scope) => self
.scoped_shortcuts
.get(&(scope.clone(), shortcut.clone()))
.filter(|existing| **existing != action_id)
.cloned(),
};
if let Some(existing_action) = conflict {
self.conflict_detected.emit((shortcut.clone(), action_id.clone(), existing_action));
return false;
}
if let Some(entry) = self.entries.remove(&action_id) {
self.remove_binding(&entry);
}
let entry = ShortcutEntry {
action_id: action_id.clone(),
description,
shortcut: shortcut.clone(),
enabled: true,
scope: scope.clone(),
};
match scope {
Some(scope) => {
self.scoped_shortcuts.insert((scope, shortcut), action_id.clone());
}
None => {
self.shortcuts.insert(shortcut, action_id.clone());
}
}
self.entries.insert(action_id, entry);
true
}
fn remove_binding(&mut self, entry: &ShortcutEntry) {
match &entry.scope {
Some(scope) => {
self.scoped_shortcuts.remove(&(scope.clone(), entry.shortcut.clone()));
}
None => {
self.shortcuts.remove(&entry.shortcut);
}
}
}
pub fn unregister(&mut self, action_id: &str) -> bool {
if let Some(entry) = self.entries.remove(action_id) {
self.remove_binding(&entry);
true
} else {
false
}
}
pub fn unregister_shortcut(&mut self, shortcut: &Shortcut) -> bool {
if let Some(action_id) = self.shortcuts.remove(shortcut) {
self.entries.remove(&action_id);
true
} else {
false
}
}
pub fn contains_action(&self, action_id: &str) -> bool {
self.entries.contains_key(action_id)
}
pub fn handle_key_event(&mut self, key: Key, modifiers: Modifiers) -> bool {
let Some(action_id) = self.action_for_key(key, modifiers) else {
return false;
};
self.shortcut_triggered.emit(action_id);
true
}
pub fn action_for_key(&self, key: Key, modifiers: Modifiers) -> Option<String> {
let action_id = self.lookup(key, modifiers)?;
let entry = self.entries.get(&action_id)?;
entry.enabled.then_some(action_id)
}
pub fn matches_binding(shortcut: &Shortcut, key: Key, modifiers: Modifiers) -> bool {
if shortcut.key != key {
return false;
}
let style = crate::platform::platform_facts().shortcut_style();
let normalized = Self::normalize_event_modifiers(modifiers, style);
if shortcut.modifiers == normalized {
return true;
}
style == PlatformShortcutStyle::Desktop
&& normalized.contains(Modifiers::CTRL)
&& !normalized.contains(Modifiers::PRIMARY)
&& shortcut.modifiers == normalized.without(Modifiers::CTRL) | Modifiers::PRIMARY
}
pub fn dispatch_shortcut(&mut self, key: Key, modifiers: Modifiers) -> Option<String> {
let action_id = self.action_for_key(key, modifiers)?;
self.shortcut_triggered.emit(action_id.clone());
Some(action_id)
}
fn lookup(&self, key: Key, modifiers: Modifiers) -> Option<String> {
for active in self.scopes.iter().rev() {
let matched = self
.scoped_shortcuts
.iter()
.find(|((scope, shortcut), _)| {
scope == &active.id && Self::matches_binding(shortcut, key, modifiers)
})
.map(|(_, action_id)| action_id.clone());
if matched.is_some() {
return matched;
}
if active.modal {
return None;
}
}
self.entries
.values()
.find(|entry| {
entry.scope.is_none() && Self::matches_binding(&entry.shortcut, key, modifiers)
})
.map(|entry| entry.action_id.clone())
}
pub fn push_scope(&mut self, scope: impl Into<String>) {
self.scopes.push(ActiveScope { id: scope.into(), modal: false });
}
pub fn push_modal_scope(&mut self, scope: impl Into<String>) {
self.scopes.push(ActiveScope { id: scope.into(), modal: true });
}
pub fn pop_scope(&mut self) -> Option<String> {
self.scopes.pop().map(|scope| scope.id)
}
pub fn active_scope(&self) -> Option<&str> {
self.scopes.last().map(|scope| scope.id.as_str())
}
pub fn is_scope_active(&self, scope: &str) -> bool {
self.scopes.iter().any(|active| active.id == scope)
}
pub fn active_scopes(&self) -> Vec<String> {
self.scopes.iter().map(|scope| scope.id.clone()).collect()
}
pub fn clear_scopes(&mut self) {
self.scopes.clear();
}
fn normalize_event_modifiers(modifiers: Modifiers, style: PlatformShortcutStyle) -> Modifiers {
if style == PlatformShortcutStyle::Mac
&& modifiers.contains(Modifiers::PRIMARY)
&& modifiers.contains(Modifiers::CTRL)
{
return modifiers.without(Modifiers::CTRL);
}
modifiers
}
pub fn handle_event(&mut self, event: &Event) -> bool {
if let Event::KeyPress { key, modifiers } = event {
if let Some(key) = Key::from_key_code(*key) {
return self.handle_key_event(key, Modifiers::from_event_bits(*modifiers));
}
}
false
}
pub fn enable(&mut self, action_id: &str) -> bool {
if let Some(entry) = self.entries.get_mut(action_id) {
entry.enabled = true;
true
} else {
false
}
}
pub fn disable(&mut self, action_id: &str) -> bool {
if let Some(entry) = self.entries.get_mut(action_id) {
entry.enabled = false;
true
} else {
false
}
}
pub fn get_shortcut(&self, action_id: &str) -> Option<&Shortcut> {
self.entries.get(action_id).map(|e| &e.shortcut)
}
pub fn get_action(&self, shortcut: &Shortcut) -> Option<&String> {
self.shortcuts.get(shortcut)
}
pub fn all_shortcuts(&self) -> &HashMap<Shortcut, String> {
&self.shortcuts
}
pub fn all_entries(&self) -> &HashMap<String, ShortcutEntry> {
&self.entries
}
pub fn clear(&mut self) {
self.shortcuts.clear();
self.scoped_shortcuts.clear();
self.entries.clear();
self.scopes.clear();
}
pub fn has_shortcut(&self, shortcut: &Shortcut) -> bool {
self.shortcuts.contains_key(shortcut)
}
pub fn has_scoped_shortcut(&self, scope: &str, shortcut: &Shortcut) -> bool {
self.scoped_shortcuts.contains_key(&(String::from(scope), shortcut.clone()))
}
pub fn has_action(&self, action_id: &str) -> bool {
self.entries.contains_key(action_id)
}
pub fn find_conflicts(&self, shortcut: &Shortcut) -> Vec<&ShortcutEntry> {
self.entries.values().filter(|e| e.shortcut.conflicts_with(shortcut)).collect()
}
}
crate::impl_default_via_new!(ShortcutManager);
impl Shortcut {
pub fn to_json(&self) -> String {
format!("{{\"shortcut\":\"{}\"}}", json_escape(&self.format_shortcut()))
}
pub fn from_json(text: &str) -> Result<Self, String> {
let spelling = json_string_field(text, "shortcut")
.ok_or_else(|| String::from("shortcut JSON has no `shortcut` string field"))?;
Self::from_string(&spelling)
.ok_or_else(|| format!("`{spelling}` is not a shortcut this build understands"))
}
}
fn json_escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out
}
fn json_string_field(text: &str, field: &str) -> Option<String> {
let key = format!("\"{}\"", field);
let start = text.find(&key)? + key.len();
let rest = &text[start..];
let colon = rest.find(':')? + 1;
let after_colon = rest[colon..].trim_start();
let mut chars = after_colon.strip_prefix('"')?.chars();
let mut out = String::new();
while let Some(ch) = chars.next() {
match ch {
'"' => return Some(out),
'\\' => match chars.next()? {
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
other => out.push(other),
},
other => out.push(other),
}
}
None
}