Skip to main content

dartboard_editor/
keymap.rs

1use crate::{AppKey, AppKeyCode, AppModifiers, EditorAction, Mode, MoveDir};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum KeyTrigger {
5    Key(AppKey),
6    AnyChar(AppModifiers),
7    HomeRowChar(AppModifiers),
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ActionSpec {
12    Fixed(EditorAction),
13    InsertMatchedChar,
14    FillWithMatchedChar,
15    ActivateSwatchFromChar,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum BindingContext {
20    Always,
21    WhenSelecting,
22    WhenNotSelecting,
23    WhenFloating,
24    WhenNotFloating,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub struct EditorContext {
29    pub mode: Mode,
30    pub has_selection_anchor: bool,
31    pub is_floating: bool,
32}
33
34#[derive(Debug, Clone, Copy)]
35pub struct KeyBinding {
36    pub trigger: KeyTrigger,
37    pub action: ActionSpec,
38    pub context: BindingContext,
39    pub description: &'static str,
40    help: Option<BindingHelp>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub enum HelpSection {
45    Drawing,
46    Selection,
47    Clipboard,
48    Transform,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct HelpEntry {
53    pub section: HelpSection,
54    pub keys: &'static str,
55    pub description: &'static str,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59struct BindingHelp {
60    section: HelpSection,
61    keys: &'static str,
62    description: &'static str,
63    order: u16,
64}
65
66#[derive(Debug, Clone)]
67pub struct KeyMap {
68    bindings: Vec<KeyBinding>,
69}
70
71impl KeyMap {
72    pub fn new(bindings: Vec<KeyBinding>) -> Self {
73        Self { bindings }
74    }
75
76    pub fn default_standalone() -> Self {
77        Self::new(default_standalone_bindings())
78    }
79
80    pub fn bindings(&self) -> &[KeyBinding] {
81        &self.bindings
82    }
83
84    pub fn help_entries(&self) -> Vec<HelpEntry> {
85        let mut rows: Vec<BindingHelp> = self.bindings.iter().filter_map(|b| b.help).collect();
86        rows.sort_by_key(|row| (row.section, row.order, row.keys, row.description));
87        rows.dedup();
88        rows.into_iter()
89            .map(|row| HelpEntry {
90                section: row.section,
91                keys: row.keys,
92                description: row.description,
93            })
94            .collect()
95    }
96
97    pub fn resolve(&self, key: AppKey, ctx: EditorContext) -> Option<EditorAction> {
98        for binding in &self.bindings {
99            if !context_matches(binding.context, ctx) {
100                continue;
101            }
102            if let Some(action) = resolve_binding(binding, key) {
103                return Some(action);
104            }
105        }
106        None
107    }
108}
109
110fn context_matches(binding_ctx: BindingContext, ctx: EditorContext) -> bool {
111    let selecting = ctx.mode.is_selecting() && ctx.has_selection_anchor;
112    match binding_ctx {
113        BindingContext::Always => true,
114        BindingContext::WhenSelecting => selecting,
115        BindingContext::WhenNotSelecting => !selecting,
116        BindingContext::WhenFloating => ctx.is_floating,
117        BindingContext::WhenNotFloating => !ctx.is_floating,
118    }
119}
120
121fn resolve_binding(binding: &KeyBinding, key: AppKey) -> Option<EditorAction> {
122    match binding.trigger {
123        KeyTrigger::Key(expected) => {
124            if expected == key {
125                build_action(binding.action, key)
126            } else {
127                None
128            }
129        }
130        KeyTrigger::AnyChar(mods) => match key.code {
131            AppKeyCode::Char(_) if key.modifiers == mods => build_action(binding.action, key),
132            _ => None,
133        },
134        KeyTrigger::HomeRowChar(mods) => match key.code {
135            AppKeyCode::Char(ch)
136                if key.modifiers == mods && swatch_home_row_index(ch).is_some() =>
137            {
138                build_action(binding.action, key)
139            }
140            _ => None,
141        },
142    }
143}
144
145fn build_action(spec: ActionSpec, key: AppKey) -> Option<EditorAction> {
146    match spec {
147        ActionSpec::Fixed(action) => Some(action),
148        ActionSpec::InsertMatchedChar => match key.code {
149            AppKeyCode::Char(ch) => Some(EditorAction::InsertChar(ch)),
150            _ => None,
151        },
152        ActionSpec::FillWithMatchedChar => match key.code {
153            AppKeyCode::Char(ch) => Some(EditorAction::FillSelectionOrCell(ch)),
154            _ => None,
155        },
156        ActionSpec::ActivateSwatchFromChar => match key.code {
157            AppKeyCode::Char(ch) => swatch_home_row_index(ch).map(EditorAction::ActivateSwatch),
158            _ => None,
159        },
160    }
161}
162
163pub(crate) fn swatch_home_row_index(ch: char) -> Option<usize> {
164    match ch {
165        'a' | 'A' => Some(0),
166        's' | 'S' => Some(1),
167        'd' | 'D' => Some(2),
168        'f' | 'F' => Some(3),
169        'g' | 'G' => Some(4),
170        _ => None,
171    }
172}
173
174fn default_standalone_bindings() -> Vec<KeyBinding> {
175    let mut out = Vec::new();
176    let help = |section, keys, description, order| {
177        Some(BindingHelp {
178            section,
179            keys,
180            description,
181            order,
182        })
183    };
184
185    let none = AppModifiers::default();
186    let shift = AppModifiers {
187        shift: true,
188        ..Default::default()
189    };
190    let ctrl = AppModifiers {
191        ctrl: true,
192        ..Default::default()
193    };
194    let ctrl_shift = AppModifiers {
195        ctrl: true,
196        shift: true,
197        ..Default::default()
198    };
199    let alt = AppModifiers {
200        alt: true,
201        ..Default::default()
202    };
203    let meta = AppModifiers {
204        meta: true,
205        ..Default::default()
206    };
207
208    // Ctrl+Shift+arrow -> pan, or stroke the floating brush when active
209    // (must precede Ctrl-only bindings).
210    let ctrl_shift_help = help(HelpSection::Drawing, "^⇧+←↑↓→", "pan / stroke floating", 80);
211    for (code, dx, dy) in [
212        (AppKeyCode::Left, -1_isize, 0_isize),
213        (AppKeyCode::Right, 1, 0),
214        (AppKeyCode::Up, 0, -1),
215        (AppKeyCode::Down, 0, 1),
216    ] {
217        out.push(KeyBinding {
218            trigger: KeyTrigger::Key(AppKey {
219                code,
220                modifiers: ctrl_shift,
221            }),
222            action: ActionSpec::Fixed(EditorAction::StrokeFloating {
223                dir: match code {
224                    AppKeyCode::Left => MoveDir::Left,
225                    AppKeyCode::Right => MoveDir::Right,
226                    AppKeyCode::Up => MoveDir::Up,
227                    AppKeyCode::Down => MoveDir::Down,
228                    _ => unreachable!(),
229                },
230            }),
231            context: BindingContext::WhenFloating,
232            description: "stroke floating",
233            help: ctrl_shift_help,
234        });
235        out.push(KeyBinding {
236            trigger: KeyTrigger::Key(AppKey {
237                code,
238                modifiers: ctrl_shift,
239            }),
240            action: ActionSpec::Fixed(EditorAction::Pan { dx, dy }),
241            context: BindingContext::WhenNotFloating,
242            description: "pan viewport",
243            help: ctrl_shift_help,
244        });
245    }
246
247    // Ctrl+T: toggle float transparency while floating, otherwise transpose
248    // the selection corner (added via the Ctrl+key loop below).
249    out.push(KeyBinding {
250        trigger: KeyTrigger::Key(AppKey {
251            code: AppKeyCode::Char('t'),
252            modifiers: ctrl,
253        }),
254        action: ActionSpec::Fixed(EditorAction::ToggleFloatingTransparency),
255        context: BindingContext::WhenFloating,
256        description: "toggle float transparency",
257        help: help(HelpSection::Selection, "^T", "flip corner / see-thru", 80),
258    });
259
260    // Ctrl+key editor commands.
261    for (code, action, desc, binding_help) in [
262        (
263            AppKeyCode::Backspace,
264            EditorAction::PushLeft,
265            "push column left",
266            help(HelpSection::Transform, "^H / ^⌫", "push column ←", 10),
267        ),
268        (
269            AppKeyCode::Char('h'),
270            EditorAction::PushLeft,
271            "push column left",
272            help(HelpSection::Transform, "^H / ^⌫", "push column ←", 10),
273        ),
274        (
275            AppKeyCode::Char('j'),
276            EditorAction::PushDown,
277            "push row down",
278            help(HelpSection::Transform, "^J", "push row ↓", 20),
279        ),
280        (
281            AppKeyCode::Char('k'),
282            EditorAction::PushUp,
283            "push row up",
284            help(HelpSection::Transform, "^K", "push row ↑", 30),
285        ),
286        (
287            AppKeyCode::Char('l'),
288            EditorAction::PushRight,
289            "push column right",
290            help(HelpSection::Transform, "^L", "push column →", 40),
291        ),
292        (
293            AppKeyCode::Char('y'),
294            EditorAction::PullFromLeft,
295            "pull from left",
296            help(HelpSection::Transform, "^Y", "pull from ←", 50),
297        ),
298        (
299            AppKeyCode::Char('u'),
300            EditorAction::PullFromDown,
301            "pull from below",
302            help(HelpSection::Transform, "^U", "pull from ↓", 60),
303        ),
304        (
305            AppKeyCode::Tab,
306            EditorAction::PullFromUp,
307            "pull from above",
308            help(HelpSection::Transform, "^I / tab", "pull from ↑", 70),
309        ),
310        (
311            AppKeyCode::Char('i'),
312            EditorAction::PullFromUp,
313            "pull from above",
314            help(HelpSection::Transform, "^I / tab", "pull from ↑", 70),
315        ),
316        (
317            AppKeyCode::Char('o'),
318            EditorAction::PullFromRight,
319            "pull from right",
320            help(HelpSection::Transform, "^O", "pull from →", 80),
321        ),
322        (
323            AppKeyCode::Char('c'),
324            EditorAction::CopySelection,
325            "copy selection",
326            help(HelpSection::Clipboard, "^C", "copy → swatch", 10),
327        ),
328        (
329            AppKeyCode::Char('x'),
330            EditorAction::CutSelection,
331            "cut selection",
332            help(HelpSection::Clipboard, "^X", "cut → swatch", 20),
333        ),
334        (
335            AppKeyCode::Char('v'),
336            EditorAction::PastePrimarySwatch,
337            "paste primary swatch",
338            help(HelpSection::Clipboard, "^V", "paste / stamp", 30),
339        ),
340        (
341            AppKeyCode::Char('b'),
342            EditorAction::DrawBorder,
343            "draw selection border",
344            help(HelpSection::Transform, "^B", "draw selection border", 90),
345        ),
346        (
347            AppKeyCode::Char('t'),
348            EditorAction::TransposeSelectionCorner,
349            "transpose selection corner",
350            help(HelpSection::Selection, "^T", "flip corner / see-thru", 80),
351        ),
352        (
353            AppKeyCode::Char(' '),
354            EditorAction::SmartFill,
355            "smart-fill selection",
356            help(
357                HelpSection::Transform,
358                "^space",
359                "fill selection or cell",
360                100,
361            ),
362        ),
363    ] {
364        out.push(KeyBinding {
365            trigger: KeyTrigger::Key(AppKey {
366                code,
367                modifiers: ctrl,
368            }),
369            action: ActionSpec::Fixed(action),
370            context: BindingContext::Always,
371            description: desc,
372            help: binding_help,
373        });
374    }
375
376    // Ctrl + home-row letter -> activate swatch slot.
377    out.push(KeyBinding {
378        trigger: KeyTrigger::HomeRowChar(ctrl),
379        action: ActionSpec::ActivateSwatchFromChar,
380        context: BindingContext::Always,
381        description: "activate swatch slot",
382        help: help(
383            HelpSection::Clipboard,
384            "^A/^S/^D/^F/^G",
385            "lift swatch 1..5",
386            50,
387        ),
388    });
389
390    // Alt/Meta + c -> export to system clipboard; Alt/Meta + ←↑↓→ -> pan.
391    for mods in [alt, meta] {
392        out.push(KeyBinding {
393            trigger: KeyTrigger::Key(AppKey {
394                code: AppKeyCode::Char('c'),
395                modifiers: mods,
396            }),
397            action: ActionSpec::Fixed(EditorAction::ExportSystemClipboard),
398            context: BindingContext::Always,
399            description: "copy to system clipboard",
400            help: help(HelpSection::Clipboard, "alt/meta+c", "os copy", 40),
401        });
402        for (code, dx, dy) in [
403            (AppKeyCode::Left, -1_isize, 0_isize),
404            (AppKeyCode::Right, 1, 0),
405            (AppKeyCode::Up, 0, -1),
406            (AppKeyCode::Down, 0, 1),
407        ] {
408            out.push(KeyBinding {
409                trigger: KeyTrigger::Key(AppKey {
410                    code,
411                    modifiers: mods,
412                }),
413                action: ActionSpec::Fixed(EditorAction::Pan { dx, dy }),
414                context: BindingContext::Always,
415                description: "pan viewport",
416                help: help(HelpSection::Drawing, "alt/meta+←↑↓→", "pan viewport", 90),
417            });
418        }
419    }
420
421    // Move keys: shift extends selection; plain moves cursor.
422    for (code, dir, move_help, extend_help) in [
423        (
424            AppKeyCode::Up,
425            MoveDir::Up,
426            help(HelpSection::Drawing, "←↑↓→", "move cursor", 40),
427            help(
428                HelpSection::Selection,
429                "shift+←↑↓→",
430                "create/extend selection",
431                10,
432            ),
433        ),
434        (
435            AppKeyCode::Down,
436            MoveDir::Down,
437            help(HelpSection::Drawing, "←↑↓→", "move cursor", 40),
438            help(
439                HelpSection::Selection,
440                "shift+←↑↓→",
441                "create/extend selection",
442                10,
443            ),
444        ),
445        (
446            AppKeyCode::Left,
447            MoveDir::Left,
448            help(HelpSection::Drawing, "←↑↓→", "move cursor", 40),
449            help(
450                HelpSection::Selection,
451                "shift+←↑↓→",
452                "create/extend selection",
453                10,
454            ),
455        ),
456        (
457            AppKeyCode::Right,
458            MoveDir::Right,
459            help(HelpSection::Drawing, "←↑↓→", "move cursor", 40),
460            help(
461                HelpSection::Selection,
462                "shift+←↑↓→",
463                "create/extend selection",
464                10,
465            ),
466        ),
467        (
468            AppKeyCode::Home,
469            MoveDir::LineStart,
470            help(HelpSection::Drawing, "home / end", "← / → edge", 50),
471            help(
472                HelpSection::Selection,
473                "shift+home / end",
474                "extend to ← / → edge",
475                20,
476            ),
477        ),
478        (
479            AppKeyCode::End,
480            MoveDir::LineEnd,
481            help(HelpSection::Drawing, "home / end", "← / → edge", 50),
482            help(
483                HelpSection::Selection,
484                "shift+home / end",
485                "extend to ← / → edge",
486                20,
487            ),
488        ),
489        (
490            AppKeyCode::PageUp,
491            MoveDir::PageUp,
492            help(HelpSection::Drawing, "pgup / pgdn", "↑ / ↓ edge", 60),
493            help(
494                HelpSection::Selection,
495                "shift+pgup / pgdn",
496                "extend to ↑ / ↓ edge",
497                30,
498            ),
499        ),
500        (
501            AppKeyCode::PageDown,
502            MoveDir::PageDown,
503            help(HelpSection::Drawing, "pgup / pgdn", "↑ / ↓ edge", 60),
504            help(
505                HelpSection::Selection,
506                "shift+pgup / pgdn",
507                "extend to ↑ / ↓ edge",
508                30,
509            ),
510        ),
511    ] {
512        out.push(KeyBinding {
513            trigger: KeyTrigger::Key(AppKey {
514                code,
515                modifiers: shift,
516            }),
517            action: ActionSpec::Fixed(EditorAction::Move {
518                dir,
519                extend_selection: true,
520            }),
521            context: BindingContext::Always,
522            description: "extend selection",
523            help: extend_help,
524        });
525        out.push(KeyBinding {
526            trigger: KeyTrigger::Key(AppKey {
527                code,
528                modifiers: none,
529            }),
530            action: ActionSpec::Fixed(EditorAction::Move {
531                dir,
532                extend_selection: false,
533            }),
534            context: BindingContext::Always,
535            description: "move cursor",
536            help: move_help,
537        });
538    }
539
540    // Enter / Esc.
541    out.push(KeyBinding {
542        trigger: KeyTrigger::Key(AppKey {
543            code: AppKeyCode::Enter,
544            modifiers: none,
545        }),
546        action: ActionSpec::Fixed(EditorAction::PastePrimarySwatch),
547        context: BindingContext::WhenFloating,
548        description: "stamp floating",
549        help: help(HelpSection::Clipboard, "enter", "stamp floating", 35),
550    });
551    out.push(KeyBinding {
552        trigger: KeyTrigger::Key(AppKey {
553            code: AppKeyCode::Enter,
554            modifiers: none,
555        }),
556        action: ActionSpec::Fixed(EditorAction::MoveDownLine),
557        context: BindingContext::Always,
558        description: "move to next row",
559        help: help(HelpSection::Drawing, "enter", "move down", 70),
560    });
561    out.push(KeyBinding {
562        trigger: KeyTrigger::Key(AppKey {
563            code: AppKeyCode::Esc,
564            modifiers: none,
565        }),
566        action: ActionSpec::Fixed(EditorAction::ClearSelection),
567        context: BindingContext::Always,
568        description: "clear selection",
569        help: None,
570    });
571
572    // While selecting with an anchor: char fills selection; BS/Del erases.
573    for mods in [none, shift] {
574        out.push(KeyBinding {
575            trigger: KeyTrigger::AnyChar(mods),
576            action: ActionSpec::FillWithMatchedChar,
577            context: BindingContext::WhenSelecting,
578            description: "fill selection with character",
579            help: help(HelpSection::Selection, "<type>", "fill selection", 40),
580        });
581    }
582    for mods in [none, shift] {
583        for code in [AppKeyCode::Backspace, AppKeyCode::Delete] {
584            out.push(KeyBinding {
585                trigger: KeyTrigger::Key(AppKey {
586                    code,
587                    modifiers: mods,
588                }),
589                action: ActionSpec::Fixed(EditorAction::FillSelectionOrCell(' ')),
590                context: BindingContext::WhenSelecting,
591                description: "erase selection",
592                help: help(
593                    HelpSection::Selection,
594                    "backspace / delete",
595                    "clear selection",
596                    50,
597                ),
598            });
599        }
600    }
601
602    // Otherwise: char inserts; BS deletes previous; Del deletes at cursor.
603    for mods in [none, shift] {
604        out.push(KeyBinding {
605            trigger: KeyTrigger::AnyChar(mods),
606            action: ActionSpec::InsertMatchedChar,
607            context: BindingContext::WhenNotSelecting,
608            description: "insert character",
609            help: help(HelpSection::Drawing, "<type>", "draw character", 10),
610        });
611    }
612    out.push(KeyBinding {
613        trigger: KeyTrigger::Key(AppKey {
614            code: AppKeyCode::Backspace,
615            modifiers: none,
616        }),
617        action: ActionSpec::Fixed(EditorAction::Backspace),
618        context: BindingContext::WhenNotSelecting,
619        description: "delete previous character",
620        help: help(HelpSection::Drawing, "backspace", "erase backward", 20),
621    });
622    out.push(KeyBinding {
623        trigger: KeyTrigger::Key(AppKey {
624            code: AppKeyCode::Delete,
625            modifiers: none,
626        }),
627        action: ActionSpec::Fixed(EditorAction::Delete),
628        context: BindingContext::WhenNotSelecting,
629        description: "delete character at cursor",
630        help: help(HelpSection::Drawing, "delete", "erase at cursor", 30),
631    });
632
633    out
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    fn map() -> KeyMap {
641        KeyMap::default_standalone()
642    }
643
644    fn resolve(key: AppKey) -> Option<EditorAction> {
645        map().resolve(key, EditorContext::default())
646    }
647
648    fn resolve_selecting(key: AppKey) -> Option<EditorAction> {
649        map().resolve(
650            key,
651            EditorContext {
652                mode: Mode::Select,
653                has_selection_anchor: true,
654                is_floating: false,
655            },
656        )
657    }
658
659    fn resolve_floating(key: AppKey) -> Option<EditorAction> {
660        map().resolve(
661            key,
662            EditorContext {
663                is_floating: true,
664                ..Default::default()
665            },
666        )
667    }
668
669    fn key(code: AppKeyCode, mods: AppModifiers) -> AppKey {
670        AppKey {
671            code,
672            modifiers: mods,
673        }
674    }
675
676    #[test]
677    fn ctrl_shift_arrow_pans_when_not_floating() {
678        let mods = AppModifiers {
679            ctrl: true,
680            shift: true,
681            ..Default::default()
682        };
683        assert_eq!(
684            resolve(key(AppKeyCode::Left, mods)),
685            Some(EditorAction::Pan { dx: -1, dy: 0 })
686        );
687    }
688
689    #[test]
690    fn ctrl_shift_arrow_strokes_when_floating() {
691        let mods = AppModifiers {
692            ctrl: true,
693            shift: true,
694            ..Default::default()
695        };
696        assert_eq!(
697            resolve_floating(key(AppKeyCode::Left, mods)),
698            Some(EditorAction::StrokeFloating { dir: MoveDir::Left })
699        );
700    }
701
702    #[test]
703    fn ctrl_char_maps_to_editor_command() {
704        let mods = AppModifiers {
705            ctrl: true,
706            ..Default::default()
707        };
708        assert_eq!(
709            resolve(key(AppKeyCode::Char('h'), mods)),
710            Some(EditorAction::PushLeft)
711        );
712        assert_eq!(
713            resolve(key(AppKeyCode::Char('v'), mods)),
714            Some(EditorAction::PastePrimarySwatch)
715        );
716    }
717
718    #[test]
719    fn enter_stamps_when_floating() {
720        assert_eq!(
721            resolve_floating(key(AppKeyCode::Enter, AppModifiers::default())),
722            Some(EditorAction::PastePrimarySwatch)
723        );
724        assert_eq!(
725            resolve(key(AppKeyCode::Enter, AppModifiers::default())),
726            Some(EditorAction::MoveDownLine)
727        );
728    }
729
730    #[test]
731    fn ctrl_home_row_activates_swatch() {
732        let mods = AppModifiers {
733            ctrl: true,
734            ..Default::default()
735        };
736        assert_eq!(
737            resolve(key(AppKeyCode::Char('d'), mods)),
738            Some(EditorAction::ActivateSwatch(2))
739        );
740        // Non-home-row letter with only ctrl is unmapped.
741        assert_eq!(resolve(key(AppKeyCode::Char('z'), mods)), None);
742    }
743
744    #[test]
745    fn shift_move_extends_selection() {
746        let mods = AppModifiers {
747            shift: true,
748            ..Default::default()
749        };
750        assert_eq!(
751            resolve(key(AppKeyCode::Right, mods)),
752            Some(EditorAction::Move {
753                dir: MoveDir::Right,
754                extend_selection: true,
755            })
756        );
757    }
758
759    #[test]
760    fn plain_move_does_not_extend() {
761        assert_eq!(
762            resolve(key(AppKeyCode::Right, AppModifiers::default())),
763            Some(EditorAction::Move {
764                dir: MoveDir::Right,
765                extend_selection: false,
766            })
767        );
768    }
769
770    #[test]
771    fn char_inserts_when_not_selecting_and_fills_when_selecting() {
772        let k = key(AppKeyCode::Char('q'), AppModifiers::default());
773        assert_eq!(resolve(k), Some(EditorAction::InsertChar('q')));
774        assert_eq!(
775            resolve_selecting(k),
776            Some(EditorAction::FillSelectionOrCell('q'))
777        );
778    }
779
780    #[test]
781    fn backspace_and_delete_switch_action_by_context() {
782        let bs = key(AppKeyCode::Backspace, AppModifiers::default());
783        let del = key(AppKeyCode::Delete, AppModifiers::default());
784        assert_eq!(resolve(bs), Some(EditorAction::Backspace));
785        assert_eq!(resolve(del), Some(EditorAction::Delete));
786        assert_eq!(
787            resolve_selecting(bs),
788            Some(EditorAction::FillSelectionOrCell(' '))
789        );
790        assert_eq!(
791            resolve_selecting(del),
792            Some(EditorAction::FillSelectionOrCell(' '))
793        );
794    }
795
796    #[test]
797    fn alt_c_exports_clipboard() {
798        let mods = AppModifiers {
799            alt: true,
800            ..Default::default()
801        };
802        assert_eq!(
803            resolve(key(AppKeyCode::Char('c'), mods)),
804            Some(EditorAction::ExportSystemClipboard)
805        );
806    }
807
808    #[test]
809    fn unmapped_key_returns_none() {
810        let mods = AppModifiers {
811            ctrl: true,
812            alt: true,
813            ..Default::default()
814        };
815        assert_eq!(resolve(key(AppKeyCode::Char('z'), mods)), None);
816    }
817
818    #[test]
819    fn ctrl_t_depends_on_floating_context() {
820        let ctrl = AppModifiers {
821            ctrl: true,
822            ..Default::default()
823        };
824        assert_eq!(
825            resolve(key(AppKeyCode::Char('t'), ctrl)),
826            Some(EditorAction::TransposeSelectionCorner)
827        );
828        assert_eq!(
829            resolve_floating(key(AppKeyCode::Char('t'), ctrl)),
830            Some(EditorAction::ToggleFloatingTransparency)
831        );
832    }
833
834    #[test]
835    fn shift_backspace_while_selecting_still_erases() {
836        let mods = AppModifiers {
837            shift: true,
838            ..Default::default()
839        };
840        assert_eq!(
841            resolve_selecting(key(AppKeyCode::Backspace, mods)),
842            Some(EditorAction::FillSelectionOrCell(' '))
843        );
844        assert_eq!(
845            resolve_selecting(key(AppKeyCode::Delete, mods)),
846            Some(EditorAction::FillSelectionOrCell(' '))
847        );
848    }
849
850    #[test]
851    fn bindings_include_descriptions() {
852        let m = map();
853        assert!(m.bindings().iter().all(|b| !b.description.is_empty()));
854        assert!(!m.bindings().is_empty());
855    }
856
857    #[test]
858    fn help_entries_are_sorted_and_deduped() {
859        let rows = map().help_entries();
860        assert!(!rows.is_empty());
861        assert_eq!(
862            rows.iter()
863                .filter(|row| row.keys == "^T" && row.description == "flip corner / see-thru")
864                .count(),
865            1
866        );
867        assert!(rows.iter().any(|row| {
868            row.section == HelpSection::Clipboard
869                && row.keys == "^C"
870                && row.description == "copy → swatch"
871        }));
872    }
873}