Skip to main content

tui_pages/
canvas.rs

1//! Integration helpers for using the `canvas` crate with `tui-pages`.
2//!
3//! Enable the `canvas` feature and make your application action type implement
4//! `From<CanvasAction>`. Then `.canvas_defaults()` on the builder installs the
5//! standard `FormEditor` keybindings and typed-character action routing, while
6//! [`PageSpec::canvas_editor`] keeps the active mode stack in sync with the
7//! editor.
8
9use crate::focus::{FocusIntent, FocusTarget};
10use crate::input::{
11    BindableActionInfo, BindingCatalog, BindingConflict, BindingInfo, BindingLayer, BindingSource,
12    CanvasRoutingPrecedence, KeyChord, KeyMap,
13};
14use crate::runtime::{
15    ActionContext, ActionOutcome, CanvasHooks, InputLayerContext, KeyHook, KeyHookKind,
16    KeyHookOutcome, KeyHookRouting, ModeId, PageSpec, TuiEffect, TuiPagesBuilder, TuiPagesStatus,
17    modes,
18};
19use crossterm::{
20    event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
21    execute,
22};
23use ratatui::{Frame, layout::Rect};
24#[cfg(feature = "tui")]
25use ratatui::style::Style;
26use std::io;
27use std::marker::PhantomData;
28
29// The `canvas` feature enables full canvas support — every surface below is
30// available unconditionally. We do not split canvas into sub-features.
31
32// --- Base surface ---
33pub use ::canvas::integration::focus_handoff::{
34    BoundaryExit, HostActionOutcome, execute_action_for_host, execute_action_for_host_with_options,
35};
36pub use ::canvas::{
37    ActionResult, AppMode, CanvasAction, DataProvider, EditorState, TextFormEventOutcome,
38    TextFormState,
39};
40
41// --- Keymap-driven host handoff ---
42pub use ::canvas::integration::focus_handoff::{
43    HostKeyEventOutcome, boundary_from_key_outcome, handle_key_event_for_host,
44    key_outcome_for_vertical_navigation, map_key_event_outcome_for_host,
45};
46pub use ::canvas::keybindings::{
47    CanvasKeyAction, CanvasKeybindingPresetError, KeyStroke,
48    try_parse_binding as try_parse_canvas_binding,
49};
50pub use ::canvas::{
51    BuiltinCanvasKeybindingPreset, CanvasActionBinding, CanvasActionKeyBinding,
52    CanvasKeyBindingEntry, CanvasKeyBindings, CanvasKeybindingConflictKind,
53    CanvasKeybindingProfile, KeyEventOutcome, default_builtin_action_bindings,
54    default_emacs_action_bindings, default_helix_action_bindings, default_vim_action_bindings,
55    display_binding, preset,
56};
57
58// --- Cursor style ---
59pub use ::canvas::CursorManager;
60
61// --- Suggestions ---
62pub use ::canvas::{
63    SuggestionItem, SuggestionQuery, SuggestionTrigger, render_suggestions_dropdown,
64};
65
66// --- Validation ---
67pub use ::canvas::validation::limits::{CountMode, LimitCheckResult};
68pub use ::canvas::{
69    AppliedValidation, CharacterFilter, CharacterLimits, CustomFormatter, DefaultPositionMapper,
70    DisplayMask, FormattingResult, PatternFilters, PositionFilter, PositionMapper, PositionRange,
71    ValidationConfig, ValidationConfigBuilder, ValidationError, ValidationResult, ValidationRule,
72    ValidationSet, ValidationSettings, ValidationState, ValidationSummary,
73};
74
75// --- Computed fields ---
76pub use ::canvas::{ComputedContext, ComputedProvider, ComputedState};
77
78// --- GUI: renderers, themes, display options ---
79pub use ::canvas::{
80    CanvasDisplayOptions, CanvasTheme, DefaultCanvasTheme, OverflowMode,
81};
82
83#[cfg(feature = "tui")]
84impl CanvasTheme for crate::ThemeStyles {
85    fn background(&self) -> Style {
86        self.background
87    }
88    fn label(&self) -> Style {
89        self.muted
90    }
91    fn label_active(&self) -> Style {
92        self.line_number_selected
93    }
94    fn input(&self) -> Style {
95        self.text
96    }
97    fn input_active(&self) -> Style {
98        self.text.patch(self.cursorline).patch(self.text_focus)
99    }
100    fn selection(&self) -> Style {
101        self.selection
102    }
103    fn cursorline(&self) -> Style {
104        self.cursorline
105    }
106    fn completion(&self) -> Style {
107        self.text_inactive
108    }
109    fn cursor_normal(&self) -> Style {
110        self.cursor_normal
111    }
112    fn cursor_insert(&self) -> Style {
113        self.cursor_insert
114    }
115    fn cursor_select(&self) -> Style {
116        self.cursor_select
117    }
118    fn suggestions(&self) -> Style {
119        self.menu
120    }
121    fn suggestion_selected(&self) -> Style {
122        self.menu_selected
123    }
124    fn warning(&self) -> Style {
125        self.warning
126    }
127    fn border(&self) -> Style {
128        self.window
129    }
130    fn border_active(&self) -> Style {
131        self.text_focus.patch(self.cursor_normal)
132    }
133}
134
135// Crossterm terminal-input session helpers (raw mode, bracketed paste, mouse
136// capture) — the canvas-side complement to [`crate::terminal`] for apps wiring
137// up text widgets that need paste support.
138pub use ::canvas::integration::crossterm_input::{
139    CrosstermInputGuard, CrosstermInputOptions, CrosstermInputSession,
140};
141
142// --- Text area ---
143pub use ::canvas::textarea::{
144    TextAreaCommandLineState, TextAreaEventOutcome, TextAreaLineNumberMode, TextAreaSearchMatch,
145    TextOverflowMode,
146};
147pub use ::canvas::{TextArea, TextAreaDataProvider, TextAreaProvider, TextAreaState};
148
149// --- Command line ---
150pub use ::canvas::{
151    CommandLine, CommandLineCommand, CommandLineCommandInvocation, CommandLineDispatchError,
152    CommandLineEventOutcome, CommandLineMode, CommandLineParseError, CommandLineParsedCommand,
153    CommandLinePlacement, CommandLineRegistrationError, CommandLineRegistry, CommandLineState,
154    CommandLineSubmit, parse_command_args, parse_command_line,
155};
156
157// --- Text input ---
158pub use ::canvas::{
159    TextInput, TextInputDataProvider, TextInputEventOutcome, TextInputProvider, TextInputState,
160};
161
162pub type FormEditor<D> = TextFormState<D>;
163pub type FormInputEventOutcome = TextFormEventOutcome;
164pub type TextAreaEditor<P> = TextAreaState<P>;
165pub type TextInputEditor<P> = TextInputState<P>;
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum DefaultCursorBehavior {
169    Hidden,
170    Active { mode: AppMode },
171    InactiveUnderscore,
172}
173
174#[derive(Debug, Clone)]
175pub enum CanvasDispatchOutcome<O = (), M = ()> {
176    Applied(::canvas::ActionResult),
177    Focus(FocusIntent<O, M>),
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum CanvasKeyDispatchOutcome<O = (), M = ()> {
182    Consumed(Option<String>),
183    PendingSequence,
184    NotHandled,
185    Focus(FocusIntent<O, M>),
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum CanvasTextWidgetOutcome<O = (), M = ()> {
190    Handled,
191    Submitted,
192    NotHandled,
193    Focus(FocusIntent<O, M>),
194}
195
196impl<O, M> CanvasDispatchOutcome<O, M> {
197    pub fn into_focus_intent(self) -> Option<FocusIntent<O, M>> {
198        match self {
199            CanvasDispatchOutcome::Applied(_) => None,
200            CanvasDispatchOutcome::Focus(intent) => Some(intent),
201        }
202    }
203}
204
205impl<O, M> CanvasKeyDispatchOutcome<O, M> {
206    pub fn into_focus_intent(self) -> Option<FocusIntent<O, M>> {
207        match self {
208            CanvasKeyDispatchOutcome::Focus(intent) => Some(intent),
209            _ => None,
210        }
211    }
212}
213
214impl<O, M> CanvasTextWidgetOutcome<O, M> {
215    pub fn into_focus_intent(self) -> Option<FocusIntent<O, M>> {
216        match self {
217            CanvasTextWidgetOutcome::Focus(intent) => Some(intent),
218            _ => None,
219        }
220    }
221}
222
223pub trait CanvasFormEditorHost {
224    fn mode(&self) -> AppMode;
225    fn has_keybindings(&self) -> bool;
226    fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset);
227    fn install_keybindings(&mut self, bindings: CanvasKeyBindings);
228    /// Whether a multi-key command (sequence/count/operator) is mid-flight, so
229    /// the runtime keeps routing keys here instead of letting the global keymap
230    /// claim the next stroke. Defaults to `false` for hosts without modal state.
231    fn is_sequence_pending(&self) -> bool {
232        false
233    }
234    fn input_key(&mut self, key: KeyEvent) -> CanvasKeyDispatchOutcome;
235    /// Insert pasted (bracketed-paste) text. Returns whether anything was
236    /// inserted.
237    fn paste(&mut self, text: &str) -> bool;
238    fn dispatch_canvas_action(&mut self, action: CanvasAction) -> CanvasDispatchOutcome;
239}
240
241impl<D> CanvasFormEditorHost for FormEditor<D>
242where
243    D: DataProvider,
244{
245    fn mode(&self) -> AppMode {
246        self.core().mode()
247    }
248
249    fn has_keybindings(&self) -> bool {
250        self.core().has_keybindings()
251    }
252
253    fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset) {
254        FormEditor::use_keybinding_preset(self, preset);
255    }
256
257    fn install_keybindings(&mut self, bindings: CanvasKeyBindings) {
258        FormEditor::set_keybindings(self, bindings);
259    }
260
261    fn is_sequence_pending(&self) -> bool {
262        FormEditor::is_sequence_pending(self)
263    }
264
265    fn input_key(&mut self, key: KeyEvent) -> CanvasKeyDispatchOutcome {
266        dispatch_key_event(self, key)
267    }
268
269    fn paste(&mut self, text: &str) -> bool {
270        matches!(FormEditor::paste(self, text), TextFormEventOutcome::Handled)
271    }
272
273    fn dispatch_canvas_action(&mut self, action: CanvasAction) -> CanvasDispatchOutcome {
274        dispatch_action(self, action)
275    }
276}
277
278pub trait CanvasTextAreaHost {
279    fn mode(&self) -> AppMode;
280    fn has_keybindings(&self) -> bool;
281    fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset);
282    fn install_keybindings(&mut self, bindings: CanvasKeyBindings);
283    /// Whether a multi-key command (sequence/count/operator/literal-char capture)
284    /// is mid-flight, so the runtime keeps routing keys here instead of letting
285    /// the global keymap claim the next stroke.
286    fn is_sequence_pending(&self) -> bool {
287        false
288    }
289    fn commandline_enabled(&self) -> bool {
290        false
291    }
292    fn boundary_for_key(&self, key: &KeyEvent) -> Option<BoundaryExit>;
293    fn input_key(&mut self, key: KeyEvent) -> TextAreaEventOutcome;
294    /// Insert pasted (bracketed-paste) text. Returns whether anything was
295    /// inserted.
296    fn paste(&mut self, text: &str) -> bool;
297    fn exit_edit_mode(&mut self);
298    fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome;
299}
300
301impl<P> CanvasTextAreaHost for TextAreaState<P>
302where
303    P: TextAreaDataProvider,
304{
305    fn mode(&self) -> AppMode {
306        self.mode()
307    }
308
309    fn has_keybindings(&self) -> bool {
310        self.core().has_keybindings()
311    }
312
313    fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset) {
314        TextAreaState::use_keybinding_preset(self, preset);
315    }
316
317    fn install_keybindings(&mut self, bindings: CanvasKeyBindings) {
318        TextAreaState::set_keybindings(self, bindings);
319    }
320
321    fn is_sequence_pending(&self) -> bool {
322        TextAreaState::is_sequence_pending(self)
323    }
324
325    fn commandline_enabled(&self) -> bool {
326        self.commandline().is_some()
327    }
328
329    fn boundary_for_key(&self, key: &KeyEvent) -> Option<BoundaryExit> {
330        text_area_boundary_for_key(self, key)
331    }
332
333    fn input_key(&mut self, key: KeyEvent) -> TextAreaEventOutcome {
334        if self.core().has_keybindings() || self.commandline_enabled() {
335            match self.handle_key_event(key) {
336                KeyEventOutcome::Consumed(_)
337                | KeyEventOutcome::Pending
338                | KeyEventOutcome::ExitTop
339                | KeyEventOutcome::ExitBottom => TextAreaEventOutcome::Handled,
340                KeyEventOutcome::NotMatched => TextAreaEventOutcome::Ignored,
341            }
342        } else {
343            self.input(key)
344        }
345    }
346
347    fn paste(&mut self, text: &str) -> bool {
348        matches!(
349            TextAreaState::paste(self, text),
350            TextAreaEventOutcome::Handled
351        )
352    }
353
354    fn exit_edit_mode(&mut self) {
355        let _ = self.core_mut().exit_edit_mode();
356    }
357
358    fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome {
359        HostActionOutcome::Applied(self.core_mut().execute(action))
360    }
361}
362
363/// A single-row text input. It is driven by the same keybinding engine as the
364/// form editor (its inner [`TextFormState`] *is* a [`FormEditor`]), so it is
365/// modal and the widget engine is the single decoder for its keys — the
366/// textinput just adds inline-suggestion (ghost-suffix) glue on top.
367pub trait CanvasTextInputHost {
368    fn mode(&self) -> AppMode;
369    fn text(&self) -> String;
370    fn has_keybindings(&self) -> bool;
371    fn install_keybindings(&mut self, bindings: CanvasKeyBindings);
372    /// Whether a multi-key command is mid-flight, so the runtime keeps routing
373    /// keys here instead of letting the global keymap claim the next stroke.
374    fn is_sequence_pending(&self) -> bool {
375        false
376    }
377    fn input_key(&mut self, key: KeyEvent) -> CanvasKeyDispatchOutcome;
378    /// Accept the current inline-suggestion suffix (ghost text). Returns whether
379    /// a non-empty suffix was inserted.
380    fn accept_suggestion_suffix(&mut self) -> bool;
381    /// Insert pasted (bracketed-paste) text. Returns whether anything was
382    /// inserted.
383    fn paste(&mut self, text: &str) -> bool;
384    fn set_suggestion_suffix(&mut self, suffix: String);
385    fn clear_suggestion_suffix(&mut self);
386    fn exit_edit_mode(&mut self);
387    fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome;
388}
389
390impl<P> CanvasTextInputHost for TextInputState<P>
391where
392    P: TextInputDataProvider,
393{
394    fn mode(&self) -> AppMode {
395        self.mode()
396    }
397
398    fn text(&self) -> String {
399        TextInputState::text(self)
400    }
401
402    fn has_keybindings(&self) -> bool {
403        self.form().core().has_keybindings()
404    }
405
406    fn install_keybindings(&mut self, bindings: CanvasKeyBindings) {
407        self.form_mut().set_keybindings(bindings);
408    }
409
410    fn is_sequence_pending(&self) -> bool {
411        self.form().is_sequence_pending()
412    }
413
414    fn input_key(&mut self, key: KeyEvent) -> CanvasKeyDispatchOutcome {
415        dispatch_key_event(self.form_mut(), key)
416    }
417
418    fn accept_suggestion_suffix(&mut self) -> bool {
419        matches!(
420            TextInputState::accept_suggestion_suffix(self),
421            TextInputEventOutcome::Handled
422        )
423    }
424
425    fn paste(&mut self, text: &str) -> bool {
426        matches!(
427            TextInputState::paste(self, text),
428            TextInputEventOutcome::Handled
429        )
430    }
431
432    fn set_suggestion_suffix(&mut self, suffix: String) {
433        TextInputState::set_suggestion_suffix(self, suffix);
434    }
435
436    fn clear_suggestion_suffix(&mut self) {
437        TextInputState::clear_suggestion_suffix(self);
438    }
439
440    fn exit_edit_mode(&mut self) {
441        let _ = self.form_mut().exit_edit_mode();
442    }
443
444    fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome {
445        execute_action_for_host_with_options(self.form_mut(), action, false)
446    }
447}
448
449pub trait CanvasWidgetState {
450    fn canvas_form_editor_ref(&self, _id: usize) -> Option<&dyn CanvasFormEditorHost> {
451        None
452    }
453
454    fn canvas_form_editor(&mut self, _id: usize) -> Option<&mut dyn CanvasFormEditorHost> {
455        None
456    }
457
458    fn canvas_textarea_ref(&self, _focus_index: usize) -> Option<&dyn CanvasTextAreaHost> {
459        None
460    }
461
462    fn canvas_textarea(&mut self, _focus_index: usize) -> Option<&mut dyn CanvasTextAreaHost> {
463        None
464    }
465
466    fn canvas_textarea_entered_ref(&self, _focus_index: usize) -> Option<&bool> {
467        None
468    }
469
470    fn canvas_textarea_entered(&mut self, _focus_index: usize) -> Option<&mut bool> {
471        None
472    }
473
474    fn canvas_textinput_ref(&self, _focus_index: usize) -> Option<&dyn CanvasTextInputHost> {
475        None
476    }
477
478    fn canvas_textinput(&mut self, _focus_index: usize) -> Option<&mut dyn CanvasTextInputHost> {
479        None
480    }
481
482    fn canvas_textinput_entered_ref(&self, _focus_index: usize) -> Option<&bool> {
483        None
484    }
485
486    fn canvas_textinput_entered(&mut self, _focus_index: usize) -> Option<&mut bool> {
487        None
488    }
489
490    fn canvas_textinput_suggestion_suffix(
491        &mut self,
492        _focus_index: usize,
493        _text: &str,
494    ) -> Option<String> {
495        None
496    }
497}
498
499pub fn mode_for_app_mode(mode: AppMode) -> ModeId {
500    match mode {
501        AppMode::Ins => modes::INSERT,
502        AppMode::Sel => modes::SELECT,
503        AppMode::Command => modes::COMMAND,
504        AppMode::General => modes::GENERAL,
505        AppMode::Nor => modes::NORMAL,
506    }
507}
508
509pub fn modes_for_app_mode(mode: AppMode) -> Vec<ModeId> {
510    match mode {
511        AppMode::Command => vec![modes::COMMAND],
512        AppMode::General => vec![modes::GENERAL, modes::GLOBAL],
513        mode => vec![mode_for_app_mode(mode), modes::COMMON, modes::GLOBAL],
514    }
515}
516
517pub fn accepts_text_input(mode: AppMode) -> bool {
518    matches!(mode, AppMode::Ins | AppMode::Command)
519}
520
521pub fn text_chord_to_canvas_action(chord: KeyChord) -> Option<CanvasAction> {
522    let is_plain_char = chord.modifiers.is_empty() || chord.modifiers == KeyModifiers::SHIFT;
523    match chord.code {
524        KeyCode::Char(c) if is_plain_char => Some(CanvasAction::InsertChar(c)),
525        _ => None,
526    }
527}
528
529pub fn text_chord_to_action<A>(chord: KeyChord) -> Option<A>
530where
531    A: From<CanvasAction>,
532{
533    text_chord_to_canvas_action(chord).map(A::from)
534}
535
536/// Seed the shared canvas keybinding profile with `preset` only if nothing has
537/// configured it yet (generation 0). This keeps a `keybindings_toml`/`config`
538/// call from being clobbered by a later widget registration, and stops the
539/// last-registered widget's preset from overwriting earlier ones — there is a
540/// single shared profile, so the first writer wins and explicit config always
541/// wins over a widget default.
542#[cfg(feature = "canvas")]
543fn seed_canvas_profile_if_unconfigured(
544    handle: &crate::runtime::CanvasKeybindingProfileHandle,
545    preset: BuiltinCanvasKeybindingPreset,
546) {
547    let mut state = handle.borrow_mut();
548    if state.generation == 0 {
549        state.replace(preset.profile());
550    }
551}
552
553pub fn focus_intent_for_boundary<O, M>(boundary: BoundaryExit) -> FocusIntent<O, M> {
554    match boundary {
555        BoundaryExit::Top => FocusIntent::ExitCanvasBackward,
556        BoundaryExit::Bottom => FocusIntent::ExitCanvasForward,
557    }
558}
559
560pub fn dispatch_action<D, O, M>(
561    editor: &mut FormEditor<D>,
562    action: CanvasAction,
563) -> CanvasDispatchOutcome<O, M>
564where
565    D: DataProvider,
566{
567    let before_field = editor.current_field();
568    let at_boundary = action_boundary(editor, &action).is_some();
569    match execute_action_for_host(editor, action) {
570        HostActionOutcome::Applied(result) => CanvasDispatchOutcome::Applied(
571            validation_aware_action_result(editor, before_field, at_boundary, result),
572        ),
573        HostActionOutcome::ExitCanvas(boundary) => {
574            CanvasDispatchOutcome::Focus(focus_intent_for_boundary(boundary))
575        }
576    }
577}
578
579pub fn render_canvas<T, D>(
580    frame: &mut Frame,
581    area: Rect,
582    editor: &FormEditor<D>,
583    theme: &T,
584) -> Option<Rect>
585where
586    T: CanvasTheme,
587    D: DataProvider,
588{
589    ::canvas::render_canvas(frame, area, editor, theme)
590}
591
592pub fn render_canvas_unmanaged_cursor<T, D>(
593    frame: &mut Frame,
594    area: Rect,
595    editor: &FormEditor<D>,
596    theme: &T,
597) -> Option<Rect>
598where
599    T: CanvasTheme,
600    D: DataProvider,
601{
602    ::canvas::render_canvas(frame, area, editor, theme)
603}
604
605pub fn render_canvas_with_options<T, D>(
606    frame: &mut Frame,
607    area: Rect,
608    editor: &FormEditor<D>,
609    theme: &T,
610    opts: CanvasDisplayOptions,
611) -> Option<Rect>
612where
613    T: CanvasTheme,
614    D: DataProvider,
615{
616    ::canvas::render_canvas_with_options(frame, area, editor, theme, opts)
617}
618
619pub fn render_canvas_with_options_unmanaged_cursor<T, D>(
620    frame: &mut Frame,
621    area: Rect,
622    editor: &FormEditor<D>,
623    theme: &T,
624    opts: CanvasDisplayOptions,
625) -> Option<Rect>
626where
627    T: CanvasTheme,
628    D: DataProvider,
629{
630    ::canvas::render_canvas_with_options(frame, area, editor, theme, opts)
631}
632
633pub fn render_canvas_default<D>(
634    frame: &mut Frame,
635    area: Rect,
636    editor: &FormEditor<D>,
637) -> Option<Rect>
638where
639    D: DataProvider,
640{
641    let theme = DefaultCanvasTheme;
642    render_canvas(frame, area, editor, &theme)
643}
644
645pub fn render_canvas_default_unmanaged_cursor<D>(
646    frame: &mut Frame,
647    area: Rect,
648    editor: &FormEditor<D>,
649) -> Option<Rect>
650where
651    D: DataProvider,
652{
653    let theme = DefaultCanvasTheme;
654    render_canvas_unmanaged_cursor(frame, area, editor, &theme)
655}
656
657pub fn render_canvas_with_suggestions<T, D>(
658    frame: &mut Frame,
659    frame_area: Rect,
660    canvas_area: Rect,
661    editor: &FormEditor<D>,
662    theme: &T,
663) -> Option<Rect>
664where
665    T: CanvasTheme,
666    D: DataProvider,
667{
668    let opts = CanvasDisplayOptions::default();
669    render_canvas_with_suggestions_with_options(frame, frame_area, canvas_area, editor, theme, opts)
670}
671
672pub fn render_canvas_with_suggestions_with_options<T, D>(
673    frame: &mut Frame,
674    frame_area: Rect,
675    canvas_area: Rect,
676    editor: &FormEditor<D>,
677    theme: &T,
678    opts: CanvasDisplayOptions,
679) -> Option<Rect>
680where
681    T: CanvasTheme,
682    D: DataProvider,
683{
684    let input_rect = render_canvas_with_options(frame, canvas_area, editor, theme, opts);
685    if let Some(input_rect) = input_rect {
686        render_suggestions_dropdown(frame, frame_area, input_rect, theme, editor);
687    }
688    input_rect
689}
690
691pub fn render_canvas_with_suggestions_default<D>(
692    frame: &mut Frame,
693    frame_area: Rect,
694    canvas_area: Rect,
695    editor: &FormEditor<D>,
696) -> Option<Rect>
697where
698    D: DataProvider,
699{
700    let theme = DefaultCanvasTheme;
701    render_canvas_with_suggestions(frame, frame_area, canvas_area, editor, &theme)
702}
703
704pub fn render_canvas_with_suggestions_default_options<D>(
705    frame: &mut Frame,
706    frame_area: Rect,
707    canvas_area: Rect,
708    editor: &FormEditor<D>,
709    opts: CanvasDisplayOptions,
710) -> Option<Rect>
711where
712    D: DataProvider,
713{
714    let theme = DefaultCanvasTheme;
715    render_canvas_with_suggestions_with_options(
716        frame,
717        frame_area,
718        canvas_area,
719        editor,
720        &theme,
721        opts,
722    )
723}
724
725pub fn update_cursor_style_for_mode(mode: AppMode) -> std::io::Result<()> {
726    CursorManager::update_for_mode(mode)
727}
728
729pub fn update_default_cursor_style(behavior: DefaultCursorBehavior) -> std::io::Result<()> {
730    match behavior {
731        DefaultCursorBehavior::Hidden => execute!(io::stdout(), crossterm::cursor::Hide),
732        DefaultCursorBehavior::Active { mode } => {
733            execute!(io::stdout(), crossterm::cursor::Show)?;
734            update_cursor_style_for_mode(mode)
735        }
736        DefaultCursorBehavior::InactiveUnderscore => {
737            execute!(io::stdout(), crossterm::cursor::Show)?;
738            update_cursor_style_for_mode(AppMode::Command)
739        }
740    }
741}
742
743pub fn update_cursor_style_for_editor<D>(editor: &FormEditor<D>) -> std::io::Result<()>
744where
745    D: DataProvider,
746{
747    update_cursor_style_for_mode(editor.mode())
748}
749
750pub fn dispatch_key_event<D, O, M>(
751    editor: &mut FormEditor<D>,
752    event: KeyEvent,
753) -> CanvasKeyDispatchOutcome<O, M>
754where
755    D: DataProvider,
756{
757    let before_field = editor.current_field();
758    let before_boundary = key_boundary(editor, &event);
759    let outcome = handle_key_event_for_host(editor, event);
760    host_key_event_outcome(validation_aware_key_event_outcome(
761        editor,
762        before_field,
763        before_boundary,
764        outcome,
765    ))
766}
767
768pub fn host_key_event_outcome<O, M>(
769    outcome: HostKeyEventOutcome,
770) -> CanvasKeyDispatchOutcome<O, M> {
771    match outcome {
772        HostKeyEventOutcome::Consumed(message) => CanvasKeyDispatchOutcome::Consumed(message),
773        HostKeyEventOutcome::PendingSequence => CanvasKeyDispatchOutcome::PendingSequence,
774        HostKeyEventOutcome::NotHandled => CanvasKeyDispatchOutcome::NotHandled,
775        HostKeyEventOutcome::ExitCanvas(boundary) => {
776            CanvasKeyDispatchOutcome::Focus(focus_intent_for_boundary(boundary))
777        }
778    }
779}
780
781fn validation_aware_action_result<D>(
782    editor: &FormEditor<D>,
783    before_field: usize,
784    at_boundary: bool,
785    result: ActionResult,
786) -> ActionResult
787where
788    D: DataProvider,
789{
790    if editor.current_field() == before_field && !at_boundary {
791        if let Some(reason) = editor.last_switch_block() {
792            return ActionResult::Error(reason.to_string());
793        }
794    }
795    result
796}
797
798fn validation_aware_key_event_outcome<D>(
799    editor: &FormEditor<D>,
800    before_field: usize,
801    before_boundary: Option<BoundaryExit>,
802    outcome: HostKeyEventOutcome,
803) -> HostKeyEventOutcome
804where
805    D: DataProvider,
806{
807    if matches!(outcome, HostKeyEventOutcome::ExitCanvas(_))
808        && editor.current_field() == before_field
809        && before_boundary.is_none()
810    {
811        if let Some(reason) = editor.last_switch_block() {
812            return HostKeyEventOutcome::Consumed(Some(reason.to_string()));
813        }
814    }
815    outcome
816}
817
818fn action_boundary<D>(editor: &FormEditor<D>, action: &CanvasAction) -> Option<BoundaryExit>
819where
820    D: DataProvider,
821{
822    match action {
823        CanvasAction::MoveUp | CanvasAction::PrevField if editor.current_field() == 0 => {
824            Some(BoundaryExit::Top)
825        }
826        CanvasAction::MoveDown | CanvasAction::NextField
827            if editor.current_field() >= editor.data_provider().field_count().saturating_sub(1) =>
828        {
829            Some(BoundaryExit::Bottom)
830        }
831        _ => None,
832    }
833}
834
835fn key_boundary<D>(editor: &FormEditor<D>, event: &KeyEvent) -> Option<BoundaryExit>
836where
837    D: DataProvider,
838{
839    match event.code {
840        KeyCode::Up | KeyCode::BackTab if editor.current_field() == 0 => Some(BoundaryExit::Top),
841        KeyCode::Down | KeyCode::Tab
842            if editor.current_field() >= editor.data_provider().field_count().saturating_sub(1) =>
843        {
844            Some(BoundaryExit::Bottom)
845        }
846        _ => None,
847    }
848}
849
850pub fn key_dispatch_status<A, O, M>(
851    outcome: &CanvasKeyDispatchOutcome<O, M>,
852) -> Option<TuiPagesStatus<A>> {
853    match outcome {
854        CanvasKeyDispatchOutcome::Consumed(_) | CanvasKeyDispatchOutcome::Focus(_) => {
855            Some(TuiPagesStatus::ActionHandled)
856        }
857        CanvasKeyDispatchOutcome::PendingSequence => Some(TuiPagesStatus::Waiting(Vec::new())),
858        CanvasKeyDispatchOutcome::NotHandled => None,
859    }
860}
861
862pub fn dispatch_text_input_key<P, O, M>(
863    input: &mut TextInputState<P>,
864    event: KeyEvent,
865) -> CanvasTextWidgetOutcome<O, M>
866where
867    P: TextInputDataProvider,
868{
869    let boundary = text_input_boundary_for_key(&event);
870    match input.input(event) {
871        TextInputEventOutcome::Handled => CanvasTextWidgetOutcome::Handled,
872        TextInputEventOutcome::Submitted => CanvasTextWidgetOutcome::Submitted,
873        TextInputEventOutcome::Ignored => boundary
874            .map(|boundary| CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(boundary)))
875            .unwrap_or(CanvasTextWidgetOutcome::NotHandled),
876    }
877}
878
879pub fn dispatch_text_area_key<P, O, M>(
880    textarea: &mut TextAreaState<P>,
881    event: KeyEvent,
882) -> CanvasTextWidgetOutcome<O, M>
883where
884    P: TextAreaDataProvider,
885{
886    if let Some(boundary) = text_area_boundary_for_key(textarea, &event) {
887        return CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(boundary));
888    }
889
890    if textarea.commandline().is_some() {
891        match textarea.handle_key_event(event) {
892            KeyEventOutcome::Consumed(_) | KeyEventOutcome::Pending => {
893                CanvasTextWidgetOutcome::Handled
894            }
895            KeyEventOutcome::ExitTop => {
896                CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(BoundaryExit::Top))
897            }
898            KeyEventOutcome::ExitBottom => {
899                CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(BoundaryExit::Bottom))
900            }
901            KeyEventOutcome::NotMatched => CanvasTextWidgetOutcome::NotHandled,
902        }
903    } else {
904        match textarea.input(event) {
905            TextAreaEventOutcome::Handled => CanvasTextWidgetOutcome::Handled,
906            TextAreaEventOutcome::Ignored => CanvasTextWidgetOutcome::NotHandled,
907        }
908    }
909}
910
911pub fn text_input_boundary_for_key(event: &KeyEvent) -> Option<BoundaryExit> {
912    if event.kind != KeyEventKind::Press {
913        return None;
914    }
915
916    match (event.code, event.modifiers) {
917        (KeyCode::Up | KeyCode::BackTab, _) => Some(BoundaryExit::Top),
918        (KeyCode::Down, _) => Some(BoundaryExit::Bottom),
919        (KeyCode::Tab, modifiers) if modifiers.is_empty() => Some(BoundaryExit::Bottom),
920        _ => None,
921    }
922}
923
924pub fn text_area_boundary_for_key<P>(
925    textarea: &TextAreaState<P>,
926    event: &KeyEvent,
927) -> Option<BoundaryExit>
928where
929    P: TextAreaDataProvider,
930{
931    if event.kind != KeyEventKind::Press {
932        return None;
933    }
934
935    let current = textarea.current_field();
936    let last = textarea.data_provider().field_count().saturating_sub(1);
937
938    match event.code {
939        KeyCode::Up | KeyCode::BackTab if current == 0 => Some(BoundaryExit::Top),
940        KeyCode::Down if current >= last => Some(BoundaryExit::Bottom),
941        _ => None,
942    }
943}
944
945pub fn bind_default_keymaps<A>(
946    normal: &mut KeyMap<A>,
947    insert: &mut KeyMap<A>,
948    select: &mut KeyMap<A>,
949) where
950    A: From<CanvasAction>,
951{
952    bind_builtin_keymaps(BuiltinCanvasKeybindingPreset::Vim, normal, insert, select);
953}
954
955pub fn bind_builtin_keymaps<A>(
956    preset: BuiltinCanvasKeybindingPreset,
957    normal: &mut KeyMap<A>,
958    insert: &mut KeyMap<A>,
959    select: &mut KeyMap<A>,
960) where
961    A: From<CanvasAction>,
962{
963    bind_builtin_defaults_for_mode(preset, normal, AppMode::Nor);
964    bind_builtin_defaults_for_mode(preset, insert, AppMode::Ins);
965    bind_suggestion_defaults(insert);
966    bind_builtin_defaults_for_mode(preset, select, AppMode::Sel);
967    bind_suggestion_defaults(select);
968}
969
970pub fn bind_normal_defaults<A>(map: &mut KeyMap<A>)
971where
972    A: From<CanvasAction>,
973{
974    bind_builtin_defaults_for_mode(BuiltinCanvasKeybindingPreset::Vim, map, AppMode::Nor);
975}
976
977pub fn bind_insert_defaults<A>(map: &mut KeyMap<A>)
978where
979    A: From<CanvasAction>,
980{
981    bind_builtin_defaults_for_mode(BuiltinCanvasKeybindingPreset::Vim, map, AppMode::Ins);
982    bind_suggestion_defaults(map);
983}
984
985pub fn bind_select_defaults<A>(map: &mut KeyMap<A>)
986where
987    A: From<CanvasAction>,
988{
989    bind_builtin_defaults_for_mode(BuiltinCanvasKeybindingPreset::Vim, map, AppMode::Sel);
990    bind_suggestion_defaults(map);
991}
992
993fn bind_builtin_defaults_for_mode<A>(
994    preset: BuiltinCanvasKeybindingPreset,
995    map: &mut KeyMap<A>,
996    mode: AppMode,
997) where
998    A: From<CanvasAction>,
999{
1000    for binding in default_builtin_action_bindings(preset)
1001        .into_iter()
1002        .filter(|binding| binding.mode == mode)
1003    {
1004        let sequence = binding
1005            .sequence
1006            .into_iter()
1007            .map(|stroke| KeyChord::new(stroke.code, stroke.modifiers))
1008            .collect::<Vec<_>>();
1009        map.bind(sequence, A::from(binding.action));
1010    }
1011}
1012
1013fn normalize_shift(mut key: KeyEvent) -> KeyEvent {
1014    if matches!(key.code, KeyCode::Char(_)) && key.modifiers == KeyModifiers::SHIFT {
1015        key.modifiers = KeyModifiers::NONE;
1016    }
1017    key
1018}
1019
1020fn focused_canvas_field<V, O>(ctx: &ActionContext<V, O>, index: usize) -> bool {
1021    matches!(
1022        ctx.focus.as_ref(),
1023        Some(FocusTarget::CanvasField(field) | FocusTarget::InternalCanvasField(field))
1024            if *field == index
1025    )
1026}
1027
1028fn input_layer_context_for_mode(mode: AppMode) -> InputLayerContext {
1029    if accepts_text_input(mode) {
1030        InputLayerContext::Text
1031    } else {
1032        InputLayerContext::Command
1033    }
1034}
1035
1036fn focus_intent_for_top_level_key<O, M>(key: KeyEvent) -> Option<FocusIntent<O, M>> {
1037    match (key.code, key.modifiers) {
1038        (KeyCode::Down | KeyCode::Tab, _) => Some(FocusIntent::ExitCanvasForward),
1039        (KeyCode::Char('j') | KeyCode::Char('l'), modifiers)
1040            if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT =>
1041        {
1042            Some(FocusIntent::ExitCanvasForward)
1043        }
1044        (KeyCode::Up | KeyCode::BackTab, _) => Some(FocusIntent::ExitCanvasBackward),
1045        (KeyCode::Char('k') | KeyCode::Char('h'), modifiers)
1046            if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT =>
1047        {
1048            Some(FocusIntent::ExitCanvasBackward)
1049        }
1050        _ => None,
1051    }
1052}
1053
1054fn hook_outcome<V, A, O, M>(
1055    status: TuiPagesStatus<A>,
1056    outcome: ActionOutcome<V, O, M>,
1057    routing: KeyHookRouting,
1058) -> Option<KeyHookOutcome<V, A, O, M>> {
1059    Some(KeyHookOutcome {
1060        status,
1061        outcome,
1062        routing,
1063    })
1064}
1065
1066fn hook_focus_outcome<V, A, O, M>(intent: FocusIntent<O, M>) -> Option<KeyHookOutcome<V, A, O, M>> {
1067    hook_outcome(
1068        TuiPagesStatus::ActionHandled,
1069        ActionOutcome::effect(TuiEffect::Focus(intent)),
1070        KeyHookRouting::Handled,
1071    )
1072}
1073
1074fn hook_status_outcome<V, A, O, M>(
1075    status: TuiPagesStatus<A>,
1076) -> Option<KeyHookOutcome<V, A, O, M>> {
1077    hook_outcome(status, ActionOutcome::none(), KeyHookRouting::Handled)
1078}
1079
1080fn hook_pending_outcome<V, A, O, M>(
1081    status: TuiPagesStatus<A>,
1082) -> Option<KeyHookOutcome<V, A, O, M>> {
1083    hook_outcome(status, ActionOutcome::none(), KeyHookRouting::Pending)
1084}
1085
1086fn refresh_textinput_suggestion_suffix<S>(state: &mut S, focus_index: usize)
1087where
1088    S: CanvasWidgetState + ?Sized,
1089{
1090    let Some(text) = state
1091        .canvas_textinput(focus_index)
1092        .map(|input| input.text())
1093    else {
1094        return;
1095    };
1096    let suffix = state.canvas_textinput_suggestion_suffix(focus_index, &text);
1097    let Some(input) = state.canvas_textinput(focus_index) else {
1098        return;
1099    };
1100    if let Some(suffix) = suffix {
1101        input.set_suggestion_suffix(suffix);
1102    } else {
1103        input.clear_suggestion_suffix();
1104    }
1105}
1106
1107pub(crate) fn canvas_hook_context<V, S, O>(
1108    kind: &KeyHookKind,
1109    ctx: &ActionContext<V, O>,
1110    state: &S,
1111) -> Option<InputLayerContext>
1112where
1113    S: CanvasWidgetState + ?Sized,
1114{
1115    match kind {
1116        KeyHookKind::CanvasFormEditor { id, .. } => {
1117            if !ctx.focus.as_ref().is_some_and(FocusTarget::is_canvas) {
1118                return None;
1119            }
1120            state
1121                .canvas_form_editor_ref(*id)
1122                .map(|editor| input_layer_context_for_mode(editor.mode()))
1123        }
1124        KeyHookKind::CanvasTextArea { focus_index, .. } => {
1125            if !focused_canvas_field(ctx, *focus_index) {
1126                return None;
1127            }
1128            if !state
1129                .canvas_textarea_entered_ref(*focus_index)
1130                .is_some_and(|entered| *entered)
1131            {
1132                return Some(InputLayerContext::Command);
1133            }
1134            state
1135                .canvas_textarea_ref(*focus_index)
1136                .map(|textarea| input_layer_context_for_mode(textarea.mode()))
1137        }
1138        KeyHookKind::CanvasTextInput { focus_index, .. } => {
1139            if !focused_canvas_field(ctx, *focus_index) {
1140                return None;
1141            }
1142            if !state
1143                .canvas_textinput_entered_ref(*focus_index)
1144                .is_some_and(|entered| *entered)
1145            {
1146                return Some(InputLayerContext::Command);
1147            }
1148            state
1149                .canvas_textinput_ref(*focus_index)
1150                .map(|input| input_layer_context_for_mode(input.mode()))
1151        }
1152    }
1153}
1154
1155pub(crate) fn canvas_hook_cursor_behavior<V, S, O>(
1156    kind: &KeyHookKind,
1157    ctx: &ActionContext<V, O>,
1158    state: &S,
1159) -> Option<DefaultCursorBehavior>
1160where
1161    S: CanvasWidgetState + ?Sized,
1162{
1163    match kind {
1164        KeyHookKind::CanvasFormEditor { id, .. } => {
1165            let editor = state.canvas_form_editor_ref(*id)?;
1166            if ctx.focus.as_ref().is_some_and(FocusTarget::is_canvas) {
1167                Some(DefaultCursorBehavior::Active {
1168                    mode: editor.mode(),
1169                })
1170            } else {
1171                Some(DefaultCursorBehavior::InactiveUnderscore)
1172            }
1173        }
1174        KeyHookKind::CanvasTextArea { focus_index, .. } => {
1175            let textarea = state.canvas_textarea_ref(*focus_index)?;
1176            if focused_canvas_field(ctx, *focus_index)
1177                && state
1178                    .canvas_textarea_entered_ref(*focus_index)
1179                    .is_some_and(|entered| *entered)
1180            {
1181                Some(DefaultCursorBehavior::Active {
1182                    mode: textarea.mode(),
1183                })
1184            } else {
1185                Some(DefaultCursorBehavior::InactiveUnderscore)
1186            }
1187        }
1188        KeyHookKind::CanvasTextInput { focus_index, .. } => {
1189            let input = state.canvas_textinput_ref(*focus_index)?;
1190            if focused_canvas_field(ctx, *focus_index)
1191                && state
1192                    .canvas_textinput_entered_ref(*focus_index)
1193                    .is_some_and(|entered| *entered)
1194            {
1195                Some(DefaultCursorBehavior::Active { mode: input.mode() })
1196            } else {
1197                Some(DefaultCursorBehavior::InactiveUnderscore)
1198            }
1199        }
1200    }
1201}
1202
1203fn canvas_profile_generation_and_bindings(
1204    profile: &crate::runtime::CanvasKeybindingProfileHandle,
1205) -> (u64, CanvasKeyBindings) {
1206    let profile = profile.borrow();
1207    (profile.generation, profile.profile.current().clone())
1208}
1209
1210fn install_form_editor_profile(
1211    editor: &mut dyn CanvasFormEditorHost,
1212    profile: &crate::runtime::CanvasKeybindingProfileHandle,
1213    installed_generation: &mut Option<u64>,
1214) {
1215    let (generation, bindings) = canvas_profile_generation_and_bindings(profile);
1216    if *installed_generation != Some(generation) || !editor.has_keybindings() {
1217        editor.install_keybindings(bindings);
1218        *installed_generation = Some(generation);
1219    }
1220}
1221
1222fn install_textarea_profile(
1223    textarea: &mut dyn CanvasTextAreaHost,
1224    profile: &crate::runtime::CanvasKeybindingProfileHandle,
1225    installed_generation: &mut Option<u64>,
1226) {
1227    let (generation, bindings) = canvas_profile_generation_and_bindings(profile);
1228    if *installed_generation != Some(generation) || !textarea.has_keybindings() {
1229        textarea.install_keybindings(bindings);
1230        *installed_generation = Some(generation);
1231    }
1232}
1233
1234fn install_textinput_profile(
1235    input: &mut dyn CanvasTextInputHost,
1236    profile: &crate::runtime::CanvasKeybindingProfileHandle,
1237    installed_generation: &mut Option<u64>,
1238) {
1239    let (generation, bindings) = canvas_profile_generation_and_bindings(profile);
1240    if *installed_generation != Some(generation) || !input.has_keybindings() {
1241        input.install_keybindings(bindings);
1242        *installed_generation = Some(generation);
1243    }
1244}
1245
1246pub(crate) fn dispatch_canvas_key_hook<V, A, S, O, M>(
1247    kind: &mut KeyHookKind,
1248    key: KeyEvent,
1249    ctx: ActionContext<V, O>,
1250    state: &mut S,
1251) -> Option<KeyHookOutcome<V, A, O, M>>
1252where
1253    S: CanvasWidgetState + ?Sized,
1254{
1255    match kind {
1256        KeyHookKind::CanvasFormEditor {
1257            id,
1258            profile,
1259            installed_generation,
1260        } => {
1261            if !ctx.focus.as_ref().is_some_and(FocusTarget::is_canvas) {
1262                return None;
1263            }
1264
1265            let editor = state.canvas_form_editor(*id)?;
1266            install_form_editor_profile(editor, profile, installed_generation);
1267
1268            let outcome = editor.input_key(normalize_shift(key));
1269            // A consumed key that left the editor mid-sequence (operator/count
1270            // awaiting more input) is reported as Waiting so the orchestrator
1271            // keeps this hook as the sticky owner of the keys that follow.
1272            let pending = state.canvas_form_editor(*id)?.is_sequence_pending();
1273            match outcome {
1274                CanvasKeyDispatchOutcome::Consumed(_) if pending => {
1275                    hook_pending_outcome(TuiPagesStatus::Waiting(Vec::new()))
1276                }
1277                CanvasKeyDispatchOutcome::Consumed(_) => {
1278                    hook_status_outcome(TuiPagesStatus::ActionHandled)
1279                }
1280                CanvasKeyDispatchOutcome::PendingSequence => {
1281                    hook_pending_outcome(TuiPagesStatus::Waiting(Vec::new()))
1282                }
1283                CanvasKeyDispatchOutcome::NotHandled => None,
1284                CanvasKeyDispatchOutcome::Focus(FocusIntent::ExitCanvasForward) => {
1285                    hook_focus_outcome(FocusIntent::ExitCanvasForward)
1286                }
1287                CanvasKeyDispatchOutcome::Focus(FocusIntent::ExitCanvasBackward) => {
1288                    hook_focus_outcome(FocusIntent::ExitCanvasBackward)
1289                }
1290                CanvasKeyDispatchOutcome::Focus(_) => {
1291                    hook_status_outcome(TuiPagesStatus::ActionHandled)
1292                }
1293            }
1294        }
1295        KeyHookKind::CanvasTextArea {
1296            focus_index,
1297            profile,
1298            installed_generation,
1299        } => {
1300            if !focused_canvas_field(&ctx, *focus_index) {
1301                if let Some(entered) = state.canvas_textarea_entered(*focus_index) {
1302                    *entered = false;
1303                }
1304                return None;
1305            }
1306
1307            let is_entered = state
1308                .canvas_textarea_entered(*focus_index)
1309                .is_some_and(|entered| *entered);
1310            if !is_entered {
1311                if key.kind != KeyEventKind::Press {
1312                    return None;
1313                }
1314                if matches!(key.code, KeyCode::Enter) {
1315                    if let Some(entered) = state.canvas_textarea_entered(*focus_index) {
1316                        *entered = true;
1317                    }
1318                    if let Some(textarea) = state.canvas_textarea(*focus_index) {
1319                        install_textarea_profile(textarea, profile, installed_generation);
1320                        textarea.exit_edit_mode();
1321                    }
1322                    return hook_status_outcome(TuiPagesStatus::ActionHandled);
1323                }
1324                return focus_intent_for_top_level_key(key).and_then(hook_focus_outcome);
1325            }
1326
1327            if let Some(textarea) = state.canvas_textarea(*focus_index) {
1328                install_textarea_profile(textarea, profile, installed_generation);
1329            }
1330
1331            let mode = state.canvas_textarea(*focus_index)?.mode();
1332            if mode == AppMode::Ins {
1333                return match (key.code, key.modifiers) {
1334                    (KeyCode::Char('c'), KeyModifiers::CONTROL) => None,
1335                    (KeyCode::Esc, _) => {
1336                        state.canvas_textarea(*focus_index)?.exit_edit_mode();
1337                        hook_status_outcome(TuiPagesStatus::ActionHandled)
1338                    }
1339                    _ => match state
1340                        .canvas_textarea(*focus_index)?
1341                        .input_key(normalize_shift(key))
1342                    {
1343                        TextAreaEventOutcome::Handled => {
1344                            hook_status_outcome(TuiPagesStatus::TextHandled)
1345                        }
1346                        TextAreaEventOutcome::Ignored => None,
1347                    },
1348                };
1349            }
1350
1351            if matches!(
1352                (key.code, key.modifiers),
1353                (KeyCode::Char('g'), KeyModifiers::CONTROL)
1354            ) && key.kind == KeyEventKind::Press
1355            {
1356                if let Some(entered) = state.canvas_textarea_entered(*focus_index) {
1357                    *entered = false;
1358                }
1359                return hook_focus_outcome(FocusIntent::ExitCanvasForward);
1360            }
1361
1362            if let Some(boundary) = state.canvas_textarea(*focus_index)?.boundary_for_key(&key) {
1363                return hook_focus_outcome(focus_intent_for_boundary(boundary));
1364            }
1365
1366            let outcome = state
1367                .canvas_textarea(*focus_index)?
1368                .input_key(normalize_shift(key));
1369            // A consumed key that left the editor mid-sequence (operator/count or
1370            // an `f`/`r` literal-char capture) is reported as Waiting so the
1371            // orchestrator keeps this hook as the sticky owner of the next keys.
1372            // A key the widget engine ignores is handed back (None) so the
1373            // orchestrator falls through to the keymap — the widget engine is the
1374            // single decoder for canvas keys.
1375            let pending = state.canvas_textarea(*focus_index)?.is_sequence_pending();
1376            match outcome {
1377                TextAreaEventOutcome::Handled if pending => {
1378                    hook_pending_outcome(TuiPagesStatus::Waiting(Vec::new()))
1379                }
1380                TextAreaEventOutcome::Handled => hook_status_outcome(TuiPagesStatus::TextHandled),
1381                TextAreaEventOutcome::Ignored => None,
1382            }
1383        }
1384        KeyHookKind::CanvasTextInput {
1385            focus_index,
1386            profile,
1387            installed_generation,
1388        } => {
1389            if !focused_canvas_field(&ctx, *focus_index) {
1390                if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
1391                    *entered = false;
1392                }
1393                return None;
1394            }
1395
1396            let is_entered = state
1397                .canvas_textinput_entered(*focus_index)
1398                .is_some_and(|entered| *entered);
1399            if !is_entered {
1400                if key.kind != KeyEventKind::Press {
1401                    return None;
1402                }
1403                if matches!(key.code, KeyCode::Enter) {
1404                    if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
1405                        *entered = true;
1406                    }
1407                    return hook_status_outcome(TuiPagesStatus::ActionHandled);
1408                }
1409                return focus_intent_for_top_level_key(key).and_then(hook_focus_outcome);
1410            }
1411
1412            if let Some(input) = state.canvas_textinput(*focus_index) {
1413                install_textinput_profile(input, profile, installed_generation);
1414            }
1415
1416            // Ctrl+C is never owned by the input — let the global keymap quit.
1417            if matches!(
1418                (key.code, key.modifiers),
1419                (KeyCode::Char('c'), KeyModifiers::CONTROL)
1420            ) {
1421                return None;
1422            }
1423
1424            // Modal Esc: from INSERT drop to NORMAL (stay in the field); from
1425            // NORMAL leave the field back to the surrounding focus order.
1426            if matches!(key.code, KeyCode::Esc) && key.kind == KeyEventKind::Press {
1427                if state.canvas_textinput(*focus_index)?.mode() == AppMode::Ins {
1428                    state.canvas_textinput(*focus_index)?.exit_edit_mode();
1429                } else if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
1430                    *entered = false;
1431                }
1432                return hook_status_outcome(TuiPagesStatus::ActionHandled);
1433            }
1434
1435            // Enter submits the single-row input and hands focus forward — the
1436            // input has no second line to open, so Enter is always "done".
1437            if matches!(key.code, KeyCode::Enter) && key.kind == KeyEventKind::Press {
1438                if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
1439                    *entered = false;
1440                }
1441                return hook_focus_outcome(FocusIntent::ExitCanvasForward);
1442            }
1443
1444            // Tab accepts the inline ghost suffix when one is present; otherwise
1445            // it falls through to the form engine, which treats it as field
1446            // navigation and exits the single-row input.
1447            if matches!(
1448                (key.code, key.modifiers),
1449                (KeyCode::Tab, KeyModifiers::NONE)
1450            ) && key.kind == KeyEventKind::Press
1451                && state
1452                    .canvas_textinput(*focus_index)?
1453                    .accept_suggestion_suffix()
1454            {
1455                refresh_textinput_suggestion_suffix(state, *focus_index);
1456                return hook_status_outcome(TuiPagesStatus::TextHandled);
1457            }
1458
1459            // Decode every other key through the inner form's keybinding engine —
1460            // the single decoder, exactly as the form editor does.
1461            let outcome = state
1462                .canvas_textinput(*focus_index)?
1463                .input_key(normalize_shift(key));
1464            let pending = state.canvas_textinput(*focus_index)?.is_sequence_pending();
1465            match outcome {
1466                CanvasKeyDispatchOutcome::Consumed(_) if pending => {
1467                    hook_pending_outcome(TuiPagesStatus::Waiting(Vec::new()))
1468                }
1469                CanvasKeyDispatchOutcome::Consumed(_) => {
1470                    refresh_textinput_suggestion_suffix(state, *focus_index);
1471                    hook_status_outcome(TuiPagesStatus::TextHandled)
1472                }
1473                CanvasKeyDispatchOutcome::PendingSequence => {
1474                    hook_pending_outcome(TuiPagesStatus::Waiting(Vec::new()))
1475                }
1476                CanvasKeyDispatchOutcome::NotHandled => None,
1477                CanvasKeyDispatchOutcome::Focus(FocusIntent::ExitCanvasBackward) => {
1478                    if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
1479                        *entered = false;
1480                    }
1481                    hook_focus_outcome(FocusIntent::ExitCanvasBackward)
1482                }
1483                CanvasKeyDispatchOutcome::Focus(FocusIntent::ExitCanvasForward) => {
1484                    if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
1485                        *entered = false;
1486                    }
1487                    hook_focus_outcome(FocusIntent::ExitCanvasForward)
1488                }
1489                CanvasKeyDispatchOutcome::Focus(_) => {
1490                    hook_status_outcome(TuiPagesStatus::ActionHandled)
1491                }
1492            }
1493        }
1494    }
1495}
1496
1497/// Forward a bracketed-paste payload to whichever canvas widget this hook owns,
1498/// but only when that widget currently holds focus (and, for the text widgets,
1499/// has been entered for editing). Mirrors the focus gating in
1500/// [`dispatch_canvas_key_hook`] so paste routes exactly like typed input.
1501pub(crate) fn dispatch_canvas_paste_hook<V, A, S, O, M>(
1502    kind: &mut KeyHookKind,
1503    text: &str,
1504    ctx: ActionContext<V, O>,
1505    state: &mut S,
1506) -> Option<KeyHookOutcome<V, A, O, M>>
1507where
1508    S: CanvasWidgetState + ?Sized,
1509{
1510    let handled = match kind {
1511        KeyHookKind::CanvasFormEditor { id, .. } => {
1512            if !ctx.focus.as_ref().is_some_and(FocusTarget::is_canvas) {
1513                return None;
1514            }
1515            state.canvas_form_editor(*id)?.paste(text)
1516        }
1517        KeyHookKind::CanvasTextArea { focus_index, .. } => {
1518            if !focused_canvas_field(&ctx, *focus_index)
1519                || !state
1520                    .canvas_textarea_entered(*focus_index)
1521                    .is_some_and(|entered| *entered)
1522            {
1523                return None;
1524            }
1525            state.canvas_textarea(*focus_index)?.paste(text)
1526        }
1527        KeyHookKind::CanvasTextInput { focus_index, .. } => {
1528            if !focused_canvas_field(&ctx, *focus_index)
1529                || !state
1530                    .canvas_textinput_entered(*focus_index)
1531                    .is_some_and(|entered| *entered)
1532            {
1533                return None;
1534            }
1535            state.canvas_textinput(*focus_index)?.paste(text)
1536        }
1537    };
1538
1539    if handled {
1540        hook_status_outcome(TuiPagesStatus::TextHandled)
1541    } else {
1542        None
1543    }
1544}
1545
1546impl<O> PageSpec<O> {
1547    pub fn canvas_mode(mut self, mode: AppMode) -> Self {
1548        self.modes = modes_for_app_mode(mode);
1549        self.accepts_text_input = accepts_text_input(mode);
1550        self
1551    }
1552
1553    pub fn canvas_editor<D>(self, editor: &FormEditor<D>) -> Self
1554    where
1555        D: DataProvider,
1556    {
1557        self.canvas_mode(editor.mode())
1558    }
1559}
1560
1561impl<V, A, O, M, Pages, Handler, Hooks> TuiPagesBuilder<V, A, O, M, Pages, Handler, Hooks>
1562where
1563    A: From<CanvasAction>,
1564{
1565    pub fn canvas_defaults(self) -> Self {
1566        self.canvas_keybindings().canvas_text_actions()
1567    }
1568
1569    pub fn canvas_keybindings(mut self) -> Self {
1570        bind_normal_defaults(self.input_registry.map_mut(modes::NORMAL.as_str()));
1571        bind_insert_defaults(self.input_registry.map_mut(modes::INSERT.as_str()));
1572        bind_select_defaults(self.input_registry.map_mut(modes::SELECT.as_str()));
1573        self
1574    }
1575
1576    pub fn canvas_text_actions(mut self) -> Self {
1577        self.text_input_mapper = Some(text_chord_to_action::<A>);
1578        self
1579    }
1580}
1581
1582impl<V, A, O, M, Pages, Handler, Hooks> TuiPagesBuilder<V, A, O, M, Pages, Handler, Hooks> {
1583    pub fn canvas_form_editor(
1584        self,
1585        id: usize,
1586    ) -> TuiPagesBuilder<V, A, O, M, Pages, Handler, CanvasHooks> {
1587        self.canvas_form_editor_with_preset(id, BuiltinCanvasKeybindingPreset::Vim)
1588    }
1589
1590    pub fn canvas_form_editor_with_preset(
1591        mut self,
1592        id: usize,
1593        preset: BuiltinCanvasKeybindingPreset,
1594    ) -> TuiPagesBuilder<V, A, O, M, Pages, Handler, CanvasHooks> {
1595        seed_canvas_profile_if_unconfigured(&self.canvas_keybinding_profile, preset);
1596        self.key_hooks.push(KeyHook {
1597            kind: KeyHookKind::CanvasFormEditor {
1598                id,
1599                profile: self.canvas_keybinding_profile.clone(),
1600                installed_generation: None,
1601            },
1602        });
1603        self.into_canvas_hooks()
1604    }
1605
1606    pub fn canvas_textarea_widget(
1607        self,
1608        focus_index: usize,
1609    ) -> TuiPagesBuilder<V, A, O, M, Pages, Handler, CanvasHooks> {
1610        self.canvas_textarea_widget_with_preset(focus_index, BuiltinCanvasKeybindingPreset::Vim)
1611    }
1612
1613    pub fn canvas_textarea_widget_with_preset(
1614        mut self,
1615        focus_index: usize,
1616        preset: BuiltinCanvasKeybindingPreset,
1617    ) -> TuiPagesBuilder<V, A, O, M, Pages, Handler, CanvasHooks> {
1618        seed_canvas_profile_if_unconfigured(&self.canvas_keybinding_profile, preset);
1619        self.key_hooks.push(KeyHook {
1620            kind: KeyHookKind::CanvasTextArea {
1621                focus_index,
1622                profile: self.canvas_keybinding_profile.clone(),
1623                installed_generation: None,
1624            },
1625        });
1626        self.into_canvas_hooks()
1627    }
1628
1629    pub fn canvas_textinput_widget(
1630        self,
1631        focus_index: usize,
1632    ) -> TuiPagesBuilder<V, A, O, M, Pages, Handler, CanvasHooks> {
1633        self.canvas_textinput_widget_with_preset(focus_index, BuiltinCanvasKeybindingPreset::Vim)
1634    }
1635
1636    pub fn canvas_textinput_widget_with_preset(
1637        mut self,
1638        focus_index: usize,
1639        preset: BuiltinCanvasKeybindingPreset,
1640    ) -> TuiPagesBuilder<V, A, O, M, Pages, Handler, CanvasHooks> {
1641        seed_canvas_profile_if_unconfigured(&self.canvas_keybinding_profile, preset);
1642        self.key_hooks.push(KeyHook {
1643            kind: KeyHookKind::CanvasTextInput {
1644                focus_index,
1645                profile: self.canvas_keybinding_profile.clone(),
1646                installed_generation: None,
1647            },
1648        });
1649        self.into_canvas_hooks()
1650    }
1651
1652    fn into_canvas_hooks(self) -> TuiPagesBuilder<V, A, O, M, Pages, Handler, CanvasHooks> {
1653        TuiPagesBuilder {
1654            initial_view: self.initial_view,
1655            fallback_view: self.fallback_view,
1656            input_registry: self.input_registry,
1657            command_registry: self.command_registry,
1658            input_timeout_ms: self.input_timeout_ms,
1659            command_timeout_ms: self.command_timeout_ms,
1660            focus_wrap: self.focus_wrap,
1661            reserve_command_line: self.reserve_command_line,
1662            text_input_mapper: self.text_input_mapper,
1663            key_hooks: self.key_hooks,
1664            keybinding_store: self.keybinding_store,
1665            keybinding_report: self.keybinding_report,
1666            keybinding_inheritances: self.keybinding_inheritances,
1667            action_registry: self.action_registry,
1668            canvas_keybinding_profile: self.canvas_keybinding_profile,
1669            pages: self.pages,
1670            handler: self.handler,
1671            _overlay: PhantomData,
1672            _modal: PhantomData,
1673            _hooks: PhantomData,
1674        }
1675    }
1676}
1677
1678fn bind_suggestion_defaults<A>(map: &mut KeyMap<A>)
1679where
1680    A: From<CanvasAction>,
1681{
1682    bind_key_with_modifiers(
1683        map,
1684        KeyCode::Char(' '),
1685        KeyModifiers::CONTROL,
1686        CanvasAction::TriggerSuggestions,
1687    );
1688    bind_key_with_modifiers(
1689        map,
1690        KeyCode::Char('n'),
1691        KeyModifiers::CONTROL,
1692        CanvasAction::SuggestionDown,
1693    );
1694    bind_key_with_modifiers(
1695        map,
1696        KeyCode::Char('p'),
1697        KeyModifiers::CONTROL,
1698        CanvasAction::SuggestionUp,
1699    );
1700    bind_key_with_modifiers(
1701        map,
1702        KeyCode::Char('y'),
1703        KeyModifiers::CONTROL,
1704        CanvasAction::SelectSuggestion,
1705    );
1706    bind_key_with_modifiers(
1707        map,
1708        KeyCode::Char('g'),
1709        KeyModifiers::CONTROL,
1710        CanvasAction::ExitSuggestions,
1711    );
1712}
1713
1714fn bind_key_with_modifiers<A>(
1715    map: &mut KeyMap<A>,
1716    code: KeyCode,
1717    modifiers: KeyModifiers,
1718    action: CanvasAction,
1719) where
1720    A: From<CanvasAction>,
1721{
1722    map.bind(vec![KeyChord::new(code, modifiers)], A::from(action));
1723}
1724
1725// --- Binding introspection (catalogs, bindable-action metadata, overlap) ---
1726//
1727// These helpers expose the canvas keybinding *defaults* as a source-tagged
1728// [`BindingCatalog`], independent of whether they were mirrored into the global
1729// keymap. They feed help screens, `:bindings` panels, and conflict diagnostics;
1730// none of them are on the input hot path.
1731
1732/// Modes a non-suggestion canvas action can be bound in.
1733const CANVAS_EDIT_MODES: &[&str] = &["nor", "ins", "sel"];
1734/// Modes the suggestion-dropdown actions are bound in.
1735const CANVAS_SUGGESTION_MODES: &[&str] = &["ins", "sel"];
1736
1737/// The config-facing name for a [`CanvasAction`], or `None` for the
1738/// parameterized variants ([`CanvasAction::InsertChar`],
1739/// [`CanvasAction::Custom`]) that can't be named statically.
1740pub fn canvas_action_name(action: &CanvasAction) -> Option<&'static str> {
1741    Some(match action {
1742        CanvasAction::MoveLeft => "move_left",
1743        CanvasAction::MoveRight => "move_right",
1744        CanvasAction::MoveUp => "move_up",
1745        CanvasAction::MoveDown => "move_down",
1746        CanvasAction::MoveWordNext => "move_word_next",
1747        CanvasAction::MoveWordPrev => "move_word_prev",
1748        CanvasAction::MoveWordEnd => "move_word_end",
1749        CanvasAction::MoveWordEndPrev => "move_word_end_prev",
1750        CanvasAction::MoveBigWordNext => "move_big_word_next",
1751        CanvasAction::MoveBigWordPrev => "move_big_word_prev",
1752        CanvasAction::MoveBigWordEnd => "move_big_word_end",
1753        CanvasAction::MoveBigWordEndPrev => "move_big_word_end_prev",
1754        CanvasAction::MoveLineStart => "move_line_start",
1755        CanvasAction::MoveLineEnd => "move_line_end",
1756        CanvasAction::NextField => "next_field",
1757        CanvasAction::PrevField => "prev_field",
1758        CanvasAction::MoveFirstLine => "move_first_line",
1759        CanvasAction::MoveLastLine => "move_last_line",
1760        CanvasAction::DeleteBackward => "delete_char_backward",
1761        CanvasAction::DeleteForward => "delete_char_forward",
1762        CanvasAction::Undo => "undo",
1763        CanvasAction::Redo => "redo",
1764        CanvasAction::TriggerSuggestions => "trigger_suggestions",
1765        CanvasAction::SuggestionUp => "suggestion_up",
1766        CanvasAction::SuggestionDown => "suggestion_down",
1767        CanvasAction::SelectSuggestion => "select_suggestion",
1768        CanvasAction::ExitSuggestions => "exit_suggestions",
1769        CanvasAction::EnterEditMode => "enter_edit_mode_before",
1770        CanvasAction::EnterEditModeAfter => "enter_edit_mode_after",
1771        CanvasAction::ExitEditMode => "exit_edit_mode",
1772        CanvasAction::EnterHighlightMode => "enter_highlight_mode",
1773        CanvasAction::EnterHighlightModeLinewise => "enter_highlight_mode_linewise",
1774        CanvasAction::ExitHighlightMode => "exit_highlight_mode",
1775        CanvasAction::OpenLineBelow => "open_line_below",
1776        CanvasAction::OpenLineAbove => "open_line_above",
1777        CanvasAction::InsertChar(_) | CanvasAction::Custom(_) => return None,
1778        // `CanvasAction` is `#[non_exhaustive]`; unknown future variants have no
1779        // stable name yet.
1780        _ => return None,
1781    })
1782}
1783
1784fn is_suggestion_action(action: &CanvasAction) -> bool {
1785    matches!(
1786        action,
1787        CanvasAction::TriggerSuggestions
1788            | CanvasAction::SuggestionUp
1789            | CanvasAction::SuggestionDown
1790            | CanvasAction::SelectSuggestion
1791            | CanvasAction::ExitSuggestions
1792    )
1793}
1794
1795/// The canvas actions that can be rebound, as [`BindableActionInfo`] lifted into
1796/// the application action type `A`. Suggestion actions are reported as bindable
1797/// in the text modes (`ins`/`sel`); the rest in all canvas modes.
1798pub fn canvas_bindable_actions<A>() -> Vec<BindableActionInfo<A>>
1799where
1800    A: From<CanvasAction>,
1801{
1802    let mut actions = CanvasAction::movement_actions();
1803    actions.extend([CanvasAction::DeleteBackward, CanvasAction::DeleteForward]);
1804    actions.extend([CanvasAction::Undo, CanvasAction::Redo]);
1805    actions.extend(CanvasAction::suggestions_actions());
1806    actions.extend([
1807        CanvasAction::EnterEditMode,
1808        CanvasAction::EnterEditModeAfter,
1809        CanvasAction::ExitEditMode,
1810        CanvasAction::EnterHighlightMode,
1811        CanvasAction::EnterHighlightModeLinewise,
1812        CanvasAction::ExitHighlightMode,
1813        CanvasAction::OpenLineBelow,
1814        CanvasAction::OpenLineAbove,
1815    ]);
1816
1817    actions
1818        .into_iter()
1819        .filter_map(|action| {
1820            let name = canvas_action_name(&action)?;
1821            let modes = if is_suggestion_action(&action) {
1822                CANVAS_SUGGESTION_MODES
1823            } else {
1824                CANVAS_EDIT_MODES
1825            };
1826            Some(BindableActionInfo {
1827                description: action.description(),
1828                action: A::from(action),
1829                name,
1830                modes,
1831            })
1832        })
1833        .collect()
1834}
1835
1836fn key_strokes_to_chords(sequence: &[KeyStroke]) -> Vec<KeyChord> {
1837    sequence
1838        .iter()
1839        .map(|stroke| KeyChord::new(stroke.code, stroke.modifiers))
1840        .collect()
1841}
1842
1843/// The canvas editing defaults for `preset` as a source-tagged catalog, *without*
1844/// requiring them to be mirrored into the global keymap. This lets a UI show
1845/// "canvas binds `u` to undo in normal mode" even though the runtime keeps those
1846/// bindings inside the canvas layer.
1847///
1848/// The suggestion-dropdown defaults installed by [`canvas_keybindings`] are
1849/// reported separately — see [`canvas_suggestion_default_bindings`].
1850pub fn canvas_default_binding_catalog<A>(preset: BuiltinCanvasKeybindingPreset) -> BindingCatalog<A>
1851where
1852    A: From<CanvasAction>,
1853{
1854    let bindings = default_builtin_action_bindings(preset)
1855        .into_iter()
1856        .map(|binding| BindingInfo {
1857            layer: BindingLayer::Canvas,
1858            mode: mode_for_app_mode(binding.mode).as_str().to_string(),
1859            sequence: key_strokes_to_chords(&binding.sequence),
1860            action: A::from(binding.action),
1861            source: BindingSource::CanvasBuiltin,
1862        })
1863        .collect();
1864    BindingCatalog { bindings }
1865}
1866
1867/// The hard-coded suggestion-dropdown bindings ([`bind_suggestion_defaults`]):
1868/// `Ctrl+Space` trigger, `Ctrl+N`/`Ctrl+P` down/up, `Ctrl+Y` accept, `Ctrl+G`
1869/// exit — reported for the `ins` and `sel` modes they are installed into.
1870pub fn canvas_suggestion_default_bindings<A>() -> Vec<BindingInfo<A>>
1871where
1872    A: From<CanvasAction>,
1873{
1874    let defaults = [
1875        (KeyCode::Char(' '), CanvasAction::TriggerSuggestions),
1876        (KeyCode::Char('n'), CanvasAction::SuggestionDown),
1877        (KeyCode::Char('p'), CanvasAction::SuggestionUp),
1878        (KeyCode::Char('y'), CanvasAction::SelectSuggestion),
1879        (KeyCode::Char('g'), CanvasAction::ExitSuggestions),
1880    ];
1881    let mut bindings = Vec::new();
1882    for mode in CANVAS_SUGGESTION_MODES {
1883        for (code, action) in &defaults {
1884            bindings.push(BindingInfo {
1885                layer: BindingLayer::Canvas,
1886                mode: (*mode).to_string(),
1887                sequence: vec![KeyChord::new(*code, KeyModifiers::CONTROL)],
1888                action: A::from(action.clone()),
1889                source: BindingSource::CanvasBuiltin,
1890            });
1891        }
1892    }
1893    bindings
1894}
1895
1896/// Report keymap/canvas sequence overlaps as [`BindingConflict::CanvasOverlap`].
1897///
1898/// When a keymap binding and a canvas binding share a sequence in the same mode,
1899/// the routing for `context` decides which layer the runtime consults first:
1900/// `Command`-context keys go to the keymap first ([`CanvasRoutingPrecedence::KeymapFirst`]),
1901/// `Text`-context keys go to the canvas layer first
1902/// ([`CanvasRoutingPrecedence::CanvasFirst`]). These are informational — the
1903/// routing is deterministic — but surface surprises like a config binding that
1904/// will never fire while editing text.
1905pub fn analyze_canvas_overlaps<A>(
1906    keymap_catalog: &BindingCatalog<A>,
1907    canvas_catalog: &BindingCatalog<CanvasAction>,
1908    context: InputLayerContext,
1909) -> Vec<BindingConflict<A>>
1910where
1911    A: Clone,
1912{
1913    let routing = match context {
1914        InputLayerContext::Command => CanvasRoutingPrecedence::KeymapFirst,
1915        InputLayerContext::Text => CanvasRoutingPrecedence::CanvasFirst,
1916    };
1917
1918    let mut conflicts = Vec::new();
1919    for keymap_binding in &keymap_catalog.bindings {
1920        if keymap_binding.layer != BindingLayer::Keymap {
1921            continue;
1922        }
1923        for canvas_binding in &canvas_catalog.bindings {
1924            if canvas_binding.mode == keymap_binding.mode
1925                && canvas_binding.sequence == keymap_binding.sequence
1926            {
1927                conflicts.push(BindingConflict::CanvasOverlap {
1928                    mode: keymap_binding.mode.clone(),
1929                    sequence: keymap_binding.sequence.clone(),
1930                    keymap_action: keymap_binding.action.clone(),
1931                    canvas_action: canvas_binding.action.clone(),
1932                    routing,
1933                });
1934            }
1935        }
1936    }
1937    conflicts
1938}
1939
1940#[cfg(test)]
1941mod report_tests {
1942    use super::*;
1943    use crate::input::InputRegistry;
1944
1945    #[derive(Debug, Clone, PartialEq, Eq)]
1946    enum AppAction {
1947        Canvas(CanvasAction),
1948    }
1949
1950    impl From<CanvasAction> for AppAction {
1951        fn from(action: CanvasAction) -> Self {
1952            AppAction::Canvas(action)
1953        }
1954    }
1955
1956    #[test]
1957    fn default_catalog_carries_canvas_layer_and_source() {
1958        let catalog: BindingCatalog<AppAction> =
1959            canvas_default_binding_catalog(BuiltinCanvasKeybindingPreset::Vim);
1960        assert!(!catalog.bindings.is_empty());
1961        assert!(catalog.bindings.iter().all(|b| {
1962            b.layer == BindingLayer::Canvas && b.source == BindingSource::CanvasBuiltin
1963        }));
1964    }
1965
1966    #[test]
1967    fn suggestion_defaults_cover_ins_and_sel() {
1968        let bindings: Vec<BindingInfo<AppAction>> = canvas_suggestion_default_bindings();
1969        assert_eq!(bindings.len(), 10);
1970        assert!(bindings.iter().any(|b| b.mode == "ins"));
1971        assert!(bindings.iter().any(|b| b.mode == "sel"));
1972    }
1973
1974    #[test]
1975    fn bindable_actions_have_names() {
1976        let actions: Vec<BindableActionInfo<AppAction>> = canvas_bindable_actions();
1977        assert!(
1978            actions
1979                .iter()
1980                .any(|a| a.name == "suggestion_down" && a.modes == CANVAS_SUGGESTION_MODES)
1981        );
1982        assert!(actions.iter().all(|a| !a.name.is_empty()));
1983    }
1984
1985    #[test]
1986    fn overlap_routing_depends_on_context() {
1987        let mut registry = InputRegistry::<AppAction>::empty();
1988        // Bind `u` in `nor` in the keymap; canvas vim also binds `u` -> undo.
1989        registry.map_mut("nor").bind(
1990            vec![KeyChord::new(KeyCode::Char('u'), KeyModifiers::empty())],
1991            AppAction::Canvas(CanvasAction::Undo),
1992        );
1993        let keymap_catalog = BindingCatalog::from_registry(&registry, BindingSource::Config);
1994        let canvas_catalog: BindingCatalog<CanvasAction> =
1995            canvas_default_binding_catalog(BuiltinCanvasKeybindingPreset::Vim);
1996
1997        let command =
1998            analyze_canvas_overlaps(&keymap_catalog, &canvas_catalog, InputLayerContext::Command);
1999        assert!(command.iter().any(|c| matches!(
2000            c,
2001            BindingConflict::CanvasOverlap {
2002                routing: CanvasRoutingPrecedence::KeymapFirst,
2003                ..
2004            }
2005        )));
2006
2007        let text =
2008            analyze_canvas_overlaps(&keymap_catalog, &canvas_catalog, InputLayerContext::Text);
2009        assert!(text.iter().any(|c| matches!(
2010            c,
2011            BindingConflict::CanvasOverlap {
2012                routing: CanvasRoutingPrecedence::CanvasFirst,
2013                ..
2014            }
2015        )));
2016    }
2017}