use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use rosace_core::types::Rect;
use rosace_render::{Color, FontWeight};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Affinity {
#[default]
Upstream,
Downstream,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SelectionRange {
pub anchor: usize,
pub head: usize,
pub affinity: Affinity,
}
impl SelectionRange {
pub fn collapsed_at(pos: usize) -> Self {
Self { anchor: pos, head: pos, affinity: Affinity::default() }
}
pub fn collapsed(&self) -> bool {
self.anchor == self.head
}
pub fn normalized(&self) -> (usize, usize) {
(self.anchor.min(self.head), self.anchor.max(self.head))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Selection {
ranges: Vec<SelectionRange>,
}
impl Selection {
pub fn single(pos: usize) -> Self {
Self { ranges: vec![SelectionRange::collapsed_at(pos)] }
}
pub fn range(anchor: usize, head: usize) -> Self {
Self { ranges: vec![SelectionRange { anchor, head, affinity: Affinity::default() }] }
}
pub fn primary(&self) -> &SelectionRange {
self.ranges.last().expect("Selection is never empty")
}
pub fn primary_range(&self) -> (usize, usize) {
self.primary().normalized()
}
pub fn ranges(&self) -> &[SelectionRange] {
&self.ranges
}
}
impl Default for Selection {
fn default() -> Self {
Selection::single(0)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Edit {
pub range: (usize, usize),
pub replacement: String,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Transaction {
pub edits: Vec<Edit>,
}
impl Transaction {
pub fn single(range: (usize, usize), replacement: impl Into<String>) -> Self {
Transaction { edits: vec![Edit { range, replacement: replacement.into() }] }
}
pub fn apply(&self, value: &str) -> (String, Transaction) {
let mut edits = self.edits.clone();
edits.sort_by_key(|e| std::cmp::Reverse(e.range.0));
let mut result = value.to_string();
let mut inverse_edits = Vec::with_capacity(edits.len());
for e in &edits {
let bs = char_byte_offset(&result, e.range.0);
let be = char_byte_offset(&result, e.range.1);
let removed = result[bs..be].to_string();
let mut next = String::with_capacity(result.len() - (be - bs) + e.replacement.len());
next.push_str(&result[..bs]);
next.push_str(&e.replacement);
next.push_str(&result[be..]);
let new_end = e.range.0 + char_count(&e.replacement);
inverse_edits.push(Edit { range: (e.range.0, new_end), replacement: removed });
result = next;
}
(result, Transaction { edits: inverse_edits })
}
}
fn edits_affected_range(edits: &[Edit]) -> Option<(usize, usize)> {
edits.iter().map(|e| (e.range.0, e.range.0 + char_count(&e.replacement)))
.fold(None, |acc: Option<(usize, usize)>, r| Some(match acc {
None => r,
Some(a) => (a.0.min(r.0), a.1.max(r.1)),
}))
}
pub fn char_count(s: &str) -> usize {
s.chars().count()
}
pub fn char_byte_offset(s: &str, idx: usize) -> usize {
s.char_indices().nth(idx).map(|(b, _)| b).unwrap_or(s.len())
}
pub fn grapheme_boundaries(s: &str) -> Vec<usize> {
let mut bounds = Vec::with_capacity(s.len() + 1);
bounds.push(0usize);
let mut char_idx = 0usize;
for g in s.graphemes(true) {
char_idx += g.chars().count();
bounds.push(char_idx);
}
bounds
}
pub fn prev_grapheme_boundary(s: &str, pos: usize) -> usize {
grapheme_boundaries(s).into_iter().rev().find(|&b| b < pos).unwrap_or(0)
}
pub fn next_grapheme_boundary(s: &str, pos: usize) -> usize {
let bounds = grapheme_boundaries(s);
bounds.iter().copied().find(|&b| b > pos).unwrap_or_else(|| *bounds.last().unwrap())
}
fn word_bound_boundaries(s: &str) -> Vec<(usize, bool)> {
let mut out = Vec::new();
let mut char_idx = 0usize;
for w in s.split_word_bounds() {
let is_word = w.chars().next().map(|c| c.is_alphanumeric()).unwrap_or(false);
out.push((char_idx, is_word));
char_idx += char_count(w);
}
out.push((char_idx, false)); out
}
pub fn prev_word_boundary(s: &str, pos: usize) -> usize {
let runs = word_bound_boundaries(s);
let mut idx = runs.len().saturating_sub(1);
for i in (0..runs.len() - 1).rev() {
if runs[i].0 < pos {
idx = i;
break;
}
}
let mut i = idx;
loop {
let (start, is_word) = runs[i];
if start < pos && is_word {
return start;
}
if i == 0 {
return 0;
}
i -= 1;
}
}
pub fn next_word_boundary(s: &str, pos: usize) -> usize {
let runs = word_bound_boundaries(s); let n = char_count(s);
let mut i = 0;
while i + 1 < runs.len() && runs[i + 1].0 <= pos {
i += 1;
}
if runs[i].1 {
let end = runs.get(i + 1).map(|&(st, _)| st).unwrap_or(n);
if end > pos {
return end;
}
}
let mut j = i + 1;
while j < runs.len() {
if runs[j].1 {
return runs.get(j + 1).map(|&(st, _)| st).unwrap_or(n);
}
j += 1;
}
n
}
pub fn word_range_at(s: &str, pos: usize) -> (usize, usize) {
let runs = word_bound_boundaries(s);
let n = char_count(s);
let mut i = 0;
while i + 1 < runs.len() && runs[i + 1].0 <= pos {
i += 1;
}
let start = runs[i].0;
let end = runs.get(i + 1).map(|&(st, _)| st).unwrap_or(n);
(start, end)
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LineLayout {
pub char_range: (usize, usize),
pub y: f32,
pub height: f32,
pub boundary_chars: Vec<usize>,
pub boundary_x: Vec<f32>,
}
impl LineLayout {
pub fn x_at(&self, target: usize) -> f32 {
let clamped = target.clamp(self.char_range.0, self.char_range.1);
if let Some(i) = self.boundary_chars.iter().position(|&c| c == clamped) {
self.boundary_x[i]
} else if clamped <= self.char_range.0 {
self.boundary_x.first().copied().unwrap_or(0.0)
} else {
self.boundary_x.last().copied().unwrap_or(0.0)
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TextLayoutSnapshot {
pub lines: Vec<LineLayout>,
}
impl TextLayoutSnapshot {
fn line_for_y(&self, y: f32) -> Option<&LineLayout> {
if self.lines.is_empty() {
return None;
}
for line in &self.lines {
if y < line.y + line.height {
return Some(line);
}
}
self.lines.last()
}
pub fn position_at(&self, x: f32, y: f32) -> usize {
let Some(line) = self.line_for_y(y) else { return 0; };
if line.boundary_x.is_empty() {
return line.char_range.0;
}
let mut idx = 0usize;
for (i, &bx) in line.boundary_x.iter().enumerate() {
if bx <= x {
idx = i;
} else {
break;
}
}
if idx + 1 < line.boundary_x.len() {
let mid = (line.boundary_x[idx] + line.boundary_x[idx + 1]) / 2.0;
if x > mid {
idx += 1;
}
}
line.boundary_chars[idx]
}
pub fn x_of(&self, char_idx: usize) -> Option<f32> {
for line in &self.lines {
if let Some(i) = line.boundary_chars.iter().position(|&c| c == char_idx) {
return Some(line.boundary_x[i]);
}
}
None
}
pub fn line_range_at(&self, char_idx: usize) -> (usize, usize) {
for line in &self.lines {
if char_idx >= line.char_range.0 && char_idx <= line.char_range.1 {
return line.char_range;
}
}
self.lines.last().map(|l| l.char_range).unwrap_or((0, 0))
}
}
#[derive(Clone, Debug)]
pub struct Span {
pub range: (usize, usize),
pub color: Option<Color>,
pub weight: Option<FontWeight>,
}
impl PartialEq for Span {
fn eq(&self, other: &Self) -> bool {
self.range == other.range
&& self.color.map(color_bits) == other.color.map(color_bits)
&& self.weight == other.weight
}
}
fn color_bits(c: Color) -> (u8, u8, u8, u8) { (c.r, c.g, c.b, c.a) }
impl Span {
pub fn new(range: (usize, usize)) -> Self {
Self { range, color: None, weight: None }
}
pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
pub fn weight(mut self, w: FontWeight) -> Self { self.weight = Some(w); self }
}
pub type SpanFn = dyn Fn(&str, Option<(usize, usize)>) -> Vec<Span> + Send + Sync;
pub fn style_runs(spans: &[Span], ls: usize, le: usize) -> Vec<(usize, usize, Option<Color>, Option<FontWeight>)> {
if ls >= le {
return Vec::new();
}
let mut points: Vec<usize> = vec![ls, le];
for s in spans {
if s.range.0 > ls && s.range.0 < le { points.push(s.range.0); }
if s.range.1 > ls && s.range.1 < le { points.push(s.range.1); }
}
points.sort_unstable();
points.dedup();
points.windows(2).map(|w| {
let (a, b) = (w[0], w[1]);
let cover = spans.iter().rev().find(|s| s.range.0 <= a && s.range.1 >= b);
(a, b, cover.and_then(|s| s.color), cover.and_then(|s| s.weight))
}).collect()
}
pub type CursorPainter = Arc<dyn Fn(&mut super::PaintCtx, Rect) + Send + Sync>;
#[derive(Clone)]
pub enum CursorShape {
Bar,
Block,
Underline,
Custom(CursorPainter),
}
impl std::fmt::Debug for CursorShape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CursorShape::Bar => write!(f, "Bar"),
CursorShape::Block => write!(f, "Block"),
CursorShape::Underline => write!(f, "Underline"),
CursorShape::Custom(_) => write!(f, "Custom(..)"),
}
}
}
impl PartialEq for CursorShape {
fn eq(&self, other: &Self) -> bool {
matches!(
(self, other),
(CursorShape::Bar, CursorShape::Bar)
| (CursorShape::Block, CursorShape::Block)
| (CursorShape::Underline, CursorShape::Underline)
) }
}
#[derive(Clone, Debug)]
pub struct CursorStyle {
pub width: f32,
pub color: Color,
pub corner_radius: f32,
pub blink_rate: f32,
pub shape: CursorShape,
}
impl PartialEq for CursorStyle {
fn eq(&self, other: &Self) -> bool {
self.width == other.width
&& color_bits(self.color) == color_bits(other.color)
&& self.corner_radius == other.corner_radius
&& self.blink_rate == other.blink_rate
&& self.shape == other.shape
}
}
impl Default for CursorStyle {
fn default() -> Self {
Self {
width: 1.5,
color: Color::rgb(180, 160, 255),
corner_radius: 0.0,
blink_rate: 0.53,
shape: CursorShape::Bar,
}
}
}
#[derive(Clone)]
pub enum InputFilter {
MaxLength(usize),
CharClass(Arc<dyn Fn(char) -> bool + Send + Sync>),
}
impl InputFilter {
pub fn max_length(n: usize) -> Self { InputFilter::MaxLength(n) }
pub fn char_class(f: impl Fn(char) -> bool + Send + Sync + 'static) -> Self {
InputFilter::CharClass(Arc::new(f))
}
pub fn digits() -> Self { Self::char_class(|c| c.is_ascii_digit()) }
pub fn alphanumeric() -> Self { Self::char_class(|c| c.is_alphanumeric()) }
}
pub fn apply_filters(value: &str, filters: &[InputFilter]) -> String {
let mut v = value.to_string();
for f in filters {
v = match f {
InputFilter::CharClass(pred) => v.chars().filter(|&c| pred(c)).collect(),
InputFilter::MaxLength(n) => v.chars().take(*n).collect(),
};
}
v
}
pub struct EditableDecl {
pub value: String,
pub rect: Rect,
pub multiline: bool,
pub obscure: bool,
pub on_change: Arc<dyn Fn(String) + Send + Sync>,
pub controller: Option<EditController>,
pub layout: TextLayoutSnapshot,
pub filters: Vec<InputFilter>,
}
const COALESCE_WINDOW_SECS: f32 = 0.5;
#[derive(Clone, Debug, PartialEq)]
struct UndoEntry {
inverse: Transaction,
selection_before: Selection,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct CoalesceInfo {
at: f32,
cursor_after: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextEditState {
pub selection: Selection,
pub last_edit_at: f32,
undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>,
coalesce: Option<CoalesceInfo>,
pub scroll_x: f32,
pub goal_x: Option<f32>,
pub last_edit_range: Option<(usize, usize)>,
pub ime_range: Option<(usize, usize)>,
ime_origin: Option<String>,
pub scrolled_cursor: Option<usize>,
}
impl Default for TextEditState {
fn default() -> Self {
Self {
selection: Selection::default(),
last_edit_at: 0.0,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
coalesce: None,
scroll_x: 0.0,
goal_x: None,
last_edit_range: None,
ime_range: None,
ime_origin: None,
scrolled_cursor: None,
}
}
}
impl TextEditState {
pub fn cursor(&self) -> usize {
self.selection.primary().head
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
let r = self.selection.primary();
if r.collapsed() { None } else { Some(r.normalized()) }
}
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn with_selection(&self, selection: Selection, now: f32) -> TextEditState {
moved(self, selection, now)
}
}
fn apply_and_record(
value: &str,
state: &TextEditState,
txn: Transaction,
new_selection: Selection,
now: f32,
coalesce_key: Option<usize>,
) -> (String, TextEditState) {
apply_and_record_with_inverse(value, state, txn, None, new_selection, now, coalesce_key)
}
fn apply_and_record_with_inverse(
value: &str,
state: &TextEditState,
txn: Transaction,
inverse_override: Option<Transaction>,
new_selection: Selection,
now: f32,
coalesce_key: Option<usize>,
) -> (String, TextEditState) {
let (new_value, auto_inverse) = txn.apply(value);
let inverse = inverse_override.unwrap_or(auto_inverse);
let can_coalesce = matches!(
(coalesce_key, &state.coalesce),
(Some(start), Some(info)) if start == info.cursor_after && (now - info.at) < COALESCE_WINDOW_SECS
);
let mut undo_stack = state.undo_stack.clone();
if can_coalesce {
if let (Some(top), Some(new_edit)) =
(undo_stack.last_mut().and_then(|e| e.inverse.edits.first_mut()), inverse.edits.first())
{
top.range.1 = new_edit.range.1;
}
} else {
undo_stack.push(UndoEntry { inverse, selection_before: state.selection.clone() });
}
let coalesce = coalesce_key.map(|_| CoalesceInfo { at: now, cursor_after: new_selection.primary().head });
let last_edit_range = edits_affected_range(&txn.edits);
let ns = TextEditState {
selection: new_selection,
last_edit_at: now,
undo_stack,
redo_stack: Vec::new(),
coalesce,
scroll_x: state.scroll_x,
goal_x: None,
last_edit_range,
ime_range: None,
ime_origin: None,
scrolled_cursor: state.scrolled_cursor,
};
(new_value, ns)
}
pub fn ime_set_preedit(
value: &str, state: &TextEditState, text: &str, cursor_in_text: Option<usize>, now: f32,
) -> (String, TextEditState) {
let (start, end) = state.ime_range.unwrap_or_else(|| state.selection.primary_range());
let origin = state.ime_origin.clone().unwrap_or_else(|| {
let sb = char_byte_offset(value, start);
let eb = char_byte_offset(value, end);
value[sb..eb].to_string()
});
let txn = Transaction::single((start, end), text);
let (new_value, _auto_inverse) = txn.apply(value);
let len = char_count(text);
let new_range = if len == 0 { None } else { Some((start, start + len)) };
let new_origin = if len == 0 { None } else { Some(origin) };
let cursor = start + cursor_in_text.unwrap_or(len).min(len);
let ns = TextEditState {
selection: Selection::single(cursor),
last_edit_at: now,
coalesce: None,
goal_x: None,
last_edit_range: Some((start, start + len)),
ime_range: new_range,
ime_origin: new_origin,
..state.clone()
};
(new_value, ns)
}
pub fn ime_commit(value: &str, state: &TextEditState, text: &str, now: f32) -> (String, TextEditState) {
let (start, end) = state.ime_range.unwrap_or_else(|| state.selection.primary_range());
let origin = state.ime_origin.clone().unwrap_or_else(|| {
let sb = char_byte_offset(value, start);
let eb = char_byte_offset(value, end);
value[sb..eb].to_string()
});
let txn = Transaction::single((start, end), text);
let committed_len = char_count(text);
let real_inverse = Transaction::single((start, start + committed_len), origin);
let cursor = start + committed_len;
let (new_value, ns) = apply_and_record_with_inverse(
value, state, txn, Some(real_inverse), Selection::single(cursor), now, None,
);
(new_value, TextEditState { ime_range: None, ime_origin: None, ..ns })
}
pub fn insert_str(value: &str, state: &TextEditState, text: &str, now: f32) -> (String, TextEditState) {
let (start, end) = state.selection.primary_range();
let txn = Transaction::single((start, end), text);
let new_cursor = start + char_count(text);
let coalesce_key = if start == end { Some(start) } else { None };
apply_and_record(value, state, txn, Selection::single(new_cursor), now, coalesce_key)
}
pub fn insert_char(value: &str, state: &TextEditState, ch: char, now: f32) -> (String, TextEditState) {
let mut buf = [0u8; 4];
insert_str(value, state, ch.encode_utf8(&mut buf), now)
}
pub fn replace_range(value: &str, state: &TextEditState, start: usize, end: usize, text: &str, now: f32) -> (String, TextEditState) {
let n = char_count(value);
let (s, e) = (start.min(n), end.min(n));
let (s, e) = (s.min(e), s.max(e));
let txn = Transaction::single((s, e), text);
let new_cursor = s + char_count(text);
apply_and_record(value, state, txn, Selection::single(new_cursor), now, None)
}
pub fn backspace(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
let (start, end) = state.selection.primary_range();
if start != end {
let txn = Transaction::single((start, end), "");
return apply_and_record(value, state, txn, Selection::single(start), now, None);
}
if start == 0 {
return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
}
let prev = prev_grapheme_boundary(value, start);
let txn = Transaction::single((prev, start), "");
apply_and_record(value, state, txn, Selection::single(prev), now, None)
}
pub fn delete_forward(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
let (start, end) = state.selection.primary_range();
if start != end {
let txn = Transaction::single((start, end), "");
return apply_and_record(value, state, txn, Selection::single(start), now, None);
}
let n = char_count(value);
if start >= n {
return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
}
let next = next_grapheme_boundary(value, start);
let txn = Transaction::single((start, next), "");
apply_and_record(value, state, txn, Selection::single(start), now, None)
}
pub fn delete_word_back(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
let (start, end) = state.selection.primary_range();
if start != end {
let txn = Transaction::single((start, end), "");
return apply_and_record(value, state, txn, Selection::single(start), now, None);
}
let prev = prev_word_boundary(value, start);
if prev == start {
return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
}
let txn = Transaction::single((prev, start), "");
apply_and_record(value, state, txn, Selection::single(prev), now, None)
}
pub fn delete_word_forward(value: &str, state: &TextEditState, now: f32) -> (String, TextEditState) {
let (start, end) = state.selection.primary_range();
if start != end {
let txn = Transaction::single((start, end), "");
return apply_and_record(value, state, txn, Selection::single(start), now, None);
}
let next = next_word_boundary(value, start);
if next == start {
return (value.to_string(), TextEditState { last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() });
}
let txn = Transaction::single((start, next), "");
apply_and_record(value, state, txn, Selection::single(start), now, None)
}
fn moved(state: &TextEditState, selection: Selection, now: f32) -> TextEditState {
TextEditState { selection, last_edit_at: now, coalesce: None, goal_x: None, last_edit_range: None, ime_range: None, ..state.clone() }
}
pub fn move_left(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
let sel = state.selection.primary();
if !extend && !sel.collapsed() {
return moved(state, Selection::single(sel.normalized().0), now);
}
let prev = prev_grapheme_boundary(value, sel.head);
let new_sel = if extend { Selection::range(sel.anchor, prev) } else { Selection::single(prev) };
moved(state, new_sel, now)
}
pub fn move_right(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
let sel = state.selection.primary();
if !extend && !sel.collapsed() {
return moved(state, Selection::single(sel.normalized().1), now);
}
let next = next_grapheme_boundary(value, sel.head);
let new_sel = if extend { Selection::range(sel.anchor, next) } else { Selection::single(next) };
moved(state, new_sel, now)
}
pub fn move_word_left(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
let sel = state.selection.primary();
let prev = prev_word_boundary(value, sel.head);
let new_sel = if extend { Selection::range(sel.anchor, prev) } else { Selection::single(prev) };
moved(state, new_sel, now)
}
pub fn move_word_right(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
let sel = state.selection.primary();
let next = next_word_boundary(value, sel.head);
let new_sel = if extend { Selection::range(sel.anchor, next) } else { Selection::single(next) };
moved(state, new_sel, now)
}
pub fn move_home(state: &TextEditState, extend: bool, now: f32) -> TextEditState {
let sel = state.selection.primary();
let new_sel = if extend { Selection::range(sel.anchor, 0) } else { Selection::single(0) };
moved(state, new_sel, now)
}
pub fn move_end(value: &str, state: &TextEditState, extend: bool, now: f32) -> TextEditState {
let n = char_count(value);
let sel = state.selection.primary();
let new_sel = if extend { Selection::range(sel.anchor, n) } else { Selection::single(n) };
moved(state, new_sel, now)
}
pub fn select_all(value: &str, state: &TextEditState, now: f32) -> TextEditState {
moved(state, Selection::range(0, char_count(value)), now)
}
pub fn selected_text(value: &str, state: &TextEditState) -> Option<String> {
state.selection_range().map(|(s, e)| {
let bs = char_byte_offset(value, s);
let be = char_byte_offset(value, e);
value[bs..be].to_string()
})
}
pub fn undo(value: &str, state: &TextEditState, now: f32) -> Option<(String, TextEditState)> {
let mut undo_stack = state.undo_stack.clone();
let entry = undo_stack.pop()?;
let last_edit_range = edits_affected_range(&entry.inverse.edits);
let (new_value, redo_inverse) = entry.inverse.apply(value);
let mut redo_stack = state.redo_stack.clone();
redo_stack.push(UndoEntry { inverse: redo_inverse, selection_before: state.selection.clone() });
Some((
new_value,
TextEditState {
selection: entry.selection_before, last_edit_at: now, undo_stack, redo_stack,
coalesce: None, scroll_x: state.scroll_x, goal_x: None, last_edit_range, ime_range: None, ime_origin: None,
scrolled_cursor: state.scrolled_cursor,
},
))
}
pub fn redo(value: &str, state: &TextEditState, now: f32) -> Option<(String, TextEditState)> {
let mut redo_stack = state.redo_stack.clone();
let entry = redo_stack.pop()?;
let last_edit_range = edits_affected_range(&entry.inverse.edits);
let (new_value, undo_inverse) = entry.inverse.apply(value);
let mut undo_stack = state.undo_stack.clone();
undo_stack.push(UndoEntry { inverse: undo_inverse, selection_before: state.selection.clone() });
Some((
new_value,
TextEditState {
selection: entry.selection_before, last_edit_at: now, undo_stack, redo_stack,
coalesce: None, scroll_x: state.scroll_x, goal_x: None, last_edit_range, ime_range: None, ime_origin: None,
scrolled_cursor: state.scrolled_cursor,
},
))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Command {
MoveLeft, MoveRight, MoveWordLeft, MoveWordRight, MoveHome, MoveEnd,
ExtendLeft, ExtendRight, ExtendWordLeft, ExtendWordRight, ExtendHome, ExtendEnd,
Backspace, DeleteForward, DeleteWordBack, DeleteWordForward,
SelectAll, Copy, Cut, Paste, Undo, Redo,
}
pub fn apply_command(value: &str, state: &TextEditState, cmd: Command, now: f32) -> Option<(String, TextEditState)> {
use Command::*;
Some(match cmd {
MoveLeft => (value.to_string(), move_left(value, state, false, now)),
ExtendLeft => (value.to_string(), move_left(value, state, true, now)),
MoveRight => (value.to_string(), move_right(value, state, false, now)),
ExtendRight => (value.to_string(), move_right(value, state, true, now)),
MoveWordLeft => (value.to_string(), move_word_left(value, state, false, now)),
ExtendWordLeft => (value.to_string(), move_word_left(value, state, true, now)),
MoveWordRight => (value.to_string(), move_word_right(value, state, false, now)),
ExtendWordRight => (value.to_string(), move_word_right(value, state, true, now)),
MoveHome => (value.to_string(), move_home(state, false, now)),
ExtendHome => (value.to_string(), move_home(state, true, now)),
MoveEnd => (value.to_string(), move_end(value, state, false, now)),
ExtendEnd => (value.to_string(), move_end(value, state, true, now)),
Backspace => backspace(value, state, now),
DeleteForward => delete_forward(value, state, now),
DeleteWordBack => delete_word_back(value, state, now),
DeleteWordForward => delete_word_forward(value, state, now),
SelectAll => (value.to_string(), select_all(value, state, now)),
Undo => return undo(value, state, now),
Redo => return redo(value, state, now),
Copy | Cut | Paste => return None,
})
}
static CONTROLLER_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug)]
pub enum ControllerOp {
ReplaceRange(usize, usize, String),
InsertAtCursor(String),
SetSelection(Selection),
SelectAll,
Undo,
Redo,
}
struct ControllerInner {
id: u64,
ops: Mutex<Vec<ControllerOp>>,
snapshot: Mutex<(String, Selection)>,
}
#[derive(Clone)]
pub struct EditController(Arc<ControllerInner>);
impl EditController {
pub fn new() -> Self {
Self(Arc::new(ControllerInner {
id: CONTROLLER_ID.fetch_add(1, Ordering::Relaxed),
ops: Mutex::new(Vec::new()),
snapshot: Mutex::new((String::new(), Selection::default())),
}))
}
pub fn id(&self) -> u64 {
self.0.id
}
fn enqueue(&self, op: ControllerOp) {
self.0.ops.lock().unwrap_or_else(|e| e.into_inner()).push(op);
rosace_state::request_frame();
}
pub fn replace_range(&self, start: usize, end: usize, text: impl Into<String>) {
self.enqueue(ControllerOp::ReplaceRange(start, end, text.into()));
}
pub fn insert_at_cursor(&self, text: impl Into<String>) {
self.enqueue(ControllerOp::InsertAtCursor(text.into()));
}
pub fn set_selection(&self, sel: Selection) {
self.enqueue(ControllerOp::SetSelection(sel));
}
pub fn select_all(&self) {
self.enqueue(ControllerOp::SelectAll);
}
pub fn undo(&self) {
self.enqueue(ControllerOp::Undo);
}
pub fn redo(&self) {
self.enqueue(ControllerOp::Redo);
}
pub fn value(&self) -> String {
self.0.snapshot.lock().unwrap_or_else(|e| e.into_inner()).0.clone()
}
pub fn selection(&self) -> Selection {
self.0.snapshot.lock().unwrap_or_else(|e| e.into_inner()).1.clone()
}
#[doc(hidden)]
pub fn take_ops(&self) -> Vec<ControllerOp> {
std::mem::take(&mut *self.0.ops.lock().unwrap_or_else(|e| e.into_inner()))
}
#[doc(hidden)]
pub fn update_snapshot(&self, value: String, selection: Selection) {
*self.0.snapshot.lock().unwrap_or_else(|e| e.into_inner()) = (value, selection);
}
}
impl Default for EditController {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for EditController {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EditController(id={})", self.0.id)
}
}
impl PartialEq for EditController {
fn eq(&self, other: &Self) -> bool {
self.0.id == other.0.id
}
}
#[cfg(test)]
mod tests {
use super::*;
fn st(cursor: usize) -> TextEditState {
TextEditState { selection: Selection::single(cursor), ..Default::default() }
}
fn st_sel(anchor: usize, head: usize) -> TextEditState {
TextEditState { selection: Selection::range(anchor, head), ..Default::default() }
}
#[test]
fn insert_char_at_end() {
let s = st(5);
let (v, ns) = insert_char("hello", &s, '!', 1.0);
assert_eq!(v, "hello!");
assert_eq!(ns.cursor(), 6);
assert_eq!(ns.last_edit_at, 1.0);
assert!(ns.selection_range().is_none());
}
#[test]
fn insert_char_in_middle() {
let (v, ns) = insert_char("helo", &st(3), 'l', 0.0);
assert_eq!(v, "hello");
assert_eq!(ns.cursor(), 4);
}
#[test]
fn insert_str_replaces_selection() {
let s = st_sel(6, 11); let (v, ns) = insert_str("hello world", &s, "there", 2.0);
assert_eq!(v, "hello there");
assert_eq!(ns.cursor(), 11);
assert!(ns.selection_range().is_none());
}
#[test]
fn insert_handles_multibyte_utf8_without_panicking() {
let (v, ns) = insert_char("café", &st(4), '!', 0.0);
assert_eq!(v, "café!");
assert_eq!(ns.cursor(), 5);
}
#[test]
fn backspace_removes_char_before_cursor() {
let (v, ns) = backspace("hello", &st(5), 1.0);
assert_eq!(v, "hell");
assert_eq!(ns.cursor(), 4);
}
#[test]
fn backspace_at_start_is_noop() {
let (v, ns) = backspace("hello", &st(0), 1.0);
assert_eq!(v, "hello");
assert_eq!(ns.cursor(), 0);
}
#[test]
fn backspace_deletes_selection_instead_of_one_char() {
let s = st_sel(1, 5);
let (v, ns) = backspace("hello", &s, 1.0);
assert_eq!(v, "h");
assert_eq!(ns.cursor(), 1);
assert!(ns.selection_range().is_none());
}
#[test]
fn delete_forward_removes_char_after_cursor() {
let (v, ns) = delete_forward("hello", &st(0), 1.0);
assert_eq!(v, "ello");
assert_eq!(ns.cursor(), 0);
}
#[test]
fn delete_forward_at_end_is_noop() {
let (v, _ns) = delete_forward("hello", &st(5), 1.0);
assert_eq!(v, "hello");
}
#[test]
fn move_left_decrements_and_clears_selection() {
let ns = move_left("hello", &st(3), false, 1.0);
assert_eq!(ns.cursor(), 2);
assert!(ns.selection_range().is_none());
}
#[test]
fn move_left_saturates_at_zero() {
let ns = move_left("hello", &st(0), false, 1.0);
assert_eq!(ns.cursor(), 0);
}
#[test]
fn move_left_without_extend_collapses_selection_to_start() {
let s = st_sel(1, 5);
let ns = move_left("hello", &s, false, 1.0);
assert_eq!(ns.cursor(), 1, "must jump to selection start, not head-1");
assert!(ns.selection_range().is_none());
}
#[test]
fn move_right_without_extend_collapses_selection_to_end() {
let s = st_sel(5, 1);
let ns = move_right("hello", &s, false, 1.0);
assert_eq!(ns.cursor(), 5);
assert!(ns.selection_range().is_none());
}
#[test]
fn move_right_extends_selection_from_fresh_anchor() {
let ns = move_right("hello", &st(2), true, 1.0);
assert_eq!(ns.cursor(), 3);
assert_eq!(ns.selection.primary().anchor, 2, "anchor seeds at the pre-move cursor");
}
#[test]
fn move_right_saturates_at_length() {
let ns = move_right("hi", &st(2), false, 1.0);
assert_eq!(ns.cursor(), 2);
}
#[test]
fn shift_arrow_sequence_grows_then_shrinks_selection() {
let s0 = st(2);
let s1 = move_right("hello world", &s0, true, 0.0);
let s2 = move_right("hello world", &s1, true, 0.0);
assert_eq!(s2.selection_range(), Some((2, 4)));
let s3 = move_left("hello world", &s2, true, 0.0);
assert_eq!(s3.selection_range(), Some((2, 3)));
}
#[test]
fn move_home_and_end() {
let h = move_home(&st(3), false, 1.0);
assert_eq!(h.cursor(), 0);
let e = move_end("hello", &st(0), false, 1.0);
assert_eq!(e.cursor(), 5);
}
#[test]
fn select_all_selects_full_range() {
let s = select_all("hello", &st(0), 1.0);
assert_eq!(s.cursor(), 5);
assert_eq!(s.selection_range(), Some((0, 5)));
}
#[test]
fn selected_text_extracts_the_right_substring() {
let s = st_sel(6, 11);
assert_eq!(selected_text("hello world", &s).as_deref(), Some("world"));
}
#[test]
fn selected_text_none_when_anchor_equals_cursor() {
let s = st(3);
assert_eq!(selected_text("hello", &s), None);
assert_eq!(s.selection_range(), None);
}
#[test]
fn selection_range_normalizes_backward_selection() {
let s = st_sel(5, 2); assert_eq!(s.selection_range(), Some((2, 5)));
}
#[test]
fn transaction_apply_and_invert_round_trips() {
let txn = Transaction::single((2, 2), "XY");
let (v1, inv) = txn.apply("hello");
assert_eq!(v1, "heXYllo");
let (v2, _) = inv.apply(&v1);
assert_eq!(v2, "hello", "applying the inverse must reconstruct the original exactly");
}
#[test]
fn undo_reverts_an_insertion_and_restores_prior_selection() {
let s0 = st(0);
let (v1, s1) = insert_str("", &s0, "hi", 1.0);
assert_eq!(v1, "hi");
assert!(s1.can_undo());
let (v2, s2) = undo(&v1, &s1, 2.0).expect("undo must produce a result");
assert_eq!(v2, "");
assert_eq!(s2.cursor(), 0, "must restore the pre-edit selection");
assert!(!s2.can_undo());
assert!(s2.can_redo());
}
#[test]
fn redo_reapplies_an_undone_edit() {
let s0 = st(0);
let (v1, s1) = insert_str("", &s0, "hi", 1.0);
let (v2, s2) = undo(&v1, &s1, 2.0).unwrap();
let (v3, s3) = redo(&v2, &s2, 3.0).expect("redo must produce a result");
assert_eq!(v3, "hi");
assert_eq!(s3.cursor(), 2);
assert!(s3.can_undo());
assert!(!s3.can_redo());
}
#[test]
fn undo_on_empty_stack_is_none() {
let s0 = st(0);
assert!(undo("hello", &s0, 1.0).is_none());
}
#[test]
fn a_real_edit_after_undo_clears_the_redo_stack() {
let s0 = st(0);
let (v1, s1) = insert_str("", &s0, "a", 1.0);
let (v2, s2) = undo(&v1, &s1, 2.0).unwrap();
assert!(s2.can_redo());
let (_, s3) = insert_str(&v2, &s2, "b", 3.0);
assert!(!s3.can_redo(), "a fresh edit must invalidate the old redo branch");
}
#[test]
fn consecutive_typing_coalesces_into_one_undo_unit() {
let s0 = st(0);
let (v1, s1) = insert_char("", &s0, 'a', 1.0);
let (v2, s2) = insert_char(&v1, &s1, 'b', 1.1);
let (v3, s3) = insert_char(&v2, &s2, 'c', 1.2);
assert_eq!(v3, "abc");
let (v4, s4) = undo(&v3, &s3, 2.0).expect("one undo");
assert_eq!(v4, "", "one undo must remove the WHOLE typed group");
assert_eq!(s4.cursor(), 0, "must restore the selection from BEFORE the whole group");
assert!(!s4.can_undo(), "the group must have been a single undo entry");
}
#[test]
fn typing_separated_by_a_pause_does_not_coalesce() {
let s0 = st(0);
let (v1, s1) = insert_char("", &s0, 'a', 0.0);
let (v2, s2) = insert_char(&v1, &s1, 'b', 0.0 + COALESCE_WINDOW_SECS + 0.01);
assert_eq!(v2, "ab");
let (v3, s3) = undo(&v2, &s2, 1.0).unwrap();
assert_eq!(v3, "a", "only the second, un-coalesced char should undo");
assert!(s3.can_undo(), "the first char's group must still be on the stack");
}
#[test]
fn typing_after_a_cursor_move_does_not_coalesce_with_earlier_typing() {
let s0 = st(0);
let (v1, s1) = insert_char("", &s0, 'a', 1.0);
let s1_moved = move_left("a", &s1, false, 1.05); let (v2, s2) = insert_char(&v1, &s1_moved, 'b', 1.06);
assert_eq!(v2, "ba");
let (v3, s3) = undo(&v2, &s2, 2.0).unwrap();
assert_eq!(v3, "a", "only the second char's group should undo");
assert!(s3.can_undo());
}
#[test]
fn replacing_a_selection_does_not_coalesce_with_prior_typing() {
let s0 = st(0);
let (v1, s1) = insert_str("", &s0, "hello", 1.0);
let s1_sel = TextEditState { selection: Selection::range(1, 3), ..s1.clone() };
let (v2, s2) = insert_str(&v1, &s1_sel, "X", 1.1);
assert_eq!(v2, "hXlo");
let (v3, _) = undo(&v2, &s2, 2.0).unwrap();
assert_eq!(v3, "hello", "undoing the selection-replace must not also undo the typed word");
}
#[test]
fn backspace_deletes_a_whole_zwj_family_emoji_in_one_press() {
let family = "👨\u{200D}👩\u{200D}👧\u{200D}👦";
let n = char_count(family);
let s = st(n);
let (v, ns) = backspace(family, &s, 1.0);
assert_eq!(v, "", "the whole cluster must vanish in one Backspace, not one char at a time");
assert_eq!(ns.cursor(), 0);
}
#[test]
fn move_left_steps_over_a_combining_accent_as_one_unit() {
let s = "e\u{0301}x"; assert_eq!(char_count(s), 3);
let state = st(3); let after_one_left = move_left(s, &state, false, 1.0);
assert_eq!(after_one_left.cursor(), 2, "must land after the é-cluster, before x");
let after_two_left = move_left(s, &after_one_left, false, 1.0);
assert_eq!(after_two_left.cursor(), 0, "the combining accent must not be a stop of its own");
}
#[test]
fn delete_forward_removes_a_flag_emoji_as_one_grapheme() {
let flag = "🇮🇳x";
let s = st(0);
let (v, ns) = delete_forward(flag, &s, 1.0);
assert_eq!(v, "x", "the flag must vanish as one unit, not one regional indicator at a time");
assert_eq!(ns.cursor(), 0);
}
#[test]
fn plain_ascii_grapheme_boundaries_match_char_boundaries() {
assert_eq!(grapheme_boundaries("abc"), vec![0, 1, 2, 3]);
}
#[test]
fn move_word_right_lands_at_the_end_of_the_next_word() {
let s = st(0);
let ns = move_word_right("hello world", &s, false, 1.0);
assert_eq!(ns.cursor(), 5);
let ns2 = move_word_right("hello world", &ns, false, 1.0);
assert_eq!(ns2.cursor(), 11);
}
#[test]
fn move_word_left_lands_at_the_start_of_the_previous_word() {
let s = st(11); let ns = move_word_left("hello world", &s, false, 1.0);
assert_eq!(ns.cursor(), 6);
let ns2 = move_word_left("hello world", &ns, false, 1.0);
assert_eq!(ns2.cursor(), 0);
}
#[test]
fn delete_word_back_removes_the_preceding_word() {
let s = st(11); let (v, ns) = delete_word_back("hello world", &s, 1.0);
assert_eq!(v, "hello ");
assert_eq!(ns.cursor(), 6);
}
#[test]
fn delete_word_forward_removes_the_following_word() {
let s = st(0);
let (v, ns) = delete_word_forward("hello world", &s, 1.0);
assert_eq!(v, " world");
assert_eq!(ns.cursor(), 0);
}
#[test]
fn extend_word_right_selects_through_a_word() {
let s = st(0);
let ns = move_word_right("hello world", &s, true, 1.0);
assert_eq!(ns.selection_range(), Some((0, 5)));
}
#[test]
fn apply_command_backspace_matches_the_direct_call() {
let s = st(5);
let (v1, s1) = apply_command("hello", &s, Command::Backspace, 1.0).unwrap();
let (v2, s2) = backspace("hello", &s, 1.0);
assert_eq!(v1, v2);
assert_eq!(s1.cursor(), s2.cursor());
}
#[test]
fn apply_command_clipboard_commands_return_none() {
let s = st(0);
assert!(apply_command("hello", &s, Command::Copy, 1.0).is_none());
assert!(apply_command("hello", &s, Command::Cut, 1.0).is_none());
assert!(apply_command("hello", &s, Command::Paste, 1.0).is_none());
}
#[test]
fn apply_command_undo_on_empty_history_returns_none() {
let s = st(0);
assert!(apply_command("hello", &s, Command::Undo, 1.0).is_none());
}
#[test]
fn edit_controller_replace_range_wraps_a_selection_like_a_toolbar_button() {
let value = "hello world";
let state = st_sel(6, 11);
let controller = EditController::new();
assert!(controller.take_ops().is_empty());
let (start, end) = state.selection_range().unwrap();
controller.replace_range(start, end, format!("**{}**", &value[start..end]));
let ops = controller.take_ops();
assert_eq!(ops.len(), 1);
let ControllerOp::ReplaceRange(s, e, text) = &ops[0] else { panic!("expected ReplaceRange") };
assert_eq!((*s, *e, text.as_str()), (6, 11, "**world**"));
let (new_value, new_state) = replace_range(value, &state, *s, *e, text, 1.0);
assert_eq!(new_value, "hello **world**");
assert_eq!(new_state.cursor(), 15);
controller.update_snapshot(new_value.clone(), new_state.selection.clone());
assert_eq!(controller.value(), "hello **world**");
}
#[test]
fn edit_controller_has_a_stable_id_distinct_from_other_controllers() {
let a = EditController::new();
let b = EditController::new();
assert_ne!(a.id(), b.id());
assert_eq!(a.clone().id(), a.id(), "cloning must share identity, not create a new controller");
}
#[test]
fn edit_controller_undo_redo_ops_enqueue_correctly() {
let c = EditController::new();
c.undo();
c.redo();
c.select_all();
let ops = c.take_ops();
assert_eq!(ops.len(), 3);
assert!(matches!(ops[0], ControllerOp::Undo));
assert!(matches!(ops[1], ControllerOp::Redo));
assert!(matches!(ops[2], ControllerOp::SelectAll));
}
type RunBits = (usize, usize, Option<(u8, u8, u8, u8)>, Option<FontWeight>);
fn runs_bits(runs: &[(usize, usize, Option<Color>, Option<FontWeight>)]) -> Vec<RunBits> {
runs.iter().map(|&(a, b, c, w)| (a, b, c.map(color_bits), w)).collect()
}
#[test]
fn style_runs_with_no_spans_is_one_default_run_covering_the_whole_line() {
let runs = style_runs(&[], 0, 10);
assert_eq!(runs_bits(&runs), vec![(0, 10, None, None)]);
}
#[test]
fn style_runs_splits_around_a_span_leaving_default_runs_in_the_gaps() {
let spans = vec![Span::new((8, 13)).color(Color::rgb(255, 0, 0))];
let runs = style_runs(&spans, 0, 15);
assert_eq!(runs_bits(&runs), vec![
(0, 8, None, None),
(8, 13, Some((255, 0, 0, 255)), None),
(13, 15, None, None),
]);
}
#[test]
fn style_runs_clips_a_span_that_extends_past_the_requested_range() {
let spans = vec![Span::new((3, 20)).weight(FontWeight::Bold)];
let runs = style_runs(&spans, 0, 5);
assert_eq!(runs_bits(&runs), vec![(0, 3, None, None), (3, 5, None, Some(FontWeight::Bold))]);
}
#[test]
fn style_runs_last_matching_span_wins_on_overlap() {
let spans = vec![
Span::new((0, 10)).color(Color::rgb(1, 1, 1)),
Span::new((0, 10)).color(Color::rgb(2, 2, 2)),
];
let runs = style_runs(&spans, 0, 10);
assert_eq!(runs_bits(&runs), vec![(0, 10, Some((2, 2, 2, 255)), None)]);
}
#[test]
fn style_runs_on_an_empty_range_returns_nothing() {
assert!(style_runs(&[], 5, 5).is_empty());
}
#[test]
fn cursor_style_default_matches_the_pre_step5_hardcoded_caret() {
let s = CursorStyle::default();
assert_eq!(s.width, 1.5);
assert_eq!(s.blink_rate, 0.53);
assert_eq!(s.shape, CursorShape::Bar);
}
#[test]
fn typing_sets_last_edit_range_to_just_the_inserted_text_not_the_whole_document() {
let value = "hello world, this is a long sentence";
let state = st(value.chars().count());
let (_, ns) = insert_char(value, &state, '!', 1.0);
assert_eq!(
ns.last_edit_range,
Some((value.chars().count(), value.chars().count() + 1)),
"an append must report only the newly inserted char's range, not (0, whole_len)"
);
}
#[test]
fn moving_the_cursor_clears_last_edit_range() {
let value = "hello";
let state = st(0);
let after_type = insert_char(value, &state, 'X', 1.0).1;
assert!(after_type.last_edit_range.is_some());
let after_move = move_right(value, &after_type, false, 1.0);
assert_eq!(after_move.last_edit_range, None, "a pure cursor move is not a content edit");
}
#[test]
fn ime_preedit_inserts_provisional_text_at_the_cursor() {
let (v, ns) = ime_set_preedit("hello ", &st(6), "に", None, 1.0);
assert_eq!(v, "hello に");
assert_eq!(ns.ime_range, Some((6, 7)));
assert_eq!(ns.cursor(), 7, "cursor defaults to the end of the preedit text");
}
#[test]
fn ime_preedit_does_not_touch_the_undo_stack() {
let s = st(0);
assert!(!s.can_undo());
let (_, ns) = ime_set_preedit("", &s, "に", None, 1.0);
assert!(!ns.can_undo(), "a preedit update must not create an undo entry");
}
#[test]
fn a_second_preedit_update_replaces_the_first_not_appends() {
let (v1, ns1) = ime_set_preedit("", &st(0), "に", None, 1.0);
assert_eq!(v1, "に");
let (v2, ns2) = ime_set_preedit(&v1, &ns1, "にほ", None, 1.0);
assert_eq!(v2, "にほ");
assert_eq!(ns2.ime_range, Some((0, 2)));
}
#[test]
fn ime_preedit_respects_the_platforms_cursor_position_within_the_text() {
let (_, ns) = ime_set_preedit("", &st(0), "にほん", Some(1), 1.0);
assert_eq!(ns.cursor(), 1, "cursor must land where the IME says, not always at the end");
}
#[test]
fn empty_preedit_clears_the_provisional_text_and_range() {
let (v1, ns1) = ime_set_preedit("", &st(0), "に", None, 1.0);
let (v2, ns2) = ime_set_preedit(&v1, &ns1, "", None, 1.0);
assert_eq!(v2, "");
assert_eq!(ns2.ime_range, None);
}
#[test]
fn ime_commit_finalizes_as_one_real_undoable_edit_and_clears_ime_range() {
let (v1, ns1) = ime_set_preedit("", &st(0), "に", None, 1.0);
let (v2, ns2) = ime_set_preedit(&v1, &ns1, "にほ", None, 1.0);
let (v3, ns3) = ime_commit(&v2, &ns2, "日本", 1.0);
assert_eq!(v3, "日本");
assert_eq!(ns3.ime_range, None);
assert_eq!(ns3.cursor(), char_count("日本"));
assert!(ns3.can_undo(), "commit must produce a real, undoable edit");
let (v4, ns4) = undo(&v3, &ns3, 2.0).expect("commit must be undoable");
assert_eq!(v4, "");
assert!(!ns4.can_undo(), "undoing the commit must remove the ONLY undo entry the whole composition produced");
}
#[test]
fn ime_commit_with_no_prior_preedit_replaces_the_selection_like_a_normal_insert() {
let (v, ns) = ime_commit("hello", &st_sel(1, 3), "X", 1.0);
assert_eq!(v, "hXlo");
assert_eq!(ns.cursor(), 2);
}
#[test]
fn apply_filters_with_no_filters_is_a_no_op() {
assert_eq!(apply_filters("hello", &[]), "hello");
}
#[test]
fn max_length_truncates_from_the_end() {
let f = [InputFilter::max_length(3)];
assert_eq!(apply_filters("hello", &f), "hel");
}
#[test]
fn max_length_leaves_a_shorter_value_untouched() {
let f = [InputFilter::max_length(10)];
assert_eq!(apply_filters("hi", &f), "hi");
}
#[test]
fn digits_strips_non_digit_characters() {
let f = [InputFilter::digits()];
assert_eq!(apply_filters("a1b2c3", &f), "123");
}
#[test]
fn alphanumeric_strips_punctuation_and_spaces() {
let f = [InputFilter::alphanumeric()];
assert_eq!(apply_filters("ab! 12-cd", &f), "ab12cd");
}
#[test]
fn custom_char_class_filter() {
let f = [InputFilter::char_class(|c| c == 'x' || c == 'y')];
assert_eq!(apply_filters("xayzbx", &f), "xyx");
}
#[test]
fn filters_apply_in_order() {
let f = [InputFilter::digits(), InputFilter::max_length(2)];
assert_eq!(apply_filters("a1b2c3", &f), "12");
}
}