Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): hotkey table, scopes, chords

Lewis: May this revision serve well! <lu5a@proton.me>

+251
+251
crates/bone-ui/src/hotkey.rs
··· 1 + use core::num::NonZeroU32; 2 + use std::collections::BTreeMap; 3 + 4 + use serde::{Deserialize, Serialize}; 5 + 6 + use crate::input::{KeyCode, KeyEvent, ModifierMask}; 7 + 8 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 9 + pub struct KeyChord { 10 + pub key: KeyCode, 11 + pub modifiers: ModifierMask, 12 + } 13 + 14 + impl KeyChord { 15 + #[must_use] 16 + pub const fn new(key: KeyCode, modifiers: ModifierMask) -> Self { 17 + Self { key, modifiers } 18 + } 19 + } 20 + 21 + impl From<KeyEvent> for KeyChord { 22 + fn from(event: KeyEvent) -> Self { 23 + Self { 24 + key: event.code, 25 + modifiers: event.modifiers, 26 + } 27 + } 28 + } 29 + 30 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 31 + pub enum HotkeyScope { 32 + Global, 33 + Viewport, 34 + FeatureTree, 35 + Sketch, 36 + Modal, 37 + TextInput, 38 + } 39 + 40 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 41 + #[serde(transparent)] 42 + pub struct ActionId(NonZeroU32); 43 + 44 + impl ActionId { 45 + #[must_use] 46 + pub const fn new(id: NonZeroU32) -> Self { 47 + Self(id) 48 + } 49 + 50 + #[must_use] 51 + pub const fn get(self) -> NonZeroU32 { 52 + self.0 53 + } 54 + } 55 + 56 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 57 + pub struct HotkeyBinding { 58 + pub chord: KeyChord, 59 + pub scope: HotkeyScope, 60 + pub action: ActionId, 61 + } 62 + 63 + impl HotkeyBinding { 64 + #[must_use] 65 + pub const fn new(chord: KeyChord, scope: HotkeyScope, action: ActionId) -> Self { 66 + Self { 67 + chord, 68 + scope, 69 + action, 70 + } 71 + } 72 + } 73 + 74 + #[derive(Debug, thiserror::Error, PartialEq, Eq)] 75 + pub enum HotkeyTableError { 76 + #[error("hotkey conflict: chord already bound in this scope")] 77 + Conflict { 78 + chord: KeyChord, 79 + scope: HotkeyScope, 80 + existing: ActionId, 81 + attempted: ActionId, 82 + }, 83 + } 84 + 85 + #[derive(Clone, Debug, Default, PartialEq)] 86 + pub struct HotkeyTable { 87 + bindings: BTreeMap<(HotkeyScope, KeyChord), ActionId>, 88 + } 89 + 90 + impl HotkeyTable { 91 + #[must_use] 92 + pub fn new() -> Self { 93 + Self::default() 94 + } 95 + 96 + pub fn try_from_bindings(bindings: Vec<HotkeyBinding>) -> Result<Self, HotkeyTableError> { 97 + bindings 98 + .into_iter() 99 + .try_fold(Self::new(), |mut table, binding| { 100 + table.try_register(binding)?; 101 + Ok(table) 102 + }) 103 + } 104 + 105 + pub fn try_register(&mut self, binding: HotkeyBinding) -> Result<(), HotkeyTableError> { 106 + let key = (binding.scope, binding.chord); 107 + if let Some(&existing) = self.bindings.get(&key) { 108 + return Err(HotkeyTableError::Conflict { 109 + chord: binding.chord, 110 + scope: binding.scope, 111 + existing, 112 + attempted: binding.action, 113 + }); 114 + } 115 + self.bindings.insert(key, binding.action); 116 + Ok(()) 117 + } 118 + 119 + #[must_use] 120 + pub fn dispatch(&self, chord: KeyChord, scopes: &HotkeyScopes) -> Option<ActionId> { 121 + scopes 122 + .innermost_first() 123 + .find_map(|scope| self.bindings.get(&(*scope, chord)).copied()) 124 + } 125 + } 126 + 127 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 128 + pub struct HotkeyScopes { 129 + stack: Vec<HotkeyScope>, 130 + } 131 + 132 + impl HotkeyScopes { 133 + #[must_use] 134 + pub fn from_outer_to_inner<I: IntoIterator<Item = HotkeyScope>>(scopes: I) -> Self { 135 + Self { 136 + stack: scopes.into_iter().collect(), 137 + } 138 + } 139 + 140 + pub fn push(&mut self, scope: HotkeyScope) { 141 + self.stack.push(scope); 142 + } 143 + 144 + pub fn pop(&mut self) -> Option<HotkeyScope> { 145 + self.stack.pop() 146 + } 147 + 148 + #[must_use] 149 + pub fn innermost_first(&self) -> impl DoubleEndedIterator<Item = &HotkeyScope> { 150 + self.stack.iter().rev() 151 + } 152 + } 153 + 154 + #[cfg(test)] 155 + mod tests { 156 + use core::num::NonZeroU32; 157 + 158 + use super::{ 159 + ActionId, HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, HotkeyTableError, KeyChord, 160 + }; 161 + use crate::input::{KeyChar, KeyCode, ModifierMask, NamedKey}; 162 + 163 + fn scopes(s: &[HotkeyScope]) -> HotkeyScopes { 164 + HotkeyScopes::from_outer_to_inner(s.iter().copied()) 165 + } 166 + 167 + fn action(n: u32) -> ActionId { 168 + let Some(nz) = NonZeroU32::new(n) else { 169 + panic!("test action id must be non-zero"); 170 + }; 171 + ActionId::new(nz) 172 + } 173 + 174 + fn ctrl_s() -> KeyChord { 175 + KeyChord::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL) 176 + } 177 + 178 + fn esc() -> KeyChord { 179 + KeyChord::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE) 180 + } 181 + 182 + #[test] 183 + fn duplicate_chord_in_same_scope_conflicts() { 184 + let result = HotkeyTable::try_from_bindings(vec![ 185 + HotkeyBinding::new(ctrl_s(), HotkeyScope::Global, action(1)), 186 + HotkeyBinding::new(ctrl_s(), HotkeyScope::Global, action(2)), 187 + ]); 188 + match result { 189 + Err(HotkeyTableError::Conflict { 190 + existing, 191 + attempted, 192 + .. 193 + }) => { 194 + assert_eq!(existing, action(1)); 195 + assert_eq!(attempted, action(2)); 196 + } 197 + _ => panic!("expected conflict"), 198 + } 199 + } 200 + 201 + #[test] 202 + fn same_chord_different_scopes_does_not_conflict() { 203 + let table = HotkeyTable::try_from_bindings(vec![ 204 + HotkeyBinding::new(esc(), HotkeyScope::Global, action(1)), 205 + HotkeyBinding::new(esc(), HotkeyScope::Modal, action(2)), 206 + ]); 207 + assert!(table.is_ok()); 208 + } 209 + 210 + #[test] 211 + fn dispatch_innermost_scope_first() { 212 + let Ok(table) = HotkeyTable::try_from_bindings(vec![ 213 + HotkeyBinding::new(esc(), HotkeyScope::Global, action(1)), 214 + HotkeyBinding::new(esc(), HotkeyScope::Modal, action(99)), 215 + ]) else { 216 + panic!("registration must succeed"); 217 + }; 218 + let with_modal = scopes(&[HotkeyScope::Global, HotkeyScope::Modal]); 219 + let no_modal = scopes(&[HotkeyScope::Global]); 220 + assert_eq!(table.dispatch(esc(), &with_modal), Some(action(99))); 221 + assert_eq!(table.dispatch(esc(), &no_modal), Some(action(1))); 222 + } 223 + 224 + #[test] 225 + fn dispatch_misses_unmatched_chord() { 226 + let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 227 + ctrl_s(), 228 + HotkeyScope::Global, 229 + action(1), 230 + )]) else { 231 + panic!("registration must succeed"); 232 + }; 233 + assert_eq!(table.dispatch(esc(), &scopes(&[HotkeyScope::Global])), None); 234 + } 235 + 236 + #[test] 237 + fn dispatch_ignores_inactive_scope() { 238 + let Ok(table) = HotkeyTable::try_from_bindings(vec![HotkeyBinding::new( 239 + esc(), 240 + HotkeyScope::Modal, 241 + action(7), 242 + )]) else { 243 + panic!("registration must succeed"); 244 + }; 245 + assert_eq!(table.dispatch(esc(), &scopes(&[HotkeyScope::Global])), None); 246 + assert_eq!( 247 + table.dispatch(esc(), &scopes(&[HotkeyScope::Modal])), 248 + Some(action(7)) 249 + ); 250 + } 251 + }