use crate::command::{CommandHint, CommandRegistry, CommandResolver, CommandResponse};
use crate::focus::{FocusController, FocusIntent, FocusManager, FocusTarget, FocusWrap};
use crate::input::{parse_binding, InputHint, InputPipeline, InputRegistry, KeyChord, KeyMap};
use crate::navigation::{BufferState, PaneSplit};
use crossterm::event::KeyEvent;
#[cfg(feature = "command-line")]
use ratatui::layout::{Constraint, Layout, Rect};
use std::borrow::Cow;
use std::error::Error;
use std::fmt;
use std::marker::PhantomData;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct ModeId(Cow<'static, str>);
impl ModeId {
pub const fn borrowed(value: &'static str) -> Self {
Self(Cow::Borrowed(value))
}
pub fn owned(value: impl Into<String>) -> Self {
Self(Cow::Owned(value.into()))
}
pub fn as_str(&self) -> &str {
self.0.as_ref()
}
}
impl AsRef<str> for ModeId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for ModeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&'static str> for ModeId {
fn from(value: &'static str) -> Self {
Self::borrowed(value)
}
}
impl From<String> for ModeId {
fn from(value: String) -> Self {
Self(Cow::Owned(value))
}
}
pub mod modes {
use super::ModeId;
pub const GENERAL: ModeId = ModeId::borrowed("general");
pub const NORMAL: ModeId = ModeId::borrowed("nor");
pub const INSERT: ModeId = ModeId::borrowed("ins");
pub const SELECT: ModeId = ModeId::borrowed("sel");
pub const COMMAND: ModeId = ModeId::borrowed("command");
pub const COMMON: ModeId = ModeId::borrowed("common");
pub const GLOBAL: ModeId = ModeId::borrowed("global");
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PageSpec<O = ()> {
pub focus_targets: Vec<FocusTarget<O>>,
pub(crate) section_items: Vec<(usize, usize)>,
pub modes: Vec<ModeId>,
pub accepts_text_input: bool,
}
impl<O> Default for PageSpec<O> {
fn default() -> Self {
Self {
focus_targets: Vec::new(),
section_items: Vec::new(),
modes: vec![modes::GENERAL, modes::GLOBAL],
accepts_text_input: false,
}
}
}
impl<O> PageSpec<O> {
pub fn new() -> Self {
Self::default()
}
pub fn focus_targets(mut self, targets: Vec<FocusTarget<O>>) -> Self {
self.focus_targets = targets;
self
}
pub fn focus(mut self, builder: crate::PageFocusBuilder<O>) -> Self {
let (targets, section_items) = builder.into_parts();
self.focus_targets = targets;
self.section_items = section_items;
self
}
pub fn modes(mut self, modes: impl IntoIterator<Item = ModeId>) -> Self {
self.modes = modes.into_iter().collect();
self
}
pub fn accepts_text_input(mut self, accepts_text_input: bool) -> Self {
self.accepts_text_input = accepts_text_input;
self
}
}
pub type PageFn<V, S, O = ()> = fn(&V, &S, Option<&FocusTarget<O>>) -> PageSpec<O>;
pub type TuiApp<V, A, S, Handler, O = (), M = ()> =
TuiPages<V, A, S, PageFn<V, S, O>, Handler, O, M>;
pub trait PageProvider<V, S, O = ()> {
fn page_spec(&self, view: &V, state: &S, focus: Option<&FocusTarget<O>>) -> PageSpec<O>;
}
impl<V, S, O, F> PageProvider<V, S, O> for F
where
F: Fn(&V, &S, Option<&FocusTarget<O>>) -> PageSpec<O>,
{
fn page_spec(&self, view: &V, state: &S, focus: Option<&FocusTarget<O>>) -> PageSpec<O> {
self(view, state, focus)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TuiEffect<V, O = (), M = ()> {
None,
Focus(FocusIntent<O, M>),
Navigate(V),
NextBuffer,
PreviousBuffer,
CloseBuffer,
SplitPane(PaneSplit),
ClosePane,
NextPane,
PreviousPane,
RefreshPage,
Quit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionOutcome<V, O = (), M = ()> {
pub effects: Vec<TuiEffect<V, O, M>>,
}
impl<V, O, M> Default for ActionOutcome<V, O, M> {
fn default() -> Self {
Self {
effects: Vec::new(),
}
}
}
impl<V, O, M> ActionOutcome<V, O, M> {
pub fn none() -> Self {
Self::default()
}
pub fn effect(effect: TuiEffect<V, O, M>) -> Self {
Self {
effects: vec![effect],
}
}
pub fn effects(effects: impl IntoIterator<Item = TuiEffect<V, O, M>>) -> Self {
Self {
effects: effects.into_iter().collect(),
}
}
pub fn push(&mut self, effect: TuiEffect<V, O, M>) {
self.effects.push(effect);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionContext<V, O = ()> {
pub current_view: V,
pub focus: Option<FocusTarget<O>>,
pub has_overlay: bool,
}
pub trait TuiActionHandler<V, A, S, O = (), M = ()> {
type Error;
fn handle_action(
&mut self,
action: A,
ctx: ActionContext<V, O>,
state: &mut S,
) -> Result<ActionOutcome<V, O, M>, Self::Error>;
fn handle_text(
&mut self,
_chord: KeyChord,
_ctx: ActionContext<V, O>,
_state: &mut S,
) -> Result<ActionOutcome<V, O, M>, Self::Error> {
Ok(ActionOutcome::none())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TuiPagesError<E> {
Handler(E),
}
impl<E> fmt::Display for TuiPagesError<E>
where
E: fmt::Display,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TuiPagesError::Handler(error) => write!(f, "handler error: {error}"),
}
}
}
impl<E> Error for TuiPagesError<E> where E: Error + 'static {}
impl<E> From<E> for TuiPagesError<E> {
fn from(error: E) -> Self {
Self::Handler(error)
}
}
pub type TuiPagesResult<T, E> = Result<T, TuiPagesError<E>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TuiPagesStatus<A> {
ActionHandled,
TextHandled,
Waiting(Vec<InputHint<A>>),
Cancelled,
CommandIncomplete(Vec<CommandHint>),
CommandUnknown,
CommandEmpty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TuiPagesOutput<A> {
pub status: TuiPagesStatus<A>,
pub quit_requested: bool,
}
impl<A> TuiPagesOutput<A> {
fn new(status: TuiPagesStatus<A>, quit_requested: bool) -> Self {
Self {
status,
quit_requested,
}
}
}
pub(crate) struct KeyHookOutcome<V, A, O, M> {
pub status: TuiPagesStatus<A>,
pub outcome: ActionOutcome<V, O, M>,
}
#[cfg(feature = "command-line")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandLineAreas {
pub page: Rect,
pub command_line: Option<Rect>,
}
#[cfg(feature = "command-line")]
impl CommandLineAreas {
pub fn split(area: Rect, reserve_command_line: bool) -> Self {
if !reserve_command_line {
return Self {
page: area,
command_line: None,
};
}
let [page, command_line] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
Self {
page,
command_line: Some(command_line),
}
}
}
#[derive(Debug, Clone)]
pub(crate) enum KeyHookKind {
#[cfg(feature = "canvas")]
CanvasFormEditor {
id: usize,
preset: crate::canvas::BuiltinCanvasKeybindingPreset,
},
#[cfg(feature = "canvas")]
CanvasTextArea {
focus_index: usize,
preset: crate::canvas::BuiltinCanvasKeybindingPreset,
pipeline: InputPipeline<crate::canvas::CanvasAction>,
},
#[cfg(feature = "canvas")]
CanvasTextInput {
focus_index: usize,
pipeline: InputPipeline<crate::canvas::CanvasAction>,
},
}
#[derive(Debug, Clone)]
pub(crate) struct KeyHook<V, A, S, O, M> {
pub kind: KeyHookKind,
pub dispatch: fn(
&mut KeyHookKind,
KeyEvent,
ActionContext<V, O>,
&mut S,
) -> Option<KeyHookOutcome<V, A, O, M>>,
pub paste: fn(
&mut KeyHookKind,
&str,
ActionContext<V, O>,
&mut S,
) -> Option<KeyHookOutcome<V, A, O, M>>,
}
#[derive(Debug, Clone)]
pub struct TuiPages<V, A, S, Pages = (), Handler = (), O = (), M = ()> {
pub input: InputPipeline<A>,
pub commands: CommandResolver<A>,
pub focus: FocusManager<O, M>,
pub buffer: BufferState<V>,
pages: Pages,
handler: Handler,
fallback_view: V,
reserve_command_line: bool,
pub(crate) text_input_mapper: Option<fn(KeyChord) -> Option<A>>,
pub(crate) key_hooks: Vec<KeyHook<V, A, S, O, M>>,
_state: PhantomData<S>,
}
impl<V, A, S, O, M> TuiPages<V, A, S, (), (), O, M>
where
V: Clone + PartialEq,
{
pub fn builder(initial_view: V) -> TuiPagesBuilder<V, A, S, O, M, (), ()> {
TuiPagesBuilder::new(initial_view)
}
}
impl<V, A, S, Pages, Handler, O, M> TuiPages<V, A, S, Pages, Handler, O, M>
where
V: Clone + PartialEq,
A: Clone,
O: Clone + PartialEq,
Pages: PageProvider<V, S, O>,
Handler: TuiActionHandler<V, A, S, O, M>,
{
pub fn current_view(&self) -> &V {
self.buffer
.get_active_view()
.expect("TuiPages buffer always contains at least one view")
}
pub fn pages(&self) -> &Pages {
&self.pages
}
pub fn pages_mut(&mut self) -> &mut Pages {
&mut self.pages
}
pub fn handler(&self) -> &Handler {
&self.handler
}
pub fn handler_mut(&mut self) -> &mut Handler {
&mut self.handler
}
pub fn reserve_command_line(&self) -> bool {
self.reserve_command_line
}
#[cfg(feature = "command-line")]
pub fn render_areas(&self, area: Rect) -> CommandLineAreas {
CommandLineAreas::split(area, self.reserve_command_line)
}
pub fn refresh_page(&mut self, state: &S) {
let spec = self.current_page_spec(state);
self.sync_focus_to_spec(spec);
}
fn sync_focus_to_spec(&mut self, spec: PageSpec<O>) {
let PageSpec {
focus_targets,
section_items,
..
} = spec;
if self.focus.targets() != focus_targets.as_slice() {
self.focus.register_page(focus_targets);
}
self.focus.set_section_items(section_items);
}
pub fn handle_key(
&mut self,
key: KeyEvent,
state: &mut S,
) -> TuiPagesResult<TuiPagesOutput<A>, Handler::Error> {
let spec = self.current_page_spec(state);
let modes = spec.modes.clone();
let accepts_text_input = spec.accepts_text_input;
self.sync_focus_to_spec(spec);
let ctx = ActionContext {
current_view: self.current_view().clone(),
focus: self.focus.current(),
has_overlay: self.focus.has_overlay(),
};
let mut hook_response = None;
for hook in &mut self.key_hooks {
if let Some(response) = (hook.dispatch)(&mut hook.kind, key, ctx.clone(), state) {
hook_response = Some(response);
break;
}
}
if let Some(response) = hook_response {
let quit_requested = self.apply_outcome(response.outcome, state);
return Ok(TuiPagesOutput::new(response.status, quit_requested));
}
let focus_accepts_mapped_text = accepts_text_input
&& self
.focus
.current()
.as_ref()
.map(FocusTarget::is_canvas)
.unwrap_or(false);
let response = match self.input.process(key, &modes, accepts_text_input) {
crate::input::PipelineResponse::Type(chord) if focus_accepts_mapped_text => self
.text_input_mapper
.and_then(|mapper| mapper(chord))
.map(crate::input::PipelineResponse::Execute)
.unwrap_or(crate::input::PipelineResponse::Type(chord)),
response => response,
};
match response {
crate::input::PipelineResponse::Execute(action) => {
let quit_requested = self.dispatch_action(action, state)?;
Ok(TuiPagesOutput::new(
TuiPagesStatus::ActionHandled,
quit_requested,
))
}
crate::input::PipelineResponse::Type(chord) => {
let quit_requested = self.dispatch_text(chord, state)?;
Ok(TuiPagesOutput::new(
TuiPagesStatus::TextHandled,
quit_requested,
))
}
crate::input::PipelineResponse::Wait(hints) => {
Ok(TuiPagesOutput::new(TuiPagesStatus::Waiting(hints), false))
}
crate::input::PipelineResponse::Cancel => {
Ok(TuiPagesOutput::new(TuiPagesStatus::Cancelled, false))
}
}
}
pub fn handle_paste(
&mut self,
text: &str,
state: &mut S,
) -> TuiPagesResult<TuiPagesOutput<A>, Handler::Error> {
let spec = self.current_page_spec(state);
self.sync_focus_to_spec(spec);
let ctx = ActionContext {
current_view: self.current_view().clone(),
focus: self.focus.current(),
has_overlay: self.focus.has_overlay(),
};
let mut paste_response = None;
for hook in &mut self.key_hooks {
if let Some(response) = (hook.paste)(&mut hook.kind, text, ctx.clone(), state) {
paste_response = Some(response);
break;
}
}
if let Some(response) = paste_response {
let quit_requested = self.apply_outcome(response.outcome, state);
return Ok(TuiPagesOutput::new(response.status, quit_requested));
}
Ok(TuiPagesOutput::new(TuiPagesStatus::Cancelled, false))
}
pub fn submit_command(
&mut self,
input: &str,
state: &mut S,
) -> TuiPagesResult<TuiPagesOutput<A>, Handler::Error> {
match self.commands.process(input) {
CommandResponse::Execute(action) => {
let quit_requested = self.dispatch_action(action, state)?;
Ok(TuiPagesOutput::new(
TuiPagesStatus::ActionHandled,
quit_requested,
))
}
CommandResponse::Incomplete(hints) => Ok(TuiPagesOutput::new(
TuiPagesStatus::CommandIncomplete(hints),
false,
)),
CommandResponse::Unknown => {
Ok(TuiPagesOutput::new(TuiPagesStatus::CommandUnknown, false))
}
CommandResponse::Empty => Ok(TuiPagesOutput::new(TuiPagesStatus::CommandEmpty, false)),
}
}
pub fn apply_effect(&mut self, effect: TuiEffect<V, O, M>, state: &S) -> bool {
match effect {
TuiEffect::None => false,
TuiEffect::Focus(intent) => {
self.focus.apply_focus_intent(intent);
false
}
TuiEffect::Navigate(view) => {
self.buffer.update_history(view);
self.refresh_page(state);
false
}
TuiEffect::NextBuffer => {
self.switch_buffer(true, state);
false
}
TuiEffect::PreviousBuffer => {
self.switch_buffer(false, state);
false
}
TuiEffect::CloseBuffer => {
self.buffer.close_active_buffer(self.fallback_view.clone());
self.refresh_page(state);
false
}
TuiEffect::SplitPane(split) => {
self.buffer.split_active_pane(split);
false
}
TuiEffect::ClosePane => {
self.buffer.close_active_pane();
self.refresh_page(state);
false
}
TuiEffect::NextPane => {
self.buffer.focus_next_pane(self.focus.focus_wrap());
self.refresh_page(state);
false
}
TuiEffect::PreviousPane => {
self.buffer.focus_previous_pane(self.focus.focus_wrap());
self.refresh_page(state);
false
}
TuiEffect::RefreshPage => {
self.refresh_page(state);
false
}
TuiEffect::Quit => true,
}
}
fn current_page_spec(&self, state: &S) -> PageSpec<O> {
let view = self.current_view();
let focus = self.focus.current();
self.pages.page_spec(view, state, focus.as_ref())
}
fn dispatch_action(
&mut self,
action: A,
state: &mut S,
) -> TuiPagesResult<bool, Handler::Error> {
let ctx = ActionContext {
current_view: self.current_view().clone(),
focus: self.focus.current(),
has_overlay: self.focus.has_overlay(),
};
let outcome = self
.handler
.handle_action(action, ctx, state)
.map_err(TuiPagesError::Handler)?;
Ok(self.apply_outcome(outcome, state))
}
fn dispatch_text(
&mut self,
chord: KeyChord,
state: &mut S,
) -> TuiPagesResult<bool, Handler::Error> {
let ctx = ActionContext {
current_view: self.current_view().clone(),
focus: self.focus.current(),
has_overlay: self.focus.has_overlay(),
};
let outcome = self
.handler
.handle_text(chord, ctx, state)
.map_err(TuiPagesError::Handler)?;
Ok(self.apply_outcome(outcome, state))
}
fn apply_outcome(&mut self, outcome: ActionOutcome<V, O, M>, state: &S) -> bool {
let mut quit_requested = false;
for effect in outcome.effects {
quit_requested |= self.apply_effect(effect, state);
}
quit_requested
}
fn switch_buffer(&mut self, forward: bool, state: &S) {
if self.buffer.history.len() <= 1 {
return;
}
let len = self.buffer.history.len();
self.buffer.active_index =
self.focus
.focus_wrap()
.step(self.buffer.active_index, len, forward);
self.buffer.sync_active_pane_to_active_buffer();
self.refresh_page(state);
}
}
#[derive(Debug, Clone)]
pub struct TuiPagesBuilder<V, A, S, O = (), M = (), Pages = (), Handler = ()> {
initial_view: V,
fallback_view: Option<V>,
pub(crate) input_registry: InputRegistry<A>,
command_registry: CommandRegistry<A>,
pub(crate) input_timeout_ms: u64,
command_timeout_ms: u64,
focus_wrap: FocusWrap,
reserve_command_line: bool,
pub(crate) text_input_mapper: Option<fn(KeyChord) -> Option<A>>,
pub(crate) key_hooks: Vec<KeyHook<V, A, S, O, M>>,
pages: Pages,
handler: Handler,
_state: PhantomData<S>,
_overlay: PhantomData<O>,
_modal: PhantomData<M>,
}
impl<V, A, S, O, M> TuiPagesBuilder<V, A, S, O, M, (), ()> {
pub fn new(initial_view: V) -> Self {
Self {
initial_view,
fallback_view: None,
input_registry: InputRegistry::empty(),
command_registry: CommandRegistry::new(),
input_timeout_ms: 1000,
command_timeout_ms: 1000,
focus_wrap: FocusWrap::default(),
reserve_command_line: cfg!(feature = "command-line"),
text_input_mapper: None,
key_hooks: Vec::new(),
pages: (),
handler: (),
_state: PhantomData,
_overlay: PhantomData,
_modal: PhantomData,
}
}
}
impl<V, A, S, O, M, Pages, Handler> TuiPagesBuilder<V, A, S, O, M, Pages, Handler> {
pub fn fallback_view(mut self, fallback_view: V) -> Self {
self.fallback_view = Some(fallback_view);
self
}
pub fn input_timeout_ms(mut self, timeout_ms: u64) -> Self {
self.input_timeout_ms = timeout_ms;
self
}
pub fn command_timeout_ms(mut self, timeout_ms: u64) -> Self {
self.command_timeout_ms = timeout_ms;
self
}
pub fn pages<NextPages>(
self,
pages: NextPages,
) -> TuiPagesBuilder<V, A, S, O, M, NextPages, Handler> {
TuiPagesBuilder {
initial_view: self.initial_view,
fallback_view: self.fallback_view,
input_registry: self.input_registry,
command_registry: self.command_registry,
input_timeout_ms: self.input_timeout_ms,
command_timeout_ms: self.command_timeout_ms,
focus_wrap: self.focus_wrap,
reserve_command_line: self.reserve_command_line,
text_input_mapper: self.text_input_mapper,
key_hooks: self.key_hooks,
pages,
handler: self.handler,
_state: PhantomData,
_overlay: PhantomData,
_modal: PhantomData,
}
}
pub fn page_fn(
self,
page_fn: PageFn<V, S, O>,
) -> TuiPagesBuilder<V, A, S, O, M, PageFn<V, S, O>, Handler> {
self.pages(page_fn)
}
pub fn handler<NextHandler>(
self,
handler: NextHandler,
) -> TuiPagesBuilder<V, A, S, O, M, Pages, NextHandler> {
TuiPagesBuilder {
initial_view: self.initial_view,
fallback_view: self.fallback_view,
input_registry: self.input_registry,
command_registry: self.command_registry,
input_timeout_ms: self.input_timeout_ms,
command_timeout_ms: self.command_timeout_ms,
focus_wrap: self.focus_wrap,
reserve_command_line: self.reserve_command_line,
text_input_mapper: self.text_input_mapper,
key_hooks: self.key_hooks,
pages: self.pages,
handler,
_state: PhantomData,
_overlay: PhantomData,
_modal: PhantomData,
}
}
pub fn focus_wrap(mut self, wrap: FocusWrap) -> Self {
self.focus_wrap = wrap;
self
}
pub fn reserve_command_line(mut self, reserve: bool) -> Self {
self.reserve_command_line = reserve;
self
}
pub fn text_input_mapper(mut self, mapper: fn(KeyChord) -> Option<A>) -> Self {
self.text_input_mapper = Some(mapper);
self
}
pub fn keymap(
mut self,
mode: impl Into<ModeId>,
configure: impl FnOnce(&mut KeyMap<A>),
) -> Self {
let mode = mode.into();
configure(self.input_registry.map_mut(mode.as_str()));
self
}
pub fn bind(mut self, mode: impl Into<ModeId>, binding: &str, action: A) -> Self {
let mode = mode.into();
self.input_registry
.map_mut(mode.as_str())
.bind(parse_binding(binding), action);
self
}
pub fn command<I, Alias>(
mut self,
action_name: impl Into<String>,
aliases: I,
action: A,
) -> Self
where
A: Clone,
I: IntoIterator<Item = Alias>,
Alias: Into<String>,
{
self.command_registry
.bind_aliases(action_name, aliases, action);
self
}
pub fn build(self) -> TuiPages<V, A, S, Pages, Handler, O, M>
where
V: Clone + PartialEq,
Pages: PageProvider<V, S, O>,
Handler: TuiActionHandler<V, A, S, O, M>,
{
let fallback_view = self
.fallback_view
.unwrap_or_else(|| self.initial_view.clone());
let mut focus = FocusManager::new();
focus.set_focus_wrap(self.focus_wrap);
TuiPages {
input: InputPipeline::new(self.input_registry, self.input_timeout_ms),
commands: CommandResolver::new(self.command_registry, self.command_timeout_ms),
focus,
buffer: BufferState::new(self.initial_view),
pages: self.pages,
handler: self.handler,
fallback_view,
reserve_command_line: self.reserve_command_line,
text_input_mapper: self.text_input_mapper,
key_hooks: self.key_hooks,
_state: PhantomData,
}
}
}