diff --git a/crates/editor/src/render/model/mod.rs b/crates/editor/src/render/model/mod.rs index 1dee0938e68..bb019bf7414 100644 --- a/crates/editor/src/render/model/mod.rs +++ b/crates/editor/src/render/model/mod.rs @@ -859,6 +859,18 @@ impl CharCellState { self.scroll_offset.get() } + /// Clamps the retained viewport offset to the current display-row count. + pub fn clamp_scroll_offset( + &self, + cursor_char_offset: CharOffset, + viewport_rows: u32, + hidden_line_ranges: &[Range], + ) { + let (_, total_rows) = self.display_geometry(cursor_char_offset, hidden_line_ranges); + let (offset, _) = self.clamped_scroll_window(total_rows, viewport_rows); + self.scroll_offset.set(offset); + } + /// Scrolls the viewport by `rows` display rows (negative scrolls toward /// the top), clamped to `[0, total_rows - visible_rows]`. Independent of /// the cursor: wheel scrolling must not snap the viewport back to it. @@ -892,14 +904,7 @@ impl CharCellState { ) { let (cursor_row, total_rows) = self.display_geometry(cursor_char_offset, hidden_line_ranges); - let visible_rows = total_rows.min(viewport_rows).max(1); - // A stale offset can point past the last remaining row (e.g. after a - // deletion shrank the content); clamp it so the visible window always - // overlaps real rows before following the cursor. - let mut offset = self - .scroll_offset - .get() - .min(total_rows.saturating_sub(visible_rows)); + let (mut offset, visible_rows) = self.clamped_scroll_window(total_rows, viewport_rows); let Some(cursor_row) = cursor_row else { self.scroll_offset.set(offset); return; @@ -912,6 +917,16 @@ impl CharCellState { self.scroll_offset.set(offset); } + /// Returns the clamped first row and visible-row count for a viewport. + fn clamped_scroll_window(&self, total_rows: u32, viewport_rows: u32) -> (u32, u32) { + let visible_rows = total_rows.min(viewport_rows).max(1); + let offset = self + .scroll_offset + .get() + .min(total_rows.saturating_sub(visible_rows)); + (offset, visible_rows) + } + /// The cursor's display row and the total display-row count — including /// the deferred-wrap phantom row the cursor sits on when a logical line /// exactly fills the terminal width, which the lattice's rows never count diff --git a/crates/editor/src/render/model/mod_tests.rs b/crates/editor/src/render/model/mod_tests.rs index 66d707baa86..eee335cd3e4 100644 --- a/crates/editor/src/render/model/mod_tests.rs +++ b/crates/editor/src/render/model/mod_tests.rs @@ -2086,4 +2086,16 @@ mod char_cell_scroll { state.follow_cursor(CharOffset::zero(), 2, &[]); assert_eq!(state.scroll_offset(), 0); } + + #[test] + fn clamp_scroll_offset_repairs_stale_offset_without_following_cursor() { + let state = CharCellState::new(3, None); + state.update_text("abcdef"); + state.scroll_by(100, 1, CharOffset::from(6), &[]); + assert_eq!(state.scroll_offset(), 2); + + state.set_terminal_width(10); + state.clamp_scroll_offset(CharOffset::from(6), 1, &[]); + assert_eq!(state.scroll_offset(), 0); + } } diff --git a/crates/warp_tui/src/editor_element.rs b/crates/warp_tui/src/editor_element.rs index 39e534b59f1..f4f8b170d77 100644 --- a/crates/warp_tui/src/editor_element.rs +++ b/crates/warp_tui/src/editor_element.rs @@ -45,7 +45,7 @@ const WHEEL_STEP: isize = 2; /// owning view translates them into its own typed actions and applies them to /// the editor model (mirroring how the GUI's element dispatches into its view). #[derive(Debug, Clone)] -pub(crate) enum TuiEditorAction { +pub enum TuiEditorAction { /// Insert a printable character (only emitted when the element is /// [`editable`](TuiEditorElement::editable)). InsertChar(char), @@ -312,10 +312,18 @@ impl TuiEditorElement { 0 }; let content_width = full_width.saturating_sub(self.gutter_cols); + let width_changed = char_cell.terminal_width() != content_width; char_cell.set_terminal_width(content_width); let chars: Vec = self.text.chars().collect(); let cursor_offset = CharOffset::from(self.cursor_offset.as_usize().saturating_sub(1)); + if let Some(viewport_rows) = self.viewport_rows { + if width_changed { + char_cell.follow_cursor(cursor_offset, viewport_rows, &hidden); + } else { + char_cell.clamp_scroll_offset(cursor_offset, viewport_rows, &hidden); + } + } // The first visible row is model-side scroll state; unwindowed // consumers always render from the top. let first_visible_row = if self.viewport_rows.is_some() { diff --git a/crates/warp_tui/src/editor_element_tests.rs b/crates/warp_tui/src/editor_element_tests.rs index 935f4cfbfd5..84671cd8ee2 100644 --- a/crates/warp_tui/src/editor_element_tests.rs +++ b/crates/warp_tui/src/editor_element_tests.rs @@ -310,3 +310,26 @@ fn scroll_windows_the_visible_rows() { }); }); } + +#[test] +fn width_change_follows_cursor_after_reflow() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let model = model(ctx, "abcde"); + model.update(ctx, |model, ctx| { + model.select_at(CharOffset::from(6), false, ctx); + model.end_selection(ctx); + }); + + let wide = TuiEditorElement::new(&model, ctx).with_viewport_rows(1); + assert_eq!(render_lines(ctx, wide, 10, 10), vec!["abcde"]); + + let narrow = TuiEditorElement::new(&model, ctx).with_viewport_rows(1); + assert_eq!(render_lines(ctx, narrow, 3, 10), vec!["de"]); + let render = model.as_ref(ctx).render_state().as_ref(ctx); + let char_cell = render.char_cell().expect("char-cell model"); + assert_eq!(char_cell.scroll_offset(), 1); + }); + }); +} diff --git a/crates/warp_tui/src/editor_interaction.rs b/crates/warp_tui/src/editor_interaction.rs new file mode 100644 index 00000000000..6aad5b1e26c --- /dev/null +++ b/crates/warp_tui/src/editor_interaction.rs @@ -0,0 +1,559 @@ +use std::ops::Range; + +use string_offset::CharOffset; +use warp::editor::CodeEditorModel; +use warp_editor::model::{CoreEditorModel, PlainTextEditorModel}; +use warp_editor::render::model::CharCellState; +use warp_editor::selection::{TextDirection, TextUnit}; +use warpui_core::text::word_boundaries::WordBoundariesPolicy; +use warpui_core::{AppContext, ModelHandle}; + +use crate::editor_element::TuiEditorAction; + +/// Editing commands shared by TUI text fields. +#[derive(Clone, Copy, Debug)] +pub enum TuiEditorCommand { + InsertNewline, + Backspace, + DeleteForward, + DeleteWordBackward, + DeleteWordForward, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveWordLeft, + MoveWordRight, + MoveToLineStart, + MoveToLineEnd, + SelectLeft, + SelectRight, + SelectUp, + SelectDown, + SelectWordLeft, + SelectWordRight, + SelectAll, + KillToLineEnd, + KillToLineStart, + Yank, + Undo, + Redo, +} + +/// Controls whether an editor accepts hard line breaks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorLineMode { + SingleLine, + Multiline, +} + +/// Editing behavior supplied by each TUI editor consumer. +#[derive(Clone, Copy, Debug)] +pub(crate) struct TuiEditorBehavior { + line_mode: TuiEditorLineMode, + viewport_rows: u32, +} + +impl TuiEditorBehavior { + /// Configures a one-row editor that rejects text after the first line. + pub(crate) const fn single_line() -> Self { + Self { + line_mode: TuiEditorLineMode::SingleLine, + viewport_rows: 1, + } + } + + /// Configures a multiline editor with a bounded viewport. + pub(crate) const fn multiline(viewport_rows: u32) -> Self { + Self { + line_mode: TuiEditorLineMode::Multiline, + viewport_rows, + } + } + + /// Returns the number of visible editor rows. + pub(crate) const fn viewport_rows(self) -> u32 { + self.viewport_rows + } + + /// Applies this editor's line policy to inserted or replacement text. + pub(crate) fn normalize_text(self, text: &str) -> &str { + match self.line_mode { + TuiEditorLineMode::SingleLine => text.lines().next().unwrap_or_default(), + TuiEditorLineMode::Multiline => text, + } + } + + /// Returns whether hard newlines are accepted. + const fn accepts_newlines(self) -> bool { + matches!(self.line_mode, TuiEditorLineMode::Multiline) + } +} + +/// Viewport work required after applying an editor interaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorInteractionOutcome { + FollowCursor, + PreserveViewport, +} + +/// Selects stable user-configurable names for each binding consumer. +#[derive(Clone, Copy)] +pub(crate) enum TuiEditorBindingTarget { + Input, + Editor, +} + +struct EditorBindingSpec { + command: TuiEditorCommand, + input_name: Option<&'static str>, + editor_name: Option<&'static str>, + description: &'static str, + keys: &'static [&'static str], +} + +/// One target-specific editor binding definition. +#[derive(Clone, Copy)] +pub(crate) struct TuiEditorBindingSpec { + pub(crate) command: TuiEditorCommand, + pub(crate) name: &'static str, + pub(crate) description: &'static str, + pub(crate) keys: &'static [&'static str], +} + +const SHARED_EDITOR_BINDINGS: &[EditorBindingSpec] = &[ + EditorBindingSpec { + command: TuiEditorCommand::InsertNewline, + input_name: Some("tui:input:insert_newline"), + editor_name: None, + description: "Insert a newline", + keys: &["shift-enter", "ctrl-j", "alt-enter"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Backspace, + input_name: Some("tui:input:backspace"), + editor_name: Some("tui:editor:backspace"), + description: "Delete the previous character", + keys: &["backspace", "shift-backspace", "ctrl-h"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteForward, + input_name: Some("tui:input:delete_forward"), + editor_name: Some("tui:editor:delete_forward"), + description: "Delete the next character", + keys: &["delete", "ctrl-d"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteWordBackward, + input_name: Some("tui:input:delete_word_backward"), + editor_name: Some("tui:editor:delete_word_backward"), + description: "Delete the previous word", + keys: &["ctrl-w", "ctrl-backspace", "alt-backspace"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteWordForward, + input_name: Some("tui:input:delete_word_forward"), + editor_name: Some("tui:editor:delete_word_forward"), + description: "Delete the next word", + keys: &["alt-d", "alt-delete", "ctrl-delete"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveLeft, + input_name: Some("tui:input:move_left"), + editor_name: Some("tui:editor:move_left"), + description: "Move cursor left", + keys: &["left", "ctrl-b"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveRight, + input_name: Some("tui:input:move_right"), + editor_name: Some("tui:editor:move_right"), + description: "Move cursor right", + keys: &["right", "ctrl-f"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveUp, + input_name: Some("tui:input:move_up"), + editor_name: None, + description: "Move cursor up", + keys: &["up", "ctrl-p"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveDown, + input_name: Some("tui:input:move_down"), + editor_name: None, + description: "Move cursor down", + keys: &["down", "ctrl-n"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveWordLeft, + input_name: Some("tui:input:move_word_left"), + editor_name: Some("tui:editor:move_word_left"), + description: "Move cursor one word left", + keys: &["alt-left", "alt-b", "ctrl-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveWordRight, + input_name: Some("tui:input:move_word_right"), + editor_name: Some("tui:editor:move_word_right"), + description: "Move cursor one word right", + keys: &["alt-right", "alt-f", "ctrl-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveToLineStart, + input_name: Some("tui:input:move_to_line_start"), + editor_name: Some("tui:editor:move_to_line_start"), + description: "Move cursor to start of line", + keys: &["home", "ctrl-a"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveToLineEnd, + input_name: Some("tui:input:move_to_line_end"), + editor_name: Some("tui:editor:move_to_line_end"), + description: "Move cursor to end of line", + keys: &["end", "ctrl-e"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectLeft, + input_name: Some("tui:input:select_left"), + editor_name: Some("tui:editor:select_left"), + description: "Extend selection left", + keys: &["shift-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectRight, + input_name: Some("tui:input:select_right"), + editor_name: Some("tui:editor:select_right"), + description: "Extend selection right", + keys: &["shift-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectUp, + input_name: Some("tui:input:select_up"), + editor_name: None, + description: "Extend selection up", + keys: &["shift-up"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectDown, + input_name: Some("tui:input:select_down"), + editor_name: None, + description: "Extend selection down", + keys: &["shift-down"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectWordLeft, + input_name: Some("tui:input:select_word_left"), + editor_name: Some("tui:editor:select_word_left"), + description: "Extend selection one word left", + keys: &["ctrl-shift-left", "alt-shift-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectWordRight, + input_name: Some("tui:input:select_word_right"), + editor_name: Some("tui:editor:select_word_right"), + description: "Extend selection one word right", + keys: &["ctrl-shift-right", "alt-shift-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectAll, + input_name: Some("tui:input:select_all"), + editor_name: Some("tui:editor:select_all"), + description: "Select all text", + keys: &["ctrl-shift-A"], + }, + EditorBindingSpec { + command: TuiEditorCommand::KillToLineEnd, + input_name: Some("tui:input:kill_to_line_end"), + editor_name: Some("tui:editor:kill_to_line_end"), + description: "Delete to end of line", + keys: &["ctrl-k"], + }, + EditorBindingSpec { + command: TuiEditorCommand::KillToLineStart, + input_name: Some("tui:input:kill_to_line_start"), + editor_name: Some("tui:editor:kill_to_line_start"), + description: "Delete to start of line", + keys: &["ctrl-u"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Yank, + input_name: Some("tui:input:yank"), + editor_name: Some("tui:editor:yank"), + description: "Paste the last deleted text", + keys: &["ctrl-y"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Undo, + input_name: Some("tui:input:undo"), + editor_name: Some("tui:editor:undo"), + description: "Undo", + keys: &["ctrl-z"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Redo, + input_name: Some("tui:input:redo"), + editor_name: Some("tui:editor:redo"), + description: "Redo", + keys: &["ctrl-shift-Z"], + }, +]; + +/// Returns the editor binding metadata applicable to one consumer. +pub(crate) fn editor_binding_specs( + target: TuiEditorBindingTarget, +) -> impl Iterator { + SHARED_EDITOR_BINDINGS.iter().filter_map(move |spec| { + let name = match target { + TuiEditorBindingTarget::Input => spec.input_name, + TuiEditorBindingTarget::Editor => spec.editor_name, + }?; + Some(TuiEditorBindingSpec { + command: spec.command, + name, + description: spec.description, + keys: spec.keys, + }) + }) +} + +/// Mutable editing state shared by TUI editor views. +#[derive(Debug, Default)] +pub(crate) struct TuiEditorState { + kill_buffer: String, +} + +impl TuiEditorState { + /// Applies a keybound command to a char-cell editor model. + pub(crate) fn apply_command( + &mut self, + model: &ModelHandle, + command: TuiEditorCommand, + behavior: TuiEditorBehavior, + ctx: &mut AppContext, + ) -> TuiEditorInteractionOutcome { + match command { + TuiEditorCommand::InsertNewline => { + if behavior.accepts_newlines() { + model.update(ctx, |model, ctx| model.user_insert("\n", ctx)); + } + } + TuiEditorCommand::Backspace => { + model.update(ctx, |model, ctx| model.backspace(ctx)); + } + TuiEditorCommand::DeleteForward => { + model.update(ctx, |model, ctx| { + model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx); + }); + } + TuiEditorCommand::DeleteWordBackward => { + model.update(ctx, |model, ctx| { + model.delete( + TextDirection::Backwards, + TextUnit::Word(WordBoundariesPolicy::Default), + false, + ctx, + ); + }); + } + TuiEditorCommand::DeleteWordForward => { + model.update(ctx, |model, ctx| { + model.delete( + TextDirection::Forwards, + TextUnit::Word(WordBoundariesPolicy::Default), + false, + ctx, + ); + }); + } + TuiEditorCommand::MoveLeft => { + model.update(ctx, |model, ctx| model.move_left(ctx)); + } + TuiEditorCommand::MoveRight => { + model.update(ctx, |model, ctx| model.move_right(ctx)); + } + TuiEditorCommand::MoveUp => { + model.update(ctx, |model, ctx| model.move_up(ctx)); + } + TuiEditorCommand::MoveDown => { + model.update(ctx, |model, ctx| model.move_down(ctx)); + } + TuiEditorCommand::MoveWordLeft => { + model.update(ctx, |model, ctx| { + model.backward_word_with_unit( + false, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::MoveWordRight => { + model.update(ctx, |model, ctx| { + model.forward_word_with_unit( + false, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::MoveToLineStart => { + model.update(ctx, |model, ctx| model.move_to_line_start(ctx)); + } + TuiEditorCommand::MoveToLineEnd => { + model.update(ctx, |model, ctx| model.move_to_line_end(ctx)); + } + TuiEditorCommand::SelectLeft => { + model.update(ctx, |model, ctx| model.select_left(ctx)); + } + TuiEditorCommand::SelectRight => { + model.update(ctx, |model, ctx| model.select_right(ctx)); + } + TuiEditorCommand::SelectUp => { + model.update(ctx, |model, ctx| model.select_up(ctx)); + } + TuiEditorCommand::SelectDown => { + model.update(ctx, |model, ctx| model.select_down(ctx)); + } + TuiEditorCommand::SelectWordLeft => { + model.update(ctx, |model, ctx| { + model.backward_word_with_unit( + true, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::SelectWordRight => { + model.update(ctx, |model, ctx| { + model.forward_word_with_unit( + true, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::SelectAll => { + model.update(ctx, |model, ctx| model.select_all(ctx)); + } + TuiEditorCommand::KillToLineEnd => { + if let Some(killed) = model.update(ctx, |model, ctx| { + model.kill_to_char_cell_visual_row_end(ctx) + }) { + self.kill_buffer = killed; + } + } + TuiEditorCommand::KillToLineStart => { + if let Some(killed) = model.update(ctx, |model, ctx| { + model.kill_to_char_cell_visual_row_start(ctx) + }) { + self.kill_buffer = killed; + } + } + TuiEditorCommand::Yank => { + if !self.kill_buffer.is_empty() { + model.update(ctx, |model, ctx| { + model.user_insert(&self.kill_buffer, ctx); + }); + } + } + TuiEditorCommand::Undo => { + model.update(ctx, |model, ctx| model.undo(ctx)); + } + TuiEditorCommand::Redo => { + model.update(ctx, |model, ctx| model.redo(ctx)); + } + } + TuiEditorInteractionOutcome::FollowCursor + } +} + +/// Applies an element-originated action and reports the required viewport work. +pub(crate) fn apply_editor_action( + model: &ModelHandle, + action: &TuiEditorAction, + behavior: TuiEditorBehavior, + ctx: &mut AppContext, +) -> TuiEditorInteractionOutcome { + match action { + TuiEditorAction::InsertChar(c) => { + model.update(ctx, |model, ctx| model.user_insert(&c.to_string(), ctx)); + } + TuiEditorAction::InsertText(text) => { + let text = behavior.normalize_text(text); + model.update(ctx, |model, ctx| model.user_insert(text, ctx)); + } + TuiEditorAction::SelectionStartAt { offset } => { + model.update(ctx, |model, ctx| model.select_at(*offset, false, ctx)); + } + TuiEditorAction::SelectionExtendTo { offset } => { + model.update(ctx, |model, ctx| { + model.set_last_selection_head(*offset, ctx) + }); + } + TuiEditorAction::SelectWordAt { offset } => { + model.update(ctx, |model, ctx| model.select_word_at(*offset, false, ctx)); + } + TuiEditorAction::SelectLineAt { offset } => { + model.update(ctx, |model, ctx| model.select_line_at(*offset, false, ctx)); + } + TuiEditorAction::SelectionUpdateTo { offset } => { + model.update(ctx, |model, ctx| { + model.update_pending_selection(*offset, ctx) + }); + } + TuiEditorAction::SelectionEnd => { + model.update(ctx, |model, ctx| model.end_selection(ctx)); + } + TuiEditorAction::Scroll { rows } => { + scroll_editor_viewport(model, *rows, behavior, ctx); + return TuiEditorInteractionOutcome::PreserveViewport; + } + } + TuiEditorInteractionOutcome::FollowCursor +} + +/// Scrolls a char-cell viewport just enough to keep its primary cursor visible. +pub(crate) fn follow_editor_cursor( + model: &ModelHandle, + behavior: TuiEditorBehavior, + ctx: &AppContext, +) { + with_editor_viewport(model, ctx, |char_cell, cursor_offset, hidden| { + char_cell.follow_cursor(cursor_offset, behavior.viewport_rows(), hidden); + }); +} + +/// Scrolls a char-cell viewport without moving its primary cursor. +fn scroll_editor_viewport( + model: &ModelHandle, + rows: isize, + behavior: TuiEditorBehavior, + ctx: &AppContext, +) { + with_editor_viewport(model, ctx, |char_cell, cursor_offset, hidden| { + char_cell.scroll_by(rows, behavior.viewport_rows(), cursor_offset, hidden); + }); +} + +/// Reads the primary cursor and hidden rows for a char-cell viewport operation. +fn with_editor_viewport( + model: &ModelHandle, + ctx: &AppContext, + f: impl FnOnce(&CharCellState, CharOffset, &[Range]), +) { + let model = model.as_ref(ctx); + let cursor_offset = model + .selection_model() + .as_ref(ctx) + .cursors(ctx) + .into_iter() + .next() + .unwrap_or_default(); + let render = model.render_state().as_ref(ctx); + let Some(char_cell) = render.char_cell() else { + return; + }; + let cursor_offset = CharOffset::from(cursor_offset.as_usize().saturating_sub(1)); + let hidden = char_cell.hidden_line_ranges(ctx); + f(char_cell, cursor_offset, &hidden); +} diff --git a/crates/warp_tui/src/editor_view.rs b/crates/warp_tui/src/editor_view.rs new file mode 100644 index 00000000000..8a1ffad1662 --- /dev/null +++ b/crates/warp_tui/src/editor_view.rs @@ -0,0 +1,190 @@ +//! Generic focusable TUI text field over the shared editor model and element. +//! +//! Unlike [`crate::input::TuiInputView`], this view owns no prompt submission, +//! input-mode, inline-menu, or form policy. Embedding views provide that chrome +//! and behavior while reusing model-backed editing and focus handling. + +use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; +use warp_editor::content::buffer::InitialBufferState; +use warp_editor::model::CoreEditorModel; +use warpui_core::elements::tui::{TuiElement, TuiHoverable}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{ + AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, + ViewContext, +}; + +use crate::editor_element::{TuiEditorAction, TuiEditorElement}; +use crate::editor_interaction::{ + apply_editor_action, follow_editor_cursor, TuiEditorBehavior, TuiEditorCommand, + TuiEditorInteractionOutcome, TuiEditorState, +}; + +/// Events emitted when the editor content changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorViewEvent { + Changed(String), +} + +/// Actions raised by the shared editor element or editor chrome. +#[derive(Clone, Debug)] +pub(crate) enum TuiEditorViewAction { + FocusRequested, + Editor(TuiEditorAction), + Command(TuiEditorCommand), +} + +/// A reusable single-line editor view. +pub(crate) struct TuiEditorView { + model: ModelHandle, + editor_state: TuiEditorState, + editor_behavior: TuiEditorBehavior, + focused: bool, + mouse_state: MouseStateHandle, +} + +impl TuiEditorView { + /// Creates an empty single-line editor backed by a char-cell model. + pub(crate) fn single_line(ctx: &mut ViewContext) -> Self { + let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(1, ctx)); + ctx.subscribe_to_model(&model, |editor, _, event, ctx| { + if !matches!(event, CodeEditorModelEvent::ContentChanged { .. }) { + return; + } + let text = editor.text(ctx); + ctx.emit(TuiEditorViewEvent::Changed(text)); + ctx.notify(); + }); + Self { + model, + editor_state: TuiEditorState::default(), + editor_behavior: TuiEditorBehavior::single_line(), + focused: false, + mouse_state: MouseStateHandle::default(), + } + } + + /// Returns the current editor text. + pub(crate) fn text(&self, ctx: &AppContext) -> String { + let model = self.model.as_ref(ctx); + let buffer = model.content().as_ref(ctx); + if buffer.is_empty() { + String::new() + } else { + buffer.text().into_string() + } + } + + /// Returns whether the editor owns focus. + pub(crate) fn is_focused(&self) -> bool { + self.focused + } + + /// Replaces editor content without emitting `Changed`. + pub(crate) fn set_text(&mut self, text: impl Into, ctx: &mut ViewContext) { + let text = text.into(); + let text = self.editor_behavior.normalize_text(&text).to_string(); + if self.text(ctx) == text { + return; + } + self.model.update(ctx, |model, ctx| { + model.reset_content(InitialBufferState::plain_text(&text), ctx); + }); + ctx.notify(); + } + + /// Renders the shared editor configured as a one-row field. + fn render_editor(&self, ctx: &AppContext) -> Box { + TuiEditorElement::new(&self.model, ctx) + .editable() + .with_view_focused(self.focused) + .with_viewport_rows(self.editor_behavior.viewport_rows()) + .on_action(|action, event_ctx| { + event_ctx.dispatch_typed_action(TuiEditorViewAction::Editor(action)); + }) + .finish() + } + + /// Applies an editor action using the same model operations as `TuiInputView`. + fn handle_editor_action( + &mut self, + action: &TuiEditorAction, + ctx: &mut ViewContext, + ) -> TuiEditorInteractionOutcome { + if matches!( + action, + TuiEditorAction::SelectionStartAt { .. } + | TuiEditorAction::SelectionExtendTo { .. } + | TuiEditorAction::SelectWordAt { .. } + | TuiEditorAction::SelectLineAt { .. } + ) { + ctx.focus_self(); + } + apply_editor_action(&self.model, action, self.editor_behavior, ctx) + } + + /// Applies a keybound editor command to the shared editor model. + fn handle_command( + &mut self, + command: TuiEditorCommand, + ctx: &mut ViewContext, + ) -> TuiEditorInteractionOutcome { + self.editor_state + .apply_command(&self.model, command, self.editor_behavior, ctx) + } +} + +impl Entity for TuiEditorView { + type Event = TuiEditorViewEvent; +} + +impl TuiView for TuiEditorView { + fn ui_name() -> &'static str { + "TuiEditorView" + } + + fn render(&self, app: &AppContext) -> Box { + TuiHoverable::new(self.mouse_state.clone(), self.render_editor(app)) + .on_click(|event_ctx, _| { + event_ctx.dispatch_typed_action(TuiEditorViewAction::FocusRequested); + }) + .finish() + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() { + self.focused = true; + ctx.notify(); + } + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } +} + +impl TypedActionView for TuiEditorView { + type Action = TuiEditorViewAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + let outcome = match action { + TuiEditorViewAction::FocusRequested => { + ctx.focus_self(); + TuiEditorInteractionOutcome::FollowCursor + } + TuiEditorViewAction::Editor(action) => self.handle_editor_action(action, ctx), + TuiEditorViewAction::Command(command) => self.handle_command(*command, ctx), + }; + if outcome == TuiEditorInteractionOutcome::FollowCursor { + follow_editor_cursor(&self.model, self.editor_behavior, ctx); + } + ctx.notify(); + } +} + +#[cfg(test)] +#[path = "editor_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/editor_view_tests.rs b/crates/warp_tui/src/editor_view_tests.rs new file mode 100644 index 00000000000..0a7bbf4bb6b --- /dev/null +++ b/crates/warp_tui/src/editor_view_tests.rs @@ -0,0 +1,351 @@ +use std::collections::HashSet; + +use string_offset::CharOffset; +use warp::tui_export::Appearance; +use warp_editor::model::CoreEditorModel; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, EntityIdMap}; +use warpui_core::elements::tui::{ + TuiBuffer, TuiBufferExt, TuiConstraint, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, + TuiRect, TuiScreenPosition, TuiSize, +}; +use warpui_core::keymap::Trigger; +use warpui_core::{App, TuiView as _, TypedActionView as _}; + +use super::{TuiEditorView, TuiEditorViewAction}; +use crate::editor_element::TuiEditorAction; +use crate::editor_interaction::TuiEditorCommand; +use crate::test_fixtures::TestHostView; + +/// Renders an editor view to trimmed lines. +fn render_lines(app: &App, editor: &warpui_core::ViewHandle) -> Vec { + render_lines_at_width(app, editor, 30) +} + +#[test] +fn layout_clamps_stale_scroll_after_resize_and_text_replacement() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines_at_width(&app, &editor, 3); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcdef".to_string())), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 2); + + assert_eq!(render_lines_at_width(&app, &editor, 30)[0], "abcdef"); + assert_eq!(scroll_offset(&app, &editor), 0); + + render_lines_at_width(&app, &editor, 3); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveToLineEnd), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 2); + + editor.update(&mut app, |editor, ctx| editor.set_text("x", ctx)); + assert_eq!(scroll_offset(&app, &editor), 2); + assert_eq!(render_lines_at_width(&app, &editor, 3)[0], "x"); + assert_eq!(scroll_offset(&app, &editor), 0); + }); +} + +/// Renders an editor view at a fixed width. +fn render_lines_at_width( + app: &App, + editor: &warpui_core::ViewHandle, + width: u16, +) -> Vec { + app.read(|ctx| { + let mut element = editor.as_ref(ctx).render(ctx); + let mut rendered_views = EntityIdMap::default(); + let mut layout_ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = element.layout( + TuiConstraint::loose(TuiSize::new(width, 4)), + &mut layout_ctx, + ctx, + ); + let area = TuiRect::new(0, 0, size.width, size.height); + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_string()) + .collect() + }) +} + +/// Returns the editor's first visible char-cell row. +fn scroll_offset(app: &App, editor: &warpui_core::ViewHandle) -> u32 { + editor.read(app, |editor, ctx| { + editor + .model + .as_ref(ctx) + .render_state() + .as_ref(ctx) + .char_cell() + .expect("TUI editor model is char-cell") + .scroll_offset() + }) +} + +#[test] +fn single_line_paste_discards_later_lines() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText( + "first\nsecond".to_string(), + )), + ctx, + ); + }); + + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "first"); + editor.update(&mut app, |editor, ctx| { + editor.set_text("third\nfourth", ctx); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::InsertNewline), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "third"); + }); +} + +#[test] +fn kill_and_yank_are_shared_with_the_generic_editor() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines(&app, &editor); + + editor.update(&mut app, |editor, ctx| { + editor.set_text("abcd", ctx); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::KillToLineEnd), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "ab"); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action(&TuiEditorViewAction::Command(TuiEditorCommand::Yank), ctx); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "abcd"); + }); +} + +#[test] +fn editor_follows_cursor_within_its_one_row_viewport() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines_at_width(&app, &editor, 3); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcd".to_string())), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 1); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 0); + }); +} + +#[test] +fn focus_hooks_update_editor_focus_without_changing_text() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (window_id, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + editor.update(&mut app, |editor, ctx| { + editor.set_text("gen", ctx); + ctx.focus_self(); + }); + assert!(editor.read(&app, |editor, _| editor.is_focused())); + assert_eq!(render_lines(&app, &editor)[0], "gen"); + + let other = app.update(|ctx| ctx.add_tui_view(window_id, |_| TestHostView)); + other.update(&mut app, |_, ctx| ctx.focus_self()); + assert!(!editor.read(&app, |editor, _| editor.is_focused())); + assert_eq!(render_lines(&app, &editor)[0], "gen"); + }); +} + +#[test] +fn keybinding_initializer_registers_line_start_for_input_and_editor() { + App::test((), |mut app| async move { + app.update(crate::keybindings::init); + + let triggers_for = |name: &str| { + app.read(|ctx| { + ctx.get_key_bindings() + .filter(|binding| binding.name == name) + .filter_map(|binding| match binding.trigger { + Trigger::Keystrokes(keys) => keys.first().map(|key| key.normalized()), + Trigger::Empty | Trigger::Standard(_) | Trigger::Custom(_) => None, + }) + .collect::>() + }) + }; + let expected = HashSet::from(["home".to_string(), "ctrl-a".to_string()]); + assert_eq!(triggers_for("tui:input:move_to_line_start"), expected); + assert_eq!(triggers_for("tui:editor:move_to_line_start"), expected); + assert_eq!( + triggers_for("tui:editor:kill_to_line_end"), + HashSet::from(["ctrl-k".to_string()]) + ); + assert_eq!( + triggers_for("tui:input:insert_newline"), + HashSet::from([ + "shift-enter".to_string(), + "ctrl-j".to_string(), + "alt-enter".to_string(), + ]) + ); + assert!(triggers_for("tui:editor:insert_newline").is_empty()); + assert!(app.read(|ctx| ctx.get_binding_by_name("tui:editor:move_up").is_none())); + }); +} + +#[test] +fn mouse_selection_action_focuses_the_editor() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (window_id, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + let other = app.update(|ctx| ctx.add_tui_view(window_id, |_| TestHostView)); + other.update(&mut app, |_, ctx| ctx.focus_self()); + assert!(!editor.read(&app, |editor, _| editor.is_focused())); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::SelectionStartAt { + offset: CharOffset::from(1), + }), + ctx, + ); + }); + assert!(editor.read(&app, |editor, _| editor.is_focused())); + }); +} + +#[test] +fn actions_edit_the_single_line_buffer() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("gen".to_string())), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::Backspace), + ctx, + ); + }); + // Line navigation is visual-row-aware; layout establishes the real + // terminal width before the command runs. + render_lines(&app, &editor); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveToLineStart), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertChar('X')), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "Xge"); + }); +} diff --git a/crates/warp_tui/src/input/kill_buffer.rs b/crates/warp_tui/src/input/kill_buffer.rs deleted file mode 100644 index ad31e61c44c..00000000000 --- a/crates/warp_tui/src/input/kill_buffer.rs +++ /dev/null @@ -1,44 +0,0 @@ -/// A single-entry kill buffer for the TUI input view. -/// -/// Stores the last text killed by `Ctrl+K`, `Ctrl+U`, `Ctrl+W`, or `Alt+D`. -/// `Ctrl+Y` yanks (pastes) the stored text back into the input. -/// -/// This is intentionally simple — a kill ring (multi-entry yank cycle) is -/// listed as a follow-up in `specs/tui-input-view/TECH.md`. -#[derive(Debug, Default)] -pub struct KillBuffer { - content: String, -} - -impl KillBuffer { - /// Store `text` as the killed content, replacing any previous entry. - pub fn kill(&mut self, text: impl Into) { - self.content = text.into(); - } - - /// Append `text` to the current kill buffer content. - /// Used when multiple consecutive kills are combined (e.g. `Ctrl+K` at - /// the end of one line followed immediately by another `Ctrl+K`). - pub fn kill_append(&mut self, text: impl Into) { - self.content.push_str(&text.into()); - } - - /// Return the killed text for yanking, if any. - pub fn yank(&self) -> Option<&str> { - if self.content.is_empty() { - None - } else { - Some(&self.content) - } - } - - /// Return whether the kill buffer is empty. - pub fn is_empty(&self) -> bool { - self.content.is_empty() - } - - /// Clear the kill buffer. - pub fn clear(&mut self) { - self.content.clear(); - } -} diff --git a/crates/warp_tui/src/input/mod.rs b/crates/warp_tui/src/input/mod.rs index 26302a549c7..8b06687d808 100644 --- a/crates/warp_tui/src/input/mod.rs +++ b/crates/warp_tui/src/input/mod.rs @@ -5,10 +5,9 @@ //! by a [`warp::editor::CodeEditorModel`] in char-cell mode //! - [`view::TuiInputViewEvent`] — events emitted by the view (e.g. `Submitted`) //! -//! TUI-specific session state (kill buffer, scroll offset, terminal width) lives on -//! the view, not on a separate model. See `specs/tui-input-view/TECH.md` for details. +//! TUI-specific prompt policy lives on the view. See +//! `specs/tui-input-view/TECH.md` for details. -pub mod kill_buffer; pub mod view; pub use view::{init, TuiInputView, TuiInputViewEvent}; diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index bbcb77cdf09..f9973dc0f9f 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -4,7 +4,7 @@ //! //! - Holds a [`ModelHandle`] constructed in `LayoutMode::CharCell`. //! - Renders the core [`TuiEditorElement`] verbatim (editable, scroll-windowed). -//! - Owns the kill buffer and the `!` shell-mode composition. +//! - Owns prompt submission and the `!` shell-mode composition. //! - Dispatches keystrokes as [`TuiInputAction`] typed actions. //! - Emits [`TuiInputViewEvent::Submitted`] when the user presses Enter. //! @@ -17,7 +17,7 @@ //! model-side, mirroring the GUI split: viewport scroll state on the char-cell //! render state (`CharCellState`), drag-selection state on the selection model, //! visual-row kill edits on `CodeEditorModel`. What stays here is input policy: -//! the readline keybinding table, the kill buffer, submit, and shell mode. +//! prompt-only keybindings, submit, inline menus, and shell mode. //! //! See `specs/tui-input-view/TECH.md` for the full keybinding table. @@ -29,20 +29,21 @@ use warp::tui_export::{ AcceptSlashCommandOrSavedPrompt, BlocklistAIInputModel, InputType, InputTypeAutoDetectionSource, LLMId, TuiMcpAction, }; -use warp_editor::model::{CoreEditorModel, PlainTextEditorModel}; -use warp_editor::selection::TextUnit; +use warp_editor::model::CoreEditorModel; use warpui_core::elements::tui::{TuiContainer, TuiElement, TuiFlex, TuiHoverable, TuiText}; use warpui_core::elements::MouseStateHandle; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, EditableBinding}; -use warpui_core::text::word_boundaries::WordBoundariesPolicy; use warpui_core::{ AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; -use super::kill_buffer::KillBuffer; use crate::editor_element::{TuiEditorAction, TuiEditorElement, TuiEditorStyles}; +use crate::editor_interaction::{ + apply_editor_action, follow_editor_cursor, TuiEditorBehavior, TuiEditorCommand, + TuiEditorInteractionOutcome, TuiEditorState, +}; use crate::inline_menu::{active_inline_menu, TuiInlineMenu, TuiInlineMenuAccepted}; use crate::input_mode_policy::{self, AI_LOCKED_CONFIG, SHELL_LOCKED_CONFIG}; use crate::input_suggestions_mode::{TuiInputSuggestionsMode, TuiInputSuggestionsModeModel}; @@ -77,7 +78,7 @@ const INPUT_HANDLES_ESCAPE_FLAG: &str = "TuiInputHandlesEscape"; /// [`TuiEditorElement`]'s event dispatch, matching the GUI. pub fn init(app: &mut AppContext) { app.register_editable_bindings([ - // ── Submit / newline ───────────────────────────────────────── + // Submit and contextual Escape are prompt policy, not editor policy. EditableBinding::new( "tui:input:submit", "Submit the input", @@ -86,30 +87,6 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("TuiInputView")) .with_group(TUI_BINDING_GROUP) .with_key_binding("enter"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-enter"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-j"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-enter"), EditableBinding::new( "tui:input:handle_escape", "Handle contextual input escape", @@ -118,350 +95,6 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("TuiInputView") & id!(INPUT_HANDLES_ESCAPE_FLAG)) .with_group(TUI_BINDING_GROUP) .with_key_binding("escape"), - // ── Deletion ─────────────────────────────────────────────────── - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("backspace"), - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-backspace"), - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-h"), - EditableBinding::new( - "tui:input:delete_forward", - "Delete the next character", - TuiInputAction::DeleteForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("delete"), - EditableBinding::new( - "tui:input:delete_forward", - "Delete the next character", - TuiInputAction::DeleteForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-d"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-w"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-backspace"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-backspace"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-d"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-delete"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-delete"), - // ── Cursor movement ───────────────────────────────────────────── - EditableBinding::new( - "tui:input:move_left", - "Move cursor left", - TuiInputAction::MoveLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("left"), - EditableBinding::new( - "tui:input:move_left", - "Move cursor left", - TuiInputAction::MoveLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-b"), - EditableBinding::new( - "tui:input:move_right", - "Move cursor right", - TuiInputAction::MoveRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("right"), - EditableBinding::new( - "tui:input:move_right", - "Move cursor right", - TuiInputAction::MoveRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-f"), - EditableBinding::new( - "tui:input:move_up", - "Move cursor up", - TuiInputAction::MoveUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("up"), - EditableBinding::new( - "tui:input:move_up", - "Move cursor up", - TuiInputAction::MoveUp, - ) - .with_context_predicate( - id!("TuiInputView") - & (!id!(PLAN_TOGGLE_AVAILABLE_FLAG) | id!(KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG)), - ) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-p"), - EditableBinding::new( - "tui:input:move_down", - "Move cursor down", - TuiInputAction::MoveDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("down"), - EditableBinding::new( - "tui:input:move_down", - "Move cursor down", - TuiInputAction::MoveDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-n"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-left"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-b"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-left"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-right"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-f"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-right"), - EditableBinding::new( - "tui:input:move_to_line_start", - "Move cursor to start of line", - TuiInputAction::MoveToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("home"), - EditableBinding::new( - "tui:input:move_to_line_start", - "Move cursor to start of line", - TuiInputAction::MoveToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-a"), - EditableBinding::new( - "tui:input:move_to_line_end", - "Move cursor to end of line", - TuiInputAction::MoveToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("end"), - EditableBinding::new( - "tui:input:move_to_line_end", - "Move cursor to end of line", - TuiInputAction::MoveToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-e"), - // ── Selection ──────────────────────────────────────────────────────────────── - EditableBinding::new( - "tui:input:select_left", - "Extend selection left", - TuiInputAction::SelectLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-left"), - EditableBinding::new( - "tui:input:select_right", - "Extend selection right", - TuiInputAction::SelectRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-right"), - EditableBinding::new( - "tui:input:select_up", - "Extend selection up", - TuiInputAction::SelectUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-up"), - EditableBinding::new( - "tui:input:select_down", - "Extend selection down", - TuiInputAction::SelectDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-down"), - EditableBinding::new( - "tui:input:select_word_left", - "Extend selection one word left", - TuiInputAction::SelectWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-left"), - EditableBinding::new( - "tui:input:select_word_left", - "Extend selection one word left", - TuiInputAction::SelectWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-shift-left"), - EditableBinding::new( - "tui:input:select_word_right", - "Extend selection one word right", - TuiInputAction::SelectWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-right"), - EditableBinding::new( - "tui:input:select_word_right", - "Extend selection one word right", - TuiInputAction::SelectWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-shift-right"), - EditableBinding::new( - "tui:input:select_all", - "Select all text", - TuiInputAction::SelectAll, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-A"), - // ── Kill / yank ───────────────────────────────────────────────── - EditableBinding::new( - "tui:input:kill_to_line_end", - "Delete to end of line", - TuiInputAction::KillToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-k"), - EditableBinding::new( - "tui:input:kill_to_line_start", - "Delete to start of line", - TuiInputAction::KillToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-u"), - EditableBinding::new( - "tui:input:yank", - "Paste the last deleted text", - TuiInputAction::Yank, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-y"), - // ── Undo / redo ───────────────────────────────────────────────── - EditableBinding::new("tui:input:undo", "Undo", TuiInputAction::Undo) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-z"), - EditableBinding::new("tui:input:redo", "Redo", TuiInputAction::Redo) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-Z"), ]); } @@ -488,87 +121,22 @@ pub enum TuiInputViewEvent { // Typed action enum // ───────────────────────────────────────────────────────────────────────────── -/// All editing operations dispatched from [`TuiEditorElement`]. +/// Prompt policy plus shared editor actions dispatched to [`TuiInputView`]. /// /// Each variant corresponds to one or more keybindings from the spec keybinding table. #[derive(Debug, Clone)] pub enum TuiInputAction { - /// Insert a character (`Char(c)` key events). - InsertChar(char), - /// Insert one complete bracketed-paste payload without submitting it. - InsertText(String), - /// Insert a hard newline (`Shift+Enter`, `Ctrl+J`, `Alt+Enter`). - InsertNewline, + /// Apply input emitted by the shared editor element. + Editor(TuiEditorAction), /// Submit the current input (`Enter`). Submit, /// Handle contextual input Escape behavior, prioritizing an open inline menu. HandleEscape, - /// Delete the character before the cursor (`Backspace`, `Ctrl+H`). - Backspace, - /// Delete the character after the cursor (`Delete`, `Ctrl+D`). - DeleteForward, - /// Move cursor left one char (`←`, `Ctrl+B`). - MoveLeft, - /// Move cursor right one char (`→`, `Ctrl+F`). - MoveRight, - /// Move cursor up one visual row (`↑`, `Ctrl+P`). - MoveUp, - /// Move cursor down one visual row (`↓`, `Ctrl+N`). - MoveDown, - /// Move cursor one word backward (`Alt+←`, `Alt+B`, `Ctrl+←`). - MoveWordLeft, - /// Move cursor one word forward (`Alt+→`, `Alt+F`, `Ctrl+→`). - MoveWordRight, - /// Move cursor to start of visual line (`Home`, `Ctrl+A`). - MoveToLineStart, - /// Move cursor to end of visual line (`End`, `Ctrl+E`). - MoveToLineEnd, - /// Extend selection left (`Shift+←`). - SelectLeft, - /// Extend selection right (`Shift+→`). - SelectRight, - /// Extend selection up (`Shift+↑`). - SelectUp, - /// Extend selection down (`Shift+↓`). - SelectDown, - /// Extend selection one word left (`Ctrl+Shift+←`, `Alt+Shift+←`). - SelectWordLeft, - /// Extend selection one word right (`Ctrl+Shift+→`, `Alt+Shift+→`). - SelectWordRight, - /// Select all text (`Ctrl+Shift+A` / `Meta+A`). - SelectAll, - /// Delete word backward (`Ctrl+W`, `Alt+Backspace`, `Ctrl+Backspace`). - DeleteWordBackward, - /// Delete word forward (`Alt+D`, `Alt+Delete`, `Ctrl+Delete`). - DeleteWordForward, - /// Kill from cursor to end of visual line (`Ctrl+K`). - KillToLineEnd, - /// Kill from cursor to start of visual line (`Ctrl+U`). - KillToLineStart, - /// Yank last killed text (`Ctrl+Y`). - Yank, - /// Undo (`Ctrl+Z`). - Undo, - /// Redo (`Ctrl+Shift+Z`). - Redo, - /// Place the cursor / begin a character selection at `offset` (single click). - SelectionStartAt { offset: CharOffset }, - /// Extend the active selection's head to `offset` (shift-click). - SelectionExtendTo { offset: CharOffset }, - /// Select the word at `offset` (double click). - SelectWordAt { offset: CharOffset }, - /// Select the line at `offset` (triple click). - SelectLineAt { offset: CharOffset }, - /// Update the in-progress drag selection to `offset` (mouse drag). - SelectionUpdateTo { offset: CharOffset }, - /// Finish the in-progress drag selection (mouse up). - SelectionEnd, + /// Apply an editing command shared with generic TUI editors. + EditorCommand(TuiEditorCommand), /// Place the cursor at `offset` without starting a drag selection /// (the `!` gutter click). SetCursor { offset: CharOffset }, - /// Scroll the viewport by `rows` visual rows without moving the cursor - /// (negative scrolls toward the top). Driven by the mouse wheel. - Scroll { rows: isize }, } // ───────────────────────────────────────────────────────────────────────────── @@ -587,10 +155,10 @@ pub struct TuiInputView { suggestions_mode: ModelHandle, /// Generalized inline menus used to route prioritized menu actions. inline_menus: Vec, - /// Single-entry kill buffer for `Ctrl+K` / `Ctrl+U` / `Ctrl+Y`. - kill_buffer: KillBuffer, - /// Maximum number of visible rows before the input scrolls. - max_visible_rows: u32, + /// Shared editor session state, including the single-entry kill buffer. + editor_state: TuiEditorState, + /// Multiline insertion and six-row viewport policy. + editor_behavior: TuiEditorBehavior, /// Mouse state for the shell-mode `!` gutter; created once here (not inline /// during render) so mouse tracking survives per-frame element rebuilds. prefix_mouse_state: MouseStateHandle, @@ -673,8 +241,8 @@ impl TuiInputView { input_mode, suggestions_mode, inline_menus, - kill_buffer: KillBuffer::default(), - max_visible_rows: 6, + editor_state: TuiEditorState::default(), + editor_behavior: TuiEditorBehavior::multiline(6), prefix_mouse_state: MouseStateHandle::default(), focused: false, transcript, @@ -740,10 +308,10 @@ impl TuiInputView { let mut element = TuiEditorElement::new(&self.model, ctx) .editable() .with_view_focused(self.focused) - .with_viewport_rows(self.max_visible_rows) + .with_viewport_rows(self.editor_behavior.viewport_rows()) .with_styles(styles) .on_action(|action, event_ctx| { - event_ctx.dispatch_typed_action(TuiInputAction::from(action)) + event_ctx.dispatch_typed_action(TuiInputAction::Editor(action)) }); if let Some(hint_text) = self .inline_menus @@ -774,6 +342,7 @@ impl TuiInputView { self.render_element(ctx).finish() } pub(crate) fn set_text(&mut self, text: &str, ctx: &mut ViewContext) { + let text = self.editor_behavior.normalize_text(text); self.model.update(ctx, |m, ctx| { m.clear_buffer(ctx); m.user_insert(text, ctx); @@ -842,22 +411,6 @@ impl TuiView for TuiInputView { } } -impl From for TuiInputAction { - fn from(action: TuiEditorAction) -> Self { - match action { - TuiEditorAction::InsertChar(c) => Self::InsertChar(c), - TuiEditorAction::InsertText(text) => Self::InsertText(text), - TuiEditorAction::SelectionStartAt { offset } => Self::SelectionStartAt { offset }, - TuiEditorAction::SelectionExtendTo { offset } => Self::SelectionExtendTo { offset }, - TuiEditorAction::SelectWordAt { offset } => Self::SelectWordAt { offset }, - TuiEditorAction::SelectLineAt { offset } => Self::SelectLineAt { offset }, - TuiEditorAction::SelectionUpdateTo { offset } => Self::SelectionUpdateTo { offset }, - TuiEditorAction::SelectionEnd => Self::SelectionEnd, - TuiEditorAction::Scroll { rows } => Self::Scroll { rows }, - } - } -} - fn input_keymap_context( input_handles_escape: bool, plan_toggle_available: bool, @@ -883,11 +436,11 @@ impl TypedActionView for TuiInputView { if self.handle_inline_menu_action(action, ctx) { return; } - match action { - TuiInputAction::InsertChar(c) => { + let outcome = match action { + TuiInputAction::Editor(editor_action) => { // A `!` typed at the very start of the input enters shell mode // instead of inserting (matching the GUI's typed-only trigger). - if *c == '!' + if matches!(editor_action, TuiEditorAction::InsertChar('!')) && !self.is_shell_mode(ctx) && self.is_cursor_at_start(ctx) && !self @@ -896,46 +449,26 @@ impl TypedActionView for TuiInputView { .is_terminal_use_active_or_pending() { self.enter_shell_mode(ctx); + TuiEditorInteractionOutcome::FollowCursor } else { - let s = c.to_string(); - self.model.update(ctx, |m, ctx| m.user_insert(&s, ctx)); + apply_editor_action(&self.model, editor_action, self.editor_behavior, ctx) } } - TuiInputAction::InsertText(text) => { - self.model.update(ctx, |m, ctx| m.user_insert(text, ctx)); - } - TuiInputAction::InsertNewline => { - self.model.update(ctx, |m, ctx| m.user_insert("\n", ctx)); + TuiInputAction::Submit => { + self.submit(ctx); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Submit => self.submit(ctx), TuiInputAction::HandleEscape => { self.handle_escape(ctx); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Backspace => { - // With nothing left to delete, backspace removes the `!` - // affordance instead; typed text is preserved. - if self.is_shell_mode(ctx) && self.is_cursor_at_start(ctx) { - self.exit_shell_mode(ctx); - } else { - self.model.update(ctx, |m, ctx| m.backspace(ctx)); - } - } - TuiInputAction::DeleteForward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Forwards, - TextUnit::Character, - false, - ctx, - ) - }); - } - TuiInputAction::MoveLeft => { + TuiInputAction::EditorCommand(command) => { // Only open the conversation list from normal agent input; in // `!` shell mode the `!` prefix is not part of `plain_text`, so // an empty shell command would otherwise trip this branch and // open the picker while the input stayed shell-mode. - if !self.is_shell_mode(ctx) + if matches!(*command, TuiEditorCommand::MoveLeft) + && !self.is_shell_mode(ctx) && self.plain_text(ctx).is_empty() && self.is_cursor_at_start(ctx) { @@ -946,161 +479,35 @@ impl TypedActionView for TuiInputView { { menu.open(ctx); } + TuiEditorInteractionOutcome::FollowCursor + // With nothing left to delete, backspace removes the `!` + // affordance instead; typed text is preserved. + } else if matches!(*command, TuiEditorCommand::Backspace) + && self.is_shell_mode(ctx) + && self.is_cursor_at_start(ctx) + { + self.exit_shell_mode(ctx); + TuiEditorInteractionOutcome::FollowCursor } else { - self.model.update(ctx, |m, ctx| m.move_left(ctx)); - } - } - TuiInputAction::MoveRight => { - self.model.update(ctx, |m, ctx| m.move_right(ctx)); - } - TuiInputAction::MoveUp => { - self.model.update(ctx, |m, ctx| m.move_up(ctx)); - } - TuiInputAction::MoveDown => { - self.model.update(ctx, |m, ctx| m.move_down(ctx)); - } - TuiInputAction::MoveWordLeft => { - self.model.update(ctx, |m, ctx| { - m.backward_word_with_unit( - false, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::MoveWordRight => { - self.model.update(ctx, |m, ctx| { - m.forward_word_with_unit( - false, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::MoveToLineStart => { - self.model.update(ctx, |m, ctx| m.move_to_line_start(ctx)); - } - TuiInputAction::MoveToLineEnd => { - self.model.update(ctx, |m, ctx| m.move_to_line_end(ctx)); - } - TuiInputAction::SelectLeft => { - self.model.update(ctx, |m, ctx| m.select_left(ctx)); - } - TuiInputAction::SelectRight => { - self.model.update(ctx, |m, ctx| m.select_right(ctx)); - } - TuiInputAction::SelectUp => { - self.model.update(ctx, |m, ctx| m.select_up(ctx)); - } - TuiInputAction::SelectDown => { - self.model.update(ctx, |m, ctx| m.select_down(ctx)); - } - TuiInputAction::SelectWordLeft => { - self.model.update(ctx, |m, ctx| { - m.backward_word_with_unit( - true, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::SelectWordRight => { - self.model.update(ctx, |m, ctx| { - m.forward_word_with_unit( - true, - TextUnit::Word(WordBoundariesPolicy::Default), + self.editor_state.apply_command( + &self.model, + *command, + self.editor_behavior, ctx, ) - }); - } - TuiInputAction::SelectAll => { - self.model.update(ctx, |m, ctx| m.select_all(ctx)); - } - TuiInputAction::DeleteWordBackward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Backwards, - TextUnit::Word(WordBoundariesPolicy::Default), - false, - ctx, - ) - }); - } - TuiInputAction::DeleteWordForward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Forwards, - TextUnit::Word(WordBoundariesPolicy::Default), - false, - ctx, - ) - }); - } - TuiInputAction::KillToLineEnd => { - if let Some(killed) = self - .model - .update(ctx, |m, ctx| m.kill_to_char_cell_visual_row_end(ctx)) - { - self.kill_buffer.kill(killed); - } - } - TuiInputAction::KillToLineStart => { - if let Some(killed) = self - .model - .update(ctx, |m, ctx| m.kill_to_char_cell_visual_row_start(ctx)) - { - self.kill_buffer.kill(killed); } } - TuiInputAction::Yank => self.yank(ctx), - TuiInputAction::Undo => { - self.model.update(ctx, |m, ctx| m.undo(ctx)); - } - TuiInputAction::Redo => { - self.model.update(ctx, |m, ctx| m.redo(ctx)); - } - TuiInputAction::SelectionStartAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_at(*offset, false, ctx)); - } - TuiInputAction::SelectionExtendTo { offset } => { - self.model - .update(ctx, |m, ctx| m.set_last_selection_head(*offset, ctx)); - } - TuiInputAction::SelectWordAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_word_at(*offset, false, ctx)); - } - TuiInputAction::SelectLineAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_line_at(*offset, false, ctx)); - } - // Both are model-side no-ops unless a drag selection is pending - // (begun by a mouse-down on the element), so no gating is needed. - TuiInputAction::SelectionUpdateTo { offset } => { - self.model - .update(ctx, |m, ctx| m.update_pending_selection(*offset, ctx)); - } - TuiInputAction::SelectionEnd => { - self.model.update(ctx, |m, ctx| m.end_selection(ctx)); - } TuiInputAction::SetCursor { offset } => { self.model.update(ctx, |m, ctx| { m.select_at(*offset, false, ctx); m.end_selection(ctx); }); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Scroll { rows } => { - // Wheel scrolling moves the viewport only; it must NOT snap back - // to the cursor, so it returns early (skipping the follow-cursor - // tail below). - self.scroll_viewport_by(*rows, ctx); - ctx.notify(); - return; - } + }; + if outcome == TuiEditorInteractionOutcome::FollowCursor { + self.follow_cursor(ctx); } - - self.follow_cursor(ctx); ctx.notify(); } } @@ -1160,32 +567,12 @@ impl TuiInputView { // The scroll offset and its clamping/follow policy live on the char-cell // render state (`CharCellState`); these helpers gather the inputs the // mechanism needs — the primary cursor and the model-derived hidden line - // ranges — and apply the input's viewport policy (`max_visible_rows`). + // ranges — and apply the input's viewport policy. /// Scrolls the viewport the minimal amount needed to keep the cursor /// visible. fn follow_cursor(&self, ctx: &AppContext) { - let model = self.model.as_ref(ctx); - let render = model.render_state().as_ref(ctx); - let Some(char_cell) = render.char_cell() else { - return; - }; - let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); - let hidden = char_cell.hidden_line_ranges(ctx); - char_cell.follow_cursor(cursor_offset, self.max_visible_rows, &hidden); - } - - /// Scrolls the viewport by `rows` display rows (negative scrolls toward - /// the top) without moving the cursor. - fn scroll_viewport_by(&self, rows: isize, ctx: &AppContext) { - let model = self.model.as_ref(ctx); - let render = model.render_state().as_ref(ctx); - let Some(char_cell) = render.char_cell() else { - return; - }; - let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); - let hidden = char_cell.hidden_line_ranges(ctx); - char_cell.scroll_by(rows, self.max_visible_rows, cursor_offset, &hidden); + follow_editor_cursor(&self.model, self.editor_behavior, ctx); } // ── Shell mode ──────────────────────────────────────────────────────────── @@ -1243,8 +630,7 @@ impl TuiInputView { ) -> bool { if !matches!( action, - TuiInputAction::MoveUp - | TuiInputAction::MoveDown + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp | TuiEditorCommand::MoveDown) | TuiInputAction::Submit | TuiInputAction::HandleEscape ) { @@ -1255,10 +641,10 @@ impl TuiInputView { }; match action { - TuiInputAction::MoveUp => { + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp) => { inline_menu.select_previous(ctx); } - TuiInputAction::MoveDown => { + TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown) => { inline_menu.select_next(ctx); } TuiInputAction::Submit => { @@ -1310,18 +696,6 @@ impl TuiInputView { ctx, ) } - - // ── Kill / yank ─────────────────────────────────────────────────────────── - // - // The kill *edits* (visual-row range computation and deletion) live on - // `CodeEditorModel::kill_to_char_cell_visual_row_end` / `_start`; the view - // owns only the kill buffer the deleted text lands in. - - fn yank(&mut self, ctx: &mut ViewContext) { - if let Some(text) = self.kill_buffer.yank().map(str::to_owned) { - self.model.update(ctx, |m, ctx| m.user_insert(&text, ctx)); - } - } } #[cfg(test)] diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index d62e6094d96..5de3270d800 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -34,7 +34,8 @@ use super::{ input_keymap_context, TuiInputAction, TuiInputView, TuiInputViewEvent, INPUT_HANDLES_ESCAPE_FLAG, }; -use crate::editor_element::TuiEditorElement; +use crate::editor_element::{TuiEditorAction, TuiEditorElement}; +use crate::editor_interaction::TuiEditorCommand; use crate::inline_menu::{ TuiInlineMenu, TuiInlineMenuAccepted, TuiInlineMenuHandle, TuiInlineMenuHeader, TuiInlineMenuSnapshot, TuiInlineMenuStatus, @@ -465,10 +466,18 @@ fn inline_menu_navigation_routes_before_editor_navigation() { let (view, menu_model, ids) = build_view_with_inline_menu(ctx); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[0])); - dispatch(&view, ctx, &[TuiInputAction::MoveDown]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown)], + ); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[1])); - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[0])); }); }); @@ -587,7 +596,9 @@ fn multiline_paste_inserts_without_submitting_until_enter() { dispatch( &view, ctx, - &[TuiInputAction::InsertText(payload.to_owned())], + &[TuiInputAction::Editor(TuiEditorAction::InsertText( + payload.to_owned(), + ))], ); }); app.read(|ctx| { @@ -633,7 +644,10 @@ fn clear_selection_collapses_to_head_without_changing_text() { } fn type_str(view: &ViewHandle, ctx: &mut AppContext, s: &str) { - let actions: Vec = s.chars().map(TuiInputAction::InsertChar).collect(); + let actions: Vec = s + .chars() + .map(|c| TuiInputAction::Editor(TuiEditorAction::InsertChar(c))) + .collect(); dispatch(view, ctx, &actions); } @@ -711,7 +725,11 @@ fn move_left_on_empty_buffer_opens_conversation_menu() { let (view, menu_model, inline_menu) = build_view_with_conversation_menu(ctx); assert!(!menu_model.as_ref(ctx).is_open); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!(menu_model.as_ref(ctx).is_open); let lines = render_element_lines( @@ -739,7 +757,11 @@ fn move_left_on_non_empty_buffer_only_moves_cursor() { type_str(&view, ctx, "ab"); assert_eq!(cursor_and_height(&view, ctx).0, Some((2, 0))); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!(!menu_model.as_ref(ctx).is_open); assert_eq!(cursor_and_height(&view, ctx).0, Some((1, 0))); @@ -763,7 +785,11 @@ fn move_left_in_shell_mode_does_not_open_conversation_menu() { "the `!` prefix is not buffered" ); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!( !menu_model.as_ref(ctx).is_open, @@ -802,12 +828,12 @@ fn navigation_on_empty_buffer_does_not_panic() { &view, ctx, &[ - TuiInputAction::MoveToLineStart, - TuiInputAction::MoveToLineEnd, - TuiInputAction::MoveLeft, - TuiInputAction::MoveRight, - TuiInputAction::MoveUp, - TuiInputAction::MoveDown, + TuiInputAction::EditorCommand(TuiEditorCommand::MoveToLineStart), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveToLineEnd), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveRight), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown), ], ); let (cursor, height) = cursor_and_height(&view, ctx); @@ -839,7 +865,13 @@ fn cursor_renders_at_start_of_new_line() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "ab"); - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); let (cursor, height) = cursor_and_height(&view, ctx); assert_eq!(cursor, Some((0, 1)), "cursor should be at row 1, col 0"); assert!(height >= 2, "two visual rows expected, got height {height}"); @@ -859,7 +891,10 @@ fn interior_empty_line_does_not_collapse() { dispatch( &view, ctx, - &[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + ], ); type_str(&view, ctx, "b"); let (cursor, height) = cursor_and_height(&view, ctx); @@ -880,11 +915,18 @@ fn move_up_through_empty_line_positions_cursor() { dispatch( &view, ctx, - &[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + ], ); type_str(&view, ctx, "b"); // Cursor on row 2 ("b"); move up to the empty row 1. - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); let (cursor, height) = cursor_and_height(&view, ctx); assert_eq!(height, 3); assert_eq!( @@ -908,9 +950,18 @@ fn kill_to_line_end_from_midline() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); assert_eq!(text(&view, ctx), "ab"); }); }); @@ -923,7 +974,13 @@ fn kill_to_line_end_at_eol_is_noop() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "abcd"); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], + ); assert_eq!(text(&view, ctx), "abcd"); }); }); @@ -940,9 +997,18 @@ fn kill_to_line_start_from_midline() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineStart, + )], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineStart]); assert_eq!(text(&view, ctx), "cd"); }); }); @@ -958,10 +1024,23 @@ fn kill_then_yank_round_trips() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); // kills "cd" -> "ab" - dispatch(&view, ctx, &[TuiInputAction::Yank]); // yanks "cd" -> "abcd" + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], + ); // kills "cd" -> "ab" + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Yank)], + ); // yanks "cd" -> "abcd" assert_eq!(text(&view, ctx), "abcd"); }); }); @@ -994,7 +1073,13 @@ fn select_word_left_selects_previous_word() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "hello world"); - dispatch(&view, ctx, &[TuiInputAction::SelectWordLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::SelectWordLeft, + )], + ); assert_eq!(selected_text(&view, ctx).as_deref(), Some("world")); }); }); @@ -1007,8 +1092,20 @@ fn select_word_right_selects_next_word() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "hello world"); - dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]); - dispatch(&view, ctx, &[TuiInputAction::SelectWordRight]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineStart, + )], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::SelectWordRight, + )], + ); assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello")); }); }); @@ -1021,12 +1118,30 @@ fn move_to_line_start_and_end_multiline() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "abc"); - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); type_str(&view, ctx, "def"); // Cursor is at end of "def" (row 1, col 3). - dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineStart, + )], + ); assert_eq!(cursor_and_height(&view, ctx).0, Some((0, 1))); - dispatch(&view, ctx, &[TuiInputAction::MoveToLineEnd]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineEnd, + )], + ); assert_eq!(cursor_and_height(&view, ctx).0, Some((3, 1))); }); }); @@ -1190,7 +1305,13 @@ fn scroll_wheel(x: u16, y: u16, delta_rows: isize) -> TuiEvent { fn type_lines(view: &ViewHandle, ctx: &mut AppContext, n: usize) { for i in 0..n { if i > 0 { - dispatch(view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); } type_str(view, ctx, &i.to_string()); } @@ -1242,7 +1363,7 @@ fn mouse(view: &ViewHandle, ctx: &mut AppContext, event: &TuiEvent }; match action { Some(action) => { - dispatch(view, ctx, &[TuiInputAction::from(action)]); + dispatch(view, ctx, &[TuiInputAction::Editor(action)]); true } None => false, @@ -1388,13 +1509,23 @@ fn drag_past_last_visible_row_autoscrolls() { // 10 logical lines, exceeding the 6-row viewport. for i in 0..10 { if i > 0 { - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); } type_str(&view, ctx, &i.to_string()); } // Scroll back to the top. for _ in 0..9 { - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); } assert_eq!(scroll_offset(&view, ctx), 0); @@ -1505,7 +1636,11 @@ fn explicit_shell_mode_survives_deleting_the_buffer() { let view = build_view(ctx); type_str(&view, ctx, "!cargo"); for _ in 0.."cargo".chars().count() { - dispatch(&view, ctx, &[TuiInputAction::Backspace]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Backspace)], + ); } assert_eq!(text(&view, ctx), ""); @@ -1720,9 +1855,10 @@ fn shell_mode_offsets_mouse_mapping_by_gutter() { let event_ctx = TuiEventContext::new(scene, &mut rendered_views); element .mouse_action(&left_down(2 + 3, 0, 1, false), &event_ctx, ctx) - .map(TuiInputAction::from) + .map(TuiInputAction::Editor) }; - let Some(TuiInputAction::SelectionStartAt { offset }) = action else { + let Some(TuiInputAction::Editor(TuiEditorAction::SelectionStartAt { offset })) = action + else { panic!("expected SelectionStartAt, got {action:?}"); }; // Screen column 5 = content column 3 = gap offset 4 (1-based). diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 8bb1fbde603..43a7d78c507 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -19,9 +19,15 @@ //! would otherwise match everywhere and, for multi-keystroke chords, swallow //! prefix keys via a pending match. -use warpui_core::keymap::{BindingLens, Context, IsBindingValid, Trigger}; -use warpui_core::{AppContext, TuiView}; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{ + BindingLens, Context, ContextPredicate, EditableBinding, IsBindingValid, Trigger, +}; +use warpui_core::{Action, AppContext, TuiView}; +use crate::editor_interaction::{editor_binding_specs, TuiEditorBindingTarget, TuiEditorCommand}; +use crate::editor_view::{TuiEditorView, TuiEditorViewAction}; +use crate::input::view::TuiInputAction; use crate::input::TuiInputView; use crate::root_view::RootTuiView; use crate::terminal_session_view::TuiTerminalSessionView; @@ -60,16 +66,60 @@ pub(crate) fn init(app: &mut AppContext) { crate::root_view::init(app); crate::terminal_session_view::init(app); crate::input::init(app); + register_editor_bindings( + app, + TuiEditorBindingTarget::Input, + id!("TuiInputView"), + TuiInputAction::EditorCommand, + ); + register_editor_bindings( + app, + TuiEditorBindingTarget::Editor, + id!("TuiEditorView"), + TuiEditorViewAction::Command, + ); register_binding_validators(app); } +/// Registers one editor binding target from interaction-owned metadata. +fn register_editor_bindings( + app: &mut AppContext, + target: TuiEditorBindingTarget, + context: ContextPredicate, + action_for: impl Fn(TuiEditorCommand) -> A, +) where + A: Action, +{ + let action_for = &action_for; + let bindings = editor_binding_specs(target).flat_map(|spec| { + let context = context.clone(); + spec.keys.iter().map(move |key| { + let context = if matches!(target, TuiEditorBindingTarget::Input) + && matches!(spec.command, TuiEditorCommand::MoveUp) + && *key == "ctrl-p" + { + context.clone() + & (!id!(PLAN_TOGGLE_AVAILABLE_FLAG) | id!(KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG)) + } else { + context.clone() + }; + EditableBinding::new(spec.name, spec.description, action_for(spec.command)) + .with_context_predicate(context) + .with_group(TUI_BINDING_GROUP) + .with_key_binding(key) + }) + }); + app.register_editable_bindings(bindings); +} + /// Debug-time guard (no-op in release): every keystroke binding that matches a /// TUI view's default keymap context must be TUI-owned. fn register_binding_validators(app: &mut AppContext) { app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 049f61f9543..3a0293497db 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -22,6 +22,10 @@ mod ui; mod conversation_menu; mod conversation_selection; mod editor_element; +mod editor_interaction; +// The option-selector slice consumes this reusable editor and removes the allow. +#[allow(dead_code)] +mod editor_view; mod exit_confirmation; mod inline_menu; mod input_mode_policy; diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index 4509ec0efc6..fb72100d4c1 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -126,10 +126,7 @@ fn user_input_event_projects_to_raw_user_bytes() { #[test] fn plan_toggle_uses_contextual_ctrl_p_and_ctrl_shift_p() { App::test((), |mut app| async move { - app.update(|ctx| { - super::init(ctx); - crate::input::init(ctx); - }); + app.update(crate::keybindings::init); app.read(|ctx| { let toggle = ctx .get_binding_by_name(PLAN_TOGGLE_BINDING_NAME) diff --git a/specs/code-1822-tui-generic-editor-view/TECH.md b/specs/code-1822-tui-generic-editor-view/TECH.md new file mode 100644 index 00000000000..6498d1dc6c7 --- /dev/null +++ b/specs/code-1822-tui-generic-editor-view/TECH.md @@ -0,0 +1,177 @@ +# TECH: Generic TUI editor view and shared editing behavior + +## Context + +The TUI already renders and hit-tests editor content through `TuiEditorElement`, +backed by `CodeEditorModel` in char-cell mode +(`crates/warp_tui/src/editor_element.rs` and +`specs/tui-editor-element/TECH.md`). Before this slice, editable behavior lived in +`TuiInputView`, which also owns prompt submission, shell mode, inline menus, and +other application-input policy. Reusing that view for selector search or custom +text would couple generic fields to prompt behavior. + +This slice adds `TuiEditorView::single_line` and extracts editing behavior shared +with `TuiInputView` into `editor_interaction.rs`. The option-selector slice stacked +above uses the generic view for search and custom-text fields; see +`specs/code-1822-tui-option-selector/TECH.md`. + +The ownership boundary is: + +- `CodeEditorModel` and `CharCellState`: text, selection, undo/redo, wrap geometry, + hidden lines, and retained viewport offset. +- `TuiEditorElement`: painting, cursor/selection geometry, hit-testing, printable + input, paste, mouse selection, and wheel events + (`crates/warp_tui/src/editor_element.rs` (44-79)). +- `editor_interaction.rs`: shared command definitions and bindings, model mutations, + behavior configuration, kill/yank state, selection action handling, and viewport + follow/scroll helpers (`crates/warp_tui/src/editor_interaction.rs` (15-559)). +- `TuiEditorView`: generic field focus, one-row configuration, content events, + and programmatic text replacement + (`crates/warp_tui/src/editor_view.rs` (37-202)). +- `TuiInputView`: prompt submission, contextual Escape, + shell-mode interception, inline-menu routing, and prompt focus + (`crates/warp_tui/src/input/view.rs` (66-604)). + +## Proposed changes + +### Shared command and binding layer + +`crates/warp_tui/src/editor_interaction.rs` owns `TuiEditorCommand`, which represents +model-backed editing semantics: + +- character/word deletion; +- hard-newline insertion for multiline consumers; +- horizontal, vertical, word, and visual-line navigation; +- character, vertical, word, and whole-buffer selection; +- visual-row kill-to-start/end plus yank; +- undo and redo. + +The same module owns pure binding metadata for either +`TuiEditorBindingTarget::Input` or `Editor`. Each target keeps stable +user-configurable names (`tui:input:*` and `tui:editor:*`) while sharing default +keys and command semantics. `keybindings.rs` converts that metadata into +`EditableBinding`s, owns the TUI binding group, and registers both targets. + +Vertical movement and selection are commands shared by both consumers, but their +bindings remain input-only. A generic single-line field is embedded in a selector +whose host owns Up/Down navigation and focus handoff, so registering +`tui:editor:move_up`, `move_down`, `select_up`, or `select_down` would consume keys +that must propagate. The metadata marks those bindings input-only while both +consumers still use the shared command variants. + +`crates/warp_tui/src/keybindings.rs` remains the TUI-wide aggregation and +cross-surface validation layer. It registers both editor targets and binding +validators but does not define editor commands or key metadata +(`crates/warp_tui/src/keybindings.rs` (22-115)). + +### Shared editor state and action application + +Each editable view owns a `TuiEditorState`. Its single-entry kill buffer backs +Ctrl-K, Ctrl-U, and Ctrl-Y for both the prompt and generic fields. Kill range +calculation and deletion remain model semantics on `CodeEditorModel`; the shared +state only retains the deleted text for yank +(`crates/warp_tui/src/editor_interaction.rs` (322-468)). + +`TuiEditorBehavior` is the single consumer configuration for line policy and +viewport height. `single_line()` rejects hard newlines and uses one visible row; +`multiline(6)` accepts full text/newline insertion and uses the prompt's six-row +viewport. The same behavior value is used for rendering, action application, +commands, programmatic replacement, scrolling, and cursor following. + +`apply_editor_action` applies every `TuiEditorAction` emitted by +`TuiEditorElement`: + +- printable insertion; +- paste normalized through `TuiEditorBehavior`; +- click, shift-click, word/line selection, drag updates, and drag completion; +- wheel scrolling without moving the cursor. + +Single-line behavior inserts only the first pasted or programmatically supplied +line and ignores `InsertNewline`. Multiline behavior inserts the complete payload +and accepts `InsertNewline`. The mutation path is shared even though consumer +configuration differs. + +The action helper returns `TuiEditorInteractionOutcome::FollowCursor` for +insertions/selection changes and `PreserveViewport` for wheel scrolling, so callers +cannot confuse an opaque boolean result. Shared mutation helpers take +`&mut AppContext`; they do not depend on a concrete view type. + +### Generic editor view + +`TuiEditorView::single_line` creates a char-cell `CodeEditorModel`, owns a +`TuiEditorState` and persistent `MouseStateHandle`, and renders +`TuiEditorElement` with a one-row viewport. It tracks focus through +`TuiView::on_focus`/`on_blur`, and mouse-originated selection focuses the field +before applying the shared action. + +The view exposes: + +- `text`; +- `set_text`, which suppresses the resulting `Changed` event; +- `is_focused`; +- `TuiEditorViewEvent::Changed` for user edits. + +Dispatched editor actions and commands call `follow_editor_cursor` after mutation. +Programmatic `set_text` does not follow immediately: it can run before layout has +pushed the real terminal width into `CharCellState`. `TuiEditorElement::build` +clamps retained scroll state after applying the real width, so replacement or +resize cannot leave a windowed editor scrolled past its content. User actions and +commands then follow the cursor unless the interaction explicitly preserves the +viewport. + +### Prompt input integration + +`TuiInputView` embeds the same `TuiEditorElement`, `TuiEditorState`, command +executor, action executor, and viewport helpers. It adds only prompt policy: + +- `!` at the buffer start enters shell mode before generic character insertion; +- Backspace at the empty shell-mode boundary exits shell mode before delegation; +- Enter submits; the shared input-only newline command handles + Shift-Enter/Ctrl-J/Alt-Enter; +- Up/Down route to an open inline menu before editor navigation; +- contextual Escape dismisses the nearest prompt-owned mode; +- the prompt chooses multiline paste and a six-row viewport. + +## Data flow + +```mermaid +flowchart LR + Keys["TUI key events"] --> Bindings["keybindings registration
from editor_interaction metadata"] + Bindings --> Command["TuiEditorCommand"] + Element["TuiEditorElement"] --> ElementAction["TuiEditorAction
text + mouse + scroll"] + + Generic["TuiEditorView
SingleLine · 1 row · focus/events"] --> SharedAction["shared action executor"] + Prompt["TuiInputView
Multiline · 6 rows"] --> SharedAction + ElementAction --> Generic + ElementAction --> Prompt + + Command --> SharedState["TuiEditorState
model mutations + kill/yank"] + SharedState --> Model["CodeEditorModel"] + SharedAction --> Model + Model --> Viewport["CharCellState
follow cursor / scroll"] + + PromptPolicy["prompt-only policy
submit · menus · shell · focus"] -. intercepts .-> Prompt +``` + +## Testing and validation + +`crates/warp_tui/src/editor_view_tests.rs` covers: + +- single-line paste truncation; +- single-line programmatic replacement and newline rejection; +- shared Ctrl-K registration and kill/yank behavior; +- cursor following plus stale-offset clamping after resize/replacement; +- shared command editing; +- focus and mouse-selection behavior; +- programmatic text replacement. + +Existing `crates/warp_tui/src/input/view_tests.rs` continues to cover multiline +paste, command navigation/selection, kill/yank, shell-mode overrides, inline-menu +routing, mouse selection, and six-row viewport behavior through the shared layer. + +Validation commands: + +- `./script/format` +- `cargo nextest run --no-fail-fast -p warp_tui -E 'test(editor_view) or test(input::view::tests)'` +- `cargo nextest run --no-fail-fast -p warp_tui` +- `cargo clippy -p warp_tui --tests -- -D warnings`