ui crate, editor

This commit is contained in:
2026-06-18 11:40:06 -05:00
parent fb8f6df501
commit 1351055d27
14 changed files with 2277 additions and 104 deletions
+51
View File
@@ -0,0 +1,51 @@
//! A clipboard shared by the text widgets ([`code_editor`](crate::code_editor),
//! [`text_field`](crate::text_field)).
//!
//! It is backed by an always-present internal buffer, mirrored to the system clipboard
//! when one is available — so copy/paste works the same everywhere, degrading cleanly.
/// A clipboard backed by an always-present internal buffer, mirrored to the system
/// clipboard when one is available.
///
/// `arboard::Clipboard::new()` fails with no display (headless Linux, etc.) and isn't
/// available on the web; in those cases `system` is `None` and copy/paste still work
/// *within* the app via `internal`. When the system clipboard is present we write
/// through to it (so other apps see copies) and prefer it on read (so we see their
/// copies), falling back to `internal` if a read fails.
pub(crate) struct Clipboard {
system: Option<arboard::Clipboard>,
internal: String,
}
impl Clipboard {
/// Creates a clipboard, attaching the system one if it initializes.
///
/// Under `cfg(test)` the system clipboard is never attached: arboard isn't safe to
/// initialize from many threads at once (the parallel test harness would otherwise
/// crash on macOS), and tests exercise copy/paste through the internal buffer anyway.
pub(crate) fn new() -> Self {
#[cfg(not(test))]
let system = arboard::Clipboard::new().ok();
#[cfg(test)]
let system = None;
Self { system, internal: String::new() }
}
/// Stores `text` internally and (best-effort) on the system clipboard.
pub(crate) fn set(&mut self, text: String) {
if let Some(sys) = &mut self.system {
let _ = sys.set_text(&text);
}
self.internal = text;
}
/// Returns the system clipboard text if readable, else the internal buffer.
pub(crate) fn get(&mut self) -> String {
if let Some(sys) = &mut self.system
&& let Ok(text) = sys.get_text()
{
return text;
}
self.internal.clone()
}
}
+863
View File
@@ -0,0 +1,863 @@
//! A small, purpose-built **modeless, keyboard-only** code editor widget.
//!
//! It edits like an ordinary text box (no vim modes): arrow keys move (wrapping across line
//! ends), printable keys insert, `shift`+arrows select, `ctrl`/`⌘`+`c`/`x`/`v` use the system
//! clipboard, `ctrl`/`⌘`+`z`/`y` undo/redo, and `Esc` asks the host to close. It shows a
//! line-number gutter and syntect syntax highlighting (the embedded Rhai syntax).
//!
//! Mouse is intentionally out of scope for now (its click hit-testing — gutter + scroll +
//! unicode width + tab expansion — is the hard part); the same per-line width model here
//! would invert to support it later. Like [`dialog`](crate::dialog) this is presentation +
//! input only; the host owns the loop and decides what open/close means.
use std::sync::Arc;
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use syntect::easy::HighlightLines;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::syntax_definition::SyntaxDefinition;
use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder};
use crate::clipboard::Clipboard;
use crate::text::{TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl};
/// The embedded Rhai Sublime-syntax definition used for highlighting.
const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax");
/// A dark syntect theme that ships with `ThemeSet::load_defaults`.
const THEME_NAME: &str = "base16-ocean.dark";
/// Maximum retained undo (and redo) snapshots.
const UNDO_DEPTH: usize = 200;
/// Default text color when no syntax color applies.
const DEFAULT_FG: Color = Color::Rgb(220, 220, 220);
/// Background of selected text.
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
/// Line-number gutter color.
const GUTTER_FG: Color = Color::Rgb(90, 100, 120);
/// What a key/paste event did to the editor, reported back to the host.
pub enum CodeEditorOutcome {
/// The editor stays open.
Continue,
/// The user pressed `Esc`; the host should close the editor.
Exit,
}
/// A cursor/selection position: `col` is a **character** index within `row`'s line.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Pos {
row: usize,
col: usize,
}
/// Which kind of edit just happened, used to coalesce undo steps.
#[derive(Clone, Copy, PartialEq, Eq)]
enum EditKind {
/// A run of single-character insertions.
Insert,
/// A run of single-character deletions.
Delete,
/// Any structural edit (newline, paste, selection delete) — never coalesced.
Other,
}
/// A point-in-time buffer snapshot for undo/redo.
#[derive(Clone)]
struct Snapshot {
lines: Vec<String>,
cursor: Pos,
}
/// Cached syntect pieces for highlighting (reused across frames).
struct HighlightParts {
theme: Theme,
syntax_ref: SyntaxReference,
syntax_set: Arc<SyntaxSet>,
}
/// A modeless, keyboard-driven code editor over a single script's text.
pub struct CodeEditor {
/// The script name, shown in the title border and used by the host on save.
name: String,
/// The text buffer, one entry per line (always at least one line).
lines: Vec<String>,
/// The cursor position.
cursor: Pos,
/// Remembered display intent for vertical movement (so up/down keep a column).
desired_col: usize,
/// Selection anchor; the selection spans `anchor..cursor` when `Some`.
anchor: Option<Pos>,
/// Scroll offset as `(top row, left display column)`, kept following the cursor.
scroll: (usize, usize),
/// Visible text rows from the last `draw`, used by PageUp/PageDown.
view_rows: usize,
/// Syntax-highlighting data, or `None` if the syntax/theme failed to load.
highlight: Option<HighlightParts>,
/// Clipboard: the system one when available, always backed by an internal buffer.
clipboard: Clipboard,
/// Undo/redo snapshot stacks and the open-edit-group marker for coalescing.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
last_edit: Option<EditKind>,
}
impl CodeEditor {
/// Builds an editor for `name` pre-filled with `text`.
pub fn new(name: &str, text: &str) -> Self {
let lines: Vec<String> = if text.is_empty() {
vec![String::new()]
} else {
text.split('\n').map(str::to_string).collect()
};
Self {
name: name.to_string(),
lines,
cursor: Pos { row: 0, col: 0 },
desired_col: 0,
anchor: None,
scroll: (0, 0),
view_rows: 0,
highlight: build_highlight_parts(),
clipboard: Clipboard::new(),
undo: Vec::new(),
redo: Vec::new(),
last_edit: None,
}
}
/// The script name (the host writes [`text`](CodeEditor::text) back under it).
pub fn name(&self) -> &str {
&self.name
}
/// The current buffer contents (lines joined with `\n`).
pub fn text(&self) -> String {
self.lines.join("\n")
}
/// Handles a key/paste event. `Esc` requests close; unrecognized keys are ignored.
pub fn handle_event(&mut self, event: &Event) -> CodeEditorOutcome {
match event {
// Terminal (bracketed) paste: insert the text as-is.
Event::Paste(text) => {
self.break_group();
self.replace_selection();
self.insert_text(text);
}
Event::Key(key) => {
// Treat ⌘ as ctrl (terminals report Super for Cmd under the kitty protocol).
let key = super_to_ctrl(*key);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => return CodeEditorOutcome::Exit,
// Clipboard / undo / select-all chords.
KeyCode::Char(c) if ctrl => match c.to_ascii_lowercase() {
'c' => self.copy(),
'x' => self.cut(),
'v' => self.paste(),
'a' => self.select_all(),
'z' => {
if shift {
self.redo()
} else {
self.undo()
}
}
'y' => self.redo(),
_ => {}
},
// Printable text.
KeyCode::Char(c) if !alt => self.insert_char(c),
KeyCode::Tab => self.insert_tab(),
KeyCode::Enter => self.insert_newline(),
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete_forward(),
KeyCode::Left => {
self.moved(shift, if ctrl { Self::move_word_left } else { Self::move_left })
}
KeyCode::Right => {
self.moved(shift, if ctrl { Self::move_word_right } else { Self::move_right })
}
KeyCode::Up => {
self.moved(shift, if ctrl { Self::move_paragraph_up } else { Self::move_up })
}
KeyCode::Down => self
.moved(shift, if ctrl { Self::move_paragraph_down } else { Self::move_down }),
KeyCode::Home => self.moved(shift, Self::move_home),
KeyCode::End => self.moved(shift, Self::move_end),
KeyCode::PageUp => self.moved(shift, Self::move_page_up),
KeyCode::PageDown => self.moved(shift, Self::move_page_down),
_ => {} // ignore anything else — never panic on an unknown key
}
}
_ => {} // mouse and other events are ignored for now
}
CodeEditorOutcome::Continue
}
// ── Movement ─────────────────────────────────────────────────────────────
/// Runs a movement, first setting/clearing the selection anchor per `shift`.
fn moved(&mut self, shift: bool, mv: fn(&mut Self)) {
self.break_group();
if shift {
// Begin (or continue) a selection from the pre-move cursor.
if self.anchor.is_none() {
self.anchor = Some(self.cursor);
}
} else {
self.anchor = None;
}
mv(self);
}
/// Number of characters in line `row`.
fn line_len(&self, row: usize) -> usize {
self.lines[row].chars().count()
}
fn move_left(&mut self) {
if self.cursor.col > 0 {
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
// Wrap to the end of the previous line.
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
}
self.desired_col = self.cursor.col;
}
fn move_right(&mut self) {
if self.cursor.col < self.line_len(self.cursor.row) {
self.cursor.col += 1;
} else if self.cursor.row + 1 < self.lines.len() {
// Wrap to the start of the next line.
self.cursor.row += 1;
self.cursor.col = 0;
}
self.desired_col = self.cursor.col;
}
fn move_up(&mut self) {
if self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
}
fn move_down(&mut self) {
if self.cursor.row + 1 < self.lines.len() {
self.cursor.row += 1;
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
}
/// ctrl+Left: jump to the previous word start (wrapping to the previous line's end).
fn move_word_left(&mut self) {
if self.cursor.col == 0 && self.cursor.row > 0 {
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
} else {
self.cursor.col = prev_word(&self.lines[self.cursor.row], self.cursor.col);
}
self.desired_col = self.cursor.col;
}
/// ctrl+Right: jump to the next word start (wrapping to the next line's start).
fn move_word_right(&mut self) {
if self.cursor.col == self.line_len(self.cursor.row) && self.cursor.row + 1 < self.lines.len() {
self.cursor.row += 1;
self.cursor.col = 0;
} else {
self.cursor.col = next_word(&self.lines[self.cursor.row], self.cursor.col);
}
self.desired_col = self.cursor.col;
}
/// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line.
fn move_paragraph_up(&mut self) {
self.cursor.row = (0..self.cursor.row).rev().find(|&r| self.is_blank_line(r)).unwrap_or(0);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
/// ctrl+Down: jump to the nearest blank/whitespace-only line below, else the last line.
fn move_paragraph_down(&mut self) {
let last = self.lines.len() - 1;
self.cursor.row = (self.cursor.row + 1..self.lines.len())
.find(|&r| self.is_blank_line(r))
.unwrap_or(last);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
/// Whether line `row` is empty or contains only whitespace.
fn is_blank_line(&self, row: usize) -> bool {
self.lines[row].trim().is_empty()
}
fn move_home(&mut self) {
self.cursor.col = 0;
self.desired_col = 0;
}
fn move_end(&mut self) {
self.cursor.col = self.line_len(self.cursor.row);
self.desired_col = self.cursor.col;
}
fn move_page_up(&mut self) {
let step = self.view_rows.max(1);
self.cursor.row = self.cursor.row.saturating_sub(step);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
fn move_page_down(&mut self) {
let step = self.view_rows.max(1);
self.cursor.row = (self.cursor.row + step).min(self.lines.len() - 1);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
// ── Editing ──────────────────────────────────────────────────────────────
fn insert_char(&mut self, c: char) {
self.replace_selection();
self.begin_edit(EditKind::Insert);
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].insert(b, c);
self.cursor.col += 1;
self.desired_col = self.cursor.col;
}
/// Inserts spaces to advance to the next tab stop.
fn insert_tab(&mut self) {
self.replace_selection();
let pad = TAB_WIDTH - (self.display_col(self.cursor.row, self.cursor.col) % TAB_WIDTH);
for _ in 0..pad {
self.insert_char(' ');
}
}
fn insert_newline(&mut self) {
self.replace_selection();
self.begin_edit(EditKind::Other);
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
let tail = self.lines[self.cursor.row].split_off(b);
self.lines.insert(self.cursor.row + 1, tail);
self.cursor.row += 1;
self.cursor.col = 0;
self.desired_col = 0;
}
/// Inserts arbitrary text (possibly multi-line) at the cursor.
fn insert_text(&mut self, text: &str) {
self.begin_edit(EditKind::Other);
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
// Each '\n' splits the current line.
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
let tail = self.lines[self.cursor.row].split_off(b);
self.lines.insert(self.cursor.row + 1, tail);
self.cursor.row += 1;
self.cursor.col = 0;
}
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].insert_str(b, part);
self.cursor.col += part.chars().count();
}
self.desired_col = self.cursor.col;
}
fn backspace(&mut self) {
if self.anchor.is_some() {
self.replace_selection();
return;
}
self.begin_edit(EditKind::Delete);
if self.cursor.col > 0 {
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col - 1);
self.lines[self.cursor.row].remove(b);
self.cursor.col -= 1;
} else if self.cursor.row > 0 {
// Join with the previous line.
let line = self.lines.remove(self.cursor.row);
self.cursor.row -= 1;
self.cursor.col = self.line_len(self.cursor.row);
self.lines[self.cursor.row].push_str(&line);
}
self.desired_col = self.cursor.col;
}
fn delete_forward(&mut self) {
if self.anchor.is_some() {
self.replace_selection();
return;
}
self.begin_edit(EditKind::Delete);
if self.cursor.col < self.line_len(self.cursor.row) {
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
self.lines[self.cursor.row].remove(b);
} else if self.cursor.row + 1 < self.lines.len() {
// Pull the next line up onto this one.
let next = self.lines.remove(self.cursor.row + 1);
self.lines[self.cursor.row].push_str(&next);
}
}
// ── Selection ──────────────────────────────────────────────────────────────
/// The selection as an ordered `(start, end)` pair, if any.
fn selection(&self) -> Option<(Pos, Pos)> {
let a = self.anchor?;
let c = self.cursor;
if (a.row, a.col) <= (c.row, c.col) {
Some((a, c))
} else {
Some((c, a))
}
}
/// Deletes the current selection (if any) and places the cursor at its start.
fn replace_selection(&mut self) {
let Some((a, b)) = self.selection() else { return };
self.begin_edit(EditKind::Other);
if a.row == b.row {
let s = &mut self.lines[a.row];
let (ba, bb) = (byte_of(s, a.col), byte_of(s, b.col));
s.replace_range(ba..bb, "");
} else {
let bb = byte_of(&self.lines[b.row], b.col);
let tail = self.lines[b.row][bb..].to_string();
let ba = byte_of(&self.lines[a.row], a.col);
self.lines[a.row].truncate(ba);
self.lines[a.row].push_str(&tail);
self.lines.drain(a.row + 1..=b.row);
}
self.cursor = a;
self.desired_col = a.col;
self.anchor = None;
}
/// The text covered by an ordered selection `(a, b)`.
fn selected_text(&self, a: Pos, b: Pos) -> String {
if a.row == b.row {
let s = &self.lines[a.row];
return s[byte_of(s, a.col)..byte_of(s, b.col)].to_string();
}
let mut out = self.lines[a.row][byte_of(&self.lines[a.row], a.col)..].to_string();
for row in a.row + 1..b.row {
out.push('\n');
out.push_str(&self.lines[row]);
}
out.push('\n');
out.push_str(&self.lines[b.row][..byte_of(&self.lines[b.row], b.col)]);
out
}
fn select_all(&mut self) {
self.break_group();
let last = self.lines.len() - 1;
self.anchor = Some(Pos { row: 0, col: 0 });
self.cursor = Pos { row: last, col: self.line_len(last) };
}
// ── Clipboard ──────────────────────────────────────────────────────────────
fn copy(&mut self) {
if let Some((a, b)) = self.selection() {
let text = self.selected_text(a, b);
self.clipboard.set(text);
}
}
fn cut(&mut self) {
self.copy();
self.replace_selection();
}
fn paste(&mut self) {
let text = self.clipboard.get();
if text.is_empty() {
return;
}
self.break_group();
self.replace_selection();
self.insert_text(&text);
}
// ── Undo / redo ──────────────────────────────────────────────────────────
/// Begins an edit of `kind`, snapshotting state unless it coalesces with the run in
/// progress (consecutive inserts, or consecutive deletes, collapse to one undo step).
fn begin_edit(&mut self, kind: EditKind) {
let coalesce = kind != EditKind::Other && self.last_edit == Some(kind);
if !coalesce {
self.push_snapshot();
}
self.last_edit = Some(kind);
}
/// Ends the current edit run so the next edit starts a fresh undo step.
fn break_group(&mut self) {
self.last_edit = None;
}
/// Pushes the current state to the undo stack and clears redo.
fn push_snapshot(&mut self) {
self.undo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor });
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0);
}
self.redo.clear();
}
fn undo(&mut self) {
if let Some(prev) = self.undo.pop() {
self.redo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor });
self.restore(prev);
}
}
fn redo(&mut self) {
if let Some(next) = self.redo.pop() {
self.undo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor });
self.restore(next);
}
}
/// Replaces the buffer/cursor with a snapshot, ending any edit run and selection.
fn restore(&mut self, snap: Snapshot) {
self.lines = snap.lines;
self.cursor = snap.cursor;
self.desired_col = self.cursor.col;
self.anchor = None;
self.last_edit = None;
}
// ── Rendering ──────────────────────────────────────────────────────────────
/// Display column (terminal cells) of character index `col` on line `row` (tabs to the
/// next [`TAB_WIDTH`] stop, wide chars as two cells).
fn display_col(&self, row: usize, col: usize) -> usize {
display_width(&self.lines[row], col)
}
/// Renders the editor (titled border, gutter, highlighted text, cursor) into `area`.
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
let block = Block::bordered().title(format!(" {} ", self.name));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let rows = inner.height as usize;
self.view_rows = rows;
// Gutter holds the largest line number plus a trailing space.
let gutter_w = (self.lines.len().to_string().len() + 1) as u16;
let text_x = inner.x + gutter_w;
let text_w = inner.width.saturating_sub(gutter_w);
if text_w == 0 {
return;
}
self.update_scroll(rows, text_w as usize);
let (top, left) = self.scroll;
let bottom = (top + rows).min(self.lines.len());
// Highlight from line 0 to the viewport bottom (syntect needs prior lines for context).
let colors = self.highlight_through(bottom);
let sel = self.selection();
for (i, row) in (top..bottom).enumerate() {
let y = inner.y + i as u16;
// Right-aligned line number in the gutter (temporary buffer borrow per call).
let num = format!("{:>w$} ", row + 1, w = gutter_w as usize - 1);
frame.buffer_mut().set_string(inner.x, y, num, Style::default().fg(GUTTER_FG));
// Styled, tab-expanded line; Paragraph applies the horizontal scroll + clipping.
let line = self.render_line(row, colors.get(row).map(Vec::as_slice), sel);
let rect = Rect { x: text_x, y, width: text_w, height: 1 };
frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect);
}
// Place the real terminal cursor if it's within the visible window.
let cx = self.display_col(self.cursor.row, self.cursor.col);
if (top..bottom).contains(&self.cursor.row) && cx >= left && cx - left < text_w as usize {
let x = text_x + (cx - left) as u16;
let y = inner.y + (self.cursor.row - top) as u16;
frame.set_cursor_position((x, y));
}
}
/// Adjusts `scroll` so the cursor stays visible (vertical by row, horizontal by cell).
fn update_scroll(&mut self, rows: usize, text_w: usize) {
let (mut top, mut left) = self.scroll;
if self.cursor.row < top {
top = self.cursor.row;
} else if self.cursor.row >= top + rows {
top = self.cursor.row + 1 - rows;
}
let cx = self.display_col(self.cursor.row, self.cursor.col);
if cx < left {
left = cx;
} else if cx >= left + text_w {
left = cx + 1 - text_w;
}
self.scroll = (top, left);
}
/// Per-character foreground colors for lines `0..upto`, via syntect (empty if disabled).
fn highlight_through(&self, upto: usize) -> Vec<Vec<Color>> {
let Some(hl) = &self.highlight else {
return Vec::new();
};
let mut h = HighlightLines::new(&hl.syntax_ref, &hl.theme);
let mut out = Vec::with_capacity(upto);
for line in self.lines.iter().take(upto) {
// Feed a trailing newline so multi-line constructs keep state, then drop it.
let with_nl = format!("{line}\n");
let regions = h.highlight_line(&with_nl, &hl.syntax_set).unwrap_or_default();
let mut colors = Vec::with_capacity(line.chars().count());
for (style, text) in regions {
let c = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
colors.extend(text.chars().map(|_| c));
}
colors.truncate(line.chars().count());
out.push(colors);
}
out
}
/// Builds one display [`Line`]: per-char fg (from `colors`), selection background, and
/// tabs expanded to spaces. Width/clipping/scroll are left to the rendering `Paragraph`.
fn render_line(&self, row: usize, colors: Option<&[Color]>, sel: Option<(Pos, Pos)>) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut col_cells = 0; // running display column, for tab expansion
for (i, c) in self.lines[row].chars().enumerate() {
let fg = colors.and_then(|cs| cs.get(i)).copied().unwrap_or(DEFAULT_FG);
let mut style = Style::default().fg(fg);
if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) {
style = style.bg(SELECT_BG);
}
if c == '\t' {
let pad = TAB_WIDTH - (col_cells % TAB_WIDTH);
spans.push(Span::styled(" ".repeat(pad), style));
col_cells += pad;
} else {
spans.push(Span::styled(c.to_string(), style));
col_cells += char_cells(c, col_cells);
}
}
Line::from(spans)
}
}
/// Whether character `(row, col)` lies within ordered selection `[a, b)`.
fn pos_in_range(row: usize, col: usize, a: Pos, b: Pos) -> bool {
let after_start = row > a.row || (row == a.row && col >= a.col);
let before_end = row < b.row || (row == b.row && col < b.col);
after_start && before_end
}
/// Parses the embedded Rhai syntax and a default theme into reusable highlight parts.
/// Returns `None` (highlighting disabled, editor still works) on any failure.
fn build_highlight_parts() -> Option<HighlightParts> {
let syntax = SyntaxDefinition::load_from_str(RHAI_SYNTAX, true, None).ok()?;
let mut builder = SyntaxSetBuilder::new();
builder.add(syntax);
let syntax_set = builder.build();
let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.clone();
let theme = ThemeSet::load_defaults().themes.get(THEME_NAME)?.clone();
Some(HighlightParts { theme, syntax_ref, syntax_set: Arc::new(syntax_set) })
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyEvent;
/// Sends a bare key code to the editor.
fn send(ed: &mut CodeEditor, code: KeyCode) {
ed.handle_event(&Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
}
/// Sends a key code with modifiers (e.g. ctrl/shift).
fn send_mod(ed: &mut CodeEditor, code: KeyCode, mods: KeyModifiers) {
ed.handle_event(&Event::Key(KeyEvent::new(code, mods)));
}
/// Types each character of `s` in order.
fn typ(ed: &mut CodeEditor, s: &str) {
for c in s.chars() {
send(ed, KeyCode::Char(c));
}
}
#[test]
fn embedded_rhai_syntax_parses_and_theme_loads() {
assert!(build_highlight_parts().is_some());
}
#[test]
fn text_round_trips_and_typing_inserts() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "let x = 1;");
assert_eq!(ed.text(), "let x = 1;");
assert_eq!(CodeEditor::new("s", "a\nb\nc").text(), "a\nb\nc");
}
#[test]
fn left_at_col0_wraps_to_previous_line_end() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::Down); // (1,0)
send(&mut ed, KeyCode::Left); // should wrap to end of line 0 = (0,2)
typ(&mut ed, "X");
assert_eq!(ed.text(), "abX\ncd");
}
#[test]
fn right_at_line_end_wraps_down() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::End); // (0,2)
send(&mut ed, KeyCode::Right); // wrap to (1,0)
typ(&mut ed, "X");
assert_eq!(ed.text(), "ab\nXcd");
}
#[test]
fn backspace_at_col0_joins_lines() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::Down); // (1,0)
send(&mut ed, KeyCode::Backspace);
assert_eq!(ed.text(), "abcd");
typ(&mut ed, "X"); // cursor should sit at the join (0,2)
assert_eq!(ed.text(), "abXcd");
}
#[test]
fn shift_selection_then_delete_removes_it() {
let mut ed = CodeEditor::new("s", "abcd");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
send(&mut ed, KeyCode::Backspace);
assert_eq!(ed.text(), "cd");
}
#[test]
fn bracketed_paste_replaces_selection() {
let mut ed = CodeEditor::new("s", "abcd");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
ed.handle_event(&Event::Paste("XY\nZ".to_string()));
assert_eq!(ed.text(), "XY\nZcd");
}
#[test]
fn copy_then_paste_round_trips_via_internal_clipboard() {
// Works even headless: the internal buffer backs copy/paste when there's no display.
let mut ed = CodeEditor::new("s", "abc");
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
send_mod(&mut ed, KeyCode::Char('c'), KeyModifiers::CONTROL); // copy
send(&mut ed, KeyCode::End); // clear selection, go to end
send_mod(&mut ed, KeyCode::Char('v'), KeyModifiers::CONTROL); // paste
assert_eq!(ed.text(), "abcab");
}
#[test]
fn undo_and_redo_round_trip() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "abc"); // coalesces to one undo step
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), "");
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
assert_eq!(ed.text(), "abc");
}
#[test]
fn movement_breaks_undo_coalescing() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "ab");
send(&mut ed, KeyCode::Left); // breaks the typing group
typ(&mut ed, "c"); // "acb" (inserted before 'b')... actually at (0,1): "acb"
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), "ab"); // only the post-move insert is undone
}
/// Sends a key code with modifiers and types a marker, returning where it landed.
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn ctrl_right_jumps_to_next_word_start() {
let mut ed = CodeEditor::new("s", "foo bar baz"); // cursor at (0,0) via Home
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
typ(&mut ed, "|"); // insert marker at the landing column
assert_eq!(ed.text(), "foo |bar baz"); // landed at start of "bar"
}
#[test]
fn ctrl_left_jumps_to_previous_word_start() {
let mut ed = CodeEditor::new("s", "foo bar baz");
send(&mut ed, KeyCode::End); // move to end (0,11) first
ed.handle_event(&Event::Key(ctrl(KeyCode::Left)));
typ(&mut ed, "|");
assert_eq!(ed.text(), "foo bar |baz"); // landed at start of "baz"
}
#[test]
fn ctrl_right_wraps_at_end_of_line() {
let mut ed = CodeEditor::new("s", "ab\ncd");
send(&mut ed, KeyCode::End); // (0,2) end of first line
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
typ(&mut ed, "X"); // should land at start of line 2
assert_eq!(ed.text(), "ab\nXcd");
}
#[test]
fn ctrl_down_and_up_jump_to_blank_lines() {
let mut ed = CodeEditor::new("s", "alpha\nbeta\n\ngamma");
send(&mut ed, KeyCode::Home); // (0,0)
ed.handle_event(&Event::Key(ctrl(KeyCode::Down))); // → blank line (row 2)
typ(&mut ed, "X");
assert_eq!(ed.text(), "alpha\nbeta\nX\ngamma");
// From the blank line, ctrl+Up returns to the top (no blank above).
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(ctrl(KeyCode::Up)));
typ(&mut ed, "Y");
assert_eq!(ed.text(), "Yalpha\nbeta\nX\ngamma");
}
#[test]
fn shift_ctrl_right_selects_a_word() {
let mut ed = CodeEditor::new("s", "foo bar");
send(&mut ed, KeyCode::Home);
ed.handle_event(&Event::Key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
))); // select "foo "
send(&mut ed, KeyCode::Backspace); // delete the selection
assert_eq!(ed.text(), "bar");
}
#[test]
fn esc_requests_exit() {
let mut ed = CodeEditor::new("s", "x");
assert!(matches!(
ed.handle_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))),
CodeEditorOutcome::Exit
));
}
#[test]
fn cmd_z_maps_to_ctrl_undo() {
let mut ed = CodeEditor::new("s", "");
typ(&mut ed, "hi");
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::SUPER);
assert_eq!(ed.text(), "");
}
}
+32 -48
View File
@@ -5,21 +5,21 @@
//! supplies its own; the editor uses its `EditorState`). While a dialog is open the
//! host routes all input to it; when dismissed it fires a stored callback with the
//! result **and** `&mut Ctx`, so the callback can apply the choice to host state. The
//! text field is backed by [`tui_input::Input`], driven through [`InputRequest`] so we
//! never depend on `tui-input`'s own event backend (avoiding any crossterm-version coupling).
//! text/filter field is a [`TextField`](crate::text_field::TextField) (the same in-house
//! single-line editor used elsewhere in kiln-ui).
//!
//! Drawing is a method ([`Dialog::draw`]) taking `&mut Frame` rather than a ratatui
//! `Widget` because it needs the [`Frame`] to place a real terminal cursor in the
//! active text field.
use crate::dim_area;
use crate::text_field::TextField;
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use tui_input::{Input, InputRequest};
/// Background color filling a dialog window.
const BG: Color = Color::Rgb(10, 15, 35);
@@ -47,11 +47,11 @@ pub enum ListDialogResponse {
/// What kind of field a dialog shows, plus its associated state.
enum DialogBody {
/// A single free-text field.
Text { input: Input },
Text { field: TextField },
/// A filter field above a selectable list. `selected` indexes the *filtered*
/// view (entries of `all` whose text starts with the filter).
List {
input: Input,
field: TextField,
all: Vec<String>,
selected: usize,
allow_create: bool,
@@ -102,7 +102,7 @@ impl<Ctx> Dialog<Ctx> {
Self {
title: title.into(),
body: DialogBody::Text {
input: Input::new(initial.into()),
field: TextField::new(initial),
},
callback: DialogCallback::Text(Box::new(on_done)),
}
@@ -122,7 +122,7 @@ impl<Ctx> Dialog<Ctx> {
Self {
title: title.into(),
body: DialogBody::List {
input: Input::new(String::new()),
field: TextField::new(""),
all: items,
selected: 0,
allow_create,
@@ -168,12 +168,10 @@ impl<Ctx> Dialog<Ctx> {
DialogResult::Continue
}
// Everything else edits the text/filter field.
code => {
if let Some(req) = key_to_request(code) {
self.input_mut().handle(req);
// The filter changed, so the previous selection index may now be
// out of range — clamp it back into the filtered list. Compute the
// new length first (immutable borrow), then clamp (mutable borrow).
_ => {
if self.field_mut().handle_key(*key) {
// The filter may have changed, so the previous selection index may now
// be out of range — clamp it back (compute length first, then clamp).
let n = self.filtered_len();
if let DialogBody::List { selected, .. } = &mut self.body {
*selected = (*selected).min(n.saturating_sub(1));
@@ -190,12 +188,12 @@ impl<Ctx> Dialog<Ctx> {
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
let submit = matches!(result, DialogResult::Submit);
match (self.body, self.callback) {
(DialogBody::Text { input }, DialogCallback::Text(cb)) => {
cb(submit.then(|| input.value().to_string()), ctx);
(DialogBody::Text { field }, DialogCallback::Text(cb)) => {
cb(submit.then(|| field.value().to_string()), ctx);
}
(
DialogBody::List {
input,
field,
all,
selected,
allow_create,
@@ -204,13 +202,13 @@ impl<Ctx> Dialog<Ctx> {
) => {
let resp = if !submit {
ListDialogResponse::Cancel
} else if let Some(entry) = filtered_items(&all, input.value())
} else if let Some(entry) = filtered_items(&all, field.value())
.into_iter()
.nth(selected)
{
ListDialogResponse::Select(entry.clone())
} else if allow_create {
ListDialogResponse::Create(input.value().to_string())
ListDialogResponse::Create(field.value().to_string())
} else {
ListDialogResponse::Cancel
};
@@ -226,17 +224,17 @@ impl<Ctx> Dialog<Ctx> {
match &self.body {
DialogBody::Text { .. } => true,
DialogBody::List {
input,
field,
allow_create,
..
} => self.filtered_len() > 0 || (*allow_create && !input.value().is_empty()),
} => self.filtered_len() > 0 || (*allow_create && !field.value().is_empty()),
}
}
/// Mutable access to whichever [`Input`] field this dialog owns.
fn input_mut(&mut self) -> &mut Input {
/// Mutable access to whichever [`TextField`] this dialog owns.
fn field_mut(&mut self) -> &mut TextField {
match &mut self.body {
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
}
}
@@ -281,22 +279,22 @@ impl<Ctx> Dialog<Ctx> {
let [body_area, footer_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
let input = match &self.body {
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
let field = match &self.body {
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
};
if let DialogBody::List {
all,
selected,
input,
field,
..
} = &self.body
{
// List dialog: the filter field sits atop the scrolling list.
let [field_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body_area);
draw_field(frame, field_area, input);
draw_list(frame, list_area, all, input.value(), *selected);
draw_field(frame, field_area, field);
draw_list(frame, list_area, all, field.value(), *selected);
} else {
// Text dialog: a single field box, centered horizontally and vertically.
let fw = body_area.width.saturating_sub(4).max(1);
@@ -306,7 +304,7 @@ impl<Ctx> Dialog<Ctx> {
width: fw,
height: 1,
};
draw_field(frame, field_area, input);
draw_field(frame, field_area, field);
}
// Footer: [enter] OK (dimmed when disabled) and [esc] Cancel.
@@ -329,7 +327,7 @@ impl<Ctx> Dialog<Ctx> {
/// Counts the entries passing a list body's current filter (0 for a text body).
fn filtered_count(body: &DialogBody) -> usize {
match body {
DialogBody::List { all, input, .. } => filtered_items(all, input.value()).len(),
DialogBody::List { all, field, .. } => filtered_items(all, field.value()).len(),
DialogBody::Text { .. } => 0,
}
}
@@ -339,33 +337,19 @@ fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> {
all.iter().filter(|s| s.starts_with(filter)).collect()
}
/// Maps a key code to a [`tui_input`] edit request, or `None` if it doesn't edit text.
fn key_to_request(code: KeyCode) -> Option<InputRequest> {
match code {
KeyCode::Char(c) => Some(InputRequest::InsertChar(c)),
KeyCode::Backspace => Some(InputRequest::DeletePrevChar),
KeyCode::Delete => Some(InputRequest::DeleteNextChar),
KeyCode::Left => Some(InputRequest::GoToPrevChar),
KeyCode::Right => Some(InputRequest::GoToNextChar),
KeyCode::Home => Some(InputRequest::GoToStart),
KeyCode::End => Some(InputRequest::GoToEnd),
_ => None,
}
}
/// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background
/// (so it reads as an input box), the current value, and a real terminal cursor at
/// the input's visual cursor column.
fn draw_field(frame: &mut Frame, area: Rect, input: &Input) {
/// the field's display cursor column.
fn draw_field(frame: &mut Frame, area: Rect, field: &TextField) {
if area.width == 0 {
return;
}
// The paragraph's style fills the whole field area, including empty cells.
frame.render_widget(
Paragraph::new(input.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)),
Paragraph::new(field.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)),
area,
);
let cur_x = area.x + (input.visual_cursor() as u16).min(area.width.saturating_sub(1));
let cur_x = area.x + (field.display_cursor() as u16).min(area.width.saturating_sub(1));
frame.set_cursor_position((cur_x, area.y));
}
+4
View File
@@ -20,7 +20,11 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
mod clipboard;
pub mod code_editor;
pub mod dialog;
mod text;
pub mod text_field;
/// Dims every cell in `area` so a modal overlay drawn on top stands out and the
/// content beneath does not show through.
+71
View File
@@ -0,0 +1,71 @@
//! Small char/width/key helpers shared by the text widgets
//! ([`code_editor`](crate::code_editor), [`text_field`](crate::text_field)).
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use unicode_width::UnicodeWidthChar;
/// Columns a tab advances to (tab stop).
pub(crate) const TAB_WIDTH: usize = 4;
/// Byte offset of character index `col` within `s` (clamped to `s.len()`).
pub(crate) fn byte_of(s: &str, col: usize) -> usize {
s.char_indices().nth(col).map_or(s.len(), |(b, _)| b)
}
/// Display width of `c` at running display column `at` (tabs advance to the next
/// [`TAB_WIDTH`] stop; wide characters are two cells; zero-width control chars are 0).
pub(crate) fn char_cells(c: char, at: usize) -> usize {
if c == '\t' {
TAB_WIDTH - (at % TAB_WIDTH)
} else {
UnicodeWidthChar::width(c).unwrap_or(0)
}
}
/// Display width (terminal cells) of the first `col` characters of `s`.
pub(crate) fn display_width(s: &str, col: usize) -> usize {
let mut w = 0;
for c in s.chars().take(col) {
w += char_cells(c, w);
}
w
}
/// Character index after moving one word forward from `col` in `line`: skip the current
/// run of non-whitespace, then the following whitespace (lands on the next word's start).
pub(crate) fn next_word(line: &str, col: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
let mut i = col;
while i < n && !chars[i].is_whitespace() {
i += 1;
}
while i < n && chars[i].is_whitespace() {
i += 1;
}
i
}
/// Character index after moving one word backward from `col` in `line`: skip whitespace,
/// then the preceding word (lands on that word's start).
pub(crate) fn prev_word(line: &str, col: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
let mut i = col;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
i
}
/// Rewrites a `⌘`-modified key to its `ctrl` equivalent (terminals report Super for Cmd
/// under the kitty protocol; edtui/our bindings key off ctrl).
pub(crate) fn super_to_ctrl(mut key: KeyEvent) -> KeyEvent {
if key.modifiers.contains(KeyModifiers::SUPER) {
key.modifiers.remove(KeyModifiers::SUPER);
key.modifiers.insert(KeyModifiers::CONTROL);
}
key
}
+178
View File
@@ -0,0 +1,178 @@
//! A single-line editable text field — a one-line sibling of [`code_editor`](crate::code_editor),
//! used by [`dialog`](crate::dialog) for its text and list-filter inputs.
//!
//! Presentation is left to the caller (it reads [`value`](TextField::value) and
//! [`display_cursor`](TextField::display_cursor)); the field only owns the text, the cursor,
//! and a [`Clipboard`] for paste. No newlines, no selection (yet) — deliberately minimal.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::clipboard::Clipboard;
use crate::text::{byte_of, display_width, next_word, prev_word, super_to_ctrl};
/// A single-line text input with a character-indexed cursor.
pub struct TextField {
/// The current text (never contains newlines).
text: String,
/// Cursor position as a character index in `0..=text.chars().count()`.
cursor: usize,
/// Clipboard for `ctrl`/`⌘`+`v` paste.
clipboard: Clipboard,
}
impl TextField {
/// Creates a field pre-filled with `initial`, cursor at the end.
pub fn new(initial: impl Into<String>) -> Self {
let text = initial.into();
let cursor = text.chars().count();
Self { text, cursor, clipboard: Clipboard::new() }
}
/// The current text.
pub fn value(&self) -> &str {
&self.text
}
/// The cursor's display column (terminal cells from the start), for placing the caret.
pub fn display_cursor(&self) -> usize {
display_width(&self.text, self.cursor)
}
/// Number of characters in the field.
fn len(&self) -> usize {
self.text.chars().count()
}
/// Handles a key event. Returns `true` if it was consumed (edited or moved the cursor).
///
/// Editing keys only: printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves),
/// Home/End, and `ctrl`/`⌘`+`v` paste. Esc/Enter/Up/Down are left to the caller.
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
let key = super_to_ctrl(key);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char('v') if ctrl => self.paste(),
KeyCode::Char(_) if ctrl => return false, // other chords aren't ours
KeyCode::Char(c) if !alt => self.insert(c),
KeyCode::Backspace => return self.backspace(),
KeyCode::Delete => return self.delete_forward(),
KeyCode::Left if ctrl => self.cursor = prev_word(&self.text, self.cursor),
KeyCode::Right if ctrl => self.cursor = next_word(&self.text, self.cursor),
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.len(),
_ => return false,
}
true
}
fn insert(&mut self, c: char) {
let b = byte_of(&self.text, self.cursor);
self.text.insert(b, c);
self.cursor += 1;
}
fn backspace(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let b = byte_of(&self.text, self.cursor - 1);
self.text.remove(b);
self.cursor -= 1;
true
}
fn delete_forward(&mut self) -> bool {
if self.cursor >= self.len() {
return false;
}
let b = byte_of(&self.text, self.cursor);
self.text.remove(b);
true
}
/// Inserts clipboard text at the cursor, with any newlines stripped (single-line).
fn paste(&mut self) {
let clean: String = self.clipboard.get().chars().filter(|c| !matches!(c, '\n' | '\r')).collect();
let b = byte_of(&self.text, self.cursor);
self.text.insert_str(b, &clean);
self.cursor += clean.chars().count();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn typing_inserts_and_value_reflects_it() {
let mut f = TextField::new("");
for c in "abc".chars() {
assert!(f.handle_key(key(KeyCode::Char(c))));
}
assert_eq!(f.value(), "abc");
assert_eq!(f.display_cursor(), 3);
}
#[test]
fn backspace_and_delete() {
let mut f = TextField::new("abc"); // cursor at end (3)
assert!(f.handle_key(key(KeyCode::Backspace)));
assert_eq!(f.value(), "ab");
f.handle_key(key(KeyCode::Home));
assert!(f.handle_key(key(KeyCode::Delete)));
assert_eq!(f.value(), "b");
// Nothing to delete at the boundaries.
assert!(!TextField::new("").handle_key(key(KeyCode::Backspace)));
}
#[test]
fn cursor_movement_inserts_at_right_place() {
let mut f = TextField::new("ac");
f.handle_key(key(KeyCode::Left)); // between a and c
f.handle_key(key(KeyCode::Char('b')));
assert_eq!(f.value(), "abc");
f.handle_key(key(KeyCode::Home));
f.handle_key(key(KeyCode::Char('X')));
assert_eq!(f.value(), "Xabc");
}
#[test]
fn ctrl_v_pastes_clipboard_with_newlines_stripped() {
let mut f = TextField::new("");
f.clipboard.set("multi\nline".to_string());
assert!(f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
assert_eq!(f.value(), "multiline");
}
#[test]
fn cmd_v_maps_to_ctrl_v() {
let mut f = TextField::new("");
f.clipboard.set("hi".to_string());
f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER));
assert_eq!(f.value(), "hi");
}
#[test]
fn display_cursor_counts_wide_chars() {
let mut f = TextField::new(""); // one double-width char, cursor at end
assert_eq!(f.display_cursor(), 2);
f.handle_key(key(KeyCode::Home));
assert_eq!(f.display_cursor(), 0);
}
#[test]
fn unhandled_keys_return_false() {
let mut f = TextField::new("x");
assert!(!f.handle_key(key(KeyCode::Enter)));
assert!(!f.handle_key(key(KeyCode::Up)));
assert!(!f.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
}
}