1use frus_core::{Rect, Scene};
49use frus_layout::Style;
50
51use crate::interaction::Status;
52use crate::theme::Theme;
53use crate::widget::{sizing_of, Widget};
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum ShortcutKey {
59 Char(char),
62 Enter,
64 Escape,
66 Tab,
68 Space,
70 Backspace,
72 Delete,
74 Up,
76 Down,
78 Left,
80 Right,
82 Home,
84 End,
86 PageUp,
88 PageDown,
90 F(u8),
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub struct KeyStroke {
99 pub key: ShortcutKey,
101 pub ctrl: bool,
103 pub shift: bool,
105 pub alt: bool,
107 pub meta: bool,
109}
110
111impl KeyStroke {
112 pub const fn new(key: ShortcutKey) -> Self {
114 Self {
115 key,
116 ctrl: false,
117 shift: false,
118 alt: false,
119 meta: false,
120 }
121 }
122
123 pub const fn ctrl(mut self) -> Self {
125 self.ctrl = true;
126 self
127 }
128
129 pub const fn shift(mut self) -> Self {
131 self.shift = true;
132 self
133 }
134
135 pub const fn alt(mut self) -> Self {
137 self.alt = true;
138 self
139 }
140
141 pub const fn meta(mut self) -> Self {
143 self.meta = true;
144 self
145 }
146
147 pub const fn is_command(&self) -> bool {
150 self.ctrl || self.alt || self.meta
151 }
152
153 pub fn matches(&self, bound: &KeyStroke) -> bool {
157 let same_key = match (self.key, bound.key) {
158 (ShortcutKey::Char(a), ShortcutKey::Char(b)) => {
159 a.eq_ignore_ascii_case(&b) || a.to_lowercase().eq(b.to_lowercase())
160 }
161 (a, b) => a == b,
162 };
163 same_key
164 && self.ctrl == bound.ctrl
165 && self.shift == bound.shift
166 && self.alt == bound.alt
167 && self.meta == bound.meta
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177pub struct Intent(pub &'static str);
178
179struct Wrapper<Msg> {
181 child: Vec<Box<dyn Widget<Msg>>>,
182 focusable: bool,
183 shortcuts: Vec<(KeyStroke, Intent)>,
184 callbacks: Vec<(KeyStroke, Msg)>,
185 actions: Vec<(Intent, Msg)>,
186 listeners: Vec<(Intent, Msg)>,
187}
188
189impl<Msg> Wrapper<Msg> {
190 fn new(child: Box<dyn Widget<Msg>>) -> Self {
191 Self {
192 child: vec![child],
193 focusable: false,
194 shortcuts: Vec::new(),
195 callbacks: Vec::new(),
196 actions: Vec::new(),
197 listeners: Vec::new(),
198 }
199 }
200}
201
202impl<Msg: Clone> Widget<Msg> for Wrapper<Msg> {
203 fn style(&self) -> Style {
204 sizing_of(self.child[0].style())
205 }
206
207 fn style_themed(&self, theme: &Theme) -> Style {
208 sizing_of(self.child[0].style_themed(theme))
209 }
210
211 fn build_themed(&self, theme: &Theme) {
212 self.child[0].build_themed(theme);
213 }
214
215 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
216 &self.child
217 }
218
219 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
220
221 fn on_click(&self) -> Option<Msg> {
222 None
223 }
224
225 fn focusable(&self) -> bool {
226 self.focusable
227 }
228
229 fn shortcut_bindings(&self) -> &[(KeyStroke, Intent)] {
230 &self.shortcuts
231 }
232
233 fn shortcut_callbacks(&self) -> &[(KeyStroke, Msg)] {
234 &self.callbacks
235 }
236
237 fn action_bindings(&self) -> &[(Intent, Msg)] {
238 &self.actions
239 }
240
241 fn action_listeners(&self) -> &[(Intent, Msg)] {
242 &self.listeners
243 }
244}
245
246pub struct Shortcuts<Msg = crate::callback::Callback>(Wrapper<Msg>);
249
250impl<Msg> Shortcuts<Msg> {
251 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
253 Self(Wrapper::new(Box::new(child)))
254 }
255
256 pub fn bind(mut self, stroke: KeyStroke, intent: Intent) -> Self {
259 self.0.shortcuts.retain(|(s, _)| !s.matches(&stroke));
260 self.0.shortcuts.push((stroke, intent));
261 self
262 }
263}
264
265pub struct CallbackShortcuts<Msg = crate::callback::Callback>(Wrapper<Msg>);
268
269impl<Msg: Clone> CallbackShortcuts<Msg> {
270 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
272 Self(Wrapper::new(Box::new(child)))
273 }
274
275 pub fn bind(mut self, stroke: KeyStroke, msg: Msg) -> Self {
277 self.0.callbacks.retain(|(s, _)| !s.matches(&stroke));
278 self.0.callbacks.push((stroke, msg));
279 self
280 }
281}
282
283pub struct Actions<Msg = crate::callback::Callback>(Wrapper<Msg>);
286
287impl<Msg> Actions<Msg> {
288 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
290 Self(Wrapper::new(Box::new(child)))
291 }
292
293 pub fn action(mut self, intent: Intent, msg: Msg) -> Self {
295 self.0.actions.push((intent, msg));
296 self
297 }
298}
299
300pub struct ActionListener<Msg = crate::callback::Callback>(Wrapper<Msg>);
306
307impl<Msg> ActionListener<Msg> {
308 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
310 Self(Wrapper::new(Box::new(child)))
311 }
312
313 pub fn on(mut self, intent: Intent, msg: Msg) -> Self {
315 self.0.listeners.push((intent, msg));
316 self
317 }
318}
319
320pub struct FocusableActionDetector<Msg = crate::callback::Callback>(Wrapper<Msg>);
328
329impl<Msg: Clone> FocusableActionDetector<Msg> {
330 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
332 let mut w = Wrapper::new(Box::new(child));
333 w.focusable = true;
334 Self(w)
335 }
336
337 pub fn enabled(mut self, enabled: bool) -> Self {
340 self.0.focusable = enabled;
341 self
342 }
343
344 pub fn bind(mut self, stroke: KeyStroke, intent: Intent) -> Self {
346 self.0.shortcuts.retain(|(s, _)| !s.matches(&stroke));
347 self.0.shortcuts.push((stroke, intent));
348 self
349 }
350
351 pub fn on_key(mut self, stroke: KeyStroke, msg: Msg) -> Self {
353 self.0.callbacks.retain(|(s, _)| !s.matches(&stroke));
354 self.0.callbacks.push((stroke, msg));
355 self
356 }
357
358 pub fn action(mut self, intent: Intent, msg: Msg) -> Self {
360 self.0.actions.push((intent, msg));
361 self
362 }
363}
364
365pub struct KeyboardListener<Msg = crate::callback::Callback> {
368 child: Vec<Box<dyn Widget<Msg>>>,
369 #[allow(clippy::type_complexity)]
372 on_key: std::rc::Rc<dyn Fn(KeyStroke) -> Option<Msg>>,
373}
374
375impl<Msg> KeyboardListener<Msg> {
376 pub fn new(
379 child: impl Widget<Msg> + 'static,
380 on_key: impl Fn(KeyStroke) -> Option<Msg> + 'static,
381 ) -> Self {
382 Self {
383 child: vec![Box::new(child)],
384 on_key: std::rc::Rc::new(on_key),
385 }
386 }
387}
388
389impl<Msg: Clone> Widget<Msg> for KeyboardListener<Msg> {
390 fn style(&self) -> Style {
391 sizing_of(self.child[0].style())
392 }
393
394 fn style_themed(&self, theme: &Theme) -> Style {
395 sizing_of(self.child[0].style_themed(theme))
396 }
397
398 fn build_themed(&self, theme: &Theme) {
399 self.child[0].build_themed(theme);
400 }
401
402 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
403 &self.child
404 }
405
406 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
407
408 fn on_click(&self) -> Option<Msg> {
409 None
410 }
411
412 fn on_keystroke(&self) -> Option<std::rc::Rc<dyn Fn(KeyStroke) -> Option<Msg>>> {
413 Some(std::rc::Rc::clone(&self.on_key))
414 }
415}
416
417macro_rules! delegate {
419 ($($ty:ident),* $(,)?) => {$(
420 impl<Msg: Clone> Widget<Msg> for $ty<Msg> {
421 fn style(&self) -> Style {
422 Widget::<Msg>::style(&self.0)
423 }
424 fn style_themed(&self, theme: &Theme) -> Style {
425 Widget::<Msg>::style_themed(&self.0, theme)
426 }
427 fn build_themed(&self, theme: &Theme) {
428 Widget::<Msg>::build_themed(&self.0, theme)
429 }
430 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
431 Widget::<Msg>::children(&self.0)
432 }
433 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
434 Widget::<Msg>::paint(&self.0, bounds, status, theme, scene)
435 }
436 fn on_click(&self) -> Option<Msg> {
437 Widget::<Msg>::on_click(&self.0)
438 }
439 fn focusable(&self) -> bool {
440 Widget::<Msg>::focusable(&self.0)
441 }
442 fn shortcut_bindings(&self) -> &[(KeyStroke, Intent)] {
443 Widget::<Msg>::shortcut_bindings(&self.0)
444 }
445 fn shortcut_callbacks(&self) -> &[(KeyStroke, Msg)] {
446 Widget::<Msg>::shortcut_callbacks(&self.0)
447 }
448 fn action_bindings(&self) -> &[(Intent, Msg)] {
449 Widget::<Msg>::action_bindings(&self.0)
450 }
451 fn action_listeners(&self) -> &[(Intent, Msg)] {
452 Widget::<Msg>::action_listeners(&self.0)
453 }
454 }
455 )*};
456}
457
458delegate!(
459 Shortcuts,
460 CallbackShortcuts,
461 Actions,
462 ActionListener,
463 FocusableActionDetector,
464);
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[derive(Clone, Debug, PartialEq)]
471 enum Msg {
472 SavePage,
473 SaveDialog,
474 Edited(String),
475 }
476
477 #[test]
478 fn a_letter_matches_without_case_and_a_modifier_does_not() {
479 let bound = KeyStroke::new(ShortcutKey::Char('s')).ctrl();
480 assert!(KeyStroke::new(ShortcutKey::Char('S'))
481 .ctrl()
482 .matches(&bound));
483 assert!(KeyStroke::new(ShortcutKey::Char('s'))
484 .ctrl()
485 .matches(&bound));
486 assert!(
487 !KeyStroke::new(ShortcutKey::Char('s')).matches(&bound),
488 "no Ctrl"
489 );
490 assert!(
491 !KeyStroke::new(ShortcutKey::Char('s'))
492 .ctrl()
493 .shift()
494 .matches(&bound),
495 "Shift is part of the pattern"
496 );
497 }
498
499 #[test]
502 fn shift_alone_is_not_a_command() {
503 assert!(!KeyStroke::new(ShortcutKey::Char('a')).shift().is_command());
504 assert!(KeyStroke::new(ShortcutKey::Char('a')).ctrl().is_command());
505 assert!(KeyStroke::new(ShortcutKey::Char('a')).alt().is_command());
506 assert!(KeyStroke::new(ShortcutKey::Char('a')).meta().is_command());
507 }
508
509 #[test]
512 fn a_key_names_an_intent_and_the_nearest_answer_wins() {
513 use crate::runtime::Runtime;
514 use crate::ui::build_ui;
515 use frus_core::Size;
516
517 let save = KeyStroke::new(ShortcutKey::Char('s')).ctrl();
518 let field = |name: &str| {
519 crate::TextField::new(name.to_string())
520 .label(name.to_string())
521 .on_input(Msg::Edited)
522 };
523
524 let tree = Actions::new(
526 Shortcuts::new(crate::column![
527 field("page"),
528 Actions::new(field("dialog")).action(Intent("save"), Msg::SaveDialog),
529 ])
530 .bind(save, Intent("save")),
531 )
532 .action(Intent("save"), Msg::SavePage);
533
534 let ui = build_ui(
535 &tree,
536 Size::new(400.0, 400.0),
537 &Runtime::default(),
538 &Theme::default(),
539 );
540 let stops: Vec<_> = ui.focusable_ids().collect();
541 assert_eq!(stops.len(), 2);
542
543 assert_eq!(ui.keystroke(save, Some(stops[0])), vec![Msg::SavePage]);
544 assert_eq!(
545 ui.keystroke(save, Some(stops[1])),
546 vec![Msg::SaveDialog],
547 "the innermost answer wins"
548 );
549 assert!(
550 ui.keystroke(KeyStroke::new(ShortcutKey::Char('s')), Some(stops[0]))
551 .is_empty(),
552 "without Ctrl it is typing, not a command"
553 );
554 }
555
556 #[test]
559 fn an_unanswered_intent_does_nothing() {
560 use crate::runtime::Runtime;
561 use crate::ui::build_ui;
562 use frus_core::Size;
563
564 let save = KeyStroke::new(ShortcutKey::Char('s')).ctrl();
565 let tree: Shortcuts<Msg> =
566 Shortcuts::new(crate::Container::new().width(10.0)).bind(save, Intent("save"));
567 let ui = build_ui(
568 &tree,
569 Size::new(100.0, 100.0),
570 &Runtime::default(),
571 &Theme::default(),
572 );
573 assert!(ui.keystroke(save, None).is_empty());
574 }
575
576 #[test]
578 fn a_detector_is_a_focus_stop_that_answers_its_own_keys() {
579 use crate::runtime::Runtime;
580 use crate::ui::build_ui;
581 use frus_core::Size;
582
583 let del = KeyStroke::new(ShortcutKey::Delete);
584 let card = FocusableActionDetector::new(crate::Container::new().width(40.0).height(40.0))
585 .on_key(del, Msg::SaveDialog);
586 assert!(Widget::<Msg>::focusable(&card));
587
588 let ui = build_ui(
589 &card,
590 Size::new(100.0, 100.0),
591 &Runtime::default(),
592 &Theme::default(),
593 );
594 let stop = ui.focusable_ids().next().expect("its own stop");
595 assert_eq!(ui.keystroke(del, Some(stop)), vec![Msg::SaveDialog]);
596
597 let off: FocusableActionDetector<Msg> =
599 FocusableActionDetector::new(crate::Container::new()).enabled(false);
600 assert!(!Widget::<Msg>::focusable(&off));
601 }
602
603 #[test]
606 fn rebinding_a_stroke_replaces_it() {
607 let s: Shortcuts<()> = Shortcuts::new(crate::Container::new())
608 .bind(
609 KeyStroke::new(ShortcutKey::Char('s')).ctrl(),
610 Intent("save"),
611 )
612 .bind(
613 KeyStroke::new(ShortcutKey::Char('s')).ctrl(),
614 Intent("save-as"),
615 );
616 let table = Widget::<()>::shortcut_bindings(&s);
617 assert_eq!(table.len(), 1);
618 assert_eq!(table[0].1, Intent("save-as"));
619 }
620}