idet-core 0.4.1

Editing logic for text editors, without a frontend
Documentation
//! Repeating one edit at several places in the text.
//!
//! The widget lets `egui::TextEdit` handle the caret the user types at and
//! keeps the others itself. Once the buffer has changed, [`taken`] reads what
//! the edit was and [`spread`] performs it again at every other caret, which is
//! what makes typing with several carets edit every one of them.

/// One change to a buffer: `removed` characters at `at` replaced by
/// `inserted`.
pub struct Edit {
    /// Character index the change starts at.
    pub at: usize,
    /// How many characters were taken out.
    pub removed: usize,
    /// What was put in their place.
    pub inserted: String,
}

/// What turned `before` into `after`, or [`None`] when they are equal.
///
/// The common prefix and suffix are stripped, so a change made anywhere in the
/// buffer comes back as one span — which is all a single caret can produce in
/// one frame.
#[must_use]
pub fn taken(before: &str, after: &str) -> Option<Edit> {
    if before == after {
        return None;
    }
    let before: Vec<char> = before.chars().collect();
    let after: Vec<char> = after.chars().collect();
    let prefix = before
        .iter()
        .zip(&after)
        .take_while(|(old, new)| old == new)
        .count();
    let rest = before.len().min(after.len()) - prefix;
    let suffix = before
        .iter()
        .rev()
        .zip(after.iter().rev())
        .take_while(|(old, new)| old == new)
        .count()
        .min(rest);
    Some(Edit {
        at: prefix,
        removed: before.len() - prefix - suffix,
        inserted: after
            .iter()
            .take(after.len() - suffix)
            .skip(prefix)
            .collect(),
    })
}

/// Performs `edit` again at every caret in `others` and moves them along.
///
/// `text` is the buffer the edit has already been applied to once, `before` is
/// where the typing caret sat beforehand — needed because the span alone does
/// not say whether the characters vanished to its left (backspace) or to its
/// right (delete), and the other carets have to lose theirs on the same side.
/// `primary` is where that caret sits now.
///
/// Returns where the typing caret ends up once every other edit is in, since
/// the ones before it push it along.
pub fn spread(
    text: &mut String,
    edit: &Edit,
    before: usize,
    primary: usize,
    others: &mut [usize],
) -> usize {
    let left = before.saturating_sub(edit.at).min(edit.removed);
    let right = edit.removed - left;
    let width = edit.inserted.chars().count();
    let delta = width.cast_signed() - edit.removed.cast_signed();
    for caret in others.iter_mut() {
        if *caret >= edit.at + edit.removed {
            *caret = shifted(*caret, delta);
        } else if *caret > edit.at {
            *caret = edit.at;
        }
    }
    others.sort_unstable();
    let mut characters: Vec<char> = text.chars().collect();
    let mut moved = primary;
    let mut shift = 0isize;
    for caret in others.iter_mut() {
        let at = shifted(*caret, shift);
        let start = at.saturating_sub(left);
        let end = (at + right).min(characters.len());
        characters.splice(start..end, edit.inserted.chars());
        *caret = start + width;
        if start < moved {
            moved = shifted(moved, delta);
        }
        shift += delta;
    }
    *text = characters.into_iter().collect();
    moved
}

fn shifted(index: usize, delta: isize) -> usize {
    usize::try_from(index.cast_signed() + delta).unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::{spread, taken};

    #[test]
    fn a_typed_character_is_the_span_it_added() {
        let edit = taken("ab", "axb").expect("the text changed");
        assert_eq!(edit.at, 1);
        assert_eq!(edit.removed, 0);
        assert_eq!(edit.inserted, "x");
    }

    #[test]
    fn a_backspace_is_the_span_it_took() {
        let edit = taken("axb", "ab").expect("the text changed");
        assert_eq!(edit.at, 1);
        assert_eq!(edit.removed, 1);
        assert_eq!(edit.inserted, "");
    }

    #[test]
    fn an_unchanged_buffer_is_no_edit() {
        assert!(taken("same", "same").is_none());
    }

    #[test]
    fn typing_reaches_every_other_caret() {
        let mut text = "aXbb".to_owned();
        let edit = taken("abb", "aXbb").expect("the text changed");
        let mut others = vec![2, 3];
        let primary = spread(&mut text, &edit, 1, 2, &mut others);
        assert_eq!(text, "aXbXbX");
        assert_eq!(others, vec![4, 6]);
        assert_eq!(primary, 2);
    }

    #[test]
    fn a_backspace_reaches_every_other_caret() {
        let mut text = "bcc".to_owned();
        let edit = taken("abcc", "bcc").expect("the text changed");
        let mut others = vec![3, 4];
        let primary = spread(&mut text, &edit, 1, 0, &mut others);
        assert_eq!(text, "b");
        assert_eq!(others, vec![1, 1]);
        assert_eq!(primary, 0);
    }

    #[test]
    fn carets_before_the_typing_one_push_it_along() {
        let mut text = "aXb".to_owned();
        let edit = taken("ab", "aXb").expect("the text changed");
        let mut others = vec![0];
        let primary = spread(&mut text, &edit, 1, 2, &mut others);
        assert_eq!(text, "XaXb");
        assert_eq!(others, vec![1]);
        assert_eq!(primary, 3);
    }
}