merge: tui text-input widget (#58)
This commit is contained in:
@@ -190,8 +190,8 @@ impl App {
|
||||
}
|
||||
SpawnInputResult::Submit => {
|
||||
effects.push(Effect::Spawn(CreateSessionRequest {
|
||||
cwd: std::path::PathBuf::from(self.spawn.cwd.clone()),
|
||||
command: self.spawn.command.clone(),
|
||||
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
|
||||
command: self.spawn.command.value().to_string(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
@@ -267,8 +267,8 @@ impl App {
|
||||
}
|
||||
Action::OpenSpawnModal => {
|
||||
self.spawn = SpawnModal {
|
||||
cwd: String::new(),
|
||||
command: String::new(),
|
||||
cwd: Default::default(),
|
||||
command: Default::default(),
|
||||
focused_field: Default::default(),
|
||||
};
|
||||
self.state.show_overlay(OverlayKind::Spawn);
|
||||
@@ -348,7 +348,10 @@ impl App {
|
||||
InputKey::Down => b"\x1b[B".to_vec(),
|
||||
InputKey::Right => b"\x1b[C".to_vec(),
|
||||
InputKey::Left => b"\x1b[D".to_vec(),
|
||||
InputKey::Mouse { .. } => return None,
|
||||
InputKey::Home => b"\x1b[H".to_vec(),
|
||||
InputKey::End => b"\x1b[F".to_vec(),
|
||||
InputKey::Delete => b"\x1b[3~".to_vec(),
|
||||
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
|
||||
};
|
||||
Some(Effect::Forward { id: sid, bytes })
|
||||
}
|
||||
@@ -587,8 +590,8 @@ mod tests {
|
||||
fn submitting_the_spawn_modal_carries_the_typed_fields() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenSpawnModal);
|
||||
app.spawn.cwd = "/srv/work".into();
|
||||
app.spawn.command = "claude".into();
|
||||
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
|
||||
app.spawn.command = crate::text_input::TextInput::with_value("claude");
|
||||
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
|
||||
|
||||
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||
|
||||
@@ -34,6 +34,12 @@ pub enum InputKey {
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
Delete,
|
||||
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
||||
/// text fields can insert it as a single operation.
|
||||
Paste(String),
|
||||
Mouse {
|
||||
kind: MouseKind,
|
||||
region: Region,
|
||||
@@ -126,6 +132,12 @@ impl InputKey {
|
||||
InputKey::Down => "down".into(),
|
||||
InputKey::Left => "left".into(),
|
||||
InputKey::Right => "right".into(),
|
||||
InputKey::Home => "home".into(),
|
||||
InputKey::End => "end".into(),
|
||||
InputKey::Delete => "delete".into(),
|
||||
// Pastes are not bindable; the canonical form exists only to
|
||||
// satisfy exhaustiveness and never round-trips through `parse`.
|
||||
InputKey::Paste(_) => "paste".into(),
|
||||
InputKey::Mouse { kind, region, .. } => {
|
||||
let k = match kind {
|
||||
MouseKind::Click => "click",
|
||||
@@ -191,6 +203,9 @@ impl InputKey {
|
||||
"down" => return Ok(InputKey::Down),
|
||||
"left" => return Ok(InputKey::Left),
|
||||
"right" => return Ok(InputKey::Right),
|
||||
"home" => return Ok(InputKey::Home),
|
||||
"end" => return Ok(InputKey::End),
|
||||
"delete" => return Ok(InputKey::Delete),
|
||||
_ => {}
|
||||
}
|
||||
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
|
||||
@@ -262,9 +277,13 @@ where
|
||||
KeyCode::Down => InputKey::Down,
|
||||
KeyCode::Left => InputKey::Left,
|
||||
KeyCode::Right => InputKey::Right,
|
||||
KeyCode::Home => InputKey::Home,
|
||||
KeyCode::End => InputKey::End,
|
||||
KeyCode::Delete => InputKey::Delete,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())),
|
||||
CtEvent::Mouse(MouseEvent {
|
||||
kind, column, row, ..
|
||||
}) => {
|
||||
@@ -578,12 +597,25 @@ mod tests {
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"home",
|
||||
"end",
|
||||
"delete",
|
||||
] {
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A bracketed paste must arrive as one key carrying the whole payload,
|
||||
/// not a burst of per-character keys.
|
||||
#[test]
|
||||
fn canonicalise_paste_event_carries_the_whole_payload() {
|
||||
use crossterm::event::Event as CtEvent;
|
||||
let ev = CtEvent::Paste("/srv/work/maestro".into());
|
||||
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
|
||||
assert_eq!(k, InputKey::Paste("/srv/work/maestro".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_mouse() {
|
||||
let s = "mouse:click(pane)";
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod sidebar;
|
||||
pub mod state;
|
||||
pub mod tabs;
|
||||
pub mod terminal;
|
||||
pub mod text_input;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -20,8 +20,8 @@ pub enum SpawnField {
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SpawnModal {
|
||||
pub cwd: String,
|
||||
pub command: String,
|
||||
pub cwd: crate::text_input::TextInput,
|
||||
pub command: crate::text_input::TextInput,
|
||||
pub focused_field: SpawnField,
|
||||
}
|
||||
|
||||
@@ -64,27 +64,16 @@ impl SpawnModal {
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
},
|
||||
InputKey::Char { c, .. } => {
|
||||
// Everything else — characters, motion, editing, paste — goes to
|
||||
// the focused field's text input.
|
||||
other => {
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => self.cwd.push(*c),
|
||||
SpawnField::Command => self.command.push(*c),
|
||||
SpawnField::Cwd => self.cwd.handle_key(other),
|
||||
SpawnField::Command => self.command.handle_key(other),
|
||||
SpawnField::Submit => {}
|
||||
}
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
InputKey::Backspace => {
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => {
|
||||
self.cwd.pop();
|
||||
}
|
||||
SpawnField::Command => {
|
||||
self.command.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
SpawnInputResult::Consumed
|
||||
}
|
||||
_ => SpawnInputResult::Consumed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,20 +96,40 @@ impl SpawnModal {
|
||||
Style::default()
|
||||
}
|
||||
};
|
||||
let field_row = |label: &str, value: &str, style: Style| {
|
||||
Paragraph::new(Line::from(vec![
|
||||
ratatui::text::Span::raw(format!("{label:8} ")),
|
||||
ratatui::text::Span::styled(value.to_string(), style),
|
||||
]))
|
||||
|
||||
// Each text field renders through the shared `TextInput`, which
|
||||
// handles horizontal scrolling and reports where the terminal cursor
|
||||
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
|
||||
let label =
|
||||
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
|
||||
f.render_widget(label("cwd:"), rows[0]);
|
||||
f.render_widget(label("command:"), rows[1]);
|
||||
let draw_field = |f: &mut Frame,
|
||||
input: &crate::text_input::TextInput,
|
||||
row: Rect,
|
||||
focused: bool|
|
||||
-> u16 {
|
||||
let style = if focused {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let area = Rect {
|
||||
x: row.x.saturating_add(9),
|
||||
y: row.y,
|
||||
width: row.width.saturating_sub(9),
|
||||
height: 1,
|
||||
};
|
||||
input.render(area, f.buffer_mut(), style)
|
||||
};
|
||||
f.render_widget(
|
||||
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
field_row("command:", &self.command, highlight(SpawnField::Command)),
|
||||
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
|
||||
let cmd_col = draw_field(
|
||||
f,
|
||||
&self.command,
|
||||
rows[1],
|
||||
self.focused_field == SpawnField::Command,
|
||||
);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
|
||||
"[ Spawn ]",
|
||||
@@ -129,20 +138,9 @@ impl SpawnModal {
|
||||
rows[2],
|
||||
);
|
||||
|
||||
// Place the cursor at the end of the currently-focused text field so
|
||||
// the user can see where their typing lands. Label is fixed-width
|
||||
// 8 chars + 1 space = column 9 inside the row.
|
||||
let cursor_col_for = |value: &str| -> u16 {
|
||||
inner
|
||||
.x
|
||||
.saturating_add(9)
|
||||
.saturating_add(value.chars().count() as u16)
|
||||
};
|
||||
match self.focused_field {
|
||||
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)),
|
||||
SpawnField::Command => {
|
||||
f.set_cursor_position((cursor_col_for(&self.command), rows[1].y))
|
||||
}
|
||||
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
|
||||
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
|
||||
SpawnField::Submit => {
|
||||
// The submit button is indicated by the REVERSED style; no
|
||||
// cursor placement.
|
||||
@@ -267,8 +265,8 @@ mod tests {
|
||||
#[test]
|
||||
fn spawn_modal_renders_field_labels() {
|
||||
let modal = SpawnModal {
|
||||
cwd: "/x".into(),
|
||||
command: "bash".into(),
|
||||
cwd: crate::text_input::TextInput::with_value("/x"),
|
||||
command: crate::text_input::TextInput::with_value("bash"),
|
||||
focused_field: SpawnField::Cwd,
|
||||
};
|
||||
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
|
||||
@@ -338,4 +336,51 @@ mod tests {
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing keys must reach the focused field's text input, not a bare
|
||||
/// String — this is what gives the operator cursor motion and word ops.
|
||||
#[test]
|
||||
fn editing_keys_reach_the_focused_text_input() {
|
||||
let mut modal = SpawnModal::default();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
for k in [
|
||||
crate::input::InputKey::char('/'),
|
||||
crate::input::InputKey::char('t'),
|
||||
crate::input::InputKey::char('m'),
|
||||
crate::input::InputKey::char('p'),
|
||||
] {
|
||||
modal.on_input(&k);
|
||||
}
|
||||
modal.on_input(&crate::input::InputKey::Left);
|
||||
modal.on_input(&crate::input::InputKey::Backspace);
|
||||
assert_eq!(modal.cwd.value(), "/tp");
|
||||
|
||||
// Tab to command, then a paste and ctrl-w land there, not in cwd.
|
||||
modal.on_input(&crate::input::InputKey::Tab);
|
||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
||||
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
|
||||
assert_eq!(modal.command.value(), "/tmp/foo");
|
||||
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
|
||||
assert_eq!(modal.command.value(), "");
|
||||
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
|
||||
}
|
||||
|
||||
/// The terminal cursor must sit right after the typed text in the focused
|
||||
/// field (label is 8 chars + 1 space, modal is centered on screen).
|
||||
#[test]
|
||||
fn render_places_the_cursor_after_the_typed_text() {
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
let modal = SpawnModal {
|
||||
cwd: crate::text_input::TextInput::with_value("abc"),
|
||||
..SpawnModal::default()
|
||||
};
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| modal.render(f, f.area())).unwrap();
|
||||
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
|
||||
let pos = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
@@ -26,7 +28,12 @@ impl TerminalGuard {
|
||||
pub fn enter() -> Result<Self, TerminalError> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = io::stdout();
|
||||
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(
|
||||
out,
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste
|
||||
)?;
|
||||
Ok(TerminalGuard)
|
||||
}
|
||||
|
||||
@@ -34,7 +41,12 @@ impl TerminalGuard {
|
||||
/// call it without producing weird errors.
|
||||
fn restore() {
|
||||
let mut out = io::stdout();
|
||||
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
|
||||
let _ = execute!(
|
||||
out,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
let _ = disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
//! Single-line text-input primitive shared by every TUI form field.
|
||||
//!
|
||||
//! Owns a `String`, a character-indexed cursor, and horizontal scroll state.
|
||||
//! All editing goes through [`TextInput::handle_key`] so callers feed the same
|
||||
//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste
|
||||
//! arrives as one [`InputKey::Paste`] and is inserted as a single operation.
|
||||
//!
|
||||
//! This is the one piece of the TUI form layer that unit-tests without a
|
||||
//! terminal: drive it with `InputKey` sequences and inspect value/cursor.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
use crate::input::InputKey;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TextInput {
|
||||
value: String,
|
||||
/// Cursor position as a char offset into `value`.
|
||||
cursor: usize,
|
||||
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
|
||||
/// re-clamp it without `&mut self` — widgets are drawn through shared
|
||||
/// references in this codebase.
|
||||
scroll: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
pub fn with_value(value: impl Into<String>) -> Self {
|
||||
let mut input = Self {
|
||||
value: value.into(),
|
||||
..Self::default()
|
||||
};
|
||||
input.cursor = input.value.chars().count();
|
||||
input
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Char offset of the leftmost visible cell; only meaningful after
|
||||
/// [`TextInput::render`] has clamped it to the field width.
|
||||
pub fn scroll_offset(&self) -> usize {
|
||||
self.scroll.get()
|
||||
}
|
||||
|
||||
/// Insert `text` at the cursor as one operation. Used both for typed
|
||||
/// characters and for whole bracketed-paste payloads.
|
||||
pub fn insert(&mut self, text: &str) {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
self.value.insert_str(at, text);
|
||||
self.cursor += text.chars().count();
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: &InputKey) {
|
||||
match key {
|
||||
InputKey::Char {
|
||||
c,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
..
|
||||
} => self.insert(&c.to_string()),
|
||||
InputKey::Char {
|
||||
c: 'w', ctrl: true, ..
|
||||
} => self.delete_word_backwards(),
|
||||
InputKey::Char {
|
||||
c: 'k', ctrl: true, ..
|
||||
} => self.kill_to_end(),
|
||||
// Emacs-style word motion; ctrl-arrow reaches us as a plain
|
||||
// Left/Right today, so these are the dependable binding.
|
||||
InputKey::Char {
|
||||
c: 'b', ctrl: true, ..
|
||||
} => self.cursor = self.word_left(),
|
||||
InputKey::Char {
|
||||
c: 'f', ctrl: true, ..
|
||||
} => self.cursor = self.word_right(),
|
||||
InputKey::Left => {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
}
|
||||
InputKey::Right => {
|
||||
if self.cursor < self.value.chars().count() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
InputKey::Home => self.cursor = 0,
|
||||
InputKey::End => self.cursor = self.value.chars().count(),
|
||||
InputKey::Backspace => {
|
||||
if self.cursor > 0 {
|
||||
let at = self.byte_offset(self.cursor - 1);
|
||||
let end = self.byte_offset(self.cursor);
|
||||
self.value.replace_range(at..end, "");
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
InputKey::Delete => {
|
||||
if self.cursor < self.value.chars().count() {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
let end = self.byte_offset(self.cursor + 1);
|
||||
self.value.replace_range(at..end, "");
|
||||
}
|
||||
}
|
||||
InputKey::Paste(text) => self.insert(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw into `area` (expected one row tall), scrolling horizontally so the
|
||||
/// cursor stays visible, and return the screen column where the caller
|
||||
/// should place the terminal cursor.
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
|
||||
let width = area.width as usize;
|
||||
let len = self.value.chars().count();
|
||||
let scroll = self.scroll.get().min(len);
|
||||
let scroll = if self.cursor < scroll {
|
||||
self.cursor
|
||||
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
|
||||
self.cursor + 1 - width
|
||||
} else {
|
||||
scroll
|
||||
};
|
||||
self.scroll.set(scroll);
|
||||
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
|
||||
buf[(area.x + col as u16, area.y)]
|
||||
.set_symbol(&c.to_string())
|
||||
.set_style(style);
|
||||
}
|
||||
area.x + self.cursor.saturating_sub(scroll) as u16
|
||||
}
|
||||
|
||||
fn byte_offset(&self, char_index: usize) -> usize {
|
||||
self.value
|
||||
.char_indices()
|
||||
.nth(char_index)
|
||||
.map(|(b, _)| b)
|
||||
.unwrap_or(self.value.len())
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Start of the word before the cursor, skipping any separators first —
|
||||
/// emacs `backward-word`.
|
||||
fn word_left(&self) -> usize {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let mut i = self.cursor;
|
||||
while i > 0 && !Self::is_word_char(chars[i - 1]) {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && Self::is_word_char(chars[i - 1]) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Start of the word after the cursor, skipping the rest of the current
|
||||
/// word first — emacs `forward-word`.
|
||||
fn word_right(&self) -> usize {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = self.cursor;
|
||||
while i < len && !Self::is_word_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
while i < len && Self::is_word_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn kill_to_end(&mut self) {
|
||||
let at = self.byte_offset(self.cursor);
|
||||
self.value.truncate(at);
|
||||
}
|
||||
|
||||
/// Shell-style ctrl-w: kill back to the previous whitespace, taking any
|
||||
/// trailing whitespace run with it — so `cd /tmp/foo` loses the whole
|
||||
/// path segment in one keystroke.
|
||||
fn delete_word_backwards(&mut self) {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let mut i = self.cursor;
|
||||
while i > 0 && chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
let at = self.byte_offset(i);
|
||||
let end = self.byte_offset(self.cursor);
|
||||
self.value.replace_range(at..end, "");
|
||||
self.cursor = i;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use InputKey::{Backspace, Delete, End, Home, Left, Right};
|
||||
|
||||
fn typed(s: &str) -> Vec<InputKey> {
|
||||
s.chars().map(InputKey::char).collect()
|
||||
}
|
||||
|
||||
fn fed(keys: &[InputKey]) -> TextInput {
|
||||
let mut input = TextInput::default();
|
||||
for k in keys {
|
||||
input.handle_key(k);
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
fn ctrl(c: char) -> InputKey {
|
||||
InputKey::ctrl_char(c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_lands_in_order() {
|
||||
let input = fed(&typed("hello"));
|
||||
assert_eq!(input.value(), "hello");
|
||||
assert_eq!(input.cursor(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_removes_the_last_typed_char() {
|
||||
let mut input = fed(&typed("hello"));
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "hell");
|
||||
assert_eq!(input.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_then_insert_fixes_a_middle_typo() {
|
||||
let mut input = fed(&typed("helo"));
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&InputKey::char('l'));
|
||||
assert_eq!(input.value(), "hello");
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_keys_stop_at_the_edges() {
|
||||
let mut input = fed(&typed("abc"));
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Left);
|
||||
}
|
||||
assert_eq!(input.cursor(), 0);
|
||||
input.handle_key(&Left);
|
||||
assert_eq!(input.cursor(), 0);
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Right);
|
||||
}
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_and_end_jump_to_the_ends() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Home);
|
||||
assert_eq!(input.cursor(), 0);
|
||||
input.handle_key(&InputKey::char('x'));
|
||||
assert_eq!(input.value(), "xabcdef");
|
||||
input.handle_key(&End);
|
||||
input.handle_key(&InputKey::char('!'));
|
||||
assert_eq!(input.value(), "xabcdef!");
|
||||
}
|
||||
|
||||
/// Cursor starts after the final `/`; word-left lands on word starts,
|
||||
/// never inside a separator run.
|
||||
#[test]
|
||||
fn ctrl_b_walks_left_one_word_at_a_time() {
|
||||
let mut input = TextInput::with_value("/tmp/foo bar-baz/");
|
||||
assert_eq!(input.cursor(), 17);
|
||||
for expected in [13, 9, 5, 1, 0, 0] {
|
||||
input.handle_key(&ctrl('b'));
|
||||
assert_eq!(input.cursor(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_f_walks_right_one_word_at_a_time() {
|
||||
let mut input = TextInput::with_value("/tmp/foo bar");
|
||||
input.handle_key(&Home);
|
||||
for expected in [4, 8, 12, 12] {
|
||||
input.handle_key(&ctrl('f'));
|
||||
assert_eq!(input.cursor(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_before_the_cursor() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "abdef");
|
||||
assert_eq!(input.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_empty_input_is_a_noop() {
|
||||
let mut input = TextInput::default();
|
||||
input.handle_key(&Backspace);
|
||||
assert_eq!(input.value(), "");
|
||||
assert_eq!(input.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_the_char_under_the_cursor() {
|
||||
let mut input = fed(&typed("abcdef"));
|
||||
input.handle_key(&Home);
|
||||
input.handle_key(&Delete);
|
||||
assert_eq!(input.value(), "bcdef");
|
||||
assert_eq!(input.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_past_the_end_is_a_noop() {
|
||||
let mut input = TextInput::with_value("abc");
|
||||
input.handle_key(&End);
|
||||
input.handle_key(&Delete);
|
||||
assert_eq!(input.value(), "abc");
|
||||
}
|
||||
|
||||
/// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one
|
||||
/// word to a shell (readline kills back to whitespace, spaces included).
|
||||
#[test]
|
||||
fn ctrl_w_deletes_the_word_behind_the_cursor() {
|
||||
let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat());
|
||||
input.handle_key(&ctrl('w'));
|
||||
assert_eq!(input.value(), "cd ");
|
||||
assert_eq!(input.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_stops_before_the_separator_run() {
|
||||
let mut input = TextInput::with_value("a b");
|
||||
input.cursor = 5;
|
||||
input.handle_key(&ctrl('w'));
|
||||
assert_eq!(input.value(), "a ");
|
||||
assert_eq!(input.cursor(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_k_kills_everything_after_the_cursor() {
|
||||
let mut input = fed(&typed("spawn --debug extra"));
|
||||
for _ in 0..14 {
|
||||
input.handle_key(&Left);
|
||||
}
|
||||
input.handle_key(&ctrl('k'));
|
||||
assert_eq!(input.value(), "spawn");
|
||||
assert_eq!(input.cursor(), 5);
|
||||
}
|
||||
|
||||
/// A paste arrives as one event carrying the whole payload and must land
|
||||
/// at the cursor as a single insertion, not per-character.
|
||||
#[test]
|
||||
fn paste_inserts_whole_payload_at_the_cursor() {
|
||||
let mut input = fed(&typed("cd "));
|
||||
input.handle_key(&InputKey::Paste("/srv/work".into()));
|
||||
assert_eq!(input.value(), "cd /srv/work");
|
||||
assert_eq!(input.cursor(), 12);
|
||||
// What follows still behaves like ordinary typed text.
|
||||
input.handle_key(&InputKey::char('/'));
|
||||
assert_eq!(input.value(), "cd /srv/work/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_between_existing_text_keeps_both_halves() {
|
||||
let mut input = TextInput::with_value("ac");
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&InputKey::Paste("b×".into()));
|
||||
assert_eq!(input.value(), "ab×c");
|
||||
assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_an_empty_field_points_at_its_first_column() {
|
||||
let input = TextInput::default();
|
||||
let area = Rect::new(10, 5, 20, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_reports_the_visible_cursor_column() {
|
||||
let mut input = TextInput::with_value("hello");
|
||||
input.handle_key(&Left);
|
||||
input.handle_key(&Left);
|
||||
let area = Rect::new(10, 5, 20, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 13);
|
||||
for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() {
|
||||
assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
|
||||
let value = "0123456789abcdefghij";
|
||||
let input = TextInput::with_value(value);
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(col, 9, "cursor sits on the last visible column");
|
||||
for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() {
|
||||
assert_eq!(
|
||||
buf[(i as u16, 0)].symbol(),
|
||||
expected.to_string(),
|
||||
"cell {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving left into the hidden region must scroll the window back so the
|
||||
/// cursor never disappears off the left edge.
|
||||
#[test]
|
||||
fn moving_left_into_the_hidden_region_scrolls_back() {
|
||||
let value = "0123456789abcdefghij";
|
||||
let mut input = TextInput::with_value(value);
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.cursor(), 20);
|
||||
|
||||
input.handle_key(&Home);
|
||||
for _ in 0..5 {
|
||||
input.handle_key(&Right);
|
||||
}
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.cursor(), 5);
|
||||
assert_eq!(
|
||||
input.value().chars().nth(input.scroll_offset()),
|
||||
Some('5'),
|
||||
"window scrolled back to the cursor"
|
||||
);
|
||||
assert_eq!(col, 0);
|
||||
assert_eq!(buf[(0, 0)].symbol(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_the_value_clamps_scroll_and_cursor() {
|
||||
let mut input = TextInput::with_value("0123456789abcdefghij");
|
||||
input.handle_key(&Home);
|
||||
input.handle_key(&ctrl('k'));
|
||||
let area = Rect::new(0, 0, 10, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let col = input.render(area, &mut buf, Style::default());
|
||||
assert_eq!(input.value(), "");
|
||||
assert_eq!(col, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user