use serde::{Deserialize, Serialize};
use crate::accelerator::KeyBinding;
use crate::event::{Key, NamedKey, Rect};
use crate::types::{Modifiers, WidgetId};
use crate::undo::UndoStack;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextInput {
pub id: WidgetId,
pub lines: Vec<String>,
#[serde(default)]
pub cursor_line: usize,
#[serde(default)]
pub cursor_col: usize,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub scroll_col: usize,
#[serde(default)]
pub has_focus: bool,
}
impl TextInput {
pub fn new(id: WidgetId) -> Self {
Self {
id,
lines: vec![String::new()],
cursor_line: 0,
cursor_col: 0,
placeholder: None,
scroll_offset: 0,
scroll_col: 0,
has_focus: false,
}
}
#[must_use]
pub fn with_lines(mut self, lines: Vec<String>) -> Self {
self.lines = lines;
self
}
#[must_use]
pub fn with_cursor_line(mut self, cursor_line: usize) -> Self {
self.cursor_line = cursor_line;
self
}
#[must_use]
pub fn with_cursor_col(mut self, cursor_col: usize) -> Self {
self.cursor_col = cursor_col;
self
}
#[must_use]
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
#[must_use]
pub fn with_scroll_offset(mut self, scroll_offset: usize) -> Self {
self.scroll_offset = scroll_offset;
self
}
#[must_use]
pub fn with_scroll_col(mut self, scroll_col: usize) -> Self {
self.scroll_col = scroll_col;
self
}
#[must_use]
pub fn with_has_focus(mut self, has_focus: bool) -> Self {
self.has_focus = has_focus;
self
}
}
impl Default for TextInput {
fn default() -> Self {
Self::new(WidgetId::new(String::new()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditOp {
InsertChar(char),
InsertText(String),
DeleteBackward,
DeleteForward,
DeleteSelection,
MoveLeft { extend: bool },
MoveRight { extend: bool },
MoveUp { extend: bool },
MoveDown { extend: bool },
MoveLineStart { extend: bool },
MoveLineEnd { extend: bool },
MoveDocStart { extend: bool },
MoveDocEnd { extend: bool },
SetCursor {
line: usize,
col: usize,
extend: bool,
},
SelectAll,
Undo,
Redo,
}
impl EditOp {
pub fn from_key(key: &Key, modifiers: Modifiers) -> Option<EditOp> {
let extend = modifiers.shift;
match key {
Key::Char(c) => Some(EditOp::InsertChar(*c)),
Key::Named(NamedKey::Enter) => Some(EditOp::InsertChar('\n')),
Key::Named(NamedKey::Backspace) => Some(EditOp::DeleteBackward),
Key::Named(NamedKey::Delete) => Some(EditOp::DeleteForward),
Key::Named(NamedKey::Left) => Some(EditOp::MoveLeft { extend }),
Key::Named(NamedKey::Right) => Some(EditOp::MoveRight { extend }),
Key::Named(NamedKey::Up) => Some(EditOp::MoveUp { extend }),
Key::Named(NamedKey::Down) => Some(EditOp::MoveDown { extend }),
Key::Named(NamedKey::Home) if modifiers.ctrl => Some(EditOp::MoveDocStart { extend }),
Key::Named(NamedKey::End) if modifiers.ctrl => Some(EditOp::MoveDocEnd { extend }),
Key::Named(NamedKey::Home) => Some(EditOp::MoveLineStart { extend }),
Key::Named(NamedKey::End) => Some(EditOp::MoveLineEnd { extend }),
_ => None,
}
}
pub fn from_key_binding(binding: &KeyBinding) -> Option<EditOp> {
match binding {
KeyBinding::Undo => Some(EditOp::Undo),
KeyBinding::Redo => Some(EditOp::Redo),
KeyBinding::SelectAll => Some(EditOp::SelectAll),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TextInputSnapshot {
lines: Vec<String>,
cursor_line: usize,
cursor_col: usize,
selection_anchor: Option<(usize, usize)>,
}
fn byte_offset_for_col(line: &str, col: usize) -> usize {
line.char_indices()
.nth(col)
.map(|(b, _)| b)
.unwrap_or(line.len())
}
impl TextInput {
fn line_len(&self, line: usize) -> usize {
self.lines.get(line).map_or(0, |l| l.chars().count())
}
fn clamp_pos(&self, line: usize, col: usize) -> (usize, usize) {
let line = line.min(self.lines.len().saturating_sub(1));
let col = col.min(self.line_len(line));
(line, col)
}
fn text_in_range(&self, start: (usize, usize), end: (usize, usize)) -> String {
let (sl, sc) = start;
let (el, ec) = end;
if sl == el {
let line = self.lines.get(sl).map(String::as_str).unwrap_or("");
let sb = byte_offset_for_col(line, sc);
let eb = byte_offset_for_col(line, ec);
return line[sb.min(eb)..sb.max(eb)].to_string();
}
let mut out = String::new();
let first = self.lines.get(sl).map(String::as_str).unwrap_or("");
out.push_str(&first[byte_offset_for_col(first, sc)..]);
for line in &self.lines[sl + 1..el] {
out.push('\n');
out.push_str(line);
}
out.push('\n');
let last = self.lines.get(el).map(String::as_str).unwrap_or("");
out.push_str(&last[..byte_offset_for_col(last, ec)]);
out
}
fn delete_range(&mut self, start: (usize, usize), end: (usize, usize)) {
let (sl, sc) = start;
let (el, ec) = end;
if sl == el {
if let Some(line) = self.lines.get_mut(sl) {
let sb = byte_offset_for_col(line, sc);
let eb = byte_offset_for_col(line, ec);
line.replace_range(sb..eb, "");
}
} else {
let head = {
let line = self.lines.get(sl).map(String::as_str).unwrap_or("");
line[..byte_offset_for_col(line, sc)].to_string()
};
let tail = {
let line = self.lines.get(el).map(String::as_str).unwrap_or("");
line[byte_offset_for_col(line, ec)..].to_string()
};
self.lines.splice(sl..=el, [format!("{head}{tail}")]);
}
self.cursor_line = sl;
self.cursor_col = sc;
}
fn insert_char_raw(&mut self, ch: char) {
if self.lines.is_empty() {
self.lines.push(String::new());
}
if ch == '\n' {
let line = self.lines[self.cursor_line].as_str();
let byte = byte_offset_for_col(line, self.cursor_col);
let rest = self.lines[self.cursor_line][byte..].to_string();
self.lines[self.cursor_line].truncate(byte);
self.lines.insert(self.cursor_line + 1, rest);
self.cursor_line += 1;
self.cursor_col = 0;
} else {
let line = &mut self.lines[self.cursor_line];
let byte = byte_offset_for_col(line, self.cursor_col);
line.insert(byte, ch);
self.cursor_col += 1;
}
}
fn insert_text_raw(&mut self, text: &str) {
for ch in text.chars() {
self.insert_char_raw(ch);
}
}
fn delete_backward_raw(&mut self) -> bool {
if self.cursor_col > 0 {
let line = &mut self.lines[self.cursor_line];
let end = byte_offset_for_col(line, self.cursor_col);
let start = byte_offset_for_col(line, self.cursor_col - 1);
line.replace_range(start..end, "");
self.cursor_col -= 1;
true
} else if self.cursor_line > 0 {
let current = self.lines.remove(self.cursor_line);
self.cursor_line -= 1;
let prev_len = self.line_len(self.cursor_line);
self.lines[self.cursor_line].push_str(¤t);
self.cursor_col = prev_len;
true
} else {
false
}
}
fn delete_forward_raw(&mut self) -> bool {
let len = self.line_len(self.cursor_line);
if self.cursor_col < len {
let line = &mut self.lines[self.cursor_line];
let start = byte_offset_for_col(line, self.cursor_col);
let end = byte_offset_for_col(line, self.cursor_col + 1);
line.replace_range(start..end, "");
true
} else if self.cursor_line + 1 < self.lines.len() {
let next = self.lines.remove(self.cursor_line + 1);
self.lines[self.cursor_line].push_str(&next);
true
} else {
false
}
}
}
const UNDO_HISTORY_LIMIT: usize = 200;
#[derive(Debug, Clone)]
pub struct TextEditor {
pub input: TextInput,
selection_anchor: Option<(usize, usize)>,
undo_stack: UndoStack<TextInputSnapshot>,
}
impl TextEditor {
pub fn new(input: TextInput) -> Self {
Self {
input,
selection_anchor: None,
undo_stack: UndoStack::with_limit(UNDO_HISTORY_LIMIT),
}
}
pub fn selection_anchor(&self) -> Option<(usize, usize)> {
self.selection_anchor
}
pub fn set_selection_anchor(&mut self, anchor: Option<(usize, usize)>) {
self.selection_anchor = anchor;
}
pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
let anchor = self.selection_anchor?;
let cursor = (self.input.cursor_line, self.input.cursor_col);
if anchor == cursor {
return None;
}
Some(if anchor <= cursor {
(anchor, cursor)
} else {
(cursor, anchor)
})
}
pub fn selected_text(&self) -> Option<String> {
let (start, end) = self.selection_range()?;
Some(self.input.text_in_range(start, end))
}
fn snapshot(&self) -> TextInputSnapshot {
TextInputSnapshot {
lines: self.input.lines.clone(),
cursor_line: self.input.cursor_line,
cursor_col: self.input.cursor_col,
selection_anchor: self.selection_anchor,
}
}
fn restore(&mut self, snap: TextInputSnapshot) {
self.input.lines = snap.lines;
self.input.cursor_line = snap.cursor_line;
self.input.cursor_col = snap.cursor_col;
self.selection_anchor = snap.selection_anchor;
}
fn delete_selection(&mut self) -> bool {
match self.selection_range() {
Some((start, end)) => {
self.input.delete_range(start, end);
self.selection_anchor = None;
true
}
None => false,
}
}
fn move_cursor_to(&mut self, pos: (usize, usize), extend: bool) -> bool {
let before = (self.input.cursor_line, self.input.cursor_col);
let anchor_before = self.selection_anchor;
if extend {
if self.selection_anchor.is_none() {
self.selection_anchor = Some(before);
}
} else {
self.selection_anchor = None;
}
self.input.cursor_line = pos.0;
self.input.cursor_col = pos.1;
before != pos || self.selection_anchor != anchor_before
}
pub fn apply(&mut self, op: EditOp) -> bool {
match op {
EditOp::Undo => {
let current = self.snapshot();
match self.undo_stack.undo(current) {
Some(prev) => {
self.restore(prev);
true
}
None => false,
}
}
EditOp::Redo => {
let current = self.snapshot();
match self.undo_stack.redo(current) {
Some(next) => {
self.restore(next);
true
}
None => false,
}
}
other => {
let content_mutating = matches!(
other,
EditOp::InsertChar(_)
| EditOp::InsertText(_)
| EditOp::DeleteBackward
| EditOp::DeleteForward
| EditOp::DeleteSelection
);
if content_mutating {
let snap = self.snapshot();
let changed = self.apply_edit(other);
if changed {
self.undo_stack.record(snap);
}
changed
} else {
self.apply_edit(other)
}
}
}
}
fn apply_edit(&mut self, op: EditOp) -> bool {
match op {
EditOp::InsertChar(ch) => {
self.delete_selection();
self.input.insert_char_raw(ch);
true
}
EditOp::InsertText(text) => {
self.delete_selection();
self.input.insert_text_raw(&text);
true
}
EditOp::DeleteBackward => {
if self.delete_selection() {
true
} else {
self.input.delete_backward_raw()
}
}
EditOp::DeleteForward => {
if self.delete_selection() {
true
} else {
self.input.delete_forward_raw()
}
}
EditOp::DeleteSelection => self.delete_selection(),
EditOp::MoveLeft { extend } => {
if !extend {
if let Some((start, _end)) = self.selection_range() {
return self.move_cursor_to(start, false);
}
}
let (line, col) = (self.input.cursor_line, self.input.cursor_col);
let target = if col > 0 {
(line, col - 1)
} else if line > 0 {
(line - 1, self.input.line_len(line - 1))
} else {
(line, col)
};
self.move_cursor_to(target, extend)
}
EditOp::MoveRight { extend } => {
if !extend {
if let Some((_start, end)) = self.selection_range() {
return self.move_cursor_to(end, false);
}
}
let (line, col) = (self.input.cursor_line, self.input.cursor_col);
let len = self.input.line_len(line);
let target = if col < len {
(line, col + 1)
} else if line + 1 < self.input.lines.len() {
(line + 1, 0)
} else {
(line, col)
};
self.move_cursor_to(target, extend)
}
EditOp::MoveUp { extend } => {
let target = if self.input.cursor_line > 0 {
self.input
.clamp_pos(self.input.cursor_line - 1, self.input.cursor_col)
} else {
(self.input.cursor_line, self.input.cursor_col)
};
self.move_cursor_to(target, extend)
}
EditOp::MoveDown { extend } => {
let target = if self.input.cursor_line + 1 < self.input.lines.len() {
self.input
.clamp_pos(self.input.cursor_line + 1, self.input.cursor_col)
} else {
(self.input.cursor_line, self.input.cursor_col)
};
self.move_cursor_to(target, extend)
}
EditOp::MoveLineStart { extend } => {
self.move_cursor_to((self.input.cursor_line, 0), extend)
}
EditOp::MoveLineEnd { extend } => {
let len = self.input.line_len(self.input.cursor_line);
self.move_cursor_to((self.input.cursor_line, len), extend)
}
EditOp::MoveDocStart { extend } => self.move_cursor_to((0, 0), extend),
EditOp::MoveDocEnd { extend } => {
let last = self.input.lines.len().saturating_sub(1);
let len = self.input.line_len(last);
self.move_cursor_to((last, len), extend)
}
EditOp::SetCursor { line, col, extend } => {
let target = self.input.clamp_pos(line, col);
self.move_cursor_to(target, extend)
}
EditOp::SelectAll => {
let last = self.input.lines.len().saturating_sub(1);
let end = (last, self.input.line_len(last));
if end == (0, 0) {
false
} else {
self.selection_anchor = Some((0, 0));
self.input.cursor_line = end.0;
self.input.cursor_col = end.1;
true
}
}
EditOp::Undo | EditOp::Redo => false,
}
}
}
impl std::ops::Deref for TextEditor {
type Target = TextInput;
fn deref(&self) -> &TextInput {
&self.input
}
}
impl std::ops::DerefMut for TextEditor {
fn deref_mut(&mut self) -> &mut TextInput {
&mut self.input
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextInputHit {
Line { line_idx: usize },
EmptyArea,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleTextInputLine {
pub line_idx: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextInputLayout {
pub bounds: Rect,
pub content_bounds: Rect,
pub visible_lines: Vec<VisibleTextInputLine>,
pub cursor_bounds: Option<Rect>,
pub resolved_scroll_offset: usize,
pub resolved_scroll_col: usize,
pub hit_regions: Vec<(Rect, TextInputHit)>,
pub placeholder_active: bool,
}
impl TextInputLayout {
pub fn hit_test(&self, x: f32, y: f32) -> TextInputHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
TextInputHit::EmptyArea
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextInputMeasure {
pub row_height: f32,
pub char_width: f32,
}
impl TextInputMeasure {
pub fn new(row_height: f32, char_width: f32) -> Self {
Self {
row_height,
char_width,
}
}
pub fn from_metrics(m: &crate::backend::Metrics) -> Self {
Self::new(m.line_height, m.char_width)
}
}
fn content_x_inset(char_w: f32) -> f32 {
let border = 1.0;
let pad_x = char_w;
border + pad_x
}
impl TextInput {
pub fn layout(&self, rect: Rect, measure: TextInputMeasure) -> TextInputLayout {
let row_h = measure.row_height.max(1.0);
let char_w = measure.char_width.max(1.0);
let border = 1.0;
let inset = content_x_inset(char_w);
let content_x = rect.x + inset;
let content_y = rect.y + border;
let content_w = (rect.width - inset * 2.0).max(0.0);
let content_h = (rect.height - border * 2.0).max(0.0);
let content_bounds = Rect::new(content_x, content_y, content_w, content_h);
let total_lines = self.lines.len().max(1);
let max_rows = ((content_h / row_h).floor() as usize).max(1);
let visible_cols = ((content_w / char_w).floor() as usize).max(1);
let cursor_line = self.cursor_line.min(total_lines.saturating_sub(1));
let cursor_col = {
let line = self.lines.get(cursor_line).map_or("", String::as_str);
self.cursor_col.min(line.chars().count())
};
let max_scroll = total_lines.saturating_sub(max_rows);
let mut scroll = self.scroll_offset.min(max_scroll);
if cursor_line < scroll {
scroll = cursor_line;
} else if cursor_line >= scroll + max_rows {
scroll = cursor_line + 1 - max_rows;
}
scroll = scroll.min(max_scroll);
let mut scroll_col = self.scroll_col;
if cursor_col < scroll_col {
scroll_col = cursor_col;
} else if cursor_col >= scroll_col + visible_cols {
scroll_col = cursor_col + 1 - visible_cols;
}
let visible_count = total_lines.saturating_sub(scroll).min(max_rows);
let mut visible_lines: Vec<VisibleTextInputLine> = Vec::with_capacity(visible_count);
let mut hit_regions: Vec<(Rect, TextInputHit)> = Vec::with_capacity(visible_count + 1);
let placeholder_active = self.placeholder.is_some()
&& (self.lines.is_empty() || (self.lines.len() == 1 && self.lines[0].is_empty()));
for i in 0..visible_count {
let line_idx = scroll + i;
let row_y = content_y + i as f32 * row_h;
let row_bounds = Rect::new(content_x, row_y, content_w, row_h);
visible_lines.push(VisibleTextInputLine {
line_idx,
bounds: row_bounds,
});
hit_regions.push((row_bounds, TextInputHit::Line { line_idx }));
}
let used_h = visible_count as f32 * row_h;
if used_h < content_h {
hit_regions.push((
Rect::new(content_x, content_y + used_h, content_w, content_h - used_h),
TextInputHit::EmptyArea,
));
}
let cursor_bounds = if cursor_line >= scroll && cursor_line < scroll + visible_count {
let row_off = cursor_line - scroll;
let col_off = cursor_col.saturating_sub(scroll_col);
let cursor_x = content_x + col_off as f32 * char_w;
let cursor_y = content_y + row_off as f32 * row_h;
Some(Rect::new(cursor_x, cursor_y, char_w, row_h))
} else {
None
};
TextInputLayout {
bounds: rect,
content_bounds,
visible_lines,
cursor_bounds,
resolved_scroll_offset: scroll,
resolved_scroll_col: scroll_col,
hit_regions,
placeholder_active,
}
}
pub fn content_cols(width: f32, char_width: f32) -> usize {
let char_w = char_width.max(1.0);
let inset = content_x_inset(char_w);
let content_w = (width - inset * 2.0).max(0.0);
((content_w / char_w).floor() as usize).max(1)
}
}
#[cfg(all(feature = "macos", target_os = "macos"))]
#[allow(dead_code)]
mod native_surface_paint {
use super::{TextInput, TextInputLayout};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
const CURSOR_W: f32 = 2.0;
fn slice_from(line: &str, off: usize) -> String {
if off == 0 {
line.to_string()
} else {
line.chars().skip(off).collect()
}
}
pub(crate) fn paint(
ti: &TextInput,
layout: &TextInputLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
) {
let rect = layout.bounds;
if rect.width <= 0.0 || rect.height <= 0.0 {
return;
}
surface.surface_fill_rect(rect, theme.background);
let border_color = if ti.has_focus {
theme.accent_fg
} else {
theme.border_fg
};
surface.surface_stroke_rect(rect, border_color, 1.0);
let h_scroll = layout.resolved_scroll_col;
if layout.placeholder_active {
if let (Some(text), Some(first)) =
(ti.placeholder.as_ref(), layout.visible_lines.first())
{
surface.surface_draw_text_run(first.bounds, text, theme.muted_fg);
}
} else {
for vis in &layout.visible_lines {
let full = ti.lines.get(vis.line_idx).map(String::as_str).unwrap_or("");
let visible = slice_from(full, h_scroll);
if visible.is_empty() {
continue;
}
surface.surface_draw_text_run(vis.bounds, &visible, theme.foreground);
}
}
if ti.has_focus {
if let Some(cb) = layout.cursor_bounds {
let cursor_rect = Rect::new(cb.x, cb.y, CURSOR_W, cb.height);
surface.surface_fill_rect(cursor_rect, theme.cursor);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::primitives::text_input::TextInputMeasure;
use crate::types::{Color, WidgetId};
use crate::{Image, Point, Viewport};
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
strokes: Vec<(Rect, Color, f32)>,
text_runs: Vec<(Rect, String, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(0.0, 0.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 16.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_stroke_rounded_rect(
&mut self,
rect: Rect,
_radius: f32,
color: Color,
stroke_width: f32,
) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: Point,
_to: Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn sample(lines: Vec<&str>, cursor_line: usize, cursor_col: usize) -> TextInput {
let mut ti = TextInput::new(WidgetId::new("ti"));
ti.lines = lines.into_iter().map(String::from).collect();
ti.cursor_line = cursor_line;
ti.cursor_col = cursor_col;
ti.has_focus = true;
ti
}
#[test]
fn value_and_cursor_both_paint() {
let ti = sample(vec!["hello"], 0, 3);
let rect = Rect::new(0.0, 0.0, 200.0, 20.0);
let layout = ti.layout(rect, TextInputMeasure::new(16.0, 8.0));
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(&ti, &layout, &mut surface, &theme);
assert!(
surface.text_runs.iter().any(|(_, t, _)| t == "hello"),
"expected the input's value to be drawn as a text run, text runs were {:?}",
surface.text_runs,
);
let cb = layout.cursor_bounds.expect("cursor visible");
assert!(
surface
.fills
.iter()
.any(|(r, c)| *c == theme.cursor && (r.x - cb.x).abs() < 0.01),
"expected a cursor-colored fill at cursor_bounds.x, fills were {:?}",
surface.fills,
);
}
#[test]
fn unfocused_input_paints_no_cursor() {
let mut ti = sample(vec!["hello"], 0, 3);
ti.has_focus = false;
let rect = Rect::new(0.0, 0.0, 200.0, 20.0);
let layout = ti.layout(rect, TextInputMeasure::new(16.0, 8.0));
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(&ti, &layout, &mut surface, &theme);
assert!(
!surface.fills.iter().any(|(_, c)| *c == theme.cursor),
"unfocused input must not paint a cursor, fills were {:?}",
surface.fills,
);
assert!(surface.text_runs.iter().any(|(_, t, _)| t == "hello"));
}
#[test]
fn placeholder_paints_in_muted_fg_when_empty() {
let mut ti = TextInput::new(WidgetId::new("ti"));
ti.placeholder = Some("type here".into());
ti.has_focus = false;
let rect = Rect::new(0.0, 0.0, 200.0, 20.0);
let layout = ti.layout(rect, TextInputMeasure::new(16.0, 8.0));
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(&ti, &layout, &mut surface, &theme);
assert!(surface
.text_runs
.iter()
.any(|(_, t, c)| t == "type here" && *c == theme.muted_fg));
}
#[test]
fn zero_size_rect_is_a_no_op() {
let ti = sample(vec!["hello"], 0, 0);
let rect = Rect::new(0.0, 0.0, 0.0, 20.0);
let layout = ti.layout(rect, TextInputMeasure::new(16.0, 8.0));
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(&ti, &layout, &mut surface, &theme);
assert!(surface.fills.is_empty());
assert!(surface.strokes.is_empty());
assert!(surface.text_runs.is_empty());
}
}
}
#[cfg(all(feature = "macos", target_os = "macos"))]
pub(crate) use native_surface_paint::paint;
#[cfg(test)]
mod tests {
use super::*;
fn input(lines: Vec<&str>) -> TextInput {
let mut ti = TextInput::new(WidgetId::new("ti"));
ti.lines = lines.into_iter().map(String::from).collect();
ti.has_focus = true;
ti
}
fn editor(lines: Vec<&str>) -> TextEditor {
TextEditor::new(input(lines))
}
fn measure() -> TextInputMeasure {
TextInputMeasure::new(1.0, 1.0)
}
fn rect(w: f32, h: f32) -> Rect {
Rect::new(0.0, 0.0, w, h)
}
#[test]
fn empty_input_renders_one_row() {
let ti = TextInput::new(WidgetId::new("ti"));
let l = ti.layout(rect(20.0, 10.0), measure());
assert_eq!(l.visible_lines.len(), 1);
assert_eq!(l.visible_lines[0].line_idx, 0);
}
#[test]
fn placeholder_active_when_empty() {
let mut ti = TextInput::new(WidgetId::new("ti"));
ti.placeholder = Some("type here".into());
let l = ti.layout(rect(20.0, 10.0), measure());
assert!(l.placeholder_active);
}
#[test]
fn placeholder_inactive_with_content() {
let mut ti = input(vec!["hello"]);
ti.placeholder = Some("type here".into());
let l = ti.layout(rect(20.0, 10.0), measure());
assert!(!l.placeholder_active);
}
#[test]
fn cursor_bounds_at_origin_when_empty() {
let ti = TextInput::new(WidgetId::new("ti"));
let l = ti.layout(rect(20.0, 10.0), measure());
let cb = l.cursor_bounds.unwrap();
assert_eq!(cb.x, 2.0);
assert_eq!(cb.y, 1.0);
assert_eq!(cb.width, 1.0);
assert_eq!(cb.height, 1.0);
}
#[test]
fn cursor_bounds_track_col() {
let mut ti = input(vec!["hello"]);
ti.cursor_col = 3;
let l = ti.layout(rect(20.0, 10.0), measure());
assert_eq!(l.cursor_bounds.unwrap().x, 2.0 + 3.0); }
#[test]
fn cursor_col_clamped_to_line_length() {
let mut ti = input(vec!["hi"]);
ti.cursor_col = 99;
let l = ti.layout(rect(20.0, 10.0), measure());
assert_eq!(l.cursor_bounds.unwrap().x, 2.0 + 2.0); }
#[test]
fn cursor_line_clamped_to_total() {
let mut ti = input(vec!["a", "b", "c"]);
ti.cursor_line = 99;
let l = ti.layout(rect(20.0, 10.0), measure());
assert!(l.cursor_bounds.is_some());
let cb = l.cursor_bounds.unwrap();
assert_eq!(cb.y, 1.0 + 2.0); }
#[test]
fn h_scroll_auto_pulls_cursor_into_view_rightward() {
let mut ti = input(vec!["abcdefghijklmnopqrstuvwxyz"]);
ti.cursor_col = 20;
let l = ti.layout(rect(10.0, 5.0), measure());
assert_eq!(l.resolved_scroll_col, 15);
assert_eq!(l.cursor_bounds.unwrap().x, 7.0);
}
#[test]
fn h_scroll_auto_pulls_cursor_into_view_leftward() {
let mut ti = input(vec!["abcdefghijklmnopqrstuvwxyz"]);
ti.cursor_col = 2;
ti.scroll_col = 20; let l = ti.layout(rect(10.0, 5.0), measure());
assert_eq!(l.resolved_scroll_col, 2);
}
#[test]
fn h_scroll_zero_when_line_fits() {
let mut ti = input(vec!["short"]);
ti.cursor_col = 5;
let l = ti.layout(rect(20.0, 5.0), measure());
assert_eq!(l.resolved_scroll_col, 0);
}
#[test]
fn scroll_auto_pulls_cursor_into_view_downward() {
let mut ti = input(vec!["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);
ti.cursor_line = 7;
let l = ti.layout(rect(20.0, 5.0), measure());
assert_eq!(l.resolved_scroll_offset, 5);
assert!(l.cursor_bounds.is_some());
}
#[test]
fn scroll_auto_pulls_cursor_into_view_upward() {
let mut ti = input(vec!["0", "1", "2", "3", "4", "5"]);
ti.cursor_line = 1;
ti.scroll_offset = 4; let l = ti.layout(rect(20.0, 5.0), measure());
assert_eq!(l.resolved_scroll_offset, 1);
}
#[test]
fn scroll_clamped_when_cursor_in_view() {
let mut ti = input(vec!["0", "1", "2", "3"]);
ti.cursor_line = 0;
ti.scroll_offset = 99; let l = ti.layout(rect(20.0, 10.0), measure());
assert_eq!(l.resolved_scroll_offset, 0);
}
#[test]
fn hit_regions_one_per_visible_line() {
let ti = input(vec!["a", "b", "c"]);
let l = ti.layout(rect(20.0, 10.0), measure());
let line_hits: Vec<_> = l
.hit_regions
.iter()
.filter(|(_, h)| matches!(h, TextInputHit::Line { .. }))
.collect();
assert_eq!(line_hits.len(), 3);
}
#[test]
fn empty_area_hit_region_below_last_line() {
let ti = input(vec!["only"]);
let l = ti.layout(rect(20.0, 10.0), measure());
let empty = l
.hit_regions
.iter()
.find(|(_, h)| matches!(h, TextInputHit::EmptyArea));
assert!(empty.is_some());
}
#[test]
fn hit_test_line_returns_line_idx() {
let ti = input(vec!["a", "b", "c"]);
let l = ti.layout(rect(20.0, 10.0), measure());
assert_eq!(l.hit_test(5.0, 2.0), TextInputHit::Line { line_idx: 1 });
}
#[test]
fn hit_test_below_last_line_returns_empty_area() {
let ti = input(vec!["only"]);
let l = ti.layout(rect(20.0, 10.0), measure());
assert_eq!(l.hit_test(5.0, 8.0), TextInputHit::EmptyArea);
}
#[test]
fn hit_test_outside_bounds_returns_empty_area() {
let ti = input(vec!["only"]);
let l = ti.layout(rect(20.0, 10.0), measure());
assert_eq!(l.hit_test(-5.0, -5.0), TextInputHit::EmptyArea);
}
#[test]
fn cursor_bounds_none_when_off_screen() {
let mut ti = input(vec!["a", "b", "c"]);
ti.cursor_line = 2;
ti.scroll_offset = 0;
let l = ti.layout(rect(20.0, 3.0), measure()); assert!(l.cursor_bounds.is_some());
assert_eq!(l.resolved_scroll_offset, 2);
}
#[test]
fn content_cols_matches_layouts_content_width() {
let ti = TextInput::new(WidgetId::new("ti"));
let l = ti.layout(rect(20.0, 10.0), measure());
let cols = TextInput::content_cols(20.0, 1.0);
assert_eq!(cols, l.content_bounds.width as usize);
}
#[test]
fn content_cols_never_below_one() {
assert_eq!(TextInput::content_cols(0.0, 1.0), 1);
assert_eq!(TextInput::content_cols(1.0, 8.0), 1);
}
#[test]
fn insert_char_advances_cursor() {
let mut ti = editor(vec![""]);
assert!(ti.apply(EditOp::InsertChar('h')));
assert!(ti.apply(EditOp::InsertChar('i')));
assert_eq!(ti.lines, vec!["hi".to_string()]);
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 2));
}
#[test]
fn insert_char_newline_splits_line() {
let mut ti = editor(vec!["helloworld"]);
ti.cursor_col = 5;
assert!(ti.apply(EditOp::InsertChar('\n')));
assert_eq!(ti.lines, vec!["hello".to_string(), "world".to_string()]);
assert_eq!((ti.cursor_line, ti.cursor_col), (1, 0));
}
#[test]
fn insert_char_is_multibyte_safe() {
let mut ti = editor(vec!["héllo"]);
ti.cursor_col = 2; assert!(ti.apply(EditOp::InsertChar('X')));
assert_eq!(ti.lines, vec!["héXllo".to_string()]);
}
#[test]
fn insert_text_with_embedded_newline_creates_lines() {
let mut ti = editor(vec![""]);
assert!(ti.apply(EditOp::InsertText("ab\ncd".to_string())));
assert_eq!(ti.lines, vec!["ab".to_string(), "cd".to_string()]);
assert_eq!((ti.cursor_line, ti.cursor_col), (1, 2));
}
#[test]
fn delete_backward_removes_prior_char() {
let mut ti = editor(vec!["hi"]);
ti.cursor_col = 2;
assert!(ti.apply(EditOp::DeleteBackward));
assert_eq!(ti.lines, vec!["h".to_string()]);
assert_eq!(ti.cursor_col, 1);
}
#[test]
fn delete_backward_at_line_start_merges_with_previous_line() {
let mut ti = editor(vec!["foo", "bar"]);
ti.cursor_line = 1;
ti.cursor_col = 0;
assert!(ti.apply(EditOp::DeleteBackward));
assert_eq!(ti.lines, vec!["foobar".to_string()]);
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 3));
}
#[test]
fn delete_backward_at_doc_start_is_a_no_op() {
let mut ti = editor(vec!["hi"]);
assert!(!ti.apply(EditOp::DeleteBackward));
assert_eq!(ti.lines, vec!["hi".to_string()]);
}
#[test]
fn delete_forward_removes_next_char() {
let mut ti = editor(vec!["hi"]);
assert!(ti.apply(EditOp::DeleteForward));
assert_eq!(ti.lines, vec!["i".to_string()]);
assert_eq!(ti.cursor_col, 0);
}
#[test]
fn delete_forward_at_line_end_merges_next_line() {
let mut ti = editor(vec!["foo", "bar"]);
ti.cursor_col = 3; assert!(ti.apply(EditOp::DeleteForward));
assert_eq!(ti.lines, vec!["foobar".to_string()]);
}
#[test]
fn delete_forward_at_doc_end_is_a_no_op() {
let mut ti = editor(vec!["hi"]);
ti.cursor_col = 2;
assert!(!ti.apply(EditOp::DeleteForward));
}
#[test]
fn move_right_then_left_round_trips() {
let mut ti = editor(vec!["hi"]);
assert!(ti.apply(EditOp::MoveRight { extend: false }));
assert_eq!(ti.cursor_col, 1);
assert!(ti.apply(EditOp::MoveLeft { extend: false }));
assert_eq!(ti.cursor_col, 0);
}
#[test]
fn move_right_at_line_end_wraps_to_next_line() {
let mut ti = editor(vec!["ab", "cd"]);
ti.cursor_col = 2;
assert!(ti.apply(EditOp::MoveRight { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (1, 0));
}
#[test]
fn move_left_at_line_start_wraps_to_previous_line_end() {
let mut ti = editor(vec!["ab", "cd"]);
ti.cursor_line = 1;
assert!(ti.apply(EditOp::MoveLeft { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 2));
}
#[test]
fn move_up_down_clamp_column_to_shorter_line() {
let mut ti = editor(vec!["abcdef", "xy"]);
ti.cursor_col = 5;
assert!(ti.apply(EditOp::MoveDown { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (1, 2)); assert!(ti.apply(EditOp::MoveUp { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 2)); }
#[test]
fn move_line_start_end_and_doc_start_end() {
let mut ti = editor(vec!["abc", "de"]);
ti.cursor_line = 1;
ti.cursor_col = 1;
assert!(ti.apply(EditOp::MoveLineEnd { extend: false }));
assert_eq!(ti.cursor_col, 2);
assert!(ti.apply(EditOp::MoveLineStart { extend: false }));
assert_eq!(ti.cursor_col, 0);
assert!(ti.apply(EditOp::MoveDocEnd { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (1, 2));
assert!(ti.apply(EditOp::MoveDocStart { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 0));
}
#[test]
fn set_cursor_clamps_out_of_range_position() {
let mut ti = editor(vec!["abc"]);
assert!(ti.apply(EditOp::SetCursor {
line: 99,
col: 99,
extend: false
}));
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 3));
}
#[test]
fn extend_move_creates_selection_and_selected_text() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: true });
assert_eq!(ti.selection_range(), Some(((0, 0), (0, 2))));
assert_eq!(ti.selected_text().as_deref(), Some("he"));
}
#[test]
fn plain_move_after_extend_collapses_selection() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: false });
assert_eq!(ti.selection_range(), None);
}
#[test]
fn unmodified_left_after_selection_lands_on_near_edge() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: true }); assert!(ti.apply(EditOp::MoveLeft { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 0));
assert_eq!(ti.selection_range(), None);
}
#[test]
fn unmodified_right_after_selection_lands_on_far_edge() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: true }); assert!(ti.apply(EditOp::MoveRight { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 2));
assert_eq!(ti.selection_range(), None);
}
#[test]
fn unmodified_left_after_leftward_selection_lands_on_near_edge() {
let mut ti = editor(vec!["hello"]);
ti.cursor_col = 3;
ti.apply(EditOp::MoveLeft { extend: true });
ti.apply(EditOp::MoveLeft { extend: true }); assert!(ti.apply(EditOp::MoveLeft { extend: false }));
assert_eq!((ti.cursor_line, ti.cursor_col), (0, 1));
assert_eq!(ti.selection_range(), None);
}
#[test]
fn select_all_selects_entire_buffer() {
let mut ti = editor(vec!["ab", "cde"]);
assert!(ti.apply(EditOp::SelectAll));
assert_eq!(ti.selected_text().as_deref(), Some("ab\ncde"));
}
#[test]
fn insert_over_selection_replaces_it() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: true }); assert!(ti.apply(EditOp::InsertChar('X')));
assert_eq!(ti.lines, vec!["Xllo".to_string()]);
assert_eq!(ti.selection_range(), None);
}
#[test]
fn delete_backward_over_selection_deletes_selection_not_one_char() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: true }); assert!(ti.apply(EditOp::DeleteBackward));
assert_eq!(ti.lines, vec!["llo".to_string()]);
}
#[test]
fn delete_selection_op_clears_selection_with_no_replacement() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: true });
assert!(ti.apply(EditOp::DeleteSelection));
assert_eq!(ti.lines, vec!["llo".to_string()]);
assert_eq!(ti.selection_range(), None);
}
#[test]
fn delete_selection_with_no_selection_is_a_no_op() {
let mut ti = editor(vec!["hello"]);
assert!(!ti.apply(EditOp::DeleteSelection));
}
#[test]
fn multiline_selection_spans_lines() {
let mut ti = editor(vec!["abc", "def"]);
ti.apply(EditOp::MoveDown { extend: true });
ti.apply(EditOp::MoveLineEnd { extend: true });
assert_eq!(ti.selected_text().as_deref(), Some("abc\ndef"));
}
#[test]
fn redo_with_no_history_is_a_no_op() {
let mut ti = editor(vec![""]);
ti.apply(EditOp::InsertChar('h'));
assert!(!ti.apply(EditOp::Redo), "nothing has been undone yet");
assert_eq!(ti.lines, vec!["h".to_string()]);
}
#[test]
fn undo_reverts_last_insert() {
let mut ti = editor(vec![""]);
ti.apply(EditOp::InsertChar('h'));
ti.apply(EditOp::InsertChar('i'));
assert_eq!(ti.lines, vec!["hi".to_string()]);
assert!(ti.apply(EditOp::Undo));
assert_eq!(ti.lines, vec!["h".to_string()]);
assert!(ti.apply(EditOp::Undo));
assert_eq!(ti.lines, vec!["".to_string()]);
}
#[test]
fn undo_with_no_history_is_a_no_op() {
let mut ti = editor(vec![""]);
assert!(!ti.apply(EditOp::Undo));
}
#[test]
fn redo_restores_undone_edit() {
let mut ti = editor(vec![""]);
ti.apply(EditOp::InsertChar('h'));
ti.apply(EditOp::Undo);
assert_eq!(ti.lines, vec!["".to_string()]);
assert!(ti.apply(EditOp::Redo));
assert_eq!(ti.lines, vec!["h".to_string()]);
}
#[test]
fn new_edit_after_undo_clears_redo_history() {
let mut ti = editor(vec![""]);
ti.apply(EditOp::InsertChar('a'));
ti.apply(EditOp::Undo);
ti.apply(EditOp::InsertChar('b'));
assert!(
!ti.apply(EditOp::Redo),
"redo history should have been invalidated by the new edit"
);
assert_eq!(ti.lines, vec!["b".to_string()]);
}
#[test]
fn cursor_movement_does_not_create_an_undo_step() {
let mut ti = editor(vec![""]);
ti.apply(EditOp::InsertChar('a'));
ti.apply(EditOp::MoveLeft { extend: false });
assert!(ti.apply(EditOp::Undo));
assert_eq!(ti.lines, vec!["".to_string()]);
}
#[test]
fn undo_restores_selection_state() {
let mut ti = editor(vec!["hello"]);
ti.apply(EditOp::MoveRight { extend: true });
ti.apply(EditOp::MoveRight { extend: true }); ti.apply(EditOp::DeleteSelection);
assert_eq!(ti.lines, vec!["llo".to_string()]);
assert!(ti.apply(EditOp::Undo));
assert_eq!(ti.lines, vec!["hello".to_string()]);
assert_eq!(ti.selection_range(), Some(((0, 0), (0, 2))));
}
#[test]
fn undo_history_is_bounded() {
let mut ti = editor(vec![""]);
for _ in 0..UNDO_HISTORY_LIMIT + 50 {
ti.apply(EditOp::InsertChar('a'));
}
let mut undone = 0;
while ti.apply(EditOp::Undo) {
undone += 1;
}
assert_eq!(undone, UNDO_HISTORY_LIMIT);
}
#[test]
fn from_key_maps_plain_char_to_insert() {
assert_eq!(
EditOp::from_key(&Key::Char('x'), Modifiers::default()),
Some(EditOp::InsertChar('x'))
);
}
#[test]
fn from_key_maps_arrows_with_shift_to_extend() {
let shift = Modifiers {
shift: true,
..Modifiers::default()
};
assert_eq!(
EditOp::from_key(&Key::Named(NamedKey::Right), shift),
Some(EditOp::MoveRight { extend: true })
);
assert_eq!(
EditOp::from_key(&Key::Named(NamedKey::Right), Modifiers::default()),
Some(EditOp::MoveRight { extend: false })
);
}
#[test]
fn from_key_ctrl_home_end_map_to_doc_start_end() {
let ctrl = Modifiers {
ctrl: true,
..Modifiers::default()
};
assert_eq!(
EditOp::from_key(&Key::Named(NamedKey::Home), ctrl),
Some(EditOp::MoveDocStart { extend: false })
);
assert_eq!(
EditOp::from_key(&Key::Named(NamedKey::End), ctrl),
Some(EditOp::MoveDocEnd { extend: false })
);
assert_eq!(
EditOp::from_key(&Key::Named(NamedKey::Home), Modifiers::default()),
Some(EditOp::MoveLineStart { extend: false })
);
}
#[test]
fn from_key_unmapped_key_returns_none() {
assert_eq!(
EditOp::from_key(&Key::Named(NamedKey::Escape), Modifiers::default()),
None
);
}
#[test]
fn from_key_binding_wires_undo_redo_select_all() {
assert_eq!(
EditOp::from_key_binding(&KeyBinding::Undo),
Some(EditOp::Undo)
);
assert_eq!(
EditOp::from_key_binding(&KeyBinding::Redo),
Some(EditOp::Redo)
);
assert_eq!(
EditOp::from_key_binding(&KeyBinding::SelectAll),
Some(EditOp::SelectAll)
);
assert_eq!(EditOp::from_key_binding(&KeyBinding::Copy), None);
}
#[test]
fn equality_ignores_undo_history() {
let mut a = editor(vec![""]);
let mut b = editor(vec![""]);
a.apply(EditOp::InsertChar('x'));
b.apply(EditOp::InsertChar('x'));
b.apply(EditOp::InsertChar('y'));
b.apply(EditOp::Undo);
assert_eq!(a.lines, b.lines);
assert_eq!(
a.input, b.input,
"equality must compare content, not undo history"
);
}
#[test]
fn default_matches_new_with_empty_id() {
assert_eq!(TextInput::default(), TextInput::new(WidgetId::new("")));
}
#[test]
fn with_builders_reach_every_field_a_struct_literal_can_set() {
let ti = TextInput::new(WidgetId::new("sc:commit_input"))
.with_lines(vec![
"subject".to_string(),
String::new(),
"body".to_string(),
])
.with_cursor_line(2)
.with_cursor_col(4)
.with_placeholder("Message (press c)")
.with_scroll_offset(0)
.with_scroll_col(0)
.with_has_focus(true);
assert_eq!(
ti,
TextInput {
id: WidgetId::new("sc:commit_input"),
lines: vec!["subject".to_string(), String::new(), "body".to_string()],
cursor_line: 2,
cursor_col: 4,
placeholder: Some("Message (press c)".to_string()),
scroll_offset: 0,
scroll_col: 0,
has_focus: true,
}
);
}
}