Files
galaxy/crates/warp_tui/src/input/view.rs
T

1110 lines
50 KiB
Rust

//! [`TuiInputView`] — ratatui-rendered TUI prompt input.
//!
//! Implements [`TuiView`] + [`TypedActionView`]. The view:
//!
//! - Holds a [`ModelHandle<CodeEditorModel>`] constructed in `LayoutMode::CharCell`.
//! - Owns all TUI-specific session state: kill buffer, scroll offset, terminal width.
//! - Dispatches keystrokes as [`TuiInputAction`] typed actions.
//! - Emits [`TuiInputViewEvent::Submitted`] when the user presses Enter.
//!
//! # Architecture
//!
//! The view works directly with [`CodeEditorModel`] (char-cell mode) so that future
//! TUI features — vim, syntax highlighting, diff, hidden lines — come for free from
//! the shared editor infrastructure. TUI-specific concepts (kill ring, terminal
//! viewport scroll, readline keybindings) belong to this view layer, not to the
//! underlying editor model.
//!
//! See `specs/tui-input-view/TECH.md` for the full keybinding table.
use std::cmp;
use std::ops::Range;
use string_offset::CharOffset;
use warp::editor::{CodeEditorModel, CodeEditorModelEvent};
use warp_editor::content::buffer::{BufferEditAction, EditOrigin};
use warp_editor::model::{CoreEditorModel, PlainTextEditorModel};
use warp_editor::render::model::{
char_cell_display_width, char_cell_line_gap_position, char_cell_line_row_starts, ColumnUnit,
SoftWrapPoint,
};
use warp_editor::selection::TextUnit;
use warpui_core::elements::tui::{
Modifier, TuiBuffer, TuiColumn, TuiConstraint, TuiElement, TuiEvent, TuiEventContext,
TuiLayoutContext, TuiParentElement, TuiPoint, TuiRect, TuiRectExt, TuiSize, TuiStyle, TuiText,
};
use warpui_core::text::word_boundaries::WordBoundariesPolicy;
use warpui_core::{AppContext, Entity, ModelHandle, TuiView, TypedActionView, ViewContext};
use super::kill_buffer::KillBuffer;
/// Logical rows scrolled per mouse-wheel notch (matches `TuiScrollable`).
const WHEEL_STEP: isize = 2;
// ─────────────────────────────────────────────────────────────────────────────
// View events
// ─────────────────────────────────────────────────────────────────────────────
/// Events emitted by [`TuiInputView`].
#[derive(Debug, Clone)]
pub enum TuiInputViewEvent {
/// The user pressed Enter to submit the current input. Contains the final text.
Submitted(String),
}
// ─────────────────────────────────────────────────────────────────────────────
// Typed action enum
// ─────────────────────────────────────────────────────────────────────────────
/// All editing operations dispatched from `TuiInputElement::dispatch_event`.
///
/// 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 a hard newline (`Shift+Enter`, `Ctrl+J`, `Alt+Enter`).
InsertNewline,
/// Submit the current input (`Enter`).
Submit,
/// 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,
/// Scroll the viewport by `rows` visual rows without moving the cursor
/// (negative scrolls toward the top). Driven by the mouse wheel.
Scroll { rows: isize },
}
// ─────────────────────────────────────────────────────────────────────────────
// View
// ─────────────────────────────────────────────────────────────────────────────
/// The `TuiView`-implementing entry point for the TUI prompt input.
pub struct TuiInputView {
/// The backing code editor in char-cell (terminal) mode.
model: ModelHandle<CodeEditorModel>,
/// Single-entry kill buffer for `Ctrl+K` / `Ctrl+U` / `Ctrl+Y`.
kill_buffer: KillBuffer,
/// First visible visual row (0-indexed).
scroll_offset: u32,
/// Maximum number of visible rows before the input scrolls.
max_visible_rows: u32,
/// Whether a mouse drag-selection is in progress (set on mouse-down, cleared
/// on mouse-up). Mirrors the GUI editor's `is_selecting`.
is_selecting: bool,
}
impl Entity for TuiInputView {
type Event = TuiInputViewEvent;
}
impl TuiInputView {
/// Construct a new `TuiInputView` backed by `model` (must be in char-cell
/// mode). The model carries the terminal width (set via
/// [`CodeEditorModel::new_tui`]); the view does not keep its own copy.
///
/// Subscribes to [`CodeEditorModelEvent::ContentChanged`] to trigger re-renders
/// whenever the buffer changes from outside `handle_action`.
pub fn new(model: ModelHandle<CodeEditorModel>, ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(&model, |_, _, event, ctx| {
if matches!(event, CodeEditorModelEvent::ContentChanged { .. }) {
ctx.notify();
}
});
Self {
model,
kill_buffer: KillBuffer::default(),
scroll_offset: 0,
max_visible_rows: 6,
is_selecting: false,
}
}
/// Returns a handle to the backing [`CodeEditorModel`].
pub fn model(&self) -> &ModelHandle<CodeEditorModel> {
&self.model
}
/// Builds the concrete `TuiInputElement` for this frame. `render` wraps it in
/// a `Box`; tests construct it directly to exercise mouse dispatch.
///
/// Only width-independent state is gathered here; width-dependent layout (row
/// wrapping, cursor placement, selection spans) happens later in
/// `TuiInputElement::layout`, the first point that knows the terminal width.
fn render_element(&self, ctx: &AppContext) -> TuiInputElement {
let text = self.plain_text(ctx);
let cursor_offset = self.cursor_offset(ctx);
let sel_char_range = self.selection_range(ctx).map(|r| {
let start = r.start.as_usize().saturating_sub(1);
let end = r.end.as_usize().saturating_sub(1);
(start, end)
});
TuiInputElement {
model: self.model.clone(),
text,
cursor_offset,
sel_char_range,
scroll_offset: self.scroll_offset,
max_visible_rows: self.max_visible_rows,
is_selecting: self.is_selecting,
column: TuiColumn::new(),
cursor_col: 0,
cursor_row_in_view: 0,
cursor_visible: false,
selected_spans: Vec::new(),
}
}
}
impl TuiView for TuiInputView {
fn ui_name() -> &'static str {
"TuiInputView"
}
fn render(&self, ctx: &AppContext) -> Box<dyn TuiElement> {
Box::new(self.render_element(ctx))
}
}
impl TypedActionView for TuiInputView {
type Action = TuiInputAction;
fn handle_action(&mut self, action: &TuiInputAction, ctx: &mut ViewContext<Self>) {
match action {
TuiInputAction::InsertChar(c) => {
let s = c.to_string();
self.model.update(ctx, |m, ctx| m.user_insert(&s, ctx));
}
TuiInputAction::InsertNewline => {
self.model.update(ctx, |m, ctx| m.user_insert("\n", ctx));
}
TuiInputAction::Submit => self.submit(ctx),
TuiInputAction::Backspace => {
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 => {
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),
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 => self.kill_to_line_end(ctx),
TuiInputAction::KillToLineStart => self.kill_to_line_start(ctx),
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.is_selecting = true;
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.is_selecting = true;
self.model
.update(ctx, |m, ctx| m.select_word_at(*offset, false, ctx));
}
TuiInputAction::SelectLineAt { offset } => {
self.is_selecting = true;
self.model
.update(ctx, |m, ctx| m.select_line_at(*offset, false, ctx));
}
TuiInputAction::SelectionUpdateTo { offset } => {
if self.is_selecting {
self.model
.update(ctx, |m, ctx| m.update_pending_selection(*offset, ctx));
}
}
TuiInputAction::SelectionEnd => {
if self.is_selecting {
self.is_selecting = false;
self.model.update(ctx, |m, ctx| m.end_selection(ctx));
}
}
TuiInputAction::Scroll { rows } => {
// Wheel scrolling moves the viewport only; it must NOT snap back
// to the cursor, so it returns early (skipping `scroll_to_cursor`).
self.scroll_by(*rows, ctx);
ctx.notify();
return;
}
}
let visible_rows = cmp::min(self.visual_line_count(ctx), self.max_visible_rows);
self.scroll_to_cursor(visible_rows.max(1), ctx);
ctx.notify();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// View-level TUI helpers
// ─────────────────────────────────────────────────────────────────────────────
impl TuiInputView {
// ── Read helpers ──────────────────────────────────────────────────────────
fn plain_text(&self, ctx: &AppContext) -> String {
let inner = self.model.as_ref(ctx);
let buffer = inner.content().as_ref(ctx);
if buffer.is_empty() {
return String::new();
}
buffer.text().into_string()
}
/// The terminal width (in cells) for char-cell layout, read from the backing
/// model. The model is the single source of truth — it must hold the width
/// for event-time navigation/scroll, so the view keeps no separate copy.
fn terminal_width(&self, ctx: &AppContext) -> u16 {
self.model
.as_ref(ctx)
.render_state()
.as_ref(ctx)
.char_cell()
.map(|cc| cc.terminal_width())
.unwrap_or(0)
}
fn cursor_offset(&self, ctx: &AppContext) -> CharOffset {
self.model
.as_ref(ctx)
.selection_model()
.as_ref(ctx)
.cursors(ctx)
.into_iter()
.next()
.unwrap_or_default()
}
fn selection_range(&self, ctx: &AppContext) -> Option<Range<CharOffset>> {
let inner = self.model.as_ref(ctx);
let sel = inner.buffer_selection_model().as_ref(ctx);
let head = sel.first_selection_head();
let tail = sel.first_selection_tail();
if head == tail {
None
} else {
let start = head.min(tail);
let end = head.max(tail);
Some(start..end)
}
}
pub fn visual_line_count(&self, ctx: &AppContext) -> u32 {
self.model
.as_ref(ctx)
.render_state()
.as_ref(ctx)
.max_line()
.as_u32()
.max(1)
}
// ── Scroll ────────────────────────────────────────────────────────────────
fn scroll_to_cursor(&mut self, visible_rows: u32, ctx: &AppContext) {
let cursor_offset = self.cursor_offset(ctx);
let inner = self.model.as_ref(ctx);
let render = inner.render_state().as_ref(ctx);
// `offset_to_softwrap_point` is 0-based (see `char_cell_offset_to_softwrap_point`),
// while the cursor is a 1-based `CharOffset`, so convert by subtracting 1.
let cursor_char_index = CharOffset::from(cursor_offset.as_usize().saturating_sub(1));
let pt = render.offset_to_softwrap_point(cursor_char_index);
let cursor_row = pt.row();
if cursor_row < self.scroll_offset {
self.scroll_offset = cursor_row;
} else if cursor_row >= self.scroll_offset + visible_rows {
self.scroll_offset = cursor_row.saturating_sub(visible_rows - 1);
}
}
/// Scrolls the viewport by `rows` visual rows (negative scrolls toward the
/// top), clamped to `[0, max_scroll]`. Independent of the cursor, so callers
/// must not follow it with `scroll_to_cursor`.
fn scroll_by(&mut self, rows: isize, ctx: &AppContext) {
let lines = self.visual_line_count(ctx);
let visible_rows = cmp::min(lines, self.max_visible_rows).max(1);
let max_scroll = lines.saturating_sub(visible_rows) as isize;
let new_offset = (self.scroll_offset as isize + rows).clamp(0, max_scroll);
self.scroll_offset = new_offset as u32;
}
// ── Submit ────────────────────────────────────────────────────────────────
fn submit(&mut self, ctx: &mut ViewContext<Self>) {
let text = self.plain_text(ctx);
ctx.emit(TuiInputViewEvent::Submitted(text));
self.model.update(ctx, |m, ctx| m.clear_buffer(ctx));
self.scroll_offset = 0;
}
// ── Kill / yank ───────────────────────────────────────────────────────────
fn kill_to_line_end(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(range) = self.range_to_visual_line_end(ctx) {
let killed = self
.model
.as_ref(ctx)
.content()
.as_ref(ctx)
.text_in_range(range.clone())
.into_string();
self.kill_buffer.kill(killed);
self.model.update(ctx, |inner, ctx| {
use warp_editor::content::buffer::{BufferEditAction, EditOrigin};
inner.update_content(
|mut content, ctx| {
content.apply_edit(
BufferEditAction::Delete(vec1::vec1![range]),
EditOrigin::UserInitiated,
inner.buffer_selection_model().clone(),
ctx,
);
},
ctx,
);
});
}
}
fn kill_to_line_start(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(range) = self.range_from_visual_line_start(ctx) {
let killed = self
.model
.as_ref(ctx)
.content()
.as_ref(ctx)
.text_in_range(range.clone())
.into_string();
self.kill_buffer.kill(killed);
self.model.update(ctx, |inner, ctx| {
inner.update_content(
|mut content, ctx| {
content.apply_edit(
BufferEditAction::Delete(vec1::vec1![range]),
EditOrigin::UserInitiated,
inner.buffer_selection_model().clone(),
ctx,
);
},
ctx,
);
});
}
}
fn yank(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(text) = self.kill_buffer.yank().map(str::to_owned) {
self.model.update(ctx, |m, ctx| m.user_insert(&text, ctx));
}
}
// ── Kill range helpers ────────────────────────────────────────────────────
//
// These compute ranges purely from the plain-text string using the same
// char-cell soft-wrap arithmetic as `build_visual_rows`, avoiding any
// dependency on the render-state softwrap API (which uses 0-indexed offsets
// that differ from the buffer's 1-indexed `CharOffset` convention).
fn range_to_visual_line_end(&self, ctx: &AppContext) -> Option<Range<CharOffset>> {
let text = self.plain_text(ctx);
// `cursor_offset` is a 1-indexed gap position (gap 1 sits before the
// first character); the buffer's `text_in_range` / `Delete` use those
// same coordinates, so the kill range starts exactly at `cursor_gap`.
let cursor_gap = self.cursor_offset(ctx).as_usize();
// 0-based index of the character at the cursor, for the pure helpers.
let cursor_idx = cursor_gap.saturating_sub(1);
let end_idx = visual_line_end_exclusive(&text, cursor_idx, self.terminal_width(ctx));
if end_idx <= cursor_idx {
return None;
}
// `text[i]` lives at gap `i + 1`, so the exclusive end gap is `end_idx + 1`.
Some(CharOffset::from(cursor_gap)..CharOffset::from(end_idx + 1))
}
fn range_from_visual_line_start(&self, ctx: &AppContext) -> Option<Range<CharOffset>> {
let text = self.plain_text(ctx);
let cursor_gap = self.cursor_offset(ctx).as_usize();
let cursor_idx = cursor_gap.saturating_sub(1);
let start_idx = visual_line_start_idx(&text, cursor_idx, self.terminal_width(ctx));
if start_idx >= cursor_idx {
return None;
}
Some(CharOffset::from(start_idx + 1)..CharOffset::from(cursor_gap))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Kill range pure-text helpers
// ─────────────────────────────────────────────────────────────────────────────
/// The `(logical_line_start, logical_line_end)` char indices bounding the
/// logical line that contains `cursor_idx` (end excludes the trailing `\n`).
fn logical_line_bounds(chars: &[char], cursor_idx: usize) -> (usize, usize) {
let ci = cursor_idx.min(chars.len());
let start = chars[..ci]
.iter()
.rposition(|&c| c == '\n')
.map(|p| p + 1)
.unwrap_or(0);
let end = chars[ci..]
.iter()
.position(|&c| c == '\n')
.map(|p| ci + p)
.unwrap_or(chars.len());
(start, end)
}
/// For the logical line containing `cursor_idx`, returns
/// `(logical_line_start, pos_in_line, row_starts, line_len)` using width-aware
/// wrapping. `row_starts` are 0-based char indices within the line where each
/// visual row begins; `line_len` is the line's char count (excluding the
/// trailing `\n`). Shared by the two kill-range helpers below.
fn visual_line_segments(
text: &str,
cursor_idx: usize,
terminal_width: u16,
) -> (usize, usize, Vec<usize>, usize) {
let chars: Vec<char> = text.chars().collect();
let (logical_line_start, logical_line_end) = logical_line_bounds(&chars, cursor_idx);
let line_widths: Vec<u8> = chars[logical_line_start..logical_line_end]
.iter()
.map(|&c| char_cell_display_width(c) as u8)
.collect();
let row_starts = char_cell_line_row_starts(&line_widths, terminal_width);
let pos_in_line = cursor_idx
.min(chars.len())
.saturating_sub(logical_line_start);
(
logical_line_start,
pos_in_line,
row_starts,
line_widths.len(),
)
}
/// Returns the 0-based exclusive end index of the visual line segment at
/// `cursor_idx`. Does NOT include any trailing `\n`. Width-aware: the segment
/// boundaries follow the same display-width wrapping as the rendered rows.
fn visual_line_end_exclusive(text: &str, cursor_idx: usize, terminal_width: u16) -> usize {
let (logical_line_start, pos_in_line, row_starts, line_len) =
visual_line_segments(text, cursor_idx, terminal_width);
let row = row_starts
.partition_point(|&s| s <= pos_in_line)
.saturating_sub(1);
// Exclusive end = start of the next visual row, or the logical line's end
// (excluding the '\n') for the final row.
let seg_end_in_line = row_starts.get(row + 1).copied().unwrap_or(line_len);
logical_line_start + seg_end_in_line
}
/// Returns the 0-based start index of the visual line segment at `cursor_idx`.
/// Width-aware (see [`visual_line_end_exclusive`]).
fn visual_line_start_idx(text: &str, cursor_idx: usize, terminal_width: u16) -> usize {
let (logical_line_start, pos_in_line, row_starts, _line_len) =
visual_line_segments(text, cursor_idx, terminal_width);
let row = row_starts
.partition_point(|&s| s <= pos_in_line)
.saturating_sub(1);
logical_line_start + row_starts[row]
}
// ─────────────────────────────────────────────────────────────────────────────
// TuiInputElement — element returned from render()
// ─────────────────────────────────────────────────────────────────────────────
/// The element returned from [`TuiInputView::render`].
///
/// `render` captures only width-independent state; the row wrapping, cursor
/// placement, and selection spans are computed in [`TuiElement::layout`], the
/// first point that knows the terminal width (from the layout constraint). This
/// mirrors the GUI, where the element computes geometry in `layout`.
struct TuiInputElement {
/// Backing model: used during layout to push the terminal width and read the
/// char-cell visual line count at that width.
model: ModelHandle<CodeEditorModel>,
/// Plain-text content captured at render time.
text: String,
/// Cursor gap offset (1-based) captured at render time.
cursor_offset: CharOffset,
/// Selection as 0-based `(start, end)` char indices, if any.
sel_char_range: Option<(usize, usize)>,
/// First visible visual row (0-indexed).
scroll_offset: u32,
/// Maximum number of visible rows before the input scrolls.
max_visible_rows: u32,
/// Whether a mouse drag-selection is in progress (captured from the view at
/// render time); gates drag/up handling in `dispatch_event`.
is_selecting: bool,
/// Visible rows, built during `layout`.
column: TuiColumn,
/// The cursor's 0-based column within the visible area (set during `layout`).
cursor_col: u16,
/// The cursor's 0-based row within the visible area (set during `layout`).
cursor_row_in_view: u16,
/// Whether the cursor's visual row falls within the scrolled viewport; when
/// false (e.g. after wheel-scrolling away) no terminal cursor is drawn.
cursor_visible: bool,
/// Selected spans `(row_in_view, start_col, exclusive_end_col)` (set during `layout`).
selected_spans: Vec<(u16, u16, u16)>,
}
impl TuiInputElement {
/// Builds the visible rows, cursor position, and selection spans for
/// `terminal_width`, storing them for `render`/`cursor_position`.
fn build(&mut self, terminal_width: u16, visible_rows: u32) {
let (cursor_visual_row, cursor_col) =
char_cell_cursor_pos(&self.text, self.cursor_offset, terminal_width);
let cursor_row_in_view = cursor_visual_row.saturating_sub(self.scroll_offset);
let cursor_visible = cursor_visual_row >= self.scroll_offset
&& cursor_visual_row < self.scroll_offset + visible_rows;
let rows_with_offsets = build_visual_rows_with_offsets(&self.text, terminal_width);
let visible_start = self.scroll_offset as usize;
let visible_end =
(self.scroll_offset as usize + visible_rows as usize).min(rows_with_offsets.len());
let visible_rows_slice: Vec<(String, usize)> = if visible_start < rows_with_offsets.len() {
rows_with_offsets[visible_start..visible_end].to_vec()
} else {
vec![(String::new(), 0)]
};
// Selection spans per visible row. Selection offsets are char indices;
// terminal highlighting works in display columns, so convert via each
// char's display width (wide chars span two columns).
let mut selected_spans: Vec<(u16, u16, u16)> = Vec::new();
if let Some((sel_start, sel_end)) = self.sel_char_range {
if sel_start < sel_end {
for (vis_idx, (row_text, row_char_start)) in visible_rows_slice.iter().enumerate() {
let row_chars: Vec<char> = row_text.chars().collect();
let row_len = row_chars.len();
let row_char_end = row_char_start + row_len;
if sel_end > *row_char_start && sel_start < row_char_end {
let start_char = sel_start.saturating_sub(*row_char_start);
let end_char = (sel_end - row_char_start).min(row_len);
let disp_start: usize = row_chars[..start_char]
.iter()
.map(|&c| char_cell_display_width(c))
.sum();
let disp_end: usize = row_chars[..end_char]
.iter()
.map(|&c| char_cell_display_width(c))
.sum();
selected_spans.push((vis_idx as u16, disp_start as u16, disp_end as u16));
}
}
}
}
let mut column = TuiColumn::new();
for (row_text, _) in &visible_rows_slice {
// An empty `TuiText` lays out to zero rows, which would collapse the
// row and clip the cursor (or following rows) off the column. Render
// a single space so every visual row keeps a height of exactly one.
let row_display = if row_text.is_empty() {
" ".to_string()
} else {
row_text.clone()
};
column = column.with_child(Box::new(TuiText::new(row_display).truncate()));
}
self.column = column;
self.cursor_col = cursor_col as u16;
self.cursor_row_in_view = cursor_row_in_view as u16;
self.cursor_visible = cursor_visible;
self.selected_spans = selected_spans;
}
/// Maps a terminal cell `position` to the 1-based buffer [`CharOffset`] under
/// it (the gap the cursor should move to for a click/drag at that cell).
///
/// The mouse reports an absolute terminal cell, so getting to a buffer offset
/// crosses three coordinate spaces:
/// 1. screen cell (`position`) -> row/col relative to the input's `area`,
/// 2. + `scroll_offset` -> the buffer's *visual* row (undoes scrolling),
/// 3. (visual row, col) -> char offset via the char-cell soft-wrap map.
///
/// Points outside the input's vertical bounds are intentionally *not* clamped
/// to the viewport: a point above the input maps toward row 0 and a point
/// below it maps past the last visible row (bounded by the buffer's last
/// visual row), so a drag that leaves the input drives auto-scroll.
fn offset_at(&self, position: TuiPoint, area: TuiRect, app: &AppContext) -> CharOffset {
let inner = self.model.as_ref(app);
let render = inner.render_state().as_ref(app);
// Step 1: row of the pointer within the input, where 0 is the input's top
// row. Signed so a point *above* the input (`position.y < area.y`) stays
// negative instead of wrapping around at 0.
let row_in_view = i64::from(position.y) - i64::from(area.y);
// Step 2: the input shows buffer visual rows starting at `scroll_offset`,
// so the buffer row under the pointer is `scroll_offset + row_in_view`
// (floored at the first row).
let visual_row = (i64::from(self.scroll_offset) + row_in_view).max(0) as u32;
// ...and capped at the last real visual row, so a drag below the text
// resolves to the buffer's end rather than past it.
let last_row = render.max_line().as_u32().max(1).saturating_sub(1);
let visual_row = visual_row.min(last_row);
// Column within that row, in display cells (0 is the input's left edge).
let col = position.x.saturating_sub(area.x);
// Step 3: resolve (visual_row, col) to a char offset. The soft-wrap map
// clamps the column to the row's end and is 0-based, while the buffer
// uses 1-based gap offsets (see the kill-range helpers), so re-add 1.
let point = SoftWrapPoint::new(visual_row, ColumnUnit::Chars(col));
let zero_based = render.softwrap_point_to_offset(point);
CharOffset::from(zero_based.as_usize() + 1)
}
/// Maps a mouse `event` to the [`TuiInputAction`] it should dispatch, or
/// `None` when the event should be ignored (a press outside the input, a
/// drag/up with no selection in progress, or a non-mouse event).
///
/// Mirrors the GUI's `left_mouse_down`/`dragged`/`up` mapping: click count 1
/// starts a selection (shift extends), 2 selects a word, 3 selects a line;
/// drag updates the pending selection and up ends it.
fn mouse_action(
&self,
event: &TuiEvent,
area: TuiRect,
app: &AppContext,
) -> Option<TuiInputAction> {
match event {
TuiEvent::LeftMouseDown {
position,
modifiers,
click_count,
is_first_mouse,
} => {
// The focus-bringing first click has no matching mouse-up, and a
// press outside the input must not start a selection.
if *is_first_mouse || !area.contains_point(*position) {
return None;
}
let offset = self.offset_at(*position, area, app);
Some(match *click_count {
0 | 1 if modifiers.shift => TuiInputAction::SelectionExtendTo { offset },
0 | 1 => TuiInputAction::SelectionStartAt { offset },
2 => TuiInputAction::SelectWordAt { offset },
_ => TuiInputAction::SelectLineAt { offset },
})
}
// Drags continue even outside the input's bounds (drag-to-scroll),
// but only while a selection that began inside it is active.
TuiEvent::LeftMouseDragged { position, .. } if self.is_selecting => {
Some(TuiInputAction::SelectionUpdateTo {
offset: self.offset_at(*position, area, app),
})
}
TuiEvent::LeftMouseUp { .. } if self.is_selecting => Some(TuiInputAction::SelectionEnd),
// Mouse wheel over the input scrolls the viewport (cursor unmoved).
TuiEvent::ScrollWheel {
position, delta, ..
} if area.contains_point(*position) => Some(TuiInputAction::Scroll {
// crossterm reports ScrollUp as +1 row / ScrollDown as -1; negate
// so wheel-up scrolls toward the top (matches `TuiScrollable`).
rows: -(delta.1 * WHEEL_STEP),
}),
_ => None,
}
}
}
impl TuiElement for TuiInputElement {
fn layout(
&mut self,
constraint: TuiConstraint,
ctx: &mut TuiLayoutContext,
app: &AppContext,
) -> TuiSize {
// The layout constraint is the first place the real terminal width is
// known. Push it onto the model (interior-mutable) so event-time
// navigation/scroll read the right width, then build the rows at that
// width — mirroring how the GUI computes geometry during layout.
let terminal_width = constraint.constrain_width(constraint.max.width);
let render_state = self.model.as_ref(app).render_state().clone();
if let Some(cc) = render_state.as_ref(app).char_cell() {
cc.set_terminal_width(terminal_width);
}
let visual_line_count = render_state.as_ref(app).max_line().as_u32().max(1);
let visible_rows = cmp::min(visual_line_count, self.max_visible_rows);
self.build(terminal_width, visible_rows);
self.column.layout(constraint, ctx, app)
}
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) {
self.column.render(area, buffer, ctx);
if !self.selected_spans.is_empty() {
let reversed = TuiStyle::default().add_modifier(Modifier::REVERSED);
for &(row_in_view, start_col, end_col) in &self.selected_spans {
let y = area.y.saturating_add(row_in_view);
let x = area.x.saturating_add(start_col);
let width = end_col.saturating_sub(start_col);
if y < area.y + area.height && width > 0 {
let sel_rect =
TuiRect::new(x, y, width.min(area.width.saturating_sub(start_col)), 1);
buffer.set_style(sel_rect, reversed);
}
}
}
}
fn cursor_position(&self, area: TuiRect, _ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> {
if !self.cursor_visible
|| self.cursor_col >= area.width
|| self.cursor_row_in_view >= area.height
{
return None;
}
Some((self.cursor_col, self.cursor_row_in_view))
}
fn dispatch_event(
&mut self,
event: &TuiEvent,
area: TuiRect,
event_ctx: &mut TuiEventContext,
ctx: &mut TuiLayoutContext,
app: &AppContext,
) -> bool {
if self.column.dispatch_event(event, area, event_ctx, ctx, app) {
return true;
}
if let Some(action) = self.mouse_action(event, area, app) {
event_ctx.dispatch_typed_action(action);
return true;
}
if let TuiEvent::KeyDown {
keystroke, chars, ..
} = event
{
let ctrl = keystroke.ctrl;
let alt = keystroke.alt;
let shift = keystroke.shift;
let key = keystroke.key.as_str();
let action: Option<TuiInputAction> = match (ctrl, alt, shift, key) {
// ── Submit / newline ─────────────────────────────────────────────
(false, false, false, "enter") => Some(TuiInputAction::Submit),
(false, false, true, "enter") => Some(TuiInputAction::InsertNewline),
(true, false, false, "j") => Some(TuiInputAction::InsertNewline),
(false, true, false, "enter") => Some(TuiInputAction::InsertNewline),
// ── Deletion ─────────────────────────────────────────────────────
(false, false, _, "backspace") => Some(TuiInputAction::Backspace),
(true, false, false, "h") => Some(TuiInputAction::Backspace),
(false, false, false, "delete") => Some(TuiInputAction::DeleteForward),
(true, false, false, "d") => Some(TuiInputAction::DeleteForward),
(true, false, false, "w") => Some(TuiInputAction::DeleteWordBackward),
(true, false, false, "backspace") => Some(TuiInputAction::DeleteWordBackward),
(false, true, false, "backspace") => Some(TuiInputAction::DeleteWordBackward),
(false, true, false, "d") => Some(TuiInputAction::DeleteWordForward),
(false, true, false, "delete") => Some(TuiInputAction::DeleteWordForward),
(true, false, false, "delete") => Some(TuiInputAction::DeleteWordForward),
// ── Cursor movement ───────────────────────────────────────────────
(false, false, false, "left") | (true, false, false, "b") => {
Some(TuiInputAction::MoveLeft)
}
(false, false, false, "right") | (true, false, false, "f") => {
Some(TuiInputAction::MoveRight)
}
(false, false, false, "up") | (true, false, false, "p") => {
Some(TuiInputAction::MoveUp)
}
(false, false, false, "down") | (true, false, false, "n") => {
Some(TuiInputAction::MoveDown)
}
(false, true, false, "left")
| (false, true, false, "b")
| (true, false, false, "left") => Some(TuiInputAction::MoveWordLeft),
(false, true, false, "right")
| (false, true, false, "f")
| (true, false, false, "right") => Some(TuiInputAction::MoveWordRight),
(false, false, false, "home") | (true, false, false, "a") => {
Some(TuiInputAction::MoveToLineStart)
}
(false, false, false, "end") | (true, false, false, "e") => {
Some(TuiInputAction::MoveToLineEnd)
}
// ── Selection ────────────────────────────────────────────────────
(false, false, true, "left") => Some(TuiInputAction::SelectLeft),
(false, false, true, "right") => Some(TuiInputAction::SelectRight),
(false, false, true, "up") => Some(TuiInputAction::SelectUp),
(false, false, true, "down") => Some(TuiInputAction::SelectDown),
(true, false, true, "a") => Some(TuiInputAction::SelectAll),
// Word-wise selection: Ctrl+Shift+Arrow (and Alt+Shift+Arrow).
(true, false, true, "left") | (false, true, true, "left") => {
Some(TuiInputAction::SelectWordLeft)
}
(true, false, true, "right") | (false, true, true, "right") => {
Some(TuiInputAction::SelectWordRight)
}
// ── Kill / yank ───────────────────────────────────────────────────
(true, false, false, "k") => Some(TuiInputAction::KillToLineEnd),
(true, false, false, "u") => Some(TuiInputAction::KillToLineStart),
(true, false, false, "y") => Some(TuiInputAction::Yank),
// ── Undo / redo ───────────────────────────────────────────────────
(true, false, false, "z") => Some(TuiInputAction::Undo),
(true, false, true, "z") => Some(TuiInputAction::Redo),
// ── Printable character ───────────────────────────────────────────
(false, false, _, _) if !chars.is_empty() && !ctrl && !alt => {
chars.chars().next().map(TuiInputAction::InsertChar)
}
_ => None,
};
if let Some(action) = action {
event_ctx.dispatch_typed_action(action);
return true;
}
}
false
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Char-cell helpers (pure functions, no model dependency)
// ─────────────────────────────────────────────────────────────────────────────
/// Splits `text` into visual rows, returning `(row_text, char_start_offset)` pairs.
///
/// Wrapping is display-width aware (wide CJK/emoji span two columns, combining
/// marks zero), sharing the same wrapping rule as the editor's char-cell layout
/// via [`char_cell_line_row_starts`].
pub fn build_visual_rows_with_offsets(text: &str, terminal_width: u16) -> Vec<(String, usize)> {
let mut rows: Vec<(String, usize)> = Vec::new();
let mut global_char_offset: usize = 0;
for logical_line in text.split('\n') {
let line_char_start = global_char_offset;
let chars: Vec<char> = logical_line.chars().collect();
let line_len = chars.len();
let widths: Vec<u8> = chars
.iter()
.map(|&c| char_cell_display_width(c) as u8)
.collect();
let row_starts = char_cell_line_row_starts(&widths, terminal_width);
for r in 0..row_starts.len() {
let start = row_starts[r];
let end = row_starts.get(r + 1).copied().unwrap_or(line_len);
rows.push((chars[start..end].iter().collect(), line_char_start + start));
}
global_char_offset += line_len + 1;
}
if rows.is_empty() {
rows.push((String::new(), 0));
}
rows
}
/// Splits `text` into visual rows, display-width aware. Thin wrapper over
/// [`build_visual_rows_with_offsets`] that drops the per-row char offsets.
pub fn build_visual_rows(text: &str, terminal_width: u16) -> Vec<String> {
build_visual_rows_with_offsets(text, terminal_width)
.into_iter()
.map(|(row_text, _)| row_text)
.collect()
}
/// Returns `(visual_row, visual_col)` for `cursor_offset` in char-cell
/// coordinates, where `visual_col` is a display column (wide chars span two).
pub fn char_cell_cursor_pos(
text: &str,
cursor_offset: CharOffset,
terminal_width: u16,
) -> (u32, u32) {
let cursor_char_idx = cursor_offset.as_usize().saturating_sub(1);
let mut visual_row: u32 = 0;
let mut chars_so_far: usize = 0;
for logical_line in text.split('\n') {
let chars: Vec<char> = logical_line.chars().collect();
let line_len = chars.len();
let widths: Vec<u8> = chars
.iter()
.map(|&c| char_cell_display_width(c) as u8)
.collect();
let line_end_exclusive = chars_so_far + line_len;
if cursor_char_idx <= line_end_exclusive {
let char_in_line = cursor_char_idx.saturating_sub(chars_so_far);
let (row_in_line, col) =
char_cell_line_gap_position(&widths, terminal_width, char_in_line);
return (visual_row + row_in_line, col as u32);
}
visual_row += char_cell_line_row_starts(&widths, terminal_width).len() as u32;
chars_so_far = line_end_exclusive + 1;
}
(visual_row, 0)
}
#[cfg(test)]
#[path = "view_tests.rs"]
mod tests;