Files
Ely-GPUI-Components/src/forms/text/edit.rs
T

199 lines
5.8 KiB
Rust

use std::{
ops::Range,
time::{Duration, Instant},
};
use unicode_segmentation::UnicodeSegmentation;
pub(crate) fn prev_grapheme(text: &str, offset: usize) -> usize {
text.grapheme_indices(true)
.rev()
.find_map(|(ix, _)| (ix < offset).then_some(ix))
.unwrap_or(0)
}
pub(crate) fn next_grapheme(text: &str, offset: usize) -> usize {
text.grapheme_indices(true)
.find_map(|(ix, _)| (ix > offset).then_some(ix))
.unwrap_or(text.len())
}
fn words(text: &str) -> impl DoubleEndedIterator<Item = (usize, &str)> {
text.split_word_bound_indices()
.filter(|(_, word)| !word.trim().is_empty())
}
/// Start of the word before `offset`.
pub(crate) fn prev_word(text: &str, offset: usize) -> usize {
words(text)
.rev()
.find_map(|(ix, _)| (ix < offset).then_some(ix))
.unwrap_or(0)
}
/// End of the word after `offset`.
pub(crate) fn next_word(text: &str, offset: usize) -> usize {
words(text)
.find_map(|(ix, word)| (ix + word.len() > offset).then_some(ix + word.len()))
.unwrap_or(text.len())
}
/// The word around `offset`, for double-click selection.
pub(crate) fn word_at(text: &str, offset: usize) -> Range<usize> {
text.split_word_bound_indices()
.find(|(ix, word)| *ix <= offset && offset < ix + word.len())
.map(|(ix, word)| ix..ix + word.len())
.unwrap_or(offset..offset)
}
pub(crate) fn line_start(text: &str, offset: usize) -> usize {
text[..offset].rfind('\n').map_or(0, |ix| ix + 1)
}
pub(crate) fn line_end(text: &str, offset: usize) -> usize {
text[offset..]
.find('\n')
.map_or(text.len(), |ix| offset + ix)
}
pub(crate) fn to_utf16(text: &str, offset: usize) -> usize {
text[..offset].chars().map(char::len_utf16).sum()
}
pub(crate) fn from_utf16(text: &str, offset: usize) -> usize {
let mut units = 0;
for (ix, ch) in text.char_indices() {
if units >= offset {
return ix;
}
units += ch.len_utf16();
}
text.len()
}
/// Display offset of a content offset when each grapheme shows as `bullet`.
pub(crate) fn masked_offset(text: &str, offset: usize, bullet: char) -> usize {
text[..offset].graphemes(true).count() * bullet.len_utf8()
}
/// Content offset of a display offset in masked text.
pub(crate) fn unmasked_offset(text: &str, display: usize, bullet: char) -> usize {
let count = display / bullet.len_utf8();
text.grapheme_indices(true)
.nth(count)
.map_or(text.len(), |(ix, _)| ix)
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Snapshot {
pub text: String,
pub selection: Range<usize>,
}
/// Typing within this pause joins one undo step.
const JOIN: Duration = Duration::from_millis(900);
/// Undo and redo as whole snapshots of what an editor holds.
pub(crate) struct History<S> {
undo: Vec<S>,
redo: Vec<S>,
typing_since: Option<Instant>,
}
impl<S> Default for History<S> {
fn default() -> Self {
Self {
undo: Vec::new(),
redo: Vec::new(),
typing_since: None,
}
}
}
impl<S> History<S> {
/// Records the state before an edit. Typing in one burst makes one step.
pub fn record(&mut self, before: S, typing: bool) {
let joins = typing && self.typing_since.is_some_and(|at| at.elapsed() < JOIN);
if !joins {
self.undo.push(before);
}
self.typing_since = typing.then(Instant::now);
self.redo.clear();
}
pub fn undo(&mut self, current: S) -> Option<S> {
let previous = self.undo.pop()?;
self.redo.push(current);
self.typing_since = None;
Some(previous)
}
pub fn redo(&mut self, current: S) -> Option<S> {
let next = self.redo.pop()?;
self.undo.push(current);
self.typing_since = None;
Some(next)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn graphemes_keep_clusters_whole() {
let text = "ae\u{301}🇯🇵z";
assert_eq!(next_grapheme(text, 0), 1);
assert_eq!(next_grapheme(text, 1), 4);
assert_eq!(next_grapheme(text, 4), 12);
assert_eq!(prev_grapheme(text, 12), 4);
assert_eq!(prev_grapheme(text, 0), 0);
}
#[test]
fn words_skip_spaces_and_punctuation_stops() {
let text = "hello brave, new";
assert_eq!(next_word(text, 0), 5);
assert_eq!(next_word(text, 5), 12);
assert_eq!(prev_word(text, 17), 14);
assert_eq!(prev_word(text, 14), 12);
assert_eq!(word_at(text, 8), 7..12);
}
#[test]
fn lines_and_utf16_round_trip() {
let text = "one\ntwo😀\nthree";
assert_eq!(line_start(text, 6), 4);
assert_eq!(line_end(text, 6), 11);
let emoji_end = 11;
assert_eq!(to_utf16(text, emoji_end), 9);
assert_eq!(from_utf16(text, 9), emoji_end);
}
#[test]
fn masking_maps_graphemes_to_bullets() {
let text = "pa😀s";
assert_eq!(masked_offset(text, 6, '•'), 9);
assert_eq!(unmasked_offset(text, 9, '•'), 6);
assert_eq!(unmasked_offset(text, 12, '•'), text.len());
}
#[test]
fn typing_joins_one_step_and_redo_clears_on_edit() {
let snap = |text: &str| Snapshot {
text: text.into(),
selection: text.len()..text.len(),
};
let mut history = History::<Snapshot>::default();
history.record(snap(""), true);
history.record(snap("a"), true);
history.record(snap("ab"), false);
assert_eq!(history.undo(snap("")).unwrap(), snap("ab"));
assert_eq!(history.undo(snap("ab")).unwrap(), snap(""));
assert!(history.undo(snap("")).is_none());
assert_eq!(history.redo(snap("")).unwrap(), snap("ab"));
history.record(snap("ab"), false);
assert!(history.redo(snap("abc")).is_none());
}
}