use std::{fmt, ops::Range};
use super::motion;
pub const DEFAULT_INSERT_TEXT_BYTES_PER_FRAME: usize = 64 * 1024;
pub const INSERT_NEWLINE: &str = "\n";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct InsertState {
exit_policy: InsertExitPolicy,
}
impl InsertState {
#[must_use]
pub const fn new(exit_policy: InsertExitPolicy) -> Self {
Self { exit_policy }
}
#[must_use]
pub const fn exit_policy(self) -> InsertExitPolicy {
self.exit_policy
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InsertEntry {
AtCursor,
AfterCursor,
FirstNonBlank,
LineEnd,
OpenLineBelow,
OpenLineAbove,
}
impl InsertEntry {
#[must_use]
pub const fn insert_state(self) -> InsertState {
InsertState::new(InsertExitPolicy::MoveLeft)
}
#[must_use]
pub const fn opens_line(self) -> bool {
matches!(self, Self::OpenLineBelow | Self::OpenLineAbove)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum InsertExitPolicy {
#[default]
MoveLeft,
KeepCursor,
}
#[derive(Clone, Eq, PartialEq)]
pub enum InsertCommand {
InsertText(String),
InsertNewline,
DeleteBackward,
Exit,
}
impl InsertCommand {
#[must_use]
pub fn insert_text(text: impl Into<String>) -> Self {
Self::InsertText(text.into())
}
}
impl fmt::Debug for InsertCommand {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InsertText(text) => formatter
.debug_struct("InsertText")
.field("byte_len", &text.len())
.finish(),
Self::InsertNewline => formatter.write_str("InsertNewline"),
Self::DeleteBackward => formatter.write_str("DeleteBackward"),
Self::Exit => formatter.write_str("Exit"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InsertEntryCaret {
edit_byte_index: usize,
caret_byte_index: usize,
}
impl InsertEntryCaret {
const fn new(edit_byte_index: usize, caret_byte_index: usize) -> Self {
Self {
edit_byte_index,
caret_byte_index,
}
}
#[must_use]
pub const fn edit_byte_index(self) -> usize {
self.edit_byte_index
}
#[must_use]
pub const fn caret_byte_index(self) -> usize {
self.caret_byte_index
}
}
#[must_use]
pub fn resolve_insert_entry_caret(
text: &str,
cursor_byte_index: usize,
entry: InsertEntry,
) -> InsertEntryCaret {
let cursor = motion::clamp_to_boundary(text, cursor_byte_index);
match entry {
InsertEntry::AtCursor => InsertEntryCaret::new(cursor, cursor),
InsertEntry::AfterCursor => {
let caret = after_cursor_cell(text, cursor);
InsertEntryCaret::new(caret, caret)
}
InsertEntry::FirstNonBlank => {
let caret = first_non_blank(text, cursor);
InsertEntryCaret::new(caret, caret)
}
InsertEntry::LineEnd => {
let caret = line_content_end(text, cursor);
InsertEntryCaret::new(caret, caret)
}
InsertEntry::OpenLineBelow => {
let edit = line_content_end(text, cursor);
InsertEntryCaret::new(edit, edit + INSERT_NEWLINE.len())
}
InsertEntry::OpenLineAbove => {
let edit = line_start(text, cursor);
InsertEntryCaret::new(edit, edit)
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub enum InsertPlanEdit {
Insert {
byte_index: usize,
text: String,
},
Delete {
range: Range<usize>,
},
}
impl InsertPlanEdit {
#[must_use]
pub fn insert(byte_index: usize, text: impl Into<String>) -> Self {
Self::Insert {
byte_index,
text: text.into(),
}
}
#[must_use]
pub const fn delete(range: Range<usize>) -> Self {
Self::Delete { range }
}
}
impl fmt::Debug for InsertPlanEdit {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Insert { byte_index, text } => formatter
.debug_struct("Insert")
.field("byte_index", byte_index)
.field("byte_len", &text.len())
.finish(),
Self::Delete { range } => formatter
.debug_struct("Delete")
.field("range", range)
.finish(),
}
}
}
pub struct InsertEditPlanner<'text> {
text: &'text str,
caret: usize,
pending_insert_start: Option<usize>,
pending_insert: String,
edits: Vec<InsertPlanEdit>,
accepted_payload_bytes: usize,
max_payload_bytes: usize,
}
impl fmt::Debug for InsertEditPlanner<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("InsertEditPlanner")
.field("text_byte_len", &self.text.len())
.field("caret", &self.caret)
.field("pending_insert_start", &self.pending_insert_start)
.field("pending_insert_byte_len", &self.pending_insert.len())
.field("edits", &self.edits)
.field("accepted_payload_bytes", &self.accepted_payload_bytes)
.field("max_payload_bytes", &self.max_payload_bytes)
.finish()
}
}
impl<'text> InsertEditPlanner<'text> {
#[must_use]
pub fn new(text: &'text str, cursor_byte_index: usize, max_payload_bytes: usize) -> Self {
Self {
text,
caret: motion::clamp_to_boundary(text, cursor_byte_index),
pending_insert_start: None,
pending_insert: String::new(),
edits: Vec::new(),
accepted_payload_bytes: 0,
max_payload_bytes,
}
}
#[must_use]
pub const fn caret(&self) -> usize {
self.caret
}
pub fn push_insert(&mut self, text: &str) -> Result<(), InsertPayloadTooLarge> {
let next_bytes = self
.accepted_payload_bytes
.checked_add(text.len())
.ok_or(InsertPayloadTooLarge)?;
if next_bytes > self.max_payload_bytes {
return Err(InsertPayloadTooLarge);
}
if text.is_empty() {
return Ok(());
}
self.accepted_payload_bytes = next_bytes;
if self.pending_insert_start.is_none() {
self.pending_insert_start = Some(self.caret);
}
self.pending_insert.push_str(text);
self.caret += text.len();
Ok(())
}
pub fn delete_backward(&mut self) {
if self.pop_pending_insert_char() {
return;
}
self.flush_pending();
let Some(previous) = previous_boundary(self.text, self.caret) else {
return;
};
self.edits
.push(InsertPlanEdit::delete(previous..self.caret));
self.caret = previous;
}
#[must_use]
pub fn exit_caret(&self, exit_policy: InsertExitPolicy) -> usize {
match exit_policy {
InsertExitPolicy::KeepCursor => self.caret,
InsertExitPolicy::MoveLeft => self
.previous_pending_insert_char()
.unwrap_or_else(|| apply_exit_policy(self.text, self.caret, exit_policy)),
}
}
#[must_use]
pub fn into_edits(mut self) -> Vec<InsertPlanEdit> {
self.flush_pending();
self.edits
}
fn pop_pending_insert_char(&mut self) -> bool {
let Some(character) = self.pending_insert.pop() else {
self.pending_insert_start = None;
return false;
};
self.caret -= character.len_utf8();
if self.pending_insert.is_empty() {
self.pending_insert_start = None;
}
true
}
fn previous_pending_insert_char(&self) -> Option<usize> {
let start = self.pending_insert_start?;
let last_character = self.pending_insert.chars().last()?;
Some(start + self.pending_insert.len() - last_character.len_utf8())
}
fn flush_pending(&mut self) {
let Some(start) = self.pending_insert_start.take() else {
return;
};
if self.pending_insert.is_empty() {
return;
}
self.edits.push(InsertPlanEdit::insert(
start,
std::mem::take(&mut self.pending_insert),
));
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InsertPayloadTooLarge;
fn apply_exit_policy(text: &str, caret_byte_index: usize, exit_policy: InsertExitPolicy) -> usize {
match exit_policy {
InsertExitPolicy::MoveLeft => previous_cursor_cell(text, caret_byte_index)
.unwrap_or_else(|| motion::clamp_to_cursor_position(text, caret_byte_index)),
InsertExitPolicy::KeepCursor => motion::clamp_to_cursor_position(text, caret_byte_index),
}
}
fn previous_boundary(text: &str, index: usize) -> Option<usize> {
let index = motion::clamp_to_boundary(text, index);
if index == 0 {
return None;
}
text[..index]
.char_indices()
.last()
.map(|(byte_index, _character)| byte_index)
}
fn previous_cursor_cell(text: &str, caret_byte_index: usize) -> Option<usize> {
let caret = motion::clamp_to_boundary(text, caret_byte_index);
text[..caret]
.char_indices()
.rev()
.find_map(|(byte_index, character)| {
(character != '\n' || is_empty_line_start(text, byte_index)).then_some(byte_index)
})
.or_else(|| (!text.is_empty()).then_some(0))
}
fn after_cursor_cell(text: &str, cursor_byte_index: usize) -> usize {
let cursor = motion::clamp_to_boundary(text, cursor_byte_index);
text[cursor..]
.chars()
.next()
.filter(|character| *character != '\n')
.map_or(cursor, |character| cursor + character.len_utf8())
}
fn line_start(text: &str, index: usize) -> usize {
let index = motion::clamp_to_boundary(text, index);
text[..index]
.rfind('\n')
.map_or(0, |newline_index| newline_index + '\n'.len_utf8())
}
fn line_content_end(text: &str, index: usize) -> usize {
let index = motion::clamp_to_boundary(text, index);
text[index..]
.find('\n')
.map_or(text.len(), |newline_offset| index + newline_offset)
}
fn first_non_blank(text: &str, index: usize) -> usize {
let start = line_start(text, index);
let end = line_content_end(text, index);
text[start..end]
.char_indices()
.find_map(|(offset, character)| (!character.is_whitespace()).then_some(start + offset))
.unwrap_or(start)
}
fn is_empty_line_start(text: &str, byte_index: usize) -> bool {
(byte_index == 0 || text[..byte_index].ends_with('\n')) && text[byte_index..].starts_with('\n')
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_INSERT_TEXT_BYTES_PER_FRAME, InsertCommand, InsertEditPlanner, InsertEntry,
InsertExitPolicy, InsertPlanEdit, InsertState, resolve_insert_entry_caret,
};
#[test]
fn insert_state_defaults_to_vim_like_exit_policy() {
assert_eq!(
InsertState::default().exit_policy(),
InsertExitPolicy::MoveLeft
);
}
#[test]
fn insert_command_carries_text_without_redacted_diagnostics() {
assert_eq!(
InsertCommand::insert_text("alma"),
InsertCommand::InsertText(String::from("alma"))
);
assert!(!format!("{:?}", InsertCommand::insert_text("alma")).contains("alma"));
}
#[test]
fn line_open_entries_are_explicit() {
assert!(InsertEntry::OpenLineBelow.opens_line());
assert!(InsertEntry::OpenLineAbove.opens_line());
assert!(!InsertEntry::AtCursor.opens_line());
}
#[test]
fn append_entry_uses_caret_after_current_cell() {
assert_eq!(
resolve_insert_entry_caret("aλc", "a".len(), InsertEntry::AfterCursor)
.caret_byte_index(),
"aλ".len()
);
}
#[test]
fn line_entries_resolve_to_vim_carets() {
let text = " one\ntwo";
assert_eq!(
resolve_insert_entry_caret(text, 0, InsertEntry::FirstNonBlank).caret_byte_index(),
" ".len()
);
assert_eq!(
resolve_insert_entry_caret(text, 0, InsertEntry::LineEnd).caret_byte_index(),
" one".len()
);
assert_eq!(
resolve_insert_entry_caret(text, 0, InsertEntry::OpenLineBelow).edit_byte_index(),
" one".len()
);
assert_eq!(
resolve_insert_entry_caret(text, " one\n".len(), InsertEntry::OpenLineAbove)
.edit_byte_index(),
" one\n".len()
);
}
#[test]
fn planner_batches_contiguous_insert_text() {
let mut planner = InsertEditPlanner::new("abc", 1, DEFAULT_INSERT_TEXT_BYTES_PER_FRAME);
planner.push_insert("λ").unwrap();
planner.push_insert("x").unwrap();
planner.delete_backward();
assert!(!format!("{planner:?}").contains('λ'));
assert_eq!(planner.into_edits(), [InsertPlanEdit::insert(1, "λ")]);
}
#[test]
fn planner_keeps_backspaces_ordered_against_original_text() {
let mut planner = InsertEditPlanner::new("abc", 3, DEFAULT_INSERT_TEXT_BYTES_PER_FRAME);
planner.delete_backward();
planner.delete_backward();
assert_eq!(
planner.into_edits(),
[InsertPlanEdit::delete(2..3), InsertPlanEdit::delete(1..2)]
);
}
#[test]
fn planner_rejects_oversized_payloads() {
let mut planner = InsertEditPlanner::new("abc", 1, 1);
assert!(planner.push_insert("ab").is_err());
assert!(planner.into_edits().is_empty());
}
}