use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Margin, Rect};
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::shared::keys;
use crate::shared::theme::Palette;
use crate::shared::ui::render_scrollbar;
use crate::shared::wrap;
const PROMPT_W: u16 = 2;
type VisualRow = (usize, usize, usize);
type RowCache = Option<(usize, u64, Vec<VisualRow>)>;
const UNDO_CAP: usize = 200;
const MASK_CHAR: char = '•';
#[derive(Clone)]
struct Snapshot {
lines: Vec<Vec<char>>,
row: usize,
col: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EditKind {
Insert,
Delete,
Structural,
}
pub struct InputBox {
lines: Vec<Vec<char>>,
row: usize,
col: usize,
scroll: usize,
single_line: bool,
mask: bool,
hscroll: usize,
last_width: usize,
goal_col: Option<usize>,
misspelled: Vec<Vec<(usize, usize)>>,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
last_edit_kind: Option<EditKind>,
revision: u64,
rows_cache: RowCache,
anchor: Option<(usize, usize)>,
last_area: Option<Rect>,
}
impl Default for InputBox {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyOutcome {
Edited,
Moved,
Ignored,
}
impl KeyOutcome {
#[allow(dead_code)]
pub fn handled(self) -> bool {
!matches!(self, KeyOutcome::Ignored)
}
pub fn edited(self) -> bool {
matches!(self, KeyOutcome::Edited)
}
}
pub struct RenderOpts<'a> {
pub title: &'a str,
pub focused: bool,
pub command: bool,
pub placeholder: &'a str,
}
impl<'a> RenderOpts<'a> {
pub fn focused(title: &'a str) -> Self {
Self {
title,
focused: true,
command: false,
placeholder: "",
}
}
}
impl InputBox {
pub fn new() -> Self {
Self {
lines: vec![Vec::new()],
row: 0,
col: 0,
scroll: 0,
single_line: false,
mask: false,
hscroll: 0,
last_width: 0,
goal_col: None,
misspelled: Vec::new(),
undo: Vec::new(),
redo: Vec::new(),
last_edit_kind: None,
revision: 0,
rows_cache: None,
anchor: None,
last_area: None,
}
}
pub fn set_single_line(&mut self, on: bool) {
self.single_line = on;
if on && self.lines.len() > 1 {
let mut merged: Vec<char> = Vec::new();
for (idx, line) in self.lines.iter().enumerate() {
if idx > 0 {
merged.push(' ');
}
merged.extend(line.iter().copied());
}
self.col = self.col.min(merged.len());
self.lines = vec![merged];
self.row = 0;
self.scroll = 0;
self.hscroll = 0;
self.goal_col = None;
self.touch();
}
}
pub fn set_mask(&mut self, on: bool) {
self.mask = on;
if on {
self.set_single_line(true);
}
}
#[allow(dead_code)]
pub fn is_masked(&self) -> bool {
self.mask
}
pub fn text(&self) -> String {
self.lines
.iter()
.map(|l| l.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
pub fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
pub fn first_non_whitespace(&self) -> Option<char> {
self.lines
.iter()
.flat_map(|l| l.iter())
.copied()
.find(|c| !c.is_whitespace())
}
pub fn any_char(&self, pred: impl Fn(char) -> bool) -> bool {
self.lines.iter().flat_map(|l| l.iter()).copied().any(pred)
}
#[allow(dead_code)]
pub fn line_count(&self) -> usize {
self.lines.len()
}
#[cfg(test)]
pub fn visual_line_count(&self, width: usize) -> usize {
self.visual_rows(width).len()
}
pub fn content_rows(&mut self, area_width: u16) -> usize {
if self.single_line {
return 1;
}
let text_w = area_width.saturating_sub(2).saturating_sub(PROMPT_W).max(1) as usize;
self.rows_cached(text_w).len()
}
pub fn clear(&mut self) {
self.lines = vec![Vec::new()];
self.row = 0;
self.col = 0;
self.scroll = 0;
self.hscroll = 0;
self.goal_col = None;
self.misspelled.clear();
self.undo.clear();
self.redo.clear();
self.last_edit_kind = None;
self.touch();
}
pub fn clear_undoable(&mut self) {
if self.is_empty() {
return;
}
self.record_undo(EditKind::Structural); self.lines = vec![Vec::new()];
self.row = 0;
self.col = 0;
self.scroll = 0;
self.hscroll = 0;
self.goal_col = None;
self.misspelled.clear();
self.touch(); }
pub fn line_strings(&self) -> Vec<String> {
self.lines.iter().map(|l| l.iter().collect()).collect()
}
pub fn cursor(&self) -> (usize, usize) {
(self.row, self.col)
}
pub fn set_misspelled(&mut self, ranges: Vec<Vec<(usize, usize)>>) {
self.misspelled = ranges;
}
fn edit_misspelled(&mut self, row: usize, at: usize, removed: usize, inserted: usize) {
let Some(ranges) = self.misspelled.get_mut(row) else {
return;
};
let end = at + removed;
let delta = inserted as isize - removed as isize;
ranges.retain_mut(|(s, e)| {
if *e <= at {
true } else if *s >= end {
*s = (*s as isize + delta).max(0) as usize; *e = (*e as isize + delta).max(0) as usize;
*e > *s
} else {
false }
});
}
#[cfg(test)]
pub fn misspelled_is_empty(&self) -> bool {
self.misspelled.iter().all(|r| r.is_empty())
}
#[cfg(test)]
fn misspelled_ranges_for_test(&self, row: usize) -> Vec<(usize, usize)> {
self.misspelled.get(row).cloned().unwrap_or_default()
}
#[cfg(test)]
fn hscroll_for_test(&self) -> usize {
self.hscroll
}
pub fn replace_range(&mut self, row: usize, start: usize, end: usize, replacement: &str) {
if row >= self.lines.len() {
return;
}
self.record_undo(EditKind::Structural); let line = &mut self.lines[row];
let end = end.min(line.len());
let start = start.min(end);
let repl: Vec<char> = replacement.chars().collect();
let repl_len = repl.len();
line.splice(start..end, repl);
self.edit_misspelled(row, start, end - start, repl_len);
self.row = row;
self.col = start + repl_len;
self.goal_col = None;
self.touch();
}
pub fn set_text(&mut self, text: &str) {
let owned;
let text = if self.single_line && text.contains('\n') {
owned = text.replace('\n', " ");
owned.as_str()
} else {
text
};
self.lines = if text.is_empty() {
vec![Vec::new()]
} else {
text.split('\n').map(|l| l.chars().collect()).collect()
};
self.row = self.lines.len() - 1;
self.col = self.lines[self.row].len();
self.scroll = 0;
self.hscroll = 0;
self.goal_col = None;
self.undo.clear();
self.redo.clear();
self.last_edit_kind = None;
self.misspelled.clear();
self.touch();
}
pub fn insert_char(&mut self, c: char) {
self.record_undo(EditKind::Insert);
self.remove_selection(); self.lines[self.row].insert(self.col, c);
self.edit_misspelled(self.row, self.col, 0, 1);
self.col += 1;
self.goal_col = None;
self.touch();
if c.is_whitespace() {
self.last_edit_kind = None;
}
}
pub fn insert_newline(&mut self) {
if self.single_line {
return; }
self.record_undo(EditKind::Structural);
self.remove_selection(); let tail = self.lines[self.row].split_off(self.col);
self.lines.insert(self.row + 1, tail);
if self.row < self.misspelled.len() {
self.misspelled[self.row].clear();
self.misspelled.insert(self.row + 1, Vec::new());
}
self.row += 1;
self.col = 0;
self.goal_col = None;
self.touch();
}
pub fn insert_str(&mut self, text: &str) {
self.record_undo(EditKind::Structural);
self.remove_selection(); let tail: Vec<char> = self.lines[self.row].split_off(self.col);
let normalized = normalize_paste(text);
let normalized = if self.single_line {
normalized.replace('\n', " ")
} else {
normalized
};
let mut first = true;
for segment in normalized.split('\n') {
if first {
first = false;
} else {
self.row += 1;
self.lines.insert(self.row, Vec::new());
}
self.lines[self.row].extend(segment.chars());
}
self.col = self.lines[self.row].len();
self.lines[self.row].extend(tail);
self.goal_col = None;
self.misspelled.clear();
self.touch();
}
pub fn backspace(&mut self) {
if !self.has_selection() && self.col == 0 && self.row == 0 {
return;
}
self.record_undo(EditKind::Delete);
if self.remove_selection() {
return; }
self.goal_col = None;
if self.col > 0 {
let start = wrap::prev_boundary(&self.lines[self.row], self.col);
self.lines[self.row].drain(start..self.col);
self.edit_misspelled(self.row, start, self.col - start, 0);
self.col = start;
self.touch();
} else if self.row > 0 {
let current = self.lines.remove(self.row);
self.join_misspelled_into_prev(self.row);
self.row -= 1;
self.col = self.lines[self.row].len();
self.lines[self.row].extend(current);
self.touch();
}
}
pub fn delete(&mut self) {
if !self.has_selection()
&& self.col >= self.lines[self.row].len()
&& self.row + 1 >= self.lines.len()
{
return;
}
self.record_undo(EditKind::Delete);
if self.remove_selection() {
return; }
self.goal_col = None;
if self.col < self.lines[self.row].len() {
let end = wrap::next_boundary(&self.lines[self.row], self.col);
self.lines[self.row].drain(self.col..end);
self.edit_misspelled(self.row, self.col, end - self.col, 0);
self.touch();
} else if self.row + 1 < self.lines.len() {
let next = self.lines.remove(self.row + 1);
self.join_misspelled_into_prev(self.row + 1);
self.lines[self.row].extend(next);
self.touch();
}
}
fn join_misspelled_into_prev(&mut self, removed: usize) {
if removed < self.misspelled.len() {
self.misspelled.remove(removed);
}
if removed > 0
&& let Some(r) = self.misspelled.get_mut(removed - 1)
{
r.clear();
}
}
pub fn has_selection(&self) -> bool {
matches!(self.anchor, Some(a) if a != (self.row, self.col))
}
fn selection_span(&self) -> Option<((usize, usize), (usize, usize))> {
let a = self.anchor?;
let c = (self.row, self.col);
if a == c {
return None;
}
Some(if a <= c { (a, c) } else { (c, a) })
}
pub fn selected_text(&self) -> Option<String> {
if self.mask {
return None;
}
let ((sr, sc), (er, ec)) = self.selection_span()?;
let mut out = String::new();
if sr == er {
out.extend(self.lines[sr][sc..ec].iter().copied());
} else {
out.extend(self.lines[sr][sc..].iter().copied());
out.push('\n');
for line in &self.lines[sr + 1..er] {
out.extend(line.iter().copied());
out.push('\n');
}
out.extend(self.lines[er][..ec].iter().copied());
}
Some(out)
}
fn set_anchor_if_none(&mut self) {
if self.anchor.is_none() {
self.anchor = Some((self.row, self.col));
}
}
pub fn clear_selection(&mut self) {
self.anchor = None;
}
fn select_all(&mut self) {
self.anchor = Some((0, 0));
self.row = self.lines.len() - 1;
self.col = self.lines[self.row].len();
self.goal_col = None;
self.last_edit_kind = None;
}
pub fn delete_selection(&mut self) -> bool {
if !self.has_selection() {
return false;
}
self.record_undo(EditKind::Structural);
self.remove_selection()
}
fn remove_selection(&mut self) -> bool {
let Some(((sr, sc), (er, ec))) = self.selection_span() else {
return false;
};
if sr == er {
self.edit_misspelled(sr, sc, ec - sc, 0);
} else {
for _ in sr..er {
if sr + 1 < self.misspelled.len() {
self.misspelled.remove(sr + 1);
}
}
if let Some(m) = self.misspelled.get_mut(sr) {
m.clear();
}
}
let tail: Vec<char> = self.lines[er][ec..].to_vec();
self.lines[sr].truncate(sc);
self.lines[sr].extend(tail);
self.lines.drain((sr + 1)..=er);
self.row = sr;
self.col = sc;
self.goal_col = None;
self.touch(); true
}
fn place_cursor_at(&mut self, mx: u16, my: u16) -> bool {
let Some(area) = self.last_area else {
return false;
};
if mx < area.x || mx >= area.x + area.width || my < area.y || my >= area.y + area.height {
return false;
}
self.goal_col = None;
self.last_edit_kind = None; let vcol = (mx - area.x) as usize;
if self.single_line {
let line = &self.lines[0];
let col = col_at_width(line, self.hscroll + vcol).min(line.len());
self.col = wrap::snap_boundary(line, col);
return true;
}
let vrow = (my - area.y) as usize + self.scroll;
let vrows = self.rows_cached(self.last_width).to_vec();
if vrow >= vrows.len() {
self.row = self.lines.len() - 1;
self.col = self.lines[self.row].len();
return true;
}
let (li, start, end) = vrows[vrow];
self.col = col_for_visual(&self.lines[li], start, end, vcol, is_soft(&vrows, vrow));
self.row = li;
true
}
pub fn mouse_press(&mut self, mx: u16, my: u16) -> bool {
if self.place_cursor_at(mx, my) {
self.anchor = Some((self.row, self.col));
true
} else {
false
}
}
pub fn mouse_drag(&mut self, mx: u16, my: u16) -> bool {
self.place_cursor_at(mx, my)
}
#[cfg(test)]
pub(crate) fn last_area_for_test(&self) -> Option<Rect> {
self.last_area
}
fn snapshot(&self) -> Snapshot {
Snapshot {
lines: self.lines.clone(),
row: self.row,
col: self.col,
}
}
fn record_undo(&mut self, kind: EditKind) {
let coalesce = self.last_edit_kind == Some(kind) && kind != EditKind::Structural;
if !coalesce {
self.undo.push(self.snapshot());
if self.undo.len() > UNDO_CAP {
self.undo.remove(0);
}
self.redo.clear();
}
self.last_edit_kind = Some(kind);
}
fn restore(&mut self, snap: Snapshot) {
self.lines = snap.lines;
self.row = snap.row;
self.col = snap.col;
self.scroll = 0;
self.hscroll = 0;
self.goal_col = None;
self.misspelled.clear();
self.last_edit_kind = None;
self.touch(); }
pub fn undo(&mut self) -> bool {
let Some(prev) = self.undo.pop() else {
return false;
};
self.redo.push(self.snapshot());
self.restore(prev);
true
}
pub fn redo(&mut self) -> bool {
let Some(next) = self.redo.pop() else {
return false;
};
self.undo.push(self.snapshot());
self.restore(next);
true
}
fn row_selection(&self, li: usize, start: usize, end: usize) -> Option<(usize, usize)> {
let ((sr, sc), (er, ec)) = self.selection_span()?;
if li < sr || li > er {
return None;
}
let s = if li == sr { sc.max(start) } else { start };
let e = if li == er { ec.min(end) } else { end };
(s < e).then(|| (s - start, e - start))
}
fn move_left(&mut self) {
self.goal_col = None;
if self.col > 0 {
self.col = wrap::prev_boundary(&self.lines[self.row], self.col);
} else if self.row > 0 {
self.row -= 1;
self.col = self.lines[self.row].len();
}
}
fn move_right(&mut self) {
self.goal_col = None;
if self.col < self.lines[self.row].len() {
self.col = wrap::next_boundary(&self.lines[self.row], self.col);
} else if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = 0;
}
}
fn move_word_left(&mut self) {
self.goal_col = None;
if self.col == 0 {
if self.row > 0 {
self.row -= 1;
self.col = self.lines[self.row].len();
}
return;
}
self.col = self.word_left_col();
}
fn move_word_right(&mut self) {
self.goal_col = None;
if self.col >= self.lines[self.row].len() {
if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = 0;
}
return;
}
self.col = self.word_right_col();
}
fn word_left_col(&self) -> usize {
let line = &self.lines[self.row];
let mut i = self.col;
while i > 0 && line[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !line[i - 1].is_whitespace() {
i -= 1;
}
i
}
fn word_right_col(&self) -> usize {
let line = &self.lines[self.row];
let mut i = self.col;
while i < line.len() && line[i].is_whitespace() {
i += 1;
}
while i < line.len() && !line[i].is_whitespace() {
i += 1;
}
i
}
fn delete_word_left(&mut self) {
self.record_undo(EditKind::Delete);
if self.remove_selection() {
return; }
self.goal_col = None;
if self.col == 0 {
self.backspace(); return;
}
let start = self.word_left_col();
self.lines[self.row].drain(start..self.col);
self.edit_misspelled(self.row, start, self.col - start, 0);
self.col = start;
self.touch();
}
fn delete_word_right(&mut self) {
self.record_undo(EditKind::Delete);
if self.remove_selection() {
return; }
self.goal_col = None;
if self.col >= self.lines[self.row].len() {
self.delete(); return;
}
let end = self.word_right_col();
self.lines[self.row].drain(self.col..end);
self.edit_misspelled(self.row, self.col, end - self.col, 0);
self.touch();
}
fn move_doc_start(&mut self) {
self.goal_col = None;
self.row = 0;
self.col = 0;
}
fn move_doc_end(&mut self) {
self.goal_col = None;
self.row = self.lines.len() - 1;
self.col = self.lines[self.row].len();
}
fn move_up(&mut self) {
if self.single_line {
return; }
if self.last_width == 0 {
self.goal_col = None;
self.move_up_logical();
return;
}
let vrows = self.rows_cached(self.last_width).to_vec();
let (vrow, vcol) = self.cursor_visual(&vrows);
let goal = *self.goal_col.get_or_insert(vcol);
if vrow == 0 {
return; }
let (li, start, end) = vrows[vrow - 1];
let col = col_for_visual(&self.lines[li], start, end, goal, is_soft(&vrows, vrow - 1));
self.row = li;
self.col = col;
}
fn move_down(&mut self) {
if self.single_line {
return; }
if self.last_width == 0 {
self.goal_col = None;
self.move_down_logical();
return;
}
let vrows = self.rows_cached(self.last_width).to_vec();
let (vrow, vcol) = self.cursor_visual(&vrows);
let goal = *self.goal_col.get_or_insert(vcol);
if vrow + 1 >= vrows.len() {
return; }
let (li, start, end) = vrows[vrow + 1];
let col = col_for_visual(&self.lines[li], start, end, goal, is_soft(&vrows, vrow + 1));
self.row = li;
self.col = col;
}
fn move_home(&mut self) {
self.goal_col = None;
if self.single_line {
self.col = 0; return;
}
if self.last_width == 0 {
self.col = 0;
return;
}
let vrows = self.rows_cached(self.last_width).to_vec();
let (vrow, _) = self.cursor_visual(&vrows);
let stops = self.home_stops(&vrows, vrow);
self.col = match stops.iter().position(|&s| s == self.col) {
Some(i) => *stops.get(i + 1).unwrap_or(&self.col),
None => stops[0],
};
}
fn home_stops(&self, vrows: &[VisualRow], vrow: usize) -> Vec<usize> {
let (li, start, end) = vrows[vrow];
let line = &self.lines[li];
let line_text = first_non_blank(line, 0, line.len());
let mut candidates = vec![first_non_blank(line, start, end), start];
if line_text < start {
candidates.push(line_text);
}
candidates.push(0);
let mut stops: Vec<usize> = Vec::with_capacity(candidates.len());
for stop in candidates {
if !stops.contains(&stop) {
stops.push(stop);
}
}
stops
}
fn move_end(&mut self) {
self.goal_col = None;
let line_end = self.lines[self.row].len();
if self.single_line {
self.col = line_end; return;
}
if self.last_width == 0 {
self.col = line_end;
return;
}
let vrows = self.rows_cached(self.last_width).to_vec();
let (vrow, _) = self.cursor_visual(&vrows);
let (li, start, end) = vrows[vrow];
let row_end = col_for_visual(
&self.lines[li],
start,
end,
usize::MAX,
is_soft(&vrows, vrow),
);
self.col = if self.col == row_end {
line_end
} else {
row_end
};
}
fn move_up_logical(&mut self) {
if self.row > 0 {
self.row -= 1;
self.col = self.col.min(self.lines[self.row].len());
}
}
fn move_down_logical(&mut self) {
if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = self.col.min(self.lines[self.row].len());
}
}
pub fn on_key(&mut self, key: KeyEvent) -> KeyOutcome {
if key.kind != KeyEventKind::Press {
return KeyOutcome::Ignored;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
if ctrl
&& let Some(physical) = keys::hotkey_char(&key)
&& let Some(outcome) = self.on_ctrl_shortcut(physical)
{
return outcome;
}
if let Some(mv) = navigation(key.code, ctrl) {
if shift {
self.set_anchor_if_none();
} else {
self.clear_selection();
}
self.last_edit_kind = None;
mv(self);
return KeyOutcome::Moved;
}
self.on_edit_key(key.code, ctrl)
}
fn on_ctrl_shortcut(&mut self, physical: char) -> Option<KeyOutcome> {
match physical {
'a' => {
self.select_all();
Some(KeyOutcome::Moved)
}
'z' => Some(if self.undo() {
KeyOutcome::Edited
} else {
KeyOutcome::Moved
}),
'y' => Some(if self.redo() {
KeyOutcome::Edited
} else {
KeyOutcome::Moved
}),
'k' => {
self.clear_undoable();
Some(KeyOutcome::Edited)
}
_ => None,
}
}
fn on_edit_key(&mut self, code: KeyCode, ctrl: bool) -> KeyOutcome {
match code {
KeyCode::Backspace if ctrl => {
self.delete_word_left();
KeyOutcome::Edited
}
KeyCode::Delete if ctrl => {
self.delete_word_right();
KeyOutcome::Edited
}
KeyCode::Char(c) if !ctrl => {
self.insert_char(c);
KeyOutcome::Edited
}
KeyCode::Backspace => {
self.backspace();
KeyOutcome::Edited
}
KeyCode::Delete => {
self.delete();
KeyOutcome::Edited
}
_ => KeyOutcome::Ignored,
}
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, opts: RenderOpts, palette: &Palette) {
let RenderOpts {
title,
focused,
command,
placeholder,
} = opts;
let block = Block::default()
.borders(Borders::ALL)
.border_type(palette.glyphs().border)
.border_style(palette.border_style(focused))
.title(Span::styled(format!(" {title} "), palette.muted_style()));
let full_inner = block.inner(area);
frame.render_widget(&block, area);
let prompt_style = if focused {
Style::new().fg(palette.assistant)
} else {
palette.muted_style()
};
if full_inner.width > PROMPT_W {
let prompt_area = Rect {
height: 1,
..full_inner
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
palette.glyphs().prompt,
prompt_style,
))),
prompt_area,
);
}
let inner = Rect {
x: full_inner.x + PROMPT_W,
width: full_inner.width.saturating_sub(PROMPT_W),
..full_inner
};
self.last_area = Some(inner);
if self.single_line || self.mask {
self.render_single_line(frame, inner, focused, command, placeholder, palette);
return;
}
let view_w = inner.width.max(1) as usize;
let visible_rows = inner.height.max(1) as usize;
self.last_width = view_w;
let vrows = self.rows_cached(view_w).to_vec();
let (cursor_row, cursor_col) = self.cursor_visual(&vrows);
self.adjust_scroll(cursor_row, vrows.len(), visible_rows);
let base_fg = command.then_some(palette.warning);
let lines: Vec<Line> = vrows
.iter()
.skip(self.scroll)
.take(visible_rows)
.map(|&(li, start, end)| {
let sub = &self.lines[li][start..end];
let mis = if command {
None
} else {
self.misspelled
.get(li)
.map(|rs| clip_ranges(rs, start, end))
};
let sel = self.row_selection(li, start, end);
styled_line(sub, mis.as_deref(), sel, base_fg, palette)
})
.collect();
let show_placeholder = self.is_empty() && !focused;
let text = if show_placeholder {
Text::from(Line::from(placeholder).dim())
} else {
Text::from(lines)
};
frame.render_widget(Paragraph::new(text), inner);
render_scrollbar(
frame,
area.inner(Margin::new(0, 1)),
vrows.len(),
visible_rows,
self.scroll,
focused,
palette,
);
if focused {
let cursor_y = inner.y + (cursor_row.saturating_sub(self.scroll)) as u16;
let cursor_x = inner.x + cursor_col as u16;
let x = cursor_x.min(inner.x + inner.width.saturating_sub(1));
let y = cursor_y.min(inner.y + inner.height.saturating_sub(1));
frame.set_cursor_position((x, y));
}
}
fn render_single_line(
&mut self,
frame: &mut Frame,
inner: Rect,
focused: bool,
command: bool,
placeholder: &str,
palette: &Palette,
) {
let view_w = inner.width.max(1) as usize;
self.last_width = view_w;
let masked: Vec<char>;
let line: &[char] = if self.mask {
masked = vec![MASK_CHAR; self.lines[0].len()];
&masked
} else {
&self.lines[0]
};
let cursor_vw = wrap::display_width(&line[..self.col]);
if cursor_vw < self.hscroll {
self.hscroll = cursor_vw;
} else if cursor_vw >= self.hscroll + view_w {
self.hscroll = cursor_vw + 1 - view_w;
}
let start = col_at_width(line, self.hscroll);
self.hscroll = wrap::display_width(&line[..start]);
let mut end = start;
let mut w = 0;
while end < line.len() {
let cw = wrap::width_at(line, end);
if w + cw > view_w {
break;
}
w += cw;
end += 1;
}
let sub = &line[start..end];
let show_placeholder = self.is_empty() && !focused;
let text = if show_placeholder {
Text::from(Line::from(placeholder).dim())
} else {
let base_fg = command.then_some(palette.warning);
let mis = if command {
None
} else {
self.misspelled
.first()
.map(|rs| clip_ranges(rs, start, end))
};
let sel = self.row_selection(0, start, end);
Text::from(styled_line(sub, mis.as_deref(), sel, base_fg, palette))
};
frame.render_widget(Paragraph::new(text), inner);
if focused {
let cursor_x = inner.x + (cursor_vw - self.hscroll) as u16;
let x = cursor_x.min(inner.x + inner.width.saturating_sub(1));
frame.set_cursor_position((x, inner.y));
}
}
fn touch(&mut self) {
self.revision = self.revision.wrapping_add(1);
self.anchor = None;
}
fn rows_cached(&mut self, width: usize) -> &[VisualRow] {
let fresh =
matches!(&self.rows_cache, Some((w, r, _)) if *w == width && *r == self.revision);
if !fresh {
let rows = self.visual_rows(width);
self.rows_cache = Some((width, self.revision, rows));
}
&self.rows_cache.as_ref().unwrap().2
}
fn visual_rows(&self, width: usize) -> Vec<VisualRow> {
let mut rows = Vec::new();
for (li, chars) in self.lines.iter().enumerate() {
for (start, end) in wrap::wrap_ranges(chars, width) {
rows.push((li, start, end));
}
}
rows
}
fn cursor_visual(&self, vrows: &[VisualRow]) -> (usize, usize) {
let mut last: Option<(usize, usize)> = None; for (idx, &(li, start, end)) in vrows.iter().enumerate() {
if li != self.row {
continue;
}
last = Some((idx, start));
if self.col < end {
let col = wrap::display_width(&self.lines[li][start..self.col]);
return (idx, col);
}
}
match last {
Some((idx, start)) => (
idx,
wrap::display_width(&self.lines[self.row][start..self.col]),
),
None => (0, 0),
}
}
fn adjust_scroll(&mut self, cursor_row: usize, total: usize, visible_rows: usize) {
if cursor_row < self.scroll {
self.scroll = cursor_row;
} else if visible_rows > 0 && cursor_row >= self.scroll + visible_rows {
self.scroll = cursor_row + 1 - visible_rows;
}
let max_scroll = total.saturating_sub(visible_rows);
if self.scroll > max_scroll {
self.scroll = max_scroll;
}
}
}
fn navigation(code: KeyCode, ctrl: bool) -> Option<fn(&mut InputBox)> {
Some(match (code, ctrl) {
(KeyCode::Left, true) => InputBox::move_word_left,
(KeyCode::Right, true) => InputBox::move_word_right,
(KeyCode::Home, true) => InputBox::move_doc_start,
(KeyCode::End, true) => InputBox::move_doc_end,
(KeyCode::Left, false) => InputBox::move_left,
(KeyCode::Right, false) => InputBox::move_right,
(KeyCode::Up, false) => InputBox::move_up,
(KeyCode::Down, false) => InputBox::move_down,
(KeyCode::Home, false) => InputBox::move_home,
(KeyCode::End, false) => InputBox::move_end,
_ => return None,
})
}
fn normalize_paste(text: &str) -> String {
text.replace("\r\n", "\n")
.replace('\r', "\n")
.replace('\t', " ")
}
fn col_at_width(line: &[char], target: usize) -> usize {
let mut w = 0;
let mut i = 0;
while i < line.len() && w < target {
w += wrap::width_at(line, i);
i += 1;
}
i
}
fn first_non_blank(line: &[char], start: usize, end: usize) -> usize {
(start..end)
.find(|&i| !line[i].is_whitespace())
.unwrap_or(start)
}
fn is_soft(vrows: &[VisualRow], idx: usize) -> bool {
idx + 1 < vrows.len() && vrows[idx + 1].0 == vrows[idx].0
}
fn col_for_visual(line: &[char], start: usize, end: usize, target_vw: usize, soft: bool) -> usize {
let mut w = 0;
let mut col = start;
while col < end {
let cw = wrap::width_at(line, col);
if w + cw > target_vw {
break;
}
w += cw;
col += 1;
}
if soft && col == end && end > start {
col -= 1;
}
wrap::snap_boundary(line, col)
}
fn clip_ranges(ranges: &[(usize, usize)], start: usize, end: usize) -> Vec<(usize, usize)> {
ranges
.iter()
.filter_map(|&(s, e)| {
let s = s.clamp(start, end);
let e = e.clamp(start, end);
(e > s).then_some((s - start, e - start))
})
.collect()
}
fn styled_line(
chars: &[char],
misspelled: Option<&[(usize, usize)]>,
selection: Option<(usize, usize)>,
base_fg: Option<Color>,
palette: &Palette,
) -> Line<'static> {
let has_mis = misspelled.is_some_and(|r| !r.is_empty());
let has_sel = selection.is_some_and(|(s, e)| e > s);
if !has_mis && !has_sel && base_fg.is_none() {
return Line::from(chars.iter().collect::<String>());
}
let n = chars.len();
let base = base_fg.map(|c| Style::new().fg(c)).unwrap_or_default();
let mut styles = vec![base; n];
if let Some(ranges) = misspelled {
let bad = Style::new().underlined().fg(palette.error);
for &(s, e) in ranges {
for st in styles.iter_mut().take(e.min(n)).skip(s.min(n)) {
*st = st.patch(bad);
}
}
}
if let Some((s, e)) = selection {
for st in styles.iter_mut().take(e.min(n)).skip(s.min(n)) {
*st = st.bg(palette.keycap_bg);
}
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut i = 0;
while i < n {
let st = styles[i];
let mut buf = String::new();
while i < n && styles[i] == st {
buf.push(chars[i]);
i += 1;
}
spans.push(Span::styled(buf, st));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyModifiers;
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn shift(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::SHIFT)
}
fn ctrl_shift(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
}
fn type_str(ib: &mut InputBox, s: &str) {
for c in s.chars() {
ib.insert_char(c);
}
}
#[test]
fn new_is_empty() {
let ib = InputBox::new();
assert!(ib.is_empty());
assert_eq!(ib.text(), "");
}
#[test]
fn typing_and_text() {
let mut ib = InputBox::new();
type_str(&mut ib, "привет");
assert!(!ib.is_empty());
assert_eq!(ib.text(), "привет");
}
#[test]
fn newline_splits_at_cursor() {
let mut ib = InputBox::new();
type_str(&mut ib, "abcd");
ib.move_left();
ib.move_left(); ib.insert_newline();
assert_eq!(ib.text(), "ab\ncd");
assert_eq!(ib.line_count(), 2);
}
#[test]
fn backspace_joins_lines() {
let mut ib = InputBox::new();
type_str(&mut ib, "ab");
ib.insert_newline();
type_str(&mut ib, "cd");
ib.col = 0;
ib.backspace();
assert_eq!(ib.text(), "abcd");
assert_eq!(ib.line_count(), 1);
}
#[test]
fn delete_at_eol_joins_next() {
let mut ib = InputBox::new();
ib.set_text("ab\ncd");
ib.row = 0;
ib.col = 2; ib.delete();
assert_eq!(ib.text(), "abcd");
}
#[test]
fn unicode_cursor_is_char_based() {
let mut ib = InputBox::new();
type_str(&mut ib, "ёжик");
ib.backspace(); assert_eq!(ib.text(), "ёжи");
ib.move_left();
ib.insert_char('!'); assert_eq!(ib.text(), "ёж!и");
}
#[test]
fn insert_str_multiline_at_cursor() {
let mut ib = InputBox::new();
ib.set_text("aXd");
ib.row = 0;
ib.col = 1; ib.insert_str("b\nc");
assert_eq!(ib.text(), "ab\ncXd");
assert_eq!(ib.line_count(), 2);
assert_eq!(ib.cursor(), (1, 1));
}
#[test]
fn insert_str_normalizes_newlines_and_tabs() {
let mut ib = InputBox::new();
ib.insert_str("a\r\nb\rc\td");
assert_eq!(ib.text(), "a\nb\nc d");
assert_eq!(ib.line_count(), 3);
}
#[test]
fn insert_str_single_line_keeps_one_row() {
let mut ib = InputBox::new();
type_str(&mut ib, "ab");
ib.insert_str("XY"); assert_eq!(ib.text(), "abXY");
assert_eq!(ib.line_count(), 1);
assert_eq!(ib.cursor(), (0, 4));
}
#[test]
fn insert_str_unicode() {
let mut ib = InputBox::new();
ib.insert_str("привет\nмир");
assert_eq!(ib.text(), "привет\nмир");
assert_eq!(ib.cursor(), (1, 3));
}
#[test]
fn clear_resets() {
let mut ib = InputBox::new();
ib.set_text("hello\nworld");
ib.clear();
assert!(ib.is_empty());
assert_eq!(ib.line_count(), 1);
}
#[test]
fn undo_typing_run_is_one_unit_then_redo() {
let mut ib = InputBox::new();
type_str(&mut ib, "hello");
assert!(ib.undo());
assert!(ib.is_empty());
assert!(ib.redo());
assert_eq!(ib.text(), "hello");
assert!(!ib.redo());
}
#[test]
fn undo_breaks_on_whitespace_word_granular() {
let mut ib = InputBox::new();
type_str(&mut ib, "ab cd");
assert!(ib.undo());
assert_eq!(ib.text(), "ab ");
assert!(ib.undo());
assert!(ib.is_empty());
}
#[test]
fn navigation_breaks_undo_coalescing() {
let mut ib = InputBox::new();
type_str(&mut ib, "abc");
ib.on_key(k(KeyCode::Left)); ib.insert_char('X'); assert_eq!(ib.text(), "abXc");
assert!(ib.undo());
assert_eq!(ib.text(), "abc"); }
#[test]
fn insert_str_is_separate_undo_unit() {
let mut ib = InputBox::new();
type_str(&mut ib, "ab");
ib.insert_str("XY"); assert_eq!(ib.text(), "abXY");
assert!(ib.undo());
assert_eq!(ib.text(), "ab"); }
#[test]
fn edit_clears_redo() {
let mut ib = InputBox::new();
type_str(&mut ib, "abc");
ib.undo(); ib.insert_char('z'); assert!(!ib.redo());
assert_eq!(ib.text(), "z");
}
#[test]
fn set_text_clears_undo_history() {
let mut ib = InputBox::new();
type_str(&mut ib, "user text");
ib.set_text("другой чат");
assert!(!ib.undo());
assert_eq!(ib.text(), "другой чат");
}
#[test]
fn ctrl_k_clears_and_ctrl_z_restores() {
let mut ib = InputBox::new();
type_str(&mut ib, "привет\nмир");
assert_eq!(ib.on_key(ctrl(KeyCode::Char('k'))), KeyOutcome::Edited);
assert!(ib.is_empty());
assert_eq!(ib.on_key(ctrl(KeyCode::Char('z'))), KeyOutcome::Edited);
assert_eq!(ib.text(), "привет\nмир");
}
#[test]
fn undo_redo_noop_returns_moved() {
let mut ib = InputBox::new();
assert_eq!(ib.on_key(ctrl(KeyCode::Char('z'))), KeyOutcome::Moved);
assert_eq!(ib.on_key(ctrl(KeyCode::Char('y'))), KeyOutcome::Moved);
}
#[test]
fn undo_cap_evicts_oldest() {
let mut ib = InputBox::new();
for _ in 0..(UNDO_CAP + 20) {
ib.insert_str("x");
}
assert_eq!(ib.undo.len(), UNDO_CAP);
}
#[test]
fn undo_restores_selection_replacement() {
let mut ib = InputBox::new();
ib.set_text("hello");
ib.on_key(ctrl(KeyCode::Char('a'))); ib.insert_char('Z'); assert_eq!(ib.text(), "Z");
assert!(ib.undo());
assert_eq!(ib.text(), "hello");
}
#[test]
fn on_key_handles_editing_but_not_enter() {
let mut ib = InputBox::new();
assert!(ib.on_key(k(KeyCode::Char('x'))).handled());
assert!(ib.on_key(k(KeyCode::Backspace)).handled());
assert!(!ib.on_key(k(KeyCode::Enter)).handled());
assert!(ib.is_empty());
}
#[test]
fn line_strings_and_cursor() {
let mut ib = InputBox::new();
ib.set_text("abc\nde");
assert_eq!(ib.line_strings(), vec!["abc".to_string(), "de".to_string()]);
assert_eq!(ib.cursor(), (1, 2)); }
#[test]
fn replace_range_swaps_word_and_moves_cursor() {
let mut ib = InputBox::new();
ib.set_text("helo world");
ib.replace_range(0, 0, 4, "hello");
assert_eq!(ib.text(), "hello world");
assert_eq!(ib.cursor(), (0, 5));
}
#[test]
fn replace_range_unicode() {
let mut ib = InputBox::new();
ib.set_text("превед мир");
ib.replace_range(0, 0, 6, "привет");
assert_eq!(ib.text(), "привет мир");
}
#[test]
fn render_with_misspelled_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut ib = InputBox::new();
ib.set_text("helo world\nпревед");
ib.set_misspelled(vec![vec![(0, 4)], vec![(0, 6)]]);
let mut term = Terminal::new(TestBackend::new(20, 4)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ввод"),
&Palette::default(),
)
})
.unwrap();
}
#[test]
fn render_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut ib = InputBox::new();
ib.set_text("строка 1\nстрока 2\nстрока 3");
let mut term = Terminal::new(TestBackend::new(20, 4)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ввод"),
&Palette::default(),
)
})
.unwrap();
}
#[test]
fn render_command_mode_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut ib = InputBox::new();
ib.set_text("/rag add d:\\dir -r");
ib.set_misspelled(vec![vec![(0, 4)]]);
let mut term = Terminal::new(TestBackend::new(24, 3)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts {
command: true,
..RenderOpts::focused("ввод")
},
&Palette::default(),
)
})
.unwrap();
}
#[test]
fn a_misspelling_range_keeps_a_combining_mark_in_its_span() {
use ratatui::style::Modifier;
let chars: Vec<char> = "исти\u{301}но".chars().collect();
let line = styled_line(&chars, Some(&[(0, 7)]), None, None, &Palette::default());
assert_eq!(line.spans.len(), 1);
assert_eq!(line.spans[0].content, "исти\u{301}но");
assert!(
line.spans[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
}
#[test]
fn content_rows_matches_render_text_width() {
let area_width: u16 = 14;
let text_w = (area_width - 2 - PROMPT_W) as usize; let mut ib = InputBox::new();
let word = "a".repeat(text_w + 1);
type_str(&mut ib, &word);
render_at(&mut ib, area_width - 2 - PROMPT_W);
let rendered_rows = ib.visual_rows(ib.last_width).len();
assert_eq!(ib.content_rows(area_width), rendered_rows);
assert!(
ib.content_rows(area_width) > 1,
"the field should grow to two rows on the character past the wrap boundary"
);
}
#[test]
fn content_rows_single_line_is_one() {
let mut ib = InputBox::new();
ib.set_single_line(true);
ib.set_text("очень длинное значение не помещающееся в узкое поле");
assert_eq!(ib.content_rows(12), 1);
}
#[test]
fn long_line_counts_as_multiple_visual_rows() {
let mut ib = InputBox::new();
type_str(&mut ib, "один два три четыре");
assert_eq!(ib.line_count(), 1);
assert!(ib.visual_line_count(8) > 1);
}
#[test]
fn cursor_moves_and_deletes_by_grapheme_cluster() {
let mut ib = InputBox::new();
ib.insert_str("a❤\u{FE0F}👍🏽");
assert_eq!(ib.cursor(), (0, 5));
ib.move_left();
assert_eq!(ib.cursor(), (0, 3));
ib.move_left();
assert_eq!(ib.cursor(), (0, 1));
ib.move_left();
assert_eq!(ib.cursor(), (0, 0));
ib.move_doc_end();
ib.backspace(); assert_eq!(ib.text(), "a❤\u{FE0F}");
ib.backspace(); assert_eq!(ib.text(), "a");
}
#[test]
fn delete_forward_removes_whole_cluster() {
let mut ib = InputBox::new();
ib.insert_str("❤\u{FE0F}👍🏽b");
ib.move_doc_start();
ib.delete(); assert_eq!(ib.text(), "👍🏽b");
ib.delete(); assert_eq!(ib.text(), "b");
ib.delete();
assert_eq!(ib.text(), "");
assert!(ib.is_empty());
}
#[test]
fn cursor_visual_accounts_for_emoji_cluster_width() {
let mut ib = InputBox::new();
ib.insert_str("❤\u{FE0F}");
let vrows = ib.visual_rows(40);
let (row, col) = ib.cursor_visual(&vrows);
assert_eq!((row, col), (0, 2));
ib.insert_char('a');
let vrows = ib.visual_rows(40);
assert_eq!(ib.cursor_visual(&vrows), (0, 3));
}
#[test]
fn cursor_maps_onto_wrapped_row() {
let mut ib = InputBox::new();
type_str(&mut ib, "один два три"); let vrows = ib.visual_rows(8);
let (row, col) = ib.cursor_visual(&vrows);
assert_eq!((row, col), (1, 3));
}
#[test]
fn cursor_at_soft_break_moves_to_next_row_start() {
let mut ib = InputBox::new();
ib.set_text("один два три");
ib.row = 0;
ib.col = 9;
let vrows = ib.visual_rows(8);
let (row, col) = ib.cursor_visual(&vrows);
assert_eq!((row, col), (1, 0));
}
fn render_at(ib: &mut InputBox, inner_w: u16) {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(inner_w + 2 + PROMPT_W, 8)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ввод"),
&Palette::default(),
)
})
.unwrap();
}
#[test]
fn mask_hides_content_on_screen_and_from_clipboard() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut ib = InputBox::new();
ib.set_mask(true);
assert!(ib.is_masked());
assert!(
ib.single_line,
"the mask switches the field to single-line mode"
);
ib.set_text("sk-secret");
let mut term = Terminal::new(TestBackend::new(30, 3)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ключ"),
&Palette::default(),
)
})
.unwrap();
let screen: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
!screen.contains("sk-secret"),
"the secret is visible on screen: {screen}"
);
assert_eq!(
screen.matches(MASK_CHAR).count(),
"sk-secret".chars().count(),
"every secret character must be masked"
);
ib.select_all();
assert!(ib.has_selection());
assert_eq!(ib.selected_text(), None);
ib.backspace();
assert_eq!(ib.text(), "");
}
#[test]
fn mask_keeps_cursor_aligned_with_wide_glyphs() {
let mut ib = InputBox::new();
ib.set_mask(true);
ib.set_text("aXb");
ib.insert_str("😀"); render_at(&mut ib, 20);
assert_eq!(ib.text().chars().count(), 4);
assert_eq!(ib.hscroll, 0, "a short value shouldn't scroll");
}
#[test]
fn col_for_visual_clamps_off_soft_break() {
let line: Vec<char> = "abcd".chars().collect();
assert_eq!(col_for_visual(&line, 0, 4, 10, true), 3);
assert_eq!(col_for_visual(&line, 0, 4, 10, false), 4);
assert_eq!(col_for_visual(&line, 0, 4, 2, true), 2);
}
#[test]
fn arrow_up_moves_within_wrapped_line() {
let mut ib = InputBox::new();
ib.set_text("один два три"); render_at(&mut ib, 8);
assert!(ib.on_key(k(KeyCode::Up)).handled());
assert_eq!(ib.cursor(), (0, 3)); assert!(ib.on_key(k(KeyCode::Up)).handled());
assert_eq!(ib.cursor(), (0, 3));
}
#[test]
fn arrow_down_moves_within_wrapped_line() {
let mut ib = InputBox::new();
ib.set_text("один два три");
render_at(&mut ib, 8);
ib.row = 0;
ib.col = 3; assert!(ib.on_key(k(KeyCode::Down)).handled());
assert_eq!(ib.cursor(), (0, 12));
assert!(ib.on_key(k(KeyCode::Down)).handled());
assert_eq!(ib.cursor(), (0, 12));
}
#[test]
fn arrow_up_down_cross_logical_lines_when_not_wrapped() {
let mut ib = InputBox::new();
ib.set_text("abc\ndef"); render_at(&mut ib, 20);
ib.row = 1;
ib.col = 2;
assert!(ib.on_key(k(KeyCode::Up)).handled());
assert_eq!(ib.cursor(), (0, 2)); assert!(ib.on_key(k(KeyCode::Down)).handled());
assert_eq!(ib.cursor(), (1, 2));
}
#[test]
fn goal_column_preserved_through_short_row() {
let mut ib = InputBox::new();
ib.set_text("abcdef\nx\nabcdef");
render_at(&mut ib, 20); ib.row = 0;
ib.col = 5; ib.goal_col = None; assert!(ib.on_key(k(KeyCode::Down)).handled());
assert_eq!(ib.cursor(), (1, 1)); assert!(ib.on_key(k(KeyCode::Down)).handled());
assert_eq!(ib.cursor(), (2, 5)); }
#[test]
fn horizontal_move_resets_goal_column() {
let mut ib = InputBox::new();
ib.set_text("abcdef\nx\nabcdef");
render_at(&mut ib, 20);
ib.row = 0;
ib.col = 5;
ib.goal_col = None;
assert!(ib.on_key(k(KeyCode::Down)).handled()); assert!(ib.on_key(k(KeyCode::Left)).handled()); assert!(ib.on_key(k(KeyCode::Down)).handled());
assert_eq!(ib.cursor(), (2, 0));
}
#[test]
fn home_end_act_on_visual_row() {
let mut ib = InputBox::new();
ib.set_text("один два три"); render_at(&mut ib, 8);
ib.row = 0;
ib.col = 10;
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 9)); assert!(ib.on_key(k(KeyCode::End)).handled());
assert_eq!(ib.cursor(), (0, 12)); ib.col = 2;
assert!(ib.on_key(k(KeyCode::End)).handled());
assert_eq!(ib.cursor(), (0, 8)); assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0)); }
#[test]
fn repeated_home_end_reach_the_whole_logical_line() {
let mut ib = InputBox::new();
ib.set_text("один два три");
render_at(&mut ib, 8);
ib.row = 0;
ib.col = 10;
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 9)); assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0)); assert!(ib.on_key(k(KeyCode::End)).handled());
assert_eq!(ib.cursor(), (0, 8)); assert!(ib.on_key(k(KeyCode::End)).handled());
assert_eq!(ib.cursor(), (0, 12)); assert!(ib.on_key(k(KeyCode::End)).handled());
assert_eq!(ib.cursor(), (0, 12));
}
#[test]
fn home_stops_at_the_text_before_the_indentation() {
let mut ib = InputBox::new();
ib.set_text(" hello");
render_at(&mut ib, 20); ib.row = 0;
ib.col = 7;
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 4)); assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0)); assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0)); ib.col = 2;
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 4));
}
#[test]
fn home_ladder_on_an_indented_wrapped_line() {
let mut ib = InputBox::new();
ib.set_text(" один два три");
render_at(&mut ib, 8);
ib.row = 0;
ib.col = 14;
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 9)); assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 4)); assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0));
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0));
}
#[test]
fn home_on_a_blank_row_falls_through_to_the_starts() {
let mut ib = InputBox::new();
ib.set_text(" ");
render_at(&mut ib, 20);
ib.row = 0;
ib.col = 3;
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0));
}
#[test]
fn repeated_home_end_stay_on_their_own_logical_line() {
let mut ib = InputBox::new();
ib.set_text("aaa\nодин два три\nbbb");
render_at(&mut ib, 8);
ib.row = 1;
ib.col = 10; for _ in 0..3 {
assert!(ib.on_key(k(KeyCode::Home)).handled());
}
assert_eq!(ib.cursor(), (1, 0));
for _ in 0..3 {
assert!(ib.on_key(k(KeyCode::End)).handled());
}
assert_eq!(ib.cursor(), (1, 12));
}
#[test]
fn arrow_up_falls_back_to_logical_before_render() {
let mut ib = InputBox::new();
ib.set_text("abc\ndef");
ib.row = 1;
ib.col = 2;
assert!(ib.on_key(k(KeyCode::Up)).handled());
assert_eq!(ib.cursor(), (0, 2));
}
#[test]
fn single_line_disables_newline_and_collapses_paste() {
let mut ib = InputBox::new();
ib.set_single_line(true);
ib.set_text("ab\ncd"); assert_eq!(ib.text(), "ab cd");
assert_eq!(ib.line_count(), 1);
ib.insert_newline(); assert_eq!(ib.line_count(), 1);
ib.insert_str("x\ny"); assert_eq!(ib.line_count(), 1);
assert!(ib.text().contains("x y"));
}
#[test]
fn single_line_arrows_up_down_are_noop() {
let mut ib = InputBox::new();
ib.set_single_line(true);
ib.set_text("hello");
ib.col = 2;
assert!(ib.on_key(k(KeyCode::Up)).handled());
assert_eq!(ib.cursor(), (0, 2));
assert!(ib.on_key(k(KeyCode::Down)).handled());
assert_eq!(ib.cursor(), (0, 2));
}
#[test]
fn single_line_home_end_span_whole_value() {
let mut ib = InputBox::new();
ib.set_single_line(true);
ib.set_text("a long value");
render_at(&mut ib, 4); ib.col = 5;
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0));
assert!(ib.on_key(k(KeyCode::End)).handled());
assert_eq!(ib.cursor(), (0, 12)); }
#[test]
fn single_line_renders_long_value_without_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut ib = InputBox::new();
ib.set_single_line(true);
ib.set_text("/very/long/path/to/a/gguf/model/that/does/not/fit.gguf");
let mut term = Terminal::new(TestBackend::new(20, 3)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ввод"),
&Palette::default(),
)
})
.unwrap();
}
#[test]
fn col_at_width_lands_on_char_boundary() {
let line: Vec<char> = "abcdef".chars().collect();
assert_eq!(col_at_width(&line, 0), 0);
assert_eq!(col_at_width(&line, 3), 3);
assert_eq!(col_at_width(&line, 100), 6); }
#[test]
fn ctrl_left_right_move_by_word() {
let mut ib = InputBox::new();
ib.set_text("один два три"); assert!(ib.on_key(ctrl(KeyCode::Left)).handled());
assert_eq!(ib.cursor(), (0, 9));
assert!(ib.on_key(ctrl(KeyCode::Left)).handled());
assert_eq!(ib.cursor(), (0, 5));
assert!(ib.on_key(ctrl(KeyCode::Left)).handled());
assert_eq!(ib.cursor(), (0, 0));
assert!(ib.on_key(ctrl(KeyCode::Right)).handled());
assert_eq!(ib.cursor(), (0, 4));
assert!(ib.on_key(ctrl(KeyCode::Right)).handled());
assert_eq!(ib.cursor(), (0, 8));
}
#[test]
fn ctrl_left_right_cross_logical_lines() {
let mut ib = InputBox::new();
ib.set_text("ab\ncd");
ib.row = 1;
ib.col = 0; assert!(ib.on_key(ctrl(KeyCode::Left)).handled());
assert_eq!(ib.cursor(), (0, 2));
assert!(ib.on_key(ctrl(KeyCode::Right)).handled());
assert_eq!(ib.cursor(), (1, 0));
}
#[test]
fn ctrl_backspace_deletes_word_left() {
let mut ib = InputBox::new();
ib.set_text("один два три"); assert!(ib.on_key(ctrl(KeyCode::Backspace)).handled());
assert_eq!(ib.text(), "один два ");
assert_eq!(ib.cursor(), (0, 9));
ib.set_text("ab\ncd");
ib.row = 1;
ib.col = 0;
assert!(ib.on_key(ctrl(KeyCode::Backspace)).handled());
assert_eq!(ib.text(), "abcd");
}
#[test]
fn ctrl_delete_deletes_word_right() {
let mut ib = InputBox::new();
ib.set_text("один два три");
ib.col = 0;
assert!(ib.on_key(ctrl(KeyCode::Delete)).handled());
assert_eq!(ib.text(), " два три"); assert_eq!(ib.cursor(), (0, 0));
}
#[test]
fn ctrl_home_end_jump_to_document_bounds() {
let mut ib = InputBox::new();
ib.set_text("abc\ndef\nghi");
ib.row = 1;
ib.col = 1;
assert!(ib.on_key(ctrl(KeyCode::Home)).handled());
assert_eq!(ib.cursor(), (0, 0));
assert!(ib.on_key(ctrl(KeyCode::End)).handled());
assert_eq!(ib.cursor(), (2, 3));
}
#[test]
fn ctrl_char_is_not_inserted() {
let mut ib = InputBox::new();
assert!(!ib.on_key(ctrl(KeyCode::Char('j'))).handled());
assert!(ib.is_empty());
}
#[test]
fn render_wrapped_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut ib = InputBox::new();
ib.set_text("очень длинная строка которая точно не влезает в узкое поле ввода");
ib.set_misspelled(vec![vec![(0, 5)]]);
let mut term = Terminal::new(TestBackend::new(12, 4)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ввод"),
&Palette::default(),
)
})
.unwrap();
}
#[test]
fn col_for_visual_snaps_off_emoji_cluster() {
let line: Vec<char> = "❤\u{FE0F}abc".chars().collect();
assert_eq!(col_for_visual(&line, 0, line.len(), 1, false), 0);
assert_eq!(col_for_visual(&line, 0, line.len(), 2, false), 2);
assert_eq!(col_for_visual(&line, 0, 2, 10, true), 0);
}
#[test]
fn arrow_up_lands_on_cluster_boundary() {
let mut ib = InputBox::new();
ib.set_text("❤\u{FE0F}xy\nz");
render_at(&mut ib, 20);
ib.row = 1;
ib.col = 1; ib.goal_col = None;
assert!(ib.on_key(k(KeyCode::Up)).handled());
assert_ne!(
ib.cursor(),
(0, 1),
"the cursor landed in the middle of the ❤️ cluster"
);
}
#[test]
fn single_line_after_multiline_content_merges_without_panic() {
let mut ib = InputBox::new();
ib.set_text("first\nsecond\nthird"); ib.set_single_line(true); assert_eq!(ib.line_count(), 1);
assert_eq!(ib.text(), "first second third");
assert_eq!(ib.cursor().0, 0);
render_at(&mut ib, 8);
}
#[test]
fn single_line_hscroll_aligns_to_char_boundary() {
let mut ib = InputBox::new();
ib.set_single_line(true);
ib.set_text("世aBcd");
ib.col = 2; render_at(&mut ib, 3);
let line: Vec<char> = "世aBcd".chars().collect();
let boundary_widths: Vec<usize> = (0..=line.len())
.map(|k| wrap::display_width(&line[..k]))
.collect();
assert!(
boundary_widths.contains(&ib.hscroll_for_test()),
"hscroll={} didn't match any prefix width (not on a character boundary)",
ib.hscroll_for_test()
);
}
#[test]
fn misspelled_shifts_on_insert_before_word() {
let mut ib = InputBox::new();
ib.set_text("foo bar");
ib.set_misspelled(vec![vec![(4, 7)]]); ib.row = 0;
ib.col = 0;
ib.insert_char('X'); assert_eq!(ib.misspelled_ranges_for_test(0), vec![(5, 8)]);
}
#[test]
fn misspelled_dropped_when_edited_inside_word() {
let mut ib = InputBox::new();
ib.set_text("foo bar");
ib.set_misspelled(vec![vec![(4, 7)]]);
ib.row = 0;
ib.col = 5; ib.insert_char('X'); assert!(ib.misspelled_ranges_for_test(0).is_empty());
}
#[test]
fn misspelled_shifts_left_on_delete_after_word() {
let mut ib = InputBox::new();
ib.set_text("X foo");
ib.set_misspelled(vec![vec![(2, 5)]]); ib.row = 0;
ib.col = 0;
ib.delete(); assert_eq!(ib.misspelled_ranges_for_test(0), vec![(1, 4)]);
}
#[test]
fn misspelled_synced_on_newline_and_join() {
let mut ib = InputBox::new();
ib.set_text("foo bar");
ib.set_misspelled(vec![vec![(0, 3), (4, 7)]]);
ib.row = 0;
ib.col = 3; ib.insert_newline(); assert!(ib.misspelled_ranges_for_test(0).is_empty());
assert!(ib.misspelled_ranges_for_test(1).is_empty());
ib.row = 1;
ib.col = 0;
ib.backspace();
assert_eq!(ib.line_count(), 1);
}
#[test]
fn set_text_and_paste_clear_misspelled() {
let mut ib = InputBox::new();
ib.set_text("helo");
ib.set_misspelled(vec![vec![(0, 4)]]);
ib.set_text("совсем другой текст"); assert!(ib.misspelled_ranges_for_test(0).is_empty());
ib.set_misspelled(vec![vec![(0, 6)]]);
ib.insert_str("abc");
assert!(ib.misspelled_is_empty());
}
#[test]
fn on_key_distinguishes_edit_move_ignore() {
let mut ib = InputBox::new();
assert_eq!(ib.on_key(k(KeyCode::Char('a'))), KeyOutcome::Edited);
assert_eq!(ib.on_key(k(KeyCode::Left)), KeyOutcome::Moved);
assert_eq!(ib.on_key(k(KeyCode::Backspace)), KeyOutcome::Edited);
assert_eq!(ib.on_key(k(KeyCode::Enter)), KeyOutcome::Ignored);
assert_eq!(ib.on_key(ctrl(KeyCode::Left)), KeyOutcome::Moved);
assert_eq!(ib.on_key(ctrl(KeyCode::Backspace)), KeyOutcome::Edited);
assert!(KeyOutcome::Edited.edited());
assert!(!KeyOutcome::Moved.edited());
assert!(KeyOutcome::Moved.handled());
assert!(!KeyOutcome::Ignored.handled());
}
#[test]
fn row_cache_invalidates_on_every_mutator() {
fn check(setup: &str, mutate: impl FnOnce(&mut InputBox)) {
const W: usize = 6;
let mut ib = InputBox::new();
ib.set_text(setup);
let _ = ib.rows_cached(W); mutate(&mut ib);
let cached = ib.rows_cached(W).to_vec();
let fresh = ib.visual_rows(W);
assert_eq!(cached, fresh, "the visual-row cache wasn't invalidated");
}
check("abc", |ib| {
ib.col = 3;
ib.insert_char('d');
});
check("abc", |ib| ib.replace_range(0, 0, 3, "xy"));
check("abc", |ib| {
ib.col = 3;
ib.backspace();
});
check("ab\ncd", |ib| {
ib.row = 1;
ib.col = 0;
ib.backspace(); });
check("abc", |ib| {
ib.col = 0;
ib.delete();
});
check("ab\ncd", |ib| {
ib.row = 0;
ib.col = 2;
ib.delete(); });
check("abc def", |ib| {
ib.col = 7;
ib.delete_word_left();
});
check("abc def", |ib| {
ib.col = 0;
ib.delete_word_right();
});
check("abc", |ib| {
ib.col = 1;
ib.insert_newline();
});
check("abc", |ib| ib.insert_str("X\nY"));
check("abc", |ib| ib.set_text("zzzz"));
check("abc", |ib| ib.clear());
}
#[test]
fn navigation_preserves_revision_but_edit_bumps_it() {
let mut ib = InputBox::new();
ib.set_text("hello world");
render_at(&mut ib, 20);
let r0 = ib.revision;
assert!(ib.on_key(k(KeyCode::Left)).handled());
assert!(ib.on_key(k(KeyCode::Home)).handled());
assert!(ib.on_key(ctrl(KeyCode::Right)).handled());
assert_eq!(ib.revision, r0, "navigation shouldn't invalidate the cache");
assert!(ib.on_key(k(KeyCode::Char('!'))).handled());
assert!(ib.revision > r0, "an edit should invalidate the cache");
}
#[test]
fn first_non_whitespace_finds_leading_glyph() {
let mut ib = InputBox::new();
assert_eq!(ib.first_non_whitespace(), None); ib.set_text(" /rag add x");
assert_eq!(ib.first_non_whitespace(), Some('/'));
ib.set_text("привет");
assert_eq!(ib.first_non_whitespace(), Some('п'));
ib.set_text("\n\n x"); assert_eq!(ib.first_non_whitespace(), Some('x'));
ib.set_text(" "); assert_eq!(ib.first_non_whitespace(), None);
}
#[test]
fn shift_arrow_extends_selection_plain_arrow_collapses() {
let mut ib = InputBox::new();
ib.set_text("hello");
ib.col = 0;
assert!(!ib.has_selection());
ib.on_key(shift(KeyCode::Right));
ib.on_key(shift(KeyCode::Right));
assert!(ib.has_selection());
assert_eq!(ib.selected_text().as_deref(), Some("he"));
ib.on_key(k(KeyCode::Right));
assert!(!ib.has_selection());
assert_eq!(ib.selected_text(), None);
}
#[test]
fn ctrl_a_selects_all() {
let mut ib = InputBox::new();
ib.set_text("line1\nline2");
assert_eq!(ib.on_key(ctrl(KeyCode::Char('a'))), KeyOutcome::Moved);
assert!(ib.has_selection());
assert_eq!(ib.selected_text().as_deref(), Some("line1\nline2"));
assert_eq!(ib.cursor(), (1, 5));
}
#[test]
fn ctrl_shift_right_selects_word() {
let mut ib = InputBox::new();
ib.set_text("one two");
ib.col = 0;
ib.on_key(ctrl_shift(KeyCode::Right)); assert_eq!(ib.selected_text().as_deref(), Some("one"));
}
#[test]
fn typing_replaces_selection() {
let mut ib = InputBox::new();
ib.set_text("hello");
ib.on_key(ctrl(KeyCode::Char('a'))); assert_eq!(ib.on_key(k(KeyCode::Char('X'))), KeyOutcome::Edited);
assert_eq!(ib.text(), "X");
assert!(!ib.has_selection());
}
#[test]
fn backspace_deletes_whole_selection() {
let mut ib = InputBox::new();
ib.set_text("abcdef");
ib.col = 1;
for _ in 0..3 {
ib.on_key(shift(KeyCode::Right)); }
assert_eq!(ib.selected_text().as_deref(), Some("bcd"));
ib.on_key(k(KeyCode::Backspace));
assert_eq!(ib.text(), "aef");
assert!(!ib.has_selection());
}
#[test]
fn delete_multiline_selection_merges_and_syncs_misspelled() {
let mut ib = InputBox::new();
ib.set_text("abc\ndef\nghi");
ib.set_misspelled(vec![vec![(0, 3)], vec![(0, 3)], vec![(0, 3)]]);
ib.anchor = Some((0, 1));
ib.row = 2;
ib.col = 2;
assert_eq!(ib.selected_text().as_deref(), Some("bc\ndef\ngh"));
assert!(ib.delete_selection());
assert_eq!(ib.text(), "ai"); assert_eq!(ib.cursor(), (0, 1));
assert_eq!(ib.line_count(), 1);
assert!(ib.misspelled_ranges_for_test(0).is_empty());
}
#[test]
fn shift_nav_is_moved_edit_over_selection_is_edited() {
let mut ib = InputBox::new();
ib.set_text("hello");
render_at(&mut ib, 20);
ib.col = 0;
let r0 = ib.revision;
assert_eq!(ib.on_key(shift(KeyCode::Right)), KeyOutcome::Moved);
assert_eq!(
ib.revision, r0,
"extending the selection doesn't bump the revision (the cache stays intact)"
);
assert!(ib.has_selection());
assert_eq!(ib.on_key(k(KeyCode::Char('Z'))), KeyOutcome::Edited);
assert!(
ib.revision > r0,
"an edit over a selection invalidates the cache"
);
}
#[test]
fn render_highlights_selection_background() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut ib = InputBox::new();
ib.set_text("hello");
ib.on_key(ctrl(KeyCode::Char('a'))); let pal = Palette::default();
let mut term = Terminal::new(TestBackend::new(20, 3)).unwrap();
term.draw(|f| ib.render(f, f.area(), RenderOpts::focused("ввод"), &pal))
.unwrap();
let buf = term.backend().buffer();
let area = buf.area;
let has_sel_bg = (area.left()..area.right()).any(|x| {
(area.top()..area.bottom()).any(|y| buf[(x, y)].style().bg == Some(pal.keycap_bg))
});
assert!(
has_sel_bg,
"the selection isn't drawn with a keycap_bg background"
);
}
#[test]
fn paste_replaces_selection() {
let mut ib = InputBox::new();
ib.set_text("hello");
ib.on_key(ctrl(KeyCode::Char('a')));
assert!(ib.has_selection());
ib.insert_str("XY");
assert_eq!(ib.text(), "XY");
assert!(!ib.has_selection());
}
const TX: u16 = 1 + PROMPT_W; const TY: u16 = 1;
#[test]
fn place_cursor_at_maps_click_to_position() {
let mut ib = InputBox::new();
ib.set_text("hello world"); render_at(&mut ib, 20); assert!(ib.place_cursor_at(TX + 3, TY));
assert_eq!(ib.cursor(), (0, 3));
assert!(ib.place_cursor_at(TX + 15, TY));
assert_eq!(ib.cursor(), (0, 11));
}
#[test]
fn place_cursor_below_last_row_goes_to_text_end() {
let mut ib = InputBox::new();
ib.set_text("abc\ndef");
render_at(&mut ib, 20);
assert!(ib.place_cursor_at(TX, TY + 5));
assert_eq!(ib.cursor(), (1, 3));
}
#[test]
fn place_cursor_snaps_to_cluster_boundary() {
let mut ib = InputBox::new();
ib.set_text("❤\u{FE0F}abc");
render_at(&mut ib, 20);
assert!(ib.place_cursor_at(TX + 1, TY)); assert_ne!(
ib.cursor(),
(0, 1),
"the cursor landed in the middle of the ❤️ cluster"
);
assert_eq!(ib.cursor(), (0, 0));
}
#[test]
fn place_cursor_outside_area_is_noop() {
let mut ib = InputBox::new();
ib.set_text("hello");
render_at(&mut ib, 20);
ib.row = 0;
ib.col = 2;
assert!(!ib.place_cursor_at(0, TY));
assert_eq!(ib.cursor(), (0, 2));
}
#[test]
fn place_cursor_before_render_is_noop() {
let mut ib = InputBox::new();
ib.set_text("hello");
assert!(!ib.place_cursor_at(3, 1));
}
#[test]
fn mouse_press_then_drag_builds_selection() {
let mut ib = InputBox::new();
ib.set_text("hello world");
render_at(&mut ib, 20);
assert!(ib.mouse_press(TX, TY));
assert_eq!(ib.cursor(), (0, 0));
assert!(!ib.has_selection());
assert!(ib.mouse_drag(TX + 5, TY));
assert_eq!(ib.cursor(), (0, 5));
assert!(ib.has_selection());
assert_eq!(ib.selected_text().as_deref(), Some("hello"));
}
#[test]
fn mouse_press_without_drag_is_empty_selection() {
let mut ib = InputBox::new();
ib.set_text("hello");
render_at(&mut ib, 20);
assert!(ib.mouse_press(TX + 3, TY));
assert_eq!(ib.cursor(), (0, 3));
assert!(!ib.has_selection()); }
#[test]
fn mouse_press_outside_keeps_cursor() {
let mut ib = InputBox::new();
ib.set_text("hello");
render_at(&mut ib, 20);
ib.row = 0;
ib.col = 4;
assert!(!ib.mouse_press(0, TY)); assert_eq!(ib.cursor(), (0, 4));
assert!(!ib.has_selection());
}
#[test]
fn mouse_drag_selects_across_wrapped_rows() {
let mut ib = InputBox::new();
ib.set_text("один два три"); render_at(&mut ib, 8);
assert!(ib.mouse_press(TX, TY)); assert_eq!(ib.cursor(), (0, 0));
assert!(ib.mouse_drag(TX + 1, TY + 1)); assert_eq!(ib.cursor(), (0, 10)); assert_eq!(ib.selected_text().as_deref(), Some("один два т"));
}
#[test]
fn scrollbar_appears_only_when_input_scrolls() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let right_col = |term: &Terminal<TestBackend>| -> Vec<String> {
let buf = term.backend().buffer();
let area = buf.area;
(area.top()..area.bottom())
.map(|y| buf[(area.right() - 1, y)].symbol().to_string())
.collect()
};
let mut ib = InputBox::new();
ib.set_text("a\nb"); let mut term = Terminal::new(TestBackend::new(20, 4)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ввод"),
&Palette::default(),
)
})
.unwrap();
assert!(
!right_col(&term).iter().any(|s| s == "█"),
"text that fits — no thumb"
);
ib.set_text("1\n2\n3\n4\n5\n6"); term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts::focused("ввод"),
&Palette::default(),
)
})
.unwrap();
assert!(
right_col(&term).iter().any(|s| s == "█"),
"a scrollable field — with a thumb"
);
}
#[test]
fn placeholder_is_configurable_on_unfocused_empty_field() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let render_ph = |ph: &str| -> String {
let mut ib = InputBox::new();
let mut term = Terminal::new(TestBackend::new(30, 3)).unwrap();
term.draw(|f| {
ib.render(
f,
f.area(),
RenderOpts {
title: "поле",
focused: false,
command: false,
placeholder: ph,
},
&Palette::default(),
)
})
.unwrap();
let buf = term.backend().buffer();
let area = buf.area;
(area.top()..area.bottom())
.map(|y| {
(area.left()..area.right())
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("")
};
assert!(render_ph("введите сообщение…").contains("введите сообщение"));
assert!(render_ph("свой плейсхолдер").contains("свой плейсхолдер"));
}
}