Skip to main content

tui_lipan/app/input/
key_dispatch.rs

1#![allow(dead_code)]
2
3use std::sync::Arc;
4
5use crate::core::event::KeyEvent;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub(crate) enum FocusKind {
9    Widget,
10    Terminal,
11}
12
13/// Ordering policy for widget key handlers versus app command shortcuts.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum KeyDispatchPolicy {
16    /// Offer keys to focused widgets before app command shortcuts.
17    WidgetFirst,
18    /// Resolve app command shortcuts before focused widget handlers.
19    AppCommandsFirst,
20}
21
22/// Ordering policy for keys when focus is inside an embedded terminal widget.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum TerminalKeyPolicy {
25    /// Framework shortcuts run before terminal passthrough.
26    FrameworkFirst,
27    /// App command shortcuts run before terminal passthrough.
28    AppCommandsThenTerminal,
29    /// Terminal preflight/passthrough runs before app/framework handling.
30    TerminalFirst,
31    /// Send keys only to the terminal except required preflight handling.
32    TerminalOnly,
33}
34
35/// Policy for resolving app command shortcut conflicts.
36#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
37pub enum CommandConflictPolicy {
38    /// Prefer the first registered command for a conflicting shortcut.
39    #[default]
40    FirstRegistered,
41    /// Prefer the highest priority command, then first registered.
42    HighestPriority,
43}
44
45/// Policy for handling a mismatched key while an app-command chord is pending.
46#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
47pub enum ChordMismatchPolicy {
48    /// Swallow the prefix and retry the current key as a fresh dispatch.
49    #[default]
50    SwallowPrefixReplayCurrent,
51    /// Forward both the swallowed prefix and the mismatching key to lower sinks.
52    ForwardPrefixAndCurrent,
53    /// Cancel pending command state without replaying either key.
54    CancelOnly,
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub(crate) enum CommandDispatchState {
59    None,
60    Pending,
61    Mismatch,
62    Matched(Arc<str>),
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub(crate) enum FrameworkDispatch {
67    None,
68    Handled,
69    Quit,
70}
71
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub(crate) enum DispatchOutcome {
74    Unhandled,
75    Widget,
76    Bubble,
77    Command,
78    CommandPending,
79    Framework,
80    FrameworkQuit,
81    TerminalPreflight,
82    Terminal,
83    AmbientScroll,
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub(crate) struct DispatchRequest {
88    key: KeyEvent,
89    focus: FocusKind,
90    key_policy: KeyDispatchPolicy,
91    terminal_policy: TerminalKeyPolicy,
92    chord_mismatch_policy: ChordMismatchPolicy,
93}
94
95impl DispatchRequest {
96    pub(crate) fn new(key: KeyEvent, focus: FocusKind) -> Self {
97        Self {
98            key,
99            focus,
100            key_policy: KeyDispatchPolicy::WidgetFirst,
101            terminal_policy: TerminalKeyPolicy::FrameworkFirst,
102            chord_mismatch_policy: ChordMismatchPolicy::default(),
103        }
104    }
105
106    pub(crate) fn key_policy(mut self, policy: KeyDispatchPolicy) -> Self {
107        self.key_policy = policy;
108        self
109    }
110
111    pub(crate) fn terminal_policy(mut self, policy: TerminalKeyPolicy) -> Self {
112        self.terminal_policy = policy;
113        self
114    }
115
116    pub(crate) fn chord_mismatch_policy(mut self, policy: ChordMismatchPolicy) -> Self {
117        self.chord_mismatch_policy = policy;
118        self
119    }
120}
121
122pub(crate) trait DispatchOps {
123    fn continue_command_chord(&mut self, key: KeyEvent) -> CommandDispatchState;
124    fn reset_command_chord(&mut self);
125    fn dispatch_widget(&mut self, key: KeyEvent) -> bool;
126    fn dispatch_bubble(&mut self, key: KeyEvent) -> bool;
127    fn dispatch_command(&mut self, key: KeyEvent) -> bool;
128    fn dispatch_framework(&mut self, key: KeyEvent) -> FrameworkDispatch;
129    fn dispatch_terminal_preflight(&mut self, key: KeyEvent) -> TerminalPreflightDispatch;
130    fn dispatch_terminal(&mut self, key: KeyEvent) -> bool;
131    fn dispatch_ambient_scroll(&mut self, key: KeyEvent) -> bool;
132}
133
134pub(crate) fn dispatch_key(
135    request: DispatchRequest,
136    ops: &mut impl DispatchOps,
137) -> DispatchOutcome {
138    match request.focus {
139        FocusKind::Widget => dispatch_widget_focus(request, ops),
140        FocusKind::Terminal => dispatch_terminal_focus(request, ops),
141    }
142}
143
144fn dispatch_widget_focus(request: DispatchRequest, ops: &mut impl DispatchOps) -> DispatchOutcome {
145    if let Some(outcome) = handle_command_chord(request, ops) {
146        return outcome;
147    }
148
149    match request.key_policy {
150        KeyDispatchPolicy::WidgetFirst => {
151            dispatch_widget_bubble_command_framework(request.key, ops)
152        }
153        KeyDispatchPolicy::AppCommandsFirst => {
154            if ops.dispatch_command(request.key) {
155                DispatchOutcome::Command
156            } else {
157                dispatch_widget_bubble_framework(request.key, ops)
158            }
159        }
160    }
161}
162
163fn handle_command_chord(
164    request: DispatchRequest,
165    ops: &mut impl DispatchOps,
166) -> Option<DispatchOutcome> {
167    match ops.continue_command_chord(request.key) {
168        CommandDispatchState::Pending => Some(DispatchOutcome::CommandPending),
169        CommandDispatchState::Matched(_) => Some(DispatchOutcome::Command),
170        CommandDispatchState::Mismatch => match request.chord_mismatch_policy {
171            ChordMismatchPolicy::SwallowPrefixReplayCurrent
172            | ChordMismatchPolicy::ForwardPrefixAndCurrent => None,
173            ChordMismatchPolicy::CancelOnly => Some(DispatchOutcome::Unhandled),
174        },
175        CommandDispatchState::None => None,
176    }
177}
178
179fn dispatch_terminal_focus(
180    request: DispatchRequest,
181    ops: &mut impl DispatchOps,
182) -> DispatchOutcome {
183    match request.terminal_policy {
184        TerminalKeyPolicy::FrameworkFirst => match ops.dispatch_framework(request.key) {
185            FrameworkDispatch::Handled => DispatchOutcome::Framework,
186            FrameworkDispatch::Quit => DispatchOutcome::FrameworkQuit,
187            FrameworkDispatch::None => dispatch_terminal_forward(request.key, ops),
188        },
189        TerminalKeyPolicy::AppCommandsThenTerminal => {
190            match ops.dispatch_terminal_preflight(request.key) {
191                TerminalPreflightDispatch::Consumed => {
192                    ops.reset_command_chord();
193                    return DispatchOutcome::TerminalPreflight;
194                }
195                TerminalPreflightDispatch::Forward => {
196                    return dispatch_terminal_forward(request.key, ops);
197                }
198                TerminalPreflightDispatch::NotApplicable
199                | TerminalPreflightDispatch::NotConsumed => {}
200            }
201            if let Some(outcome) = handle_command_chord(request, ops) {
202                return outcome;
203            }
204            if ops.dispatch_command(request.key) {
205                return DispatchOutcome::Command;
206            }
207            if let Some(outcome) = dispatch_terminal_then_bubble_framework(request.key, ops) {
208                return outcome;
209            }
210            DispatchOutcome::Unhandled
211        }
212        TerminalKeyPolicy::TerminalFirst => {
213            match ops.dispatch_terminal_preflight(request.key) {
214                TerminalPreflightDispatch::Consumed => {
215                    ops.reset_command_chord();
216                    return DispatchOutcome::TerminalPreflight;
217                }
218                TerminalPreflightDispatch::Forward => {
219                    return dispatch_terminal_forward(request.key, ops);
220                }
221                TerminalPreflightDispatch::NotApplicable
222                | TerminalPreflightDispatch::NotConsumed => {}
223            }
224            if ops.dispatch_terminal(request.key) {
225                return DispatchOutcome::Terminal;
226            }
227            if ops.dispatch_command(request.key) {
228                return DispatchOutcome::Command;
229            }
230            if ops.dispatch_bubble(request.key) {
231                return DispatchOutcome::Bubble;
232            }
233            dispatch_framework_only(request.key, ops)
234        }
235        TerminalKeyPolicy::TerminalOnly => match ops.dispatch_terminal_preflight(request.key) {
236            TerminalPreflightDispatch::Consumed => {
237                ops.reset_command_chord();
238                DispatchOutcome::TerminalPreflight
239            }
240            TerminalPreflightDispatch::Forward => dispatch_terminal_forward(request.key, ops),
241            TerminalPreflightDispatch::NotApplicable | TerminalPreflightDispatch::NotConsumed => {
242                dispatch_terminal_forward(request.key, ops)
243            }
244        },
245    }
246}
247
248fn dispatch_widget_bubble_command_framework(
249    key: KeyEvent,
250    ops: &mut impl DispatchOps,
251) -> DispatchOutcome {
252    if ops.dispatch_widget(key) {
253        DispatchOutcome::Widget
254    } else if ops.dispatch_bubble(key) {
255        DispatchOutcome::Bubble
256    } else if ops.dispatch_command(key) {
257        DispatchOutcome::Command
258    } else {
259        dispatch_framework_ambient(key, ops)
260    }
261}
262
263fn dispatch_widget_bubble_framework(key: KeyEvent, ops: &mut impl DispatchOps) -> DispatchOutcome {
264    if ops.dispatch_widget(key) {
265        DispatchOutcome::Widget
266    } else if ops.dispatch_bubble(key) {
267        DispatchOutcome::Bubble
268    } else {
269        dispatch_framework_ambient(key, ops)
270    }
271}
272
273fn dispatch_framework_ambient(key: KeyEvent, ops: &mut impl DispatchOps) -> DispatchOutcome {
274    match ops.dispatch_framework(key) {
275        FrameworkDispatch::Handled => DispatchOutcome::Framework,
276        FrameworkDispatch::Quit => DispatchOutcome::FrameworkQuit,
277        FrameworkDispatch::None if ops.dispatch_ambient_scroll(key) => {
278            DispatchOutcome::AmbientScroll
279        }
280        FrameworkDispatch::None => DispatchOutcome::Unhandled,
281    }
282}
283
284fn dispatch_terminal_forward(key: KeyEvent, ops: &mut impl DispatchOps) -> DispatchOutcome {
285    if ops.dispatch_terminal(key) {
286        DispatchOutcome::Terminal
287    } else {
288        DispatchOutcome::Unhandled
289    }
290}
291
292fn dispatch_terminal_then_bubble_framework(
293    key: KeyEvent,
294    ops: &mut impl DispatchOps,
295) -> Option<DispatchOutcome> {
296    if ops.dispatch_terminal(key) {
297        return Some(DispatchOutcome::Terminal);
298    }
299    if ops.dispatch_bubble(key) {
300        return Some(DispatchOutcome::Bubble);
301    }
302    match ops.dispatch_framework(key) {
303        FrameworkDispatch::Handled => Some(DispatchOutcome::Framework),
304        FrameworkDispatch::Quit => Some(DispatchOutcome::FrameworkQuit),
305        FrameworkDispatch::None => None,
306    }
307}
308
309fn dispatch_framework_only(key: KeyEvent, ops: &mut impl DispatchOps) -> DispatchOutcome {
310    match ops.dispatch_framework(key) {
311        FrameworkDispatch::Handled => DispatchOutcome::Framework,
312        FrameworkDispatch::Quit => DispatchOutcome::FrameworkQuit,
313        FrameworkDispatch::None => DispatchOutcome::Unhandled,
314    }
315}
316
317#[derive(Clone, Copy, Debug, Eq, PartialEq)]
318pub(crate) enum TerminalPreflightDispatch {
319    Consumed,
320    Forward,
321    NotApplicable,
322    NotConsumed,
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::core::event::{KeyCode, KeyEvent, KeyMods};
329
330    #[derive(Default)]
331    struct FakeOps {
332        widget_handles: bool,
333        bubble_handles: bool,
334        terminal_preflight_handles: bool,
335        terminal_preflight_not_consumed: bool,
336        terminal_handles: bool,
337        command_chord_match: Option<&'static str>,
338        command_match: Option<&'static str>,
339        command_pending: bool,
340        command_mismatch: bool,
341        framework_quit: bool,
342        framework_handles: bool,
343        ambient_handles: bool,
344        calls: Vec<&'static str>,
345    }
346
347    impl FakeOps {
348        fn from_case(case: &Case) -> Self {
349            Self {
350                widget_handles: case.widget_handles,
351                bubble_handles: case.bubble_handles,
352                terminal_preflight_handles: case.terminal_preflight_handles,
353                terminal_preflight_not_consumed: case.terminal_preflight_not_consumed,
354                terminal_handles: case.terminal_handles,
355                command_chord_match: case.command_chord_match,
356                command_match: case.command_match,
357                command_pending: case.command_pending,
358                command_mismatch: case.command_mismatch,
359                framework_quit: case.framework_quit,
360                framework_handles: case.framework_handles,
361                ambient_handles: case.ambient_handles,
362                calls: Vec::new(),
363            }
364        }
365    }
366
367    impl DispatchOps for FakeOps {
368        fn continue_command_chord(&mut self, _key: KeyEvent) -> CommandDispatchState {
369            self.calls.push("command-chord");
370            if self.command_pending {
371                return CommandDispatchState::Pending;
372            }
373            if self.command_mismatch {
374                return CommandDispatchState::Mismatch;
375            }
376            self.command_chord_match
377                .map(|id| CommandDispatchState::Matched(id.into()))
378                .unwrap_or(CommandDispatchState::None)
379        }
380
381        fn reset_command_chord(&mut self) {
382            self.calls.push("reset-chord");
383        }
384
385        fn dispatch_widget(&mut self, _key: KeyEvent) -> bool {
386            self.calls.push("widget");
387            self.widget_handles
388        }
389
390        fn dispatch_bubble(&mut self, _key: KeyEvent) -> bool {
391            self.calls.push("bubble");
392            self.bubble_handles
393        }
394
395        fn dispatch_command(&mut self, _key: KeyEvent) -> bool {
396            self.calls.push("command");
397            self.command_match.is_some()
398        }
399
400        fn dispatch_framework(&mut self, _key: KeyEvent) -> FrameworkDispatch {
401            self.calls.push("framework");
402            if self.framework_quit {
403                FrameworkDispatch::Quit
404            } else if self.framework_handles {
405                FrameworkDispatch::Handled
406            } else {
407                FrameworkDispatch::None
408            }
409        }
410
411        fn dispatch_terminal_preflight(&mut self, _key: KeyEvent) -> TerminalPreflightDispatch {
412            self.calls.push("terminal-preflight");
413            if self.terminal_preflight_handles {
414                TerminalPreflightDispatch::Consumed
415            } else if self.terminal_preflight_not_consumed {
416                TerminalPreflightDispatch::NotConsumed
417            } else {
418                TerminalPreflightDispatch::NotApplicable
419            }
420        }
421
422        fn dispatch_terminal(&mut self, _key: KeyEvent) -> bool {
423            self.calls.push("terminal");
424            self.terminal_handles
425        }
426
427        fn dispatch_ambient_scroll(&mut self, _key: KeyEvent) -> bool {
428            self.calls.push("ambient");
429            self.ambient_handles
430        }
431    }
432
433    fn ctrl(ch: char) -> KeyEvent {
434        KeyEvent {
435            code: KeyCode::Char(ch),
436            mods: KeyMods {
437                ctrl: true,
438                ..KeyMods::default()
439            },
440        }
441    }
442
443    struct Case {
444        name: &'static str,
445        focus: FocusKind,
446        key_policy: KeyDispatchPolicy,
447        terminal_policy: TerminalKeyPolicy,
448        widget_handles: bool,
449        bubble_handles: bool,
450        terminal_preflight_handles: bool,
451        terminal_preflight_not_consumed: bool,
452        terminal_handles: bool,
453        command_chord_match: Option<&'static str>,
454        command_match: Option<&'static str>,
455        command_pending: bool,
456        command_mismatch: bool,
457        framework_quit: bool,
458        framework_handles: bool,
459        ambient_handles: bool,
460        expected_calls: &'static [&'static str],
461        expected: DispatchOutcome,
462    }
463
464    #[test]
465    fn dispatch_matrix_covers_focus_policy_and_consumption_paths() {
466        let cases = [
467            Case {
468                name: "widget-first widget consumes",
469                focus: FocusKind::Widget,
470                key_policy: KeyDispatchPolicy::WidgetFirst,
471                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
472                widget_handles: true,
473                bubble_handles: false,
474                terminal_preflight_handles: false,
475                terminal_preflight_not_consumed: false,
476                terminal_handles: false,
477                command_chord_match: None,
478                command_match: Some("app.save"),
479                command_pending: false,
480                command_mismatch: false,
481                framework_quit: true,
482                framework_handles: false,
483                ambient_handles: false,
484                expected_calls: &["command-chord", "widget"],
485                expected: DispatchOutcome::Widget,
486            },
487            Case {
488                name: "widget-first bubble consumes before command",
489                focus: FocusKind::Widget,
490                key_policy: KeyDispatchPolicy::WidgetFirst,
491                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
492                widget_handles: false,
493                bubble_handles: true,
494                terminal_preflight_handles: false,
495                terminal_preflight_not_consumed: false,
496                terminal_handles: false,
497                command_chord_match: None,
498                command_match: Some("app.save"),
499                command_pending: false,
500                command_mismatch: false,
501                framework_quit: true,
502                framework_handles: false,
503                ambient_handles: false,
504                expected_calls: &["command-chord", "widget", "bubble"],
505                expected: DispatchOutcome::Bubble,
506            },
507            Case {
508                name: "widget-first command after bubble miss",
509                focus: FocusKind::Widget,
510                key_policy: KeyDispatchPolicy::WidgetFirst,
511                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
512                widget_handles: false,
513                bubble_handles: false,
514                terminal_preflight_handles: false,
515                terminal_preflight_not_consumed: false,
516                terminal_handles: false,
517                command_chord_match: None,
518                command_match: Some("app.save"),
519                command_pending: false,
520                command_mismatch: false,
521                framework_quit: true,
522                framework_handles: false,
523                ambient_handles: false,
524                expected_calls: &["command-chord", "widget", "bubble", "command"],
525                expected: DispatchOutcome::Command,
526            },
527            Case {
528                name: "widget-first framework fallback",
529                focus: FocusKind::Widget,
530                key_policy: KeyDispatchPolicy::WidgetFirst,
531                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
532                widget_handles: false,
533                bubble_handles: false,
534                terminal_preflight_handles: false,
535                terminal_preflight_not_consumed: false,
536                terminal_handles: false,
537                command_chord_match: None,
538                command_match: None,
539                command_pending: false,
540                command_mismatch: false,
541                framework_quit: true,
542                framework_handles: false,
543                ambient_handles: false,
544                expected_calls: &["command-chord", "widget", "bubble", "command", "framework"],
545                expected: DispatchOutcome::FrameworkQuit,
546            },
547            Case {
548                name: "widget-first ambient fallback",
549                focus: FocusKind::Widget,
550                key_policy: KeyDispatchPolicy::WidgetFirst,
551                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
552                widget_handles: false,
553                bubble_handles: false,
554                terminal_preflight_handles: false,
555                terminal_preflight_not_consumed: false,
556                terminal_handles: false,
557                command_chord_match: None,
558                command_match: None,
559                command_pending: false,
560                command_mismatch: false,
561                framework_quit: false,
562                framework_handles: false,
563                ambient_handles: true,
564                expected_calls: &[
565                    "command-chord",
566                    "widget",
567                    "bubble",
568                    "command",
569                    "framework",
570                    "ambient",
571                ],
572                expected: DispatchOutcome::AmbientScroll,
573            },
574            Case {
575                name: "app-commands-first command before widget",
576                focus: FocusKind::Widget,
577                key_policy: KeyDispatchPolicy::AppCommandsFirst,
578                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
579                widget_handles: true,
580                bubble_handles: false,
581                terminal_preflight_handles: false,
582                terminal_preflight_not_consumed: false,
583                terminal_handles: false,
584                command_chord_match: None,
585                command_match: Some("app.save"),
586                command_pending: false,
587                command_mismatch: false,
588                framework_quit: false,
589                framework_handles: false,
590                ambient_handles: false,
591                expected_calls: &["command-chord", "command"],
592                expected: DispatchOutcome::Command,
593            },
594            Case {
595                name: "completed widget command chord consumes before widget",
596                focus: FocusKind::Widget,
597                key_policy: KeyDispatchPolicy::WidgetFirst,
598                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
599                widget_handles: true,
600                bubble_handles: true,
601                terminal_preflight_handles: false,
602                terminal_preflight_not_consumed: false,
603                terminal_handles: false,
604                command_chord_match: Some("app.save"),
605                command_match: None,
606                command_pending: false,
607                command_mismatch: false,
608                framework_quit: true,
609                framework_handles: false,
610                ambient_handles: false,
611                expected_calls: &["command-chord"],
612                expected: DispatchOutcome::Command,
613            },
614            Case {
615                name: "widget framework handled fallback",
616                focus: FocusKind::Widget,
617                key_policy: KeyDispatchPolicy::WidgetFirst,
618                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
619                widget_handles: false,
620                bubble_handles: false,
621                terminal_preflight_handles: false,
622                terminal_preflight_not_consumed: false,
623                terminal_handles: false,
624                command_chord_match: None,
625                command_match: None,
626                command_pending: false,
627                command_mismatch: false,
628                framework_quit: false,
629                framework_handles: true,
630                ambient_handles: true,
631                expected_calls: &["command-chord", "widget", "bubble", "command", "framework"],
632                expected: DispatchOutcome::Framework,
633            },
634            Case {
635                name: "terminal framework-first quit",
636                focus: FocusKind::Terminal,
637                key_policy: KeyDispatchPolicy::WidgetFirst,
638                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
639                widget_handles: false,
640                bubble_handles: false,
641                terminal_preflight_handles: false,
642                terminal_preflight_not_consumed: false,
643                terminal_handles: true,
644                command_chord_match: None,
645                command_match: Some("mux.detach"),
646                command_pending: false,
647                command_mismatch: false,
648                framework_quit: true,
649                framework_handles: false,
650                ambient_handles: false,
651                expected_calls: &["framework"],
652                expected: DispatchOutcome::FrameworkQuit,
653            },
654            Case {
655                name: "terminal framework-first handled",
656                focus: FocusKind::Terminal,
657                key_policy: KeyDispatchPolicy::WidgetFirst,
658                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
659                widget_handles: false,
660                bubble_handles: false,
661                terminal_preflight_handles: false,
662                terminal_preflight_not_consumed: false,
663                terminal_handles: true,
664                command_chord_match: None,
665                command_match: Some("mux.detach"),
666                command_pending: false,
667                command_mismatch: false,
668                framework_quit: false,
669                framework_handles: true,
670                ambient_handles: false,
671                expected_calls: &["framework"],
672                expected: DispatchOutcome::Framework,
673            },
674            Case {
675                name: "terminal app-then-terminal preflight wins",
676                focus: FocusKind::Terminal,
677                key_policy: KeyDispatchPolicy::WidgetFirst,
678                terminal_policy: TerminalKeyPolicy::AppCommandsThenTerminal,
679                widget_handles: false,
680                bubble_handles: false,
681                terminal_preflight_handles: true,
682                terminal_preflight_not_consumed: false,
683                terminal_handles: true,
684                command_chord_match: None,
685                command_match: Some("mux.copy"),
686                command_pending: false,
687                command_mismatch: false,
688                framework_quit: true,
689                framework_handles: false,
690                ambient_handles: false,
691                expected_calls: &["terminal-preflight", "reset-chord"],
692                expected: DispatchOutcome::TerminalPreflight,
693            },
694            Case {
695                name: "terminal app-then-terminal command wins after preflight miss",
696                focus: FocusKind::Terminal,
697                key_policy: KeyDispatchPolicy::WidgetFirst,
698                terminal_policy: TerminalKeyPolicy::AppCommandsThenTerminal,
699                widget_handles: false,
700                bubble_handles: false,
701                terminal_preflight_handles: false,
702                terminal_preflight_not_consumed: false,
703                terminal_handles: true,
704                command_chord_match: None,
705                command_match: Some("mux.detach"),
706                command_pending: false,
707                command_mismatch: false,
708                framework_quit: true,
709                framework_handles: false,
710                ambient_handles: false,
711                expected_calls: &["terminal-preflight", "command-chord", "command"],
712                expected: DispatchOutcome::Command,
713            },
714            Case {
715                name: "terminal app command chord consumes after preflight miss",
716                focus: FocusKind::Terminal,
717                key_policy: KeyDispatchPolicy::WidgetFirst,
718                terminal_policy: TerminalKeyPolicy::AppCommandsThenTerminal,
719                widget_handles: false,
720                bubble_handles: false,
721                terminal_preflight_handles: false,
722                terminal_preflight_not_consumed: false,
723                terminal_handles: true,
724                command_chord_match: Some("mux.detach"),
725                command_match: None,
726                command_pending: false,
727                command_mismatch: false,
728                framework_quit: true,
729                framework_handles: false,
730                ambient_handles: false,
731                expected_calls: &["terminal-preflight", "command-chord"],
732                expected: DispatchOutcome::Command,
733            },
734            Case {
735                name: "terminal preflight not-consumed forwards to terminal",
736                focus: FocusKind::Terminal,
737                key_policy: KeyDispatchPolicy::WidgetFirst,
738                terminal_policy: TerminalKeyPolicy::TerminalFirst,
739                widget_handles: false,
740                bubble_handles: false,
741                terminal_preflight_handles: false,
742                terminal_preflight_not_consumed: true,
743                terminal_handles: true,
744                command_chord_match: None,
745                command_match: None,
746                command_pending: false,
747                command_mismatch: false,
748                framework_quit: true,
749                framework_handles: false,
750                ambient_handles: false,
751                expected_calls: &["terminal-preflight", "terminal"],
752                expected: DispatchOutcome::Terminal,
753            },
754            Case {
755                name: "terminal-first terminal consumes",
756                focus: FocusKind::Terminal,
757                key_policy: KeyDispatchPolicy::WidgetFirst,
758                terminal_policy: TerminalKeyPolicy::TerminalFirst,
759                widget_handles: false,
760                bubble_handles: false,
761                terminal_preflight_handles: false,
762                terminal_preflight_not_consumed: false,
763                terminal_handles: true,
764                command_chord_match: None,
765                command_match: Some("mux.detach"),
766                command_pending: false,
767                command_mismatch: false,
768                framework_quit: true,
769                framework_handles: false,
770                ambient_handles: false,
771                expected_calls: &["terminal-preflight", "terminal"],
772                expected: DispatchOutcome::Terminal,
773            },
774            Case {
775                name: "terminal-only never command/framework",
776                focus: FocusKind::Terminal,
777                key_policy: KeyDispatchPolicy::WidgetFirst,
778                terminal_policy: TerminalKeyPolicy::TerminalOnly,
779                widget_handles: false,
780                bubble_handles: false,
781                terminal_preflight_handles: false,
782                terminal_preflight_not_consumed: false,
783                terminal_handles: true,
784                command_chord_match: None,
785                command_match: Some("mux.detach"),
786                command_pending: false,
787                command_mismatch: false,
788                framework_quit: true,
789                framework_handles: false,
790                ambient_handles: false,
791                expected_calls: &["terminal-preflight", "terminal"],
792                expected: DispatchOutcome::Terminal,
793            },
794            Case {
795                name: "command chord pending consumes",
796                focus: FocusKind::Widget,
797                key_policy: KeyDispatchPolicy::WidgetFirst,
798                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
799                widget_handles: true,
800                bubble_handles: false,
801                terminal_preflight_handles: false,
802                terminal_preflight_not_consumed: false,
803                terminal_handles: false,
804                command_chord_match: None,
805                command_match: None,
806                command_pending: true,
807                command_mismatch: false,
808                framework_quit: false,
809                framework_handles: false,
810                ambient_handles: false,
811                expected_calls: &["command-chord"],
812                expected: DispatchOutcome::CommandPending,
813            },
814            Case {
815                name: "command chord mismatch replays current to widget",
816                focus: FocusKind::Widget,
817                key_policy: KeyDispatchPolicy::WidgetFirst,
818                terminal_policy: TerminalKeyPolicy::FrameworkFirst,
819                widget_handles: true,
820                bubble_handles: false,
821                terminal_preflight_handles: false,
822                terminal_preflight_not_consumed: false,
823                terminal_handles: false,
824                command_chord_match: None,
825                command_match: None,
826                command_pending: false,
827                command_mismatch: true,
828                framework_quit: false,
829                framework_handles: false,
830                ambient_handles: false,
831                expected_calls: &["command-chord", "widget"],
832                expected: DispatchOutcome::Widget,
833            },
834        ];
835
836        for case in cases {
837            let mut ops = FakeOps::from_case(&case);
838            let outcome = dispatch_key(
839                DispatchRequest::new(ctrl('s'), case.focus)
840                    .key_policy(case.key_policy)
841                    .terminal_policy(case.terminal_policy),
842                &mut ops,
843            );
844            assert_eq!(ops.calls, case.expected_calls, "{} calls", case.name);
845            assert_eq!(outcome, case.expected, "{} outcome", case.name);
846        }
847    }
848}