use crate::focus::{FocusIntent, FocusTarget};
use crate::input::{InputPipeline, InputRegistry, KeyChord, KeyMap, PipelineResponse};
use crate::runtime::{
modes, ActionContext, ActionOutcome, KeyHook, KeyHookKind, KeyHookOutcome, ModeId, PageSpec,
TuiEffect, TuiPagesBuilder, TuiPagesStatus,
};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{layout::Rect, Frame};
pub use ::canvas::{
ActionResult, AppMode, CanvasAction, DataProvider, EditorState, TextFormEventOutcome,
TextFormState,
};
pub use ::canvas::integration::focus_handoff::{
execute_action_for_host, execute_action_for_host_with_options, BoundaryExit, HostActionOutcome,
};
pub use ::canvas::integration::focus_handoff::{
boundary_from_key_outcome, handle_key_event_for_host, key_outcome_for_vertical_navigation,
map_key_event_outcome_for_host, HostKeyEventOutcome,
};
pub use ::canvas::{
default_builtin_action_bindings, default_emacs_action_bindings,
default_helix_action_bindings, default_vim_action_bindings, display_binding, preset,
BuiltinCanvasKeybindingPreset, CanvasActionBinding, CanvasActionKeyBinding,
CanvasKeyBindingEntry, CanvasKeyBindings, CanvasKeybindingConflictKind,
CanvasKeybindingProfile, KeyEventOutcome,
};
pub use ::canvas::keybindings::{CanvasKeyAction, KeyStroke};
pub use ::canvas::CursorManager;
pub use ::canvas::{
render_suggestions_dropdown, SuggestionItem, SuggestionQuery, SuggestionTrigger,
};
pub use ::canvas::{
AppliedValidation, CharacterFilter, CharacterLimits, CustomFormatter, DefaultPositionMapper,
DisplayMask, FormattingResult, PatternFilters, PositionFilter, PositionMapper, PositionRange,
ValidationConfig, ValidationConfigBuilder, ValidationError, ValidationResult, ValidationRule,
ValidationSet, ValidationSettings, ValidationState, ValidationSummary,
};
pub use ::canvas::validation::limits::{CountMode, LimitCheckResult};
pub use ::canvas::{ComputedContext, ComputedProvider, ComputedState};
pub use ::canvas::{
render_canvas, render_canvas_default, render_canvas_with_options, CanvasDisplayOptions,
CanvasTheme, DefaultCanvasTheme, OverflowMode,
};
pub use ::canvas::integration::crossterm_input::{
CrosstermInputGuard, CrosstermInputOptions, CrosstermInputSession,
};
pub use ::canvas::{
TextArea, TextAreaDataProvider, TextAreaProvider, TextAreaState,
};
pub use ::canvas::textarea::{
TextAreaCommandLineState, TextAreaEventOutcome, TextAreaLineNumberMode, TextAreaSearchMatch,
TextOverflowMode,
};
pub use ::canvas::{
parse_command_args, parse_command_line, CommandLine, CommandLineCommand,
CommandLineCommandInvocation, CommandLineDispatchError, CommandLineEventOutcome,
CommandLineMode, CommandLineParseError, CommandLineParsedCommand, CommandLinePlacement,
CommandLineRegistry, CommandLineRegistrationError, CommandLineState, CommandLineSubmit,
};
pub use ::canvas::{
TextInput, TextInputDataProvider, TextInputEventOutcome, TextInputProvider,
TextInputState,
};
pub type FormEditor<D> = TextFormState<D>;
pub type FormInputEventOutcome = TextFormEventOutcome;
pub type TextAreaEditor<P> = TextAreaState<P>;
pub type TextInputEditor<P> = TextInputState<P>;
#[derive(Debug, Clone)]
pub enum CanvasDispatchOutcome<O = (), M = ()> {
Applied(::canvas::ActionResult),
Focus(FocusIntent<O, M>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CanvasKeyDispatchOutcome<O = (), M = ()> {
Consumed(Option<String>),
PendingSequence,
NotHandled,
Focus(FocusIntent<O, M>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CanvasTextWidgetOutcome<O = (), M = ()> {
Handled,
Submitted,
NotHandled,
Focus(FocusIntent<O, M>),
}
impl<O, M> CanvasDispatchOutcome<O, M> {
pub fn into_focus_intent(self) -> Option<FocusIntent<O, M>> {
match self {
CanvasDispatchOutcome::Applied(_) => None,
CanvasDispatchOutcome::Focus(intent) => Some(intent),
}
}
}
impl<O, M> CanvasKeyDispatchOutcome<O, M> {
pub fn into_focus_intent(self) -> Option<FocusIntent<O, M>> {
match self {
CanvasKeyDispatchOutcome::Focus(intent) => Some(intent),
_ => None,
}
}
}
impl<O, M> CanvasTextWidgetOutcome<O, M> {
pub fn into_focus_intent(self) -> Option<FocusIntent<O, M>> {
match self {
CanvasTextWidgetOutcome::Focus(intent) => Some(intent),
_ => None,
}
}
}
pub trait CanvasFormEditorHost {
fn mode(&self) -> AppMode;
fn has_keybindings(&self) -> bool;
fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset);
fn input_key(&mut self, key: KeyEvent) -> CanvasKeyDispatchOutcome;
fn paste(&mut self, text: &str) -> bool;
fn dispatch_canvas_action(&mut self, action: CanvasAction) -> CanvasDispatchOutcome;
}
impl<D> CanvasFormEditorHost for FormEditor<D>
where
D: DataProvider,
{
fn mode(&self) -> AppMode {
self.core().mode()
}
fn has_keybindings(&self) -> bool {
self.core().has_keybindings()
}
fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset) {
FormEditor::use_keybinding_preset(self, preset);
}
fn input_key(&mut self, key: KeyEvent) -> CanvasKeyDispatchOutcome {
dispatch_key_event(self, key)
}
fn paste(&mut self, text: &str) -> bool {
matches!(FormEditor::paste(self, text), TextFormEventOutcome::Handled)
}
fn dispatch_canvas_action(&mut self, action: CanvasAction) -> CanvasDispatchOutcome {
dispatch_action(self, action)
}
}
pub trait CanvasTextAreaHost {
fn mode(&self) -> AppMode;
fn has_keybindings(&self) -> bool;
fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset);
fn commandline_enabled(&self) -> bool {
false
}
fn boundary_for_key(&self, key: &KeyEvent) -> Option<BoundaryExit>;
fn input_key(&mut self, key: KeyEvent) -> TextAreaEventOutcome;
fn paste(&mut self, text: &str) -> bool;
fn exit_edit_mode(&mut self);
fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome;
}
impl<P> CanvasTextAreaHost for TextAreaState<P>
where
P: TextAreaDataProvider,
{
fn mode(&self) -> AppMode {
self.mode()
}
fn has_keybindings(&self) -> bool {
self.core().has_keybindings()
}
fn use_keybinding_preset(&mut self, preset: BuiltinCanvasKeybindingPreset) {
TextAreaState::use_keybinding_preset(self, preset);
}
fn commandline_enabled(&self) -> bool {
self.commandline().is_some()
}
fn boundary_for_key(&self, key: &KeyEvent) -> Option<BoundaryExit> {
text_area_boundary_for_key(self, key)
}
fn input_key(&mut self, key: KeyEvent) -> TextAreaEventOutcome {
if self.core().has_keybindings() || self.commandline_enabled() {
match self.handle_key_event(key) {
KeyEventOutcome::Consumed(_)
| KeyEventOutcome::Pending
| KeyEventOutcome::ExitTop
| KeyEventOutcome::ExitBottom => TextAreaEventOutcome::Handled,
KeyEventOutcome::NotMatched => TextAreaEventOutcome::Ignored,
}
} else {
self.input(key)
}
}
fn paste(&mut self, text: &str) -> bool {
matches!(TextAreaState::paste(self, text), TextAreaEventOutcome::Handled)
}
fn exit_edit_mode(&mut self) {
let _ = self.core_mut().exit_edit_mode();
}
fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome {
HostActionOutcome::Applied(self.core_mut().execute(action))
}
}
pub trait CanvasTextInputHost {
fn mode(&self) -> AppMode;
fn text(&self) -> String;
fn input_key(&mut self, key: KeyEvent) -> CanvasTextWidgetOutcome;
fn paste(&mut self, text: &str) -> bool;
fn set_suggestion_suffix(&mut self, suffix: String);
fn clear_suggestion_suffix(&mut self);
fn exit_edit_mode(&mut self);
fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome;
}
impl<P> CanvasTextInputHost for TextInputState<P>
where
P: TextInputDataProvider,
{
fn mode(&self) -> AppMode {
self.mode()
}
fn text(&self) -> String {
TextInputState::text(self)
}
fn input_key(&mut self, key: KeyEvent) -> CanvasTextWidgetOutcome {
dispatch_text_input_key(self, key)
}
fn paste(&mut self, text: &str) -> bool {
matches!(
TextInputState::paste(self, text),
TextInputEventOutcome::Handled
)
}
fn set_suggestion_suffix(&mut self, suffix: String) {
TextInputState::set_suggestion_suffix(self, suffix);
}
fn clear_suggestion_suffix(&mut self) {
TextInputState::clear_suggestion_suffix(self);
}
fn exit_edit_mode(&mut self) {
let _ = self.form_mut().exit_edit_mode();
}
fn dispatch_canvas_action(&mut self, action: CanvasAction) -> HostActionOutcome {
execute_action_for_host_with_options(self.form_mut(), action, false)
}
}
pub trait CanvasWidgetState {
fn canvas_form_editor(&mut self, _id: usize) -> Option<&mut dyn CanvasFormEditorHost> {
None
}
fn canvas_textarea(&mut self, _focus_index: usize) -> Option<&mut dyn CanvasTextAreaHost> {
None
}
fn canvas_textarea_entered(&mut self, _focus_index: usize) -> Option<&mut bool> {
None
}
fn canvas_textinput(&mut self, _focus_index: usize) -> Option<&mut dyn CanvasTextInputHost> {
None
}
fn canvas_textinput_entered(&mut self, _focus_index: usize) -> Option<&mut bool> {
None
}
fn canvas_textinput_suggestion_suffix(
&mut self,
_focus_index: usize,
_text: &str,
) -> Option<String> {
None
}
}
pub fn mode_for_app_mode(mode: AppMode) -> ModeId {
match mode {
AppMode::Ins => modes::INSERT,
AppMode::Sel => modes::SELECT,
AppMode::Command => modes::COMMAND,
AppMode::General => modes::GENERAL,
AppMode::Nor => modes::NORMAL,
}
}
pub fn modes_for_app_mode(mode: AppMode) -> Vec<ModeId> {
match mode {
AppMode::Command => vec![modes::COMMAND],
AppMode::General => vec![modes::GENERAL, modes::GLOBAL],
mode => vec![mode_for_app_mode(mode), modes::COMMON, modes::GLOBAL],
}
}
pub fn accepts_text_input(mode: AppMode) -> bool {
matches!(mode, AppMode::Ins | AppMode::Command)
}
pub fn text_chord_to_canvas_action(chord: KeyChord) -> Option<CanvasAction> {
let is_plain_char =
chord.modifiers.is_empty() || chord.modifiers == KeyModifiers::SHIFT;
match chord.code {
KeyCode::Char(c) if is_plain_char => Some(CanvasAction::InsertChar(c)),
_ => None,
}
}
pub fn text_chord_to_action<A>(chord: KeyChord) -> Option<A>
where
A: From<CanvasAction>,
{
text_chord_to_canvas_action(chord).map(A::from)
}
pub fn focus_intent_for_boundary<O, M>(boundary: BoundaryExit) -> FocusIntent<O, M> {
match boundary {
BoundaryExit::Top => FocusIntent::ExitCanvasBackward,
BoundaryExit::Bottom => FocusIntent::ExitCanvasForward,
}
}
pub fn dispatch_action<D, O, M>(
editor: &mut FormEditor<D>,
action: CanvasAction,
) -> CanvasDispatchOutcome<O, M>
where
D: DataProvider,
{
let before_field = editor.current_field();
let at_boundary = action_boundary(editor, &action).is_some();
match execute_action_for_host(editor, action) {
HostActionOutcome::Applied(result) => {
CanvasDispatchOutcome::Applied(validation_aware_action_result(
editor,
before_field,
at_boundary,
result,
))
}
HostActionOutcome::ExitCanvas(boundary) => {
CanvasDispatchOutcome::Focus(focus_intent_for_boundary(boundary))
}
}
}
pub fn render_canvas_with_suggestions<T, D>(
frame: &mut Frame,
frame_area: Rect,
canvas_area: Rect,
editor: &FormEditor<D>,
theme: &T,
) -> Option<Rect>
where
T: CanvasTheme,
D: DataProvider,
{
let opts = CanvasDisplayOptions::default();
render_canvas_with_suggestions_with_options(frame, frame_area, canvas_area, editor, theme, opts)
}
pub fn render_canvas_with_suggestions_with_options<T, D>(
frame: &mut Frame,
frame_area: Rect,
canvas_area: Rect,
editor: &FormEditor<D>,
theme: &T,
opts: CanvasDisplayOptions,
) -> Option<Rect>
where
T: CanvasTheme,
D: DataProvider,
{
let input_rect = render_canvas_with_options(frame, canvas_area, editor, theme, opts);
if let Some(input_rect) = input_rect {
render_suggestions_dropdown(frame, frame_area, input_rect, theme, editor);
}
input_rect
}
pub fn render_canvas_with_suggestions_default<D>(
frame: &mut Frame,
frame_area: Rect,
canvas_area: Rect,
editor: &FormEditor<D>,
) -> Option<Rect>
where
D: DataProvider,
{
let theme = DefaultCanvasTheme;
render_canvas_with_suggestions(frame, frame_area, canvas_area, editor, &theme)
}
pub fn render_canvas_with_suggestions_default_options<D>(
frame: &mut Frame,
frame_area: Rect,
canvas_area: Rect,
editor: &FormEditor<D>,
opts: CanvasDisplayOptions,
) -> Option<Rect>
where
D: DataProvider,
{
let theme = DefaultCanvasTheme;
render_canvas_with_suggestions_with_options(
frame,
frame_area,
canvas_area,
editor,
&theme,
opts,
)
}
pub fn update_cursor_style_for_mode(mode: AppMode) -> std::io::Result<()> {
CursorManager::update_for_mode(mode)
}
pub fn update_cursor_style_for_editor<D>(editor: &FormEditor<D>) -> std::io::Result<()>
where
D: DataProvider,
{
update_cursor_style_for_mode(editor.mode())
}
pub fn dispatch_key_event<D, O, M>(
editor: &mut FormEditor<D>,
event: KeyEvent,
) -> CanvasKeyDispatchOutcome<O, M>
where
D: DataProvider,
{
let before_field = editor.current_field();
let before_boundary = key_boundary(editor, &event);
let outcome = handle_key_event_for_host(editor, event);
host_key_event_outcome(validation_aware_key_event_outcome(
editor,
before_field,
before_boundary,
outcome,
))
}
pub fn host_key_event_outcome<O, M>(
outcome: HostKeyEventOutcome,
) -> CanvasKeyDispatchOutcome<O, M> {
match outcome {
HostKeyEventOutcome::Consumed(message) => CanvasKeyDispatchOutcome::Consumed(message),
HostKeyEventOutcome::PendingSequence => CanvasKeyDispatchOutcome::PendingSequence,
HostKeyEventOutcome::NotHandled => CanvasKeyDispatchOutcome::NotHandled,
HostKeyEventOutcome::ExitCanvas(boundary) => {
CanvasKeyDispatchOutcome::Focus(focus_intent_for_boundary(boundary))
}
}
}
fn validation_aware_action_result<D>(
editor: &FormEditor<D>,
before_field: usize,
at_boundary: bool,
result: ActionResult,
) -> ActionResult
where
D: DataProvider,
{
if editor.current_field() == before_field && !at_boundary {
if let Some(reason) = editor.last_switch_block() {
return ActionResult::Error(reason.to_string());
}
}
result
}
fn validation_aware_key_event_outcome<D>(
editor: &FormEditor<D>,
before_field: usize,
before_boundary: Option<BoundaryExit>,
outcome: HostKeyEventOutcome,
) -> HostKeyEventOutcome
where
D: DataProvider,
{
if matches!(outcome, HostKeyEventOutcome::ExitCanvas(_))
&& editor.current_field() == before_field
&& before_boundary.is_none()
{
if let Some(reason) = editor.last_switch_block() {
return HostKeyEventOutcome::Consumed(Some(reason.to_string()));
}
}
outcome
}
fn action_boundary<D>(editor: &FormEditor<D>, action: &CanvasAction) -> Option<BoundaryExit>
where
D: DataProvider,
{
match action {
CanvasAction::MoveUp | CanvasAction::PrevField if editor.current_field() == 0 => {
Some(BoundaryExit::Top)
}
CanvasAction::MoveDown | CanvasAction::NextField
if editor.current_field()
>= editor.data_provider().field_count().saturating_sub(1) =>
{
Some(BoundaryExit::Bottom)
}
_ => None,
}
}
fn key_boundary<D>(editor: &FormEditor<D>, event: &KeyEvent) -> Option<BoundaryExit>
where
D: DataProvider,
{
match event.code {
KeyCode::Up | KeyCode::BackTab if editor.current_field() == 0 => Some(BoundaryExit::Top),
KeyCode::Down | KeyCode::Tab
if editor.current_field()
>= editor.data_provider().field_count().saturating_sub(1) =>
{
Some(BoundaryExit::Bottom)
}
_ => None,
}
}
pub fn key_dispatch_status<A, O, M>(
outcome: &CanvasKeyDispatchOutcome<O, M>,
) -> Option<TuiPagesStatus<A>> {
match outcome {
CanvasKeyDispatchOutcome::Consumed(_) | CanvasKeyDispatchOutcome::Focus(_) => {
Some(TuiPagesStatus::ActionHandled)
}
CanvasKeyDispatchOutcome::PendingSequence => Some(TuiPagesStatus::Waiting(Vec::new())),
CanvasKeyDispatchOutcome::NotHandled => None,
}
}
pub fn dispatch_text_input_key<P, O, M>(
input: &mut TextInputState<P>,
event: KeyEvent,
) -> CanvasTextWidgetOutcome<O, M>
where
P: TextInputDataProvider,
{
let boundary = text_input_boundary_for_key(&event);
match input.input(event) {
TextInputEventOutcome::Handled => CanvasTextWidgetOutcome::Handled,
TextInputEventOutcome::Submitted => CanvasTextWidgetOutcome::Submitted,
TextInputEventOutcome::Ignored => boundary
.map(|boundary| CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(boundary)))
.unwrap_or(CanvasTextWidgetOutcome::NotHandled),
}
}
pub fn dispatch_text_area_key<P, O, M>(
textarea: &mut TextAreaState<P>,
event: KeyEvent,
) -> CanvasTextWidgetOutcome<O, M>
where
P: TextAreaDataProvider,
{
if let Some(boundary) = text_area_boundary_for_key(textarea, &event) {
return CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(boundary));
}
if textarea.commandline().is_some() {
match textarea.handle_key_event(event) {
KeyEventOutcome::Consumed(_) | KeyEventOutcome::Pending => {
CanvasTextWidgetOutcome::Handled
}
KeyEventOutcome::ExitTop => {
CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(BoundaryExit::Top))
}
KeyEventOutcome::ExitBottom => {
CanvasTextWidgetOutcome::Focus(focus_intent_for_boundary(BoundaryExit::Bottom))
}
KeyEventOutcome::NotMatched => CanvasTextWidgetOutcome::NotHandled,
}
} else {
match textarea.input(event) {
TextAreaEventOutcome::Handled => CanvasTextWidgetOutcome::Handled,
TextAreaEventOutcome::Ignored => CanvasTextWidgetOutcome::NotHandled,
}
}
}
pub fn text_input_boundary_for_key(event: &KeyEvent) -> Option<BoundaryExit> {
if event.kind != KeyEventKind::Press {
return None;
}
match (event.code, event.modifiers) {
(KeyCode::Up | KeyCode::BackTab, _) => Some(BoundaryExit::Top),
(KeyCode::Down, _) => Some(BoundaryExit::Bottom),
(KeyCode::Tab, modifiers) if modifiers.is_empty() => Some(BoundaryExit::Bottom),
_ => None,
}
}
pub fn text_area_boundary_for_key<P>(
textarea: &TextAreaState<P>,
event: &KeyEvent,
) -> Option<BoundaryExit>
where
P: TextAreaDataProvider,
{
if event.kind != KeyEventKind::Press {
return None;
}
let current = textarea.current_field();
let last = textarea.data_provider().field_count().saturating_sub(1);
match event.code {
KeyCode::Up | KeyCode::BackTab if current == 0 => Some(BoundaryExit::Top),
KeyCode::Down if current >= last => Some(BoundaryExit::Bottom),
_ => None,
}
}
pub fn bind_default_keymaps<A>(
normal: &mut KeyMap<A>,
insert: &mut KeyMap<A>,
select: &mut KeyMap<A>,
)
where
A: From<CanvasAction>,
{
bind_builtin_keymaps(
BuiltinCanvasKeybindingPreset::Vim,
normal,
insert,
select,
);
}
pub fn bind_builtin_keymaps<A>(
preset: BuiltinCanvasKeybindingPreset,
normal: &mut KeyMap<A>,
insert: &mut KeyMap<A>,
select: &mut KeyMap<A>,
)
where
A: From<CanvasAction>,
{
bind_builtin_defaults_for_mode(preset, normal, AppMode::Nor);
bind_builtin_defaults_for_mode(preset, insert, AppMode::Ins);
bind_suggestion_defaults(insert);
bind_builtin_defaults_for_mode(preset, select, AppMode::Sel);
bind_suggestion_defaults(select);
}
pub fn bind_normal_defaults<A>(map: &mut KeyMap<A>)
where
A: From<CanvasAction>,
{
bind_builtin_defaults_for_mode(BuiltinCanvasKeybindingPreset::Vim, map, AppMode::Nor);
}
pub fn bind_insert_defaults<A>(map: &mut KeyMap<A>)
where
A: From<CanvasAction>,
{
bind_builtin_defaults_for_mode(BuiltinCanvasKeybindingPreset::Vim, map, AppMode::Ins);
bind_suggestion_defaults(map);
}
pub fn bind_select_defaults<A>(map: &mut KeyMap<A>)
where
A: From<CanvasAction>,
{
bind_builtin_defaults_for_mode(BuiltinCanvasKeybindingPreset::Vim, map, AppMode::Sel);
bind_suggestion_defaults(map);
}
fn bind_builtin_defaults_for_mode<A>(
preset: BuiltinCanvasKeybindingPreset,
map: &mut KeyMap<A>,
mode: AppMode,
)
where
A: From<CanvasAction>,
{
for binding in default_builtin_action_bindings(preset)
.into_iter()
.filter(|binding| binding.mode == mode)
{
let sequence = binding
.sequence
.into_iter()
.map(|stroke| KeyChord::new(stroke.code, stroke.modifiers))
.collect::<Vec<_>>();
map.bind(sequence, A::from(binding.action));
}
}
fn canvas_action_pipeline_with_preset(
preset: BuiltinCanvasKeybindingPreset,
timeout_ms: u64,
) -> InputPipeline<CanvasAction> {
let mut registry = InputRegistry::empty();
bind_builtin_defaults_for_mode(preset, registry.map_mut(modes::NORMAL.as_str()), AppMode::Nor);
bind_builtin_defaults_for_mode(preset, registry.map_mut(modes::INSERT.as_str()), AppMode::Ins);
bind_suggestion_defaults(registry.map_mut(modes::INSERT.as_str()));
bind_builtin_defaults_for_mode(preset, registry.map_mut(modes::SELECT.as_str()), AppMode::Sel);
bind_suggestion_defaults(registry.map_mut(modes::SELECT.as_str()));
InputPipeline::new(registry, timeout_ms)
}
fn normalize_shift(mut key: KeyEvent) -> KeyEvent {
if matches!(key.code, KeyCode::Char(_)) && key.modifiers == KeyModifiers::SHIFT {
key.modifiers = KeyModifiers::NONE;
}
key
}
fn focused_canvas_field<V, O>(ctx: &ActionContext<V, O>, index: usize) -> bool {
matches!(
ctx.focus.as_ref(),
Some(FocusTarget::CanvasField(field) | FocusTarget::InternalCanvasField(field))
if *field == index
)
}
fn focus_intent_for_top_level_key<O, M>(key: KeyEvent) -> Option<FocusIntent<O, M>> {
match (key.code, key.modifiers) {
(KeyCode::Down | KeyCode::Tab, _) => Some(FocusIntent::ExitCanvasForward),
(KeyCode::Char('j') | KeyCode::Char('l'), modifiers)
if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT =>
{
Some(FocusIntent::ExitCanvasForward)
}
(KeyCode::Up | KeyCode::BackTab, _) => Some(FocusIntent::ExitCanvasBackward),
(KeyCode::Char('k') | KeyCode::Char('h'), modifiers)
if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT =>
{
Some(FocusIntent::ExitCanvasBackward)
}
_ => None,
}
}
fn hook_outcome<V, A, O, M>(
status: TuiPagesStatus<A>,
outcome: ActionOutcome<V, O, M>,
) -> Option<KeyHookOutcome<V, A, O, M>> {
Some(KeyHookOutcome { status, outcome })
}
fn hook_focus_outcome<V, A, O, M>(
intent: FocusIntent<O, M>,
) -> Option<KeyHookOutcome<V, A, O, M>> {
hook_outcome(
TuiPagesStatus::ActionHandled,
ActionOutcome::effect(TuiEffect::Focus(intent)),
)
}
fn hook_status_outcome<V, A, O, M>(
status: TuiPagesStatus<A>,
) -> Option<KeyHookOutcome<V, A, O, M>> {
hook_outcome(status, ActionOutcome::none())
}
fn widget_action_hook_outcome<V, A, O, M>(
outcome: HostActionOutcome,
) -> Option<KeyHookOutcome<V, A, O, M>> {
match outcome {
HostActionOutcome::Applied(_) => hook_status_outcome(TuiPagesStatus::ActionHandled),
HostActionOutcome::ExitCanvas(boundary) => {
hook_focus_outcome(focus_intent_for_boundary(boundary))
}
}
}
fn pipeline_hook_outcome<V, A, O, M>(
response: PipelineResponse<CanvasAction>,
) -> Option<KeyHookOutcome<V, A, O, M>> {
match response {
PipelineResponse::Wait(_) => hook_status_outcome(TuiPagesStatus::Waiting(Vec::new())),
PipelineResponse::Cancel => hook_status_outcome(TuiPagesStatus::Cancelled),
PipelineResponse::Execute(_) | PipelineResponse::Type(_) => None,
}
}
fn refresh_textinput_suggestion_suffix<S>(state: &mut S, focus_index: usize)
where
S: CanvasWidgetState,
{
let Some(text) = state.canvas_textinput(focus_index).map(|input| input.text()) else {
return;
};
let suffix = state.canvas_textinput_suggestion_suffix(focus_index, &text);
let Some(input) = state.canvas_textinput(focus_index) else {
return;
};
if let Some(suffix) = suffix {
input.set_suggestion_suffix(suffix);
} else {
input.clear_suggestion_suffix();
}
}
pub(crate) fn dispatch_canvas_key_hook<V, A, S, O, M>(
kind: &mut KeyHookKind,
key: KeyEvent,
ctx: ActionContext<V, O>,
state: &mut S,
) -> Option<KeyHookOutcome<V, A, O, M>>
where
S: CanvasWidgetState,
{
match kind {
KeyHookKind::CanvasFormEditor {
id,
preset,
} => {
if !ctx.focus.as_ref().is_some_and(FocusTarget::is_canvas) {
return None;
}
let editor = state.canvas_form_editor(*id)?;
if !editor.has_keybindings() {
editor.use_keybinding_preset(*preset);
}
match editor.input_key(normalize_shift(key)) {
CanvasKeyDispatchOutcome::Consumed(_) => {
hook_status_outcome(TuiPagesStatus::ActionHandled)
}
CanvasKeyDispatchOutcome::PendingSequence => {
hook_status_outcome(TuiPagesStatus::Waiting(Vec::new()))
}
CanvasKeyDispatchOutcome::NotHandled => None,
CanvasKeyDispatchOutcome::Focus(FocusIntent::ExitCanvasForward) => {
hook_focus_outcome(FocusIntent::ExitCanvasForward)
}
CanvasKeyDispatchOutcome::Focus(FocusIntent::ExitCanvasBackward) => {
hook_focus_outcome(FocusIntent::ExitCanvasBackward)
}
CanvasKeyDispatchOutcome::Focus(_) => {
hook_status_outcome(TuiPagesStatus::ActionHandled)
}
}
}
KeyHookKind::CanvasTextArea {
focus_index,
preset,
pipeline,
} => {
if !focused_canvas_field(&ctx, *focus_index) {
if let Some(entered) = state.canvas_textarea_entered(*focus_index) {
*entered = false;
}
return None;
}
let is_entered = state
.canvas_textarea_entered(*focus_index)
.is_some_and(|entered| *entered);
if !is_entered {
if key.kind != KeyEventKind::Press {
return None;
}
if matches!(key.code, KeyCode::Enter) {
if let Some(entered) = state.canvas_textarea_entered(*focus_index) {
*entered = true;
}
if let Some(textarea) = state.canvas_textarea(*focus_index) {
if !textarea.has_keybindings() {
textarea.use_keybinding_preset(*preset);
}
textarea.exit_edit_mode();
}
return hook_status_outcome(TuiPagesStatus::ActionHandled);
}
return focus_intent_for_top_level_key(key).and_then(hook_focus_outcome);
}
if let Some(textarea) = state.canvas_textarea(*focus_index) {
if !textarea.has_keybindings() {
textarea.use_keybinding_preset(*preset);
}
}
let mode = state.canvas_textarea(*focus_index)?.mode();
if mode == AppMode::Ins {
return match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => None,
(KeyCode::Esc, _) => {
state.canvas_textarea(*focus_index)?.exit_edit_mode();
hook_status_outcome(TuiPagesStatus::ActionHandled)
}
_ => match state
.canvas_textarea(*focus_index)?
.input_key(normalize_shift(key))
{
TextAreaEventOutcome::Handled => {
hook_status_outcome(TuiPagesStatus::TextHandled)
}
TextAreaEventOutcome::Ignored => None,
},
};
}
if matches!((key.code, key.modifiers), (KeyCode::Char('g'), KeyModifiers::CONTROL))
&& key.kind == KeyEventKind::Press
{
if let Some(entered) = state.canvas_textarea_entered(*focus_index) {
*entered = false;
}
return hook_focus_outcome(FocusIntent::ExitCanvasForward);
}
if let Some(boundary) = state.canvas_textarea(*focus_index)?.boundary_for_key(&key) {
return hook_focus_outcome(focus_intent_for_boundary(boundary));
}
match state
.canvas_textarea(*focus_index)?
.input_key(normalize_shift(key))
{
TextAreaEventOutcome::Handled => {
return hook_status_outcome(TuiPagesStatus::TextHandled);
}
TextAreaEventOutcome::Ignored => {}
}
let modes = modes_for_app_mode(mode);
match pipeline.process(key, &modes, accepts_text_input(mode)) {
PipelineResponse::Execute(action) => widget_action_hook_outcome(
state.canvas_textarea(*focus_index)?.dispatch_canvas_action(action),
),
response => pipeline_hook_outcome(response),
}
}
KeyHookKind::CanvasTextInput {
focus_index,
pipeline,
} => {
if !focused_canvas_field(&ctx, *focus_index) {
if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
*entered = false;
}
return None;
}
let is_entered = state
.canvas_textinput_entered(*focus_index)
.is_some_and(|entered| *entered);
if !is_entered {
if key.kind != KeyEventKind::Press {
return None;
}
if matches!(key.code, KeyCode::Enter) {
if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
*entered = true;
}
return hook_status_outcome(TuiPagesStatus::ActionHandled);
}
return focus_intent_for_top_level_key(key).and_then(hook_focus_outcome);
}
let mode = state.canvas_textinput(*focus_index)?.mode();
if mode == AppMode::Ins {
return match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => None,
(KeyCode::Esc, _) => {
state.canvas_textinput(*focus_index)?.exit_edit_mode();
hook_status_outcome(TuiPagesStatus::ActionHandled)
}
_ => match state
.canvas_textinput(*focus_index)?
.input_key(normalize_shift(key))
{
CanvasTextWidgetOutcome::Handled => {
refresh_textinput_suggestion_suffix(state, *focus_index);
hook_status_outcome(TuiPagesStatus::TextHandled)
}
CanvasTextWidgetOutcome::Submitted => {
if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
*entered = false;
}
hook_focus_outcome(FocusIntent::ExitCanvasForward)
}
CanvasTextWidgetOutcome::Focus(intent) => {
if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
*entered = false;
}
match intent {
FocusIntent::ExitCanvasForward => {
hook_focus_outcome(FocusIntent::ExitCanvasForward)
}
FocusIntent::ExitCanvasBackward => {
hook_focus_outcome(FocusIntent::ExitCanvasBackward)
}
_ => hook_status_outcome(TuiPagesStatus::ActionHandled),
}
}
CanvasTextWidgetOutcome::NotHandled => None,
},
};
}
if matches!(key.code, KeyCode::Esc) && key.kind == KeyEventKind::Press {
if let Some(entered) = state.canvas_textinput_entered(*focus_index) {
*entered = false;
}
return hook_status_outcome(TuiPagesStatus::ActionHandled);
}
let modes = modes_for_app_mode(mode);
match pipeline.process(key, &modes, accepts_text_input(mode)) {
PipelineResponse::Execute(action) => widget_action_hook_outcome(
state.canvas_textinput(*focus_index)?.dispatch_canvas_action(action),
),
response => pipeline_hook_outcome(response),
}
}
}
}
pub(crate) fn dispatch_canvas_paste_hook<V, A, S, O, M>(
kind: &mut KeyHookKind,
text: &str,
ctx: ActionContext<V, O>,
state: &mut S,
) -> Option<KeyHookOutcome<V, A, O, M>>
where
S: CanvasWidgetState,
{
let handled = match kind {
KeyHookKind::CanvasFormEditor { id, .. } => {
if !ctx.focus.as_ref().is_some_and(FocusTarget::is_canvas) {
return None;
}
state.canvas_form_editor(*id)?.paste(text)
}
KeyHookKind::CanvasTextArea { focus_index, .. } => {
if !focused_canvas_field(&ctx, *focus_index)
|| !state
.canvas_textarea_entered(*focus_index)
.is_some_and(|entered| *entered)
{
return None;
}
state.canvas_textarea(*focus_index)?.paste(text)
}
KeyHookKind::CanvasTextInput { focus_index, .. } => {
if !focused_canvas_field(&ctx, *focus_index)
|| !state
.canvas_textinput_entered(*focus_index)
.is_some_and(|entered| *entered)
{
return None;
}
state.canvas_textinput(*focus_index)?.paste(text)
}
};
if handled {
hook_status_outcome(TuiPagesStatus::TextHandled)
} else {
None
}
}
impl<O> PageSpec<O> {
pub fn canvas_mode(mut self, mode: AppMode) -> Self {
self.modes = modes_for_app_mode(mode);
self.accepts_text_input = accepts_text_input(mode);
self
}
pub fn canvas_editor<D>(self, editor: &FormEditor<D>) -> Self
where
D: DataProvider,
{
self.canvas_mode(editor.mode())
}
}
impl<V, A, S, O, M, Pages, Handler> TuiPagesBuilder<V, A, S, O, M, Pages, Handler>
where
A: From<CanvasAction>,
{
pub fn canvas_defaults(self) -> Self {
self.canvas_keybindings().canvas_text_actions()
}
pub fn canvas_keybindings(mut self) -> Self {
bind_normal_defaults(self.input_registry.map_mut(modes::NORMAL.as_str()));
bind_insert_defaults(self.input_registry.map_mut(modes::INSERT.as_str()));
bind_select_defaults(self.input_registry.map_mut(modes::SELECT.as_str()));
self
}
pub fn canvas_text_actions(mut self) -> Self {
self.text_input_mapper = Some(text_chord_to_action::<A>);
self
}
}
impl<V, A, S, O, M, Pages, Handler> TuiPagesBuilder<V, A, S, O, M, Pages, Handler>
where
S: CanvasWidgetState,
{
pub fn canvas_form_editor(self, id: usize) -> Self {
self.canvas_form_editor_with_preset(id, BuiltinCanvasKeybindingPreset::Vim)
}
pub fn canvas_form_editor_with_preset(
mut self,
id: usize,
preset: BuiltinCanvasKeybindingPreset,
) -> Self {
self.key_hooks.push(KeyHook {
kind: KeyHookKind::CanvasFormEditor {
id,
preset,
},
dispatch: dispatch_canvas_key_hook::<V, A, S, O, M>,
paste: dispatch_canvas_paste_hook::<V, A, S, O, M>,
});
self
}
pub fn canvas_textarea_widget(self, focus_index: usize) -> Self {
self.canvas_textarea_widget_with_preset(focus_index, BuiltinCanvasKeybindingPreset::Vim)
}
pub fn canvas_textarea_widget_with_preset(
mut self,
focus_index: usize,
preset: BuiltinCanvasKeybindingPreset,
) -> Self {
self.key_hooks.push(KeyHook {
kind: KeyHookKind::CanvasTextArea {
focus_index,
preset,
pipeline: canvas_action_pipeline_with_preset(preset, self.input_timeout_ms),
},
dispatch: dispatch_canvas_key_hook::<V, A, S, O, M>,
paste: dispatch_canvas_paste_hook::<V, A, S, O, M>,
});
self
}
pub fn canvas_textinput_widget(self, focus_index: usize) -> Self {
self.canvas_textinput_widget_with_preset(focus_index, BuiltinCanvasKeybindingPreset::Vim)
}
pub fn canvas_textinput_widget_with_preset(
mut self,
focus_index: usize,
preset: BuiltinCanvasKeybindingPreset,
) -> Self {
self.key_hooks.push(KeyHook {
kind: KeyHookKind::CanvasTextInput {
focus_index,
pipeline: canvas_action_pipeline_with_preset(preset, self.input_timeout_ms),
},
dispatch: dispatch_canvas_key_hook::<V, A, S, O, M>,
paste: dispatch_canvas_paste_hook::<V, A, S, O, M>,
});
self
}
}
fn bind_suggestion_defaults<A>(map: &mut KeyMap<A>)
where
A: From<CanvasAction>,
{
bind_key_with_modifiers(
map,
KeyCode::Char(' '),
KeyModifiers::CONTROL,
CanvasAction::TriggerSuggestions,
);
bind_key_with_modifiers(
map,
KeyCode::Char('n'),
KeyModifiers::CONTROL,
CanvasAction::SuggestionDown,
);
bind_key_with_modifiers(
map,
KeyCode::Char('p'),
KeyModifiers::CONTROL,
CanvasAction::SuggestionUp,
);
bind_key_with_modifiers(
map,
KeyCode::Char('y'),
KeyModifiers::CONTROL,
CanvasAction::SelectSuggestion,
);
bind_key_with_modifiers(
map,
KeyCode::Char('g'),
KeyModifiers::CONTROL,
CanvasAction::ExitSuggestions,
);
}
fn bind_key_with_modifiers<A>(
map: &mut KeyMap<A>,
code: KeyCode,
modifiers: KeyModifiers,
action: CanvasAction,
)
where
A: From<CanvasAction>,
{
map.bind(vec![KeyChord::new(code, modifiers)], A::from(action));
}