use ratatui::{
Frame,
layout::{Position, Rect},
style::Style,
};
use std::{cell::RefCell, fmt, sync::Arc};
use tui_textarea::{CursorMove, CursorRenderMode, TextArea, WrapMode};
pub(crate) const MAX_PROMPT_BYTES: usize = 1_048_576;
pub(crate) const MAX_VISIBLE_ROWS: u16 = 5;
pub(crate) const COLLAPSED_PASTE_MIN_LINES: usize = 3;
pub(crate) const COLLAPSED_PASTE_BYTE_THRESHOLD: usize = 800;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PromptEditOutcome {
Changed,
Unchanged,
Failed,
RejectedTooLarge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PromptMovementOutcome {
Moved,
Unchanged,
Failed,
}
pub(crate) const PROMPT_MOVEMENT_FAILED_STATUS: &str =
"Prompt editor could not reach that text position";
fn movement_outcome(result: Result<usize, ()>) -> PromptMovementOutcome {
match result {
Ok(0) => PromptMovementOutcome::Unchanged,
Ok(_) => PromptMovementOutcome::Moved,
Err(()) => PromptMovementOutcome::Failed,
}
}
pub(crate) type PromptEditResult = PromptEditOutcome;
pub(crate) const PROMPT_TOO_LARGE_STATUS: &str = "Prompt is limited to 1 MiB; edit rejected";
pub(crate) struct PromptEditor {
textarea: RefCell<TextArea<'static>>,
cursor_visible: bool,
pending_submission: Option<PendingSubmission>,
paste_atoms: Vec<PasteAtom>,
next_paste_id: u64,
semantic_history: SemanticHistory,
semantic_history_active: bool,
content_revision: u64,
}
struct PendingSubmission {
textarea: TextArea<'static>,
state: PromptSnapshot,
history: SemanticHistory,
semantic_history_active: bool,
}
#[derive(Clone, PartialEq, Eq)]
struct PasteAtom {
id: u64,
start: usize,
end: usize,
line_count: usize,
payload: Arc<str>,
}
impl PasteAtom {
fn new(id: u64, payload: String) -> Self {
let line_count = logical_line_count(&payload);
Self {
id,
start: 0,
end: 0,
line_count,
payload: Arc::from(payload),
}
}
fn label(&self) -> String {
format!("[Pasted #{} · {} lines]", self.id, self.line_count)
}
}
#[derive(Clone)]
struct PromptSnapshot {
visible: String,
atoms: Vec<PasteAtom>,
next_paste_id: u64,
cursor: usize,
}
#[derive(Clone)]
struct SemanticHistory {
snapshots: Vec<PromptSnapshot>,
position: usize,
max_edits: usize,
}
impl SemanticHistory {
fn new(max_edits: usize, snapshot: PromptSnapshot) -> Self {
Self {
snapshots: vec![snapshot],
position: 0,
max_edits,
}
}
fn from_snapshots(max_edits: usize, snapshots: Vec<PromptSnapshot>) -> Self {
let position = snapshots.len().saturating_sub(1);
Self {
snapshots,
position,
max_edits,
}
}
fn reset(&mut self, snapshot: PromptSnapshot) {
self.snapshots.clear();
self.snapshots.push(snapshot);
self.position = 0;
}
fn update_current_cursor(&mut self, cursor: usize) {
if let Some(snapshot) = self.snapshots.get_mut(self.position) {
snapshot.cursor = cursor;
}
}
fn record(&mut self, snapshot: PromptSnapshot) {
self.snapshots.truncate(self.position.saturating_add(1));
self.snapshots.push(snapshot);
self.position = self.snapshots.len().saturating_sub(1);
let max_states = self.max_edits.saturating_add(1);
if self.snapshots.len() > max_states {
let remove = self.snapshots.len() - max_states;
self.snapshots.drain(..remove);
self.position = self.position.saturating_sub(remove);
}
}
fn undo_target(&mut self) -> Option<PromptSnapshot> {
if self.position == 0 {
return None;
}
self.position -= 1;
self.snapshots.get(self.position).cloned()
}
fn redo_target(&mut self) -> Option<PromptSnapshot> {
if self.position + 1 >= self.snapshots.len() {
return None;
}
self.position += 1;
self.snapshots.get(self.position).cloned()
}
}
#[derive(Clone, Copy)]
enum BoundaryBias {
Backward,
Forward,
}
#[derive(Clone, Copy)]
enum DeleteOperation {
BackwardChar,
ForwardChar,
BackwardWord,
ForwardWord,
}
impl DeleteOperation {
fn atomic_direction(self) -> tui_textarea::AtomicDeleteDirection {
match self {
Self::BackwardChar | Self::BackwardWord => {
tui_textarea::AtomicDeleteDirection::Backward
}
Self::ForwardChar | Self::ForwardWord => tui_textarea::AtomicDeleteDirection::Forward,
}
}
fn apply(self, textarea: &mut TextArea<'_>) -> bool {
match self {
Self::BackwardChar => textarea.delete_char(),
Self::ForwardChar => textarea.delete_next_char(),
Self::BackwardWord => textarea.delete_word(),
Self::ForwardWord => textarea.delete_next_word(),
}
}
}
impl fmt::Debug for PromptEditor {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PromptEditor")
.field("visible_text", &self.visible_text())
.field("cursor", &self.cursor())
.field("cursor_visible", &self.cursor_visible)
.field("content_revision", &self.content_revision)
.finish()
}
}
impl Default for PromptEditor {
fn default() -> Self {
Self::new("", 0)
}
}
impl PromptEditor {
pub(crate) fn new(text: &str, cursor: usize) -> Self {
let mut editor = Self::empty();
if let Ok(normalized) = normalize_prompt_text_bounded(text) {
let cursor = normalized_cursor_offset(text, cursor, &normalized);
editor.set_canonical_text(&normalized, cursor);
}
editor
}
pub(crate) fn from_canonical(text: &str, cursor: usize) -> Self {
debug_assert!(text.len() <= MAX_PROMPT_BYTES);
let mut editor = Self::empty();
editor.set_canonical_text(text, cursor);
editor
}
fn empty() -> Self {
let textarea = configured_textarea();
let max_edits = textarea.max_histories();
let mut editor = Self {
textarea: RefCell::new(textarea),
cursor_visible: true,
pending_submission: None,
paste_atoms: Vec::new(),
next_paste_id: 1,
semantic_history: SemanticHistory::new(
max_edits,
PromptSnapshot {
visible: String::new(),
atoms: Vec::new(),
next_paste_id: 1,
cursor: 0,
},
),
semantic_history_active: false,
content_revision: 0,
};
editor.refresh_atomic_ranges();
editor
}
fn set_canonical_text(&mut self, text: &str, cursor: usize) {
let cursor = clamp_char_boundary(text, cursor).min(text.len());
self.textarea
.borrow_mut()
.set_lines(split_lines(text), cursor_for_byte_offset(text, cursor));
self.paste_atoms.clear();
self.next_paste_id = 1;
self.semantic_history_active = false;
self.refresh_atomic_ranges();
self.semantic_history.reset(self.semantic_snapshot());
}
#[cfg(test)]
pub(crate) fn new_with_visibility(text: &str, cursor: usize, visible: bool) -> Self {
let mut editor = Self::new(text, cursor);
editor.set_cursor_visible(visible);
editor
}
pub(crate) fn visible_text(&self) -> String {
self.textarea.borrow().lines().join("\n")
}
pub(crate) fn visible_cursor(&self) -> usize {
let textarea = self.textarea.borrow();
byte_offset_for_cursor(textarea.lines(), textarea.cursor())
}
pub(crate) fn text(&self) -> String {
let visible = self.visible_text();
expand_visible_range_with_atoms(&visible, &self.paste_atoms, 0, visible.len())
}
pub(crate) fn is_empty(&self) -> bool {
self.visible_text().is_empty()
}
pub(crate) fn cursor(&self) -> usize {
let visible = self.visible_cursor();
self.expanded_offset_for_visible_offset(visible, BoundaryBias::Forward)
}
pub(crate) fn set_cursor_visible(&mut self, visible: bool) {
self.cursor_visible = visible;
}
#[cfg(test)]
pub(crate) fn cursor_visible(&self) -> bool {
self.cursor_visible
}
pub(crate) fn toggle_cursor(&mut self) {
if self.is_empty() {
self.cursor_visible = !self.cursor_visible;
} else {
self.cursor_visible = true;
}
}
pub(crate) fn content_revision(&self) -> u64 {
self.content_revision
}
pub(crate) fn starts_with_bang(&self) -> bool {
self.text().starts_with('!')
}
fn content_equals(&self, text: &str) -> bool {
self.text() == text
}
fn bump_content_revision(&mut self) {
self.content_revision = self.content_revision.wrapping_add(1);
}
pub(crate) fn set_text(&mut self, text: &str, cursor: usize) -> PromptEditResult {
let normalized = match normalize_prompt_text_bounded(text) {
Ok(normalized) => normalized,
Err(result) => return result,
};
let cursor = normalized_cursor_offset(text, cursor, &normalized);
let content_changed = !self.content_equals(&normalized);
let representation_changed =
!self.paste_atoms.is_empty() || self.visible_text() != normalized;
let current_cursor = self.cursor();
if !representation_changed && current_cursor == cursor {
return PromptEditResult::Unchanged;
}
self.set_canonical_text(&normalized, cursor);
if content_changed {
self.bump_content_revision();
}
self.pending_submission = None;
self.cursor_visible = true;
PromptEditResult::Changed
}
pub(crate) fn clear(&mut self) -> PromptEditResult {
let content_changed = !self.text().is_empty();
if !content_changed && !self.selection_active() {
return PromptEditResult::Unchanged;
}
self.textarea
.borrow_mut()
.set_lines(vec![String::new()], (0, 0));
self.paste_atoms.clear();
self.next_paste_id = 1;
self.semantic_history_active = false;
self.refresh_atomic_ranges();
self.semantic_history.reset(self.semantic_snapshot());
if content_changed {
self.bump_content_revision();
}
self.pending_submission = None;
self.cursor_visible = true;
PromptEditResult::Changed
}
pub(crate) fn insert_char(
&mut self,
ch: char,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
let replacement = normalize_prompt_text(&ch.to_string());
if replacement.is_empty() {
return PromptEditResult::Unchanged;
}
self.replace_active_selection(&replacement, &replacement, None)
}
pub(crate) fn insert_paste(
&mut self,
text: &str,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
let replacement = match normalize_prompt_text_bounded(text) {
Ok(replacement) => replacement,
Err(result) => return result,
};
if replacement.is_empty() {
return PromptEditResult::Unchanged;
}
let (start, end) = self.selection_visible_bounds().unwrap_or_else(|| {
let cursor = self.visible_cursor();
(cursor, cursor)
});
let removed_len = self.expanded_visible_range(start, end).len();
let Some(current_len) = self.checked_expanded_text_len() else {
return PromptEditResult::RejectedTooLarge;
};
if !replacement_fits(current_len, removed_len, replacement.len()) {
return PromptEditResult::RejectedTooLarge;
}
let atom = self
.should_collapse_paste(start, end, &replacement)
.then(|| PasteAtom::new(self.next_paste_id, replacement.clone()));
let visible_replacement = atom
.as_ref()
.map(PasteAtom::label)
.unwrap_or_else(|| replacement.clone());
self.replace_active_selection(&visible_replacement, &replacement, atom)
}
pub(crate) fn backspace(&mut self, _visible_rows: u16, _wrap_width: u16) -> PromptEditResult {
self.edit(DeleteOperation::BackwardChar)
}
pub(crate) fn delete_forward(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
self.edit(DeleteOperation::ForwardChar)
}
pub(crate) fn move_left(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.move_character_left()
}
pub(crate) fn move_right(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.move_cursor(CursorMove::Forward)
}
pub(crate) fn move_up(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::Up);
}
pub(crate) fn move_down(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::Down);
}
pub(crate) fn move_word_left(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::WordBack);
}
pub(crate) fn move_word_right(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.move_cursor(CursorMove::WordForward);
}
pub(crate) fn extend_selection_left(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.extend_character_left()
}
pub(crate) fn extend_selection_right(
&mut self,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
self.extend_cursor(CursorMove::Forward)
}
pub(crate) fn extend_selection_up(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.extend_cursor(CursorMove::Up);
}
pub(crate) fn extend_selection_down(&mut self, _visible_rows: u16, _wrap_width: u16) {
self.extend_cursor(CursorMove::Down);
}
pub(crate) fn delete_word(&mut self) -> PromptEditResult {
self.edit(DeleteOperation::BackwardWord)
}
pub(crate) fn delete_next_word(&mut self) -> PromptEditResult {
self.edit(DeleteOperation::ForwardWord)
}
pub(crate) fn select_all(&mut self) {
let mut textarea = self.textarea.borrow_mut();
textarea.cancel_selection();
textarea.move_cursor(CursorMove::Top);
textarea.move_cursor(CursorMove::Head);
textarea.start_selection();
textarea.move_cursor(CursorMove::Bottom);
textarea.move_cursor(CursorMove::End);
drop(textarea);
self.cursor_visible = true;
}
pub(crate) fn move_to_byte(
&mut self,
byte: usize,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptMovementOutcome {
let byte = self.visible_offset_for_expanded_offset(byte, BoundaryBias::Forward);
let result = self.move_to_byte_inner(byte, false);
self.cursor_visible = true;
movement_outcome(result)
}
pub(crate) fn undo(&mut self, _visible_rows: u16, _wrap_width: u16) -> PromptEditResult {
if !self.semantic_history_active {
return self.edit_without_atom_tracking(|textarea| textarea.undo());
}
let Some(snapshot) = self.semantic_history.undo_target() else {
return PromptEditResult::Unchanged;
};
let before = self.text();
self.restore_semantic_snapshot(snapshot);
if before != self.text() {
self.bump_content_revision();
}
self.pending_submission = None;
self.cursor_visible = true;
PromptEditResult::Changed
}
pub(crate) fn redo(&mut self, _visible_rows: u16, _wrap_width: u16) -> PromptEditResult {
if !self.semantic_history_active {
return self.edit_without_atom_tracking(|textarea| textarea.redo());
}
let Some(snapshot) = self.semantic_history.redo_target() else {
return PromptEditResult::Unchanged;
};
let before = self.text();
self.restore_semantic_snapshot(snapshot);
if before != self.text() {
self.bump_content_revision();
}
self.pending_submission = None;
self.cursor_visible = true;
PromptEditResult::Changed
}
#[cfg(test)]
pub(crate) fn replace_byte_range(
&mut self,
start: usize,
end: usize,
replacement: &str,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
let replacement = match normalize_prompt_text_bounded(replacement) {
Ok(replacement) => replacement,
Err(result) => return result,
};
let text = self.text();
let start = clamp_char_boundary(&text, start);
let end = clamp_char_boundary(&text, end);
if start > end || (start == end && replacement.is_empty()) {
return PromptEditResult::Unchanged;
}
let (visible_start, visible_end) = self.visible_bounds_for_expanded_range(start, end);
self.replace_visible_byte_range_normalized(visible_start, visible_end, &replacement)
}
pub(crate) fn replace_visible_byte_range(
&mut self,
start: usize,
end: usize,
replacement: &str,
_visible_rows: u16,
_wrap_width: u16,
) -> PromptEditResult {
let replacement = match normalize_prompt_text_bounded(replacement) {
Ok(replacement) => replacement,
Err(result) => return result,
};
self.replace_visible_byte_range_normalized(start, end, &replacement)
}
fn replace_visible_byte_range_normalized(
&mut self,
start: usize,
end: usize,
replacement: &str,
) -> PromptEditResult {
let visible = self.visible_text();
let start = clamp_char_boundary(&visible, start);
let end = clamp_char_boundary(&visible, end);
if start > end || (start == end && replacement.is_empty()) {
return PromptEditResult::Unchanged;
}
let (start, end) = self.expanded_visible_bounds(start, end);
let removed_len = self.expanded_visible_range(start, end).len();
let Some(current_len) = self.checked_expanded_text_len() else {
return PromptEditResult::RejectedTooLarge;
};
if !replacement_fits(current_len, removed_len, replacement.len()) {
return PromptEditResult::RejectedTooLarge;
}
let rollback = self.textarea.borrow().clone();
if self.move_to_byte_inner(start, false).is_err() {
*self.textarea.borrow_mut() = rollback;
return PromptEditResult::Failed;
}
self.textarea.borrow_mut().start_selection();
if self.move_to_byte_inner(end, true).is_err() {
*self.textarea.borrow_mut() = rollback;
return PromptEditResult::Failed;
}
self.replace_active_selection(replacement, replacement, None)
}
pub(crate) fn take_for_submit(&mut self) -> String {
let state = self.semantic_snapshot();
let history = self.semantic_history.clone();
let semantic_history_active = self.semantic_history_active;
let mut textarea = self.textarea.borrow_mut();
let submitted = std::mem::replace(&mut *textarea, configured_textarea());
drop(textarea);
let prompt =
expand_visible_range_with_atoms(&state.visible, &state.atoms, 0, state.visible.len());
let content_changed = !prompt.is_empty();
self.pending_submission = Some(PendingSubmission {
textarea: submitted,
state,
history,
semantic_history_active,
});
self.paste_atoms.clear();
self.next_paste_id = 1;
self.semantic_history_active = false;
self.semantic_history.reset(self.semantic_snapshot());
if content_changed {
self.bump_content_revision();
}
self.cursor_visible = true;
prompt
}
pub(crate) fn restore_rejected_submit(&mut self, _text: String) -> PromptEditResult {
if let Some(submitted) = self.pending_submission.take() {
let before = self.text();
*self.textarea.borrow_mut() = submitted.textarea;
self.paste_atoms = submitted.state.atoms;
self.next_paste_id = submitted.state.next_paste_id;
self.semantic_history = submitted.history;
self.semantic_history_active = submitted.semantic_history_active;
self.refresh_atomic_ranges();
if before != self.text() {
self.bump_content_revision();
}
self.cursor_visible = true;
PromptEditResult::Changed
} else {
self.set_text(&_text, _text.len())
}
}
pub(crate) fn commit_submission(&mut self) {
self.pending_submission = None;
}
pub(crate) fn selection_active(&self) -> bool {
self.textarea.borrow().is_selecting()
}
pub(crate) fn selection_byte_range(&self) -> Option<(usize, usize)> {
let (start, end) = self.selection_byte_bounds()?;
if start >= end {
return None;
}
let (start, end) = self.expanded_visible_bounds(start, end);
Some((
self.expanded_offset_for_visible_offset(start, BoundaryBias::Backward),
self.expanded_offset_for_visible_offset(end, BoundaryBias::Forward),
))
}
pub(crate) fn selection_text(&self) -> Option<String> {
let (start, end) = self.selection_byte_range()?;
self.text().get(start..end).map(ToOwned::to_owned)
}
fn selection_byte_bounds(&self) -> Option<(usize, usize)> {
let textarea = self.textarea.borrow();
let ((start_row, start_col), (end_row, end_col)) = textarea.selection_range()?;
Some((
byte_offset_for_cursor(textarea.lines(), (start_row, start_col)),
byte_offset_for_cursor(textarea.lines(), (end_row, end_col)),
))
}
fn selection_visible_bounds(&self) -> Option<(usize, usize)> {
let (start, end) = self.selection_byte_bounds()?;
Some(self.expanded_visible_bounds(start, end))
}
pub(crate) fn start_selection_at(&mut self, byte: usize) -> PromptMovementOutcome {
let byte = self.visible_offset_for_expanded_offset(byte, BoundaryBias::Backward);
let result = self.move_to_byte_inner(byte, false);
if result.is_err() {
return PromptMovementOutcome::Failed;
}
self.textarea.borrow_mut().start_selection();
movement_outcome(result)
}
pub(crate) fn update_selection_at(&mut self, byte: usize) -> PromptMovementOutcome {
if !self.selection_active() {
return PromptMovementOutcome::Unchanged;
}
let bias = if byte < self.cursor() {
BoundaryBias::Backward
} else {
BoundaryBias::Forward
};
let byte = self.visible_offset_for_expanded_offset(byte, bias);
movement_outcome(self.move_to_byte_inner(byte, true))
}
pub(crate) fn finish_selection(&mut self) -> Option<String> {
let selected = self.selection_text().filter(|text| !text.is_empty());
self.textarea.borrow_mut().cancel_selection();
selected
}
pub(crate) fn cancel_selection(&mut self) -> bool {
let active = self.selection_active();
self.textarea.borrow_mut().cancel_selection();
active
}
pub(crate) fn visible_rows(&self, wrap_width: u16) -> u16 {
self.visual_rows(wrap_width)
.min(usize::from(MAX_VISIBLE_ROWS))
.max(1) as u16
}
pub(crate) fn visual_rows(&self, wrap_width: u16) -> usize {
usize::from(
self.textarea
.borrow_mut()
.measure(textarea_width_for_wrap(wrap_width))
.content_rows,
)
}
pub(crate) fn cursor_at_position(&self, position: Position) -> Option<usize> {
let visible = {
let textarea = self.textarea.borrow();
textarea
.cursor_at_position(position)
.map(|cursor| byte_offset_for_cursor(textarea.lines(), cursor))
}?;
let bias = if self.selection_active() && visible < self.visible_cursor() {
BoundaryBias::Backward
} else {
BoundaryBias::Forward
};
Some(self.expanded_offset_for_visible_offset(visible, bias))
}
#[cfg(test)]
pub(crate) fn rendered_cursor_position(&self) -> Option<Position> {
self.textarea.borrow().rendered_cursor_position()
}
pub(crate) fn render_with_focus(
&self,
frame: &mut Frame<'_>,
area: Rect,
focused: bool,
theme: crate::tui::theme::MissionControlTheme,
) {
if area.is_empty() {
return;
}
let cursor_position = {
let mut textarea = self.textarea.borrow_mut();
textarea.set_style(theme.pane());
textarea.set_selection_style(theme.selection());
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
frame.render_widget(&*textarea, area);
textarea.rendered_cursor_position()
};
if focused
&& self.cursor_visible
&& let Some(position) = cursor_position
{
frame.set_cursor_position(position);
}
}
fn replace_active_selection(
&mut self,
visible_replacement: &str,
expanded_replacement: &str,
atom: Option<PasteAtom>,
) -> PromptEditResult {
let (start, end) = self.selection_visible_bounds().unwrap_or_else(|| {
let cursor = self.visible_cursor();
(cursor, cursor)
});
let selected_len = self.expanded_visible_range(start, end).len();
if visible_replacement.is_empty() && selected_len == 0 {
return PromptEditResult::Unchanged;
}
let Some(current_len) = self.checked_expanded_text_len() else {
return PromptEditResult::RejectedTooLarge;
};
if !replacement_fits(current_len, selected_len, expanded_replacement.len()) {
return PromptEditResult::RejectedTooLarge;
}
let semantic_history_before = (!self.semantic_history_active && atom.is_some())
.then(|| self.semantic_history.clone());
if atom.is_some() {
self.activate_semantic_history();
}
self.prepare_semantic_edit();
let modified = self.textarea.borrow_mut().insert_str(visible_replacement);
if !modified {
if let Some(semantic_history) = semantic_history_before {
self.semantic_history = semantic_history;
self.semantic_history_active = false;
}
return PromptEditResult::Unchanged;
}
let new_end = start.saturating_add(visible_replacement.len());
self.update_atoms_for_visible_replacement(start, end, start, new_end, atom);
self.finish_content_edit();
PromptEditResult::Changed
}
fn edit(&mut self, operation: DeleteOperation) -> PromptEditResult {
self.prepare_semantic_edit();
let Some((old_start, old_end)) = self.deletion_range(operation) else {
return PromptEditResult::Unchanged;
};
let modified = operation.apply(&mut self.textarea.borrow_mut());
if modified {
self.update_atoms_for_visible_replacement(
old_start, old_end, old_start, old_start, None,
);
self.finish_content_edit();
PromptEditResult::Changed
} else {
PromptEditResult::Unchanged
}
}
fn deletion_range(&self, operation: DeleteOperation) -> Option<(usize, usize)> {
if let Some((start, end)) = self.selection_byte_bounds()
&& start < end
{
return Some(self.expanded_visible_bounds(start, end));
}
let atomic_range = {
let textarea = self.textarea.borrow();
textarea
.atomic_range_at_cursor(operation.atomic_direction())
.map(|range| {
(
byte_offset_for_cursor(textarea.lines(), (range.row, range.start_col)),
byte_offset_for_cursor(textarea.lines(), (range.row, range.end_col)),
)
})
};
if let Some(range) = atomic_range {
return Some(range);
}
let visible = self.visible_text();
let cursor = self.visible_cursor();
let (start, end) = ordinary_deletion_range(&visible, cursor, operation)?;
Some(self.expanded_visible_bounds(start, end))
}
fn edit_without_atom_tracking<F>(&mut self, operation: F) -> PromptEditResult
where
F: FnOnce(&mut TextArea<'static>) -> bool,
{
let modified = operation(&mut self.textarea.borrow_mut());
if modified {
self.bump_content_revision();
self.pending_submission = None;
self.cursor_visible = true;
PromptEditResult::Changed
} else {
PromptEditResult::Unchanged
}
}
fn move_character_left(&mut self) -> PromptMovementOutcome {
let collapse_to = self.selection_byte_bounds().map(|(start, _)| start);
let result = if let Some(byte) = collapse_to {
self.move_to_byte_inner(byte, false)
} else {
self.move_one_character(false, false)
};
self.cursor_visible = true;
movement_outcome(result)
}
fn extend_character_left(&mut self) -> PromptMovementOutcome {
let mut textarea = self.textarea.borrow_mut();
let snapshot = textarea.clone();
if !textarea.is_selecting() {
textarea.start_selection();
}
drop(textarea);
let result = self.move_one_character(false, true);
if result.is_err() {
*self.textarea.borrow_mut() = snapshot;
}
self.cursor_visible = true;
movement_outcome(result)
}
fn extend_cursor(&mut self, movement: CursorMove) -> PromptMovementOutcome {
let before = self.visible_cursor();
let mut textarea = self.textarea.borrow_mut();
let snapshot = textarea.clone();
if !textarea.is_selecting() {
textarea.start_selection();
}
drop(textarea);
let result = if matches!(movement, CursorMove::Forward | CursorMove::Back) {
self.move_one_character(matches!(movement, CursorMove::Forward), true)
} else {
let mut textarea = self.textarea.borrow_mut();
textarea.move_cursor(movement);
Ok(1)
};
if result.is_err() {
*self.textarea.borrow_mut() = snapshot;
}
self.cursor_visible = true;
if result.is_err() {
PromptMovementOutcome::Failed
} else if self.visible_cursor() == before {
PromptMovementOutcome::Unchanged
} else {
PromptMovementOutcome::Moved
}
}
fn move_cursor(&mut self, movement: CursorMove) -> PromptMovementOutcome {
let collapse_to = match movement {
CursorMove::Back | CursorMove::WordBack => {
self.selection_byte_bounds().map(|(start, _)| start)
}
CursorMove::Forward | CursorMove::WordForward => {
self.selection_byte_bounds().map(|(_, end)| end)
}
_ => None,
};
if let Some(byte) = collapse_to {
return movement_outcome(self.move_to_byte_inner(byte, false));
}
let before = self.visible_cursor();
let result = if matches!(movement, CursorMove::Forward | CursorMove::Back) {
self.move_one_character(matches!(movement, CursorMove::Forward), false)
} else {
let mut textarea = self.textarea.borrow_mut();
textarea.cancel_selection();
textarea.move_cursor(movement);
Ok(1)
};
self.cursor_visible = true;
if result.is_err() {
PromptMovementOutcome::Failed
} else if self.visible_cursor() == before {
PromptMovementOutcome::Unchanged
} else {
PromptMovementOutcome::Moved
}
}
fn move_to_byte_inner(&mut self, byte: usize, extend_selection: bool) -> Result<usize, ()> {
let target = {
let textarea = self.textarea.borrow();
cursor_for_byte_offset_lines(textarea.lines(), byte)
};
let mut textarea = self.textarea.borrow_mut();
let snapshot = (!extend_selection && textarea.is_selecting()).then(|| textarea.clone());
if !extend_selection {
textarea.cancel_selection();
}
let result = move_textarea_cursor_to(&mut textarea, target);
if result.is_err()
&& let Some(snapshot) = snapshot
{
*textarea = snapshot;
}
result
}
fn move_one_character(&mut self, forward: bool, extend_selection: bool) -> Result<usize, ()> {
let visible = self.visible_text();
let current = self.visible_cursor().min(visible.len());
let target = if forward {
let next = visible[current..]
.chars()
.next()
.map_or(current, |ch| current.saturating_add(ch.len_utf8()));
self.paste_atoms
.iter()
.find(|atom| atom.start <= current && current < atom.end)
.map_or_else(
|| {
self.paste_atoms
.iter()
.find(|atom| atom.start < next && next < atom.end)
.map_or(next, |atom| atom.end)
},
|atom| atom.end,
)
} else {
self.paste_atoms
.iter()
.find(|atom| atom.start < current && current <= atom.end)
.map_or_else(
|| {
visible[..current]
.chars()
.next_back()
.map_or(current, |ch| current.saturating_sub(ch.len_utf8()))
},
|atom| atom.start,
)
};
self.move_to_byte_inner(target, extend_selection)
}
fn checked_expanded_text_len(&self) -> Option<usize> {
let visible = self.visible_text();
let mut expanded_len: usize = 0;
let mut visible_cursor: usize = 0;
for atom in &self.paste_atoms {
if atom.start < visible_cursor || atom.start >= atom.end || atom.end > visible.len() {
return None;
}
expanded_len = expanded_len.checked_add(atom.start.checked_sub(visible_cursor)?)?;
expanded_len = expanded_len.checked_add(atom.payload.len())?;
visible_cursor = atom.end;
}
expanded_len.checked_add(visible.len().checked_sub(visible_cursor)?)
}
fn expanded_visible_range(&self, start: usize, end: usize) -> String {
let visible = self.visible_text();
expand_visible_range_with_atoms(&visible, &self.paste_atoms, start, end)
}
fn expanded_visible_bounds(&self, start: usize, end: usize) -> (usize, usize) {
let visible = self.visible_text();
let mut start = clamp_char_boundary(&visible, start);
let mut end = clamp_char_boundary(&visible, end);
for atom in &self.paste_atoms {
if atom.end <= start || atom.start >= end {
continue;
}
start = start.min(atom.start);
end = end.max(atom.end);
}
(start, end)
}
fn expanded_offset_for_visible_offset(&self, offset: usize, bias: BoundaryBias) -> usize {
let visible = self.visible_text();
let offset = clamp_char_boundary(&visible, offset);
let mut visible_cursor = 0;
let mut expanded_cursor = 0;
for atom in &self.paste_atoms {
if offset <= atom.start {
return expanded_cursor + (offset - visible_cursor);
}
expanded_cursor += atom.start - visible_cursor;
if offset < atom.end {
return match bias {
BoundaryBias::Backward => expanded_cursor,
BoundaryBias::Forward => expanded_cursor + atom.payload.len(),
};
}
expanded_cursor += atom.payload.len();
visible_cursor = atom.end;
}
expanded_cursor + (offset - visible_cursor)
}
fn visible_offset_for_expanded_offset(&self, offset: usize, bias: BoundaryBias) -> usize {
let visible = self.visible_text();
let Some(expanded_len) = self.checked_expanded_text_len() else {
return visible.len();
};
let offset = offset.min(expanded_len);
let mut visible_cursor = 0;
let mut expanded_cursor = 0;
for atom in &self.paste_atoms {
let ordinary_len = atom.start - visible_cursor;
let ordinary_end = expanded_cursor + ordinary_len;
if offset <= ordinary_end {
return clamp_char_boundary(&visible, visible_cursor + (offset - expanded_cursor));
}
expanded_cursor = ordinary_end;
let payload_end = expanded_cursor + atom.payload.len();
if offset < payload_end {
return match bias {
BoundaryBias::Backward => atom.start,
BoundaryBias::Forward => atom.end,
};
}
if offset == payload_end {
return atom.end;
}
expanded_cursor = payload_end;
visible_cursor = atom.end;
}
clamp_char_boundary(&visible, visible_cursor + (offset - expanded_cursor))
}
#[cfg(test)]
fn visible_bounds_for_expanded_range(&self, start: usize, end: usize) -> (usize, usize) {
(
self.visible_offset_for_expanded_offset(start, BoundaryBias::Backward),
self.visible_offset_for_expanded_offset(end, BoundaryBias::Forward),
)
}
fn semantic_snapshot(&self) -> PromptSnapshot {
PromptSnapshot {
visible: self.visible_text(),
atoms: self.paste_atoms.clone(),
next_paste_id: self.next_paste_id,
cursor: self.visible_cursor(),
}
}
fn restore_semantic_snapshot(&mut self, snapshot: PromptSnapshot) {
let cursor = snapshot.cursor.min(snapshot.visible.len());
self.textarea.borrow_mut().set_lines(
split_lines(&snapshot.visible),
cursor_for_byte_offset(&snapshot.visible, cursor),
);
self.paste_atoms = snapshot.atoms;
self.next_paste_id = snapshot.next_paste_id;
self.refresh_atomic_ranges();
}
fn activate_semantic_history(&mut self) {
if !self.semantic_history_active {
self.semantic_history = self.semantic_history_from_native();
self.semantic_history_active = true;
}
}
fn semantic_history_from_native(&self) -> SemanticHistory {
let (max_edits, mut probe) = {
let textarea = self.textarea.borrow();
(textarea.max_histories(), textarea.clone())
};
let mut snapshots = vec![self.semantic_snapshot()];
while probe.undo() {
snapshots.push(PromptSnapshot {
visible: probe.lines().join("\n"),
atoms: Vec::new(),
next_paste_id: 1,
cursor: byte_offset_for_cursor(probe.lines(), probe.cursor()),
});
}
snapshots.reverse();
SemanticHistory::from_snapshots(max_edits, snapshots)
}
fn prepare_semantic_edit(&mut self) {
if self.semantic_history_active {
self.semantic_history
.update_current_cursor(self.visible_cursor());
}
}
fn finish_content_edit(&mut self) {
if self.semantic_history_active {
self.semantic_history.record(self.semantic_snapshot());
}
self.bump_content_revision();
self.pending_submission = None;
self.cursor_visible = true;
}
fn update_atoms_for_visible_replacement(
&mut self,
old_start: usize,
old_end: usize,
new_start: usize,
new_end: usize,
inserted: Option<PasteAtom>,
) {
let mut atoms = self
.paste_atoms
.drain(..)
.filter_map(|mut atom| {
if atom.end <= old_start {
Some(atom)
} else if atom.start >= old_end {
let shift = new_end
.saturating_sub(new_start)
.saturating_sub(old_end.saturating_sub(old_start));
if new_end.saturating_sub(new_start) >= old_end.saturating_sub(old_start) {
atom.start = atom.start.saturating_add(shift);
atom.end = atom.end.saturating_add(shift);
} else {
let shrink = old_end
.saturating_sub(old_start)
.saturating_sub(new_end.saturating_sub(new_start));
atom.start = atom.start.saturating_sub(shrink);
atom.end = atom.end.saturating_sub(shrink);
}
Some(atom)
} else {
None
}
})
.collect::<Vec<_>>();
if let Some(mut atom) = inserted {
atom.start = new_start;
atom.end = new_end;
atoms.push(atom);
self.next_paste_id = self.next_paste_id.saturating_add(1);
}
atoms.sort_by_key(|atom| atom.start);
self.paste_atoms = atoms;
self.refresh_atomic_ranges();
}
fn refresh_atomic_ranges(&mut self) {
let visible = self.visible_text();
let ranges = self
.paste_atoms
.iter()
.filter_map(|atom| {
let start = cursor_for_byte_offset(&visible, atom.start);
let end = cursor_for_byte_offset(&visible, atom.end);
(start.0 == end.0 && start.1 < end.1).then_some(tui_textarea::AtomicRange {
row: start.0,
start_col: start.1,
end_col: end.1,
})
})
.collect::<Vec<_>>();
self.textarea.borrow_mut().set_atomic_ranges(ranges);
}
fn should_collapse_paste(&self, start: usize, end: usize, text: &str) -> bool {
let (start, end) = self.expanded_visible_bounds(start, end);
let current = self.text();
let expanded_start = self.expanded_offset_for_visible_offset(start, BoundaryBias::Backward);
let expanded_end = self.expanded_offset_for_visible_offset(end, BoundaryBias::Forward);
if expanded_start > expanded_end {
return false;
}
let (Some(prefix), Some(suffix)) =
(current.get(..expanded_start), current.get(expanded_end..))
else {
return false;
};
let mut prospective = String::with_capacity(
prefix
.len()
.checked_add(text.len())
.and_then(|length| suffix.len().checked_add(length))
.unwrap_or(0),
);
prospective.push_str(prefix);
prospective.push_str(text);
prospective.push_str(suffix);
if prospective.trim_start().starts_with('/') || prospective.starts_with('!') {
return false;
}
logical_line_count(text) >= COLLAPSED_PASTE_MIN_LINES
|| text.len() > COLLAPSED_PASTE_BYTE_THRESHOLD
}
}
fn configured_textarea() -> TextArea<'static> {
let mut textarea = TextArea::default();
let theme = crate::tui::theme::MissionControlTheme::default();
textarea.set_style(theme.pane());
textarea.set_selection_style(theme.selection());
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
textarea
}
fn split_lines(text: &str) -> Vec<String> {
text.split('\n').map(str::to_owned).collect()
}
fn logical_line_count(text: &str) -> usize {
text.split('\n').count().max(1)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum WordCharKind {
Space,
Punct,
Other,
}
impl WordCharKind {
fn new(ch: char) -> Self {
if ch.is_whitespace() {
Self::Space
} else if ch == '_' {
Self::Other
} else if ch.is_ascii_punctuation() {
Self::Punct
} else {
Self::Other
}
}
}
fn ordinary_deletion_range(
visible: &str,
cursor: usize,
operation: DeleteOperation,
) -> Option<(usize, usize)> {
let cursor = clamp_char_boundary(visible, cursor);
match operation {
DeleteOperation::BackwardChar | DeleteOperation::ForwardChar => {
if matches!(operation, DeleteOperation::BackwardChar) {
visible[..cursor]
.char_indices()
.next_back()
.map(|(start, _)| (start, cursor))
} else {
visible[cursor..]
.chars()
.next()
.map(|ch| (cursor, cursor.saturating_add(ch.len_utf8())))
}
}
DeleteOperation::BackwardWord | DeleteOperation::ForwardWord => {
let line_start = visible[..cursor]
.rfind('\n')
.map_or(0, |newline| newline.saturating_add(1));
let line_end = visible[cursor..]
.find('\n')
.map_or(visible.len(), |newline| cursor.saturating_add(newline));
let line = &visible[line_start..line_end];
let column = visible[line_start..cursor].chars().count();
match operation {
DeleteOperation::BackwardWord => {
if column > 0 {
let start_column = find_word_start_backward(line, column).unwrap_or(0);
Some((
line_start
.saturating_add(byte_offset_for_char_column(line, start_column)),
cursor,
))
} else if line_start > 0 {
Some((line_start - 1, cursor))
} else {
None
}
}
DeleteOperation::ForwardWord => {
let line_char_count = line.chars().count();
if column < line_char_count {
let end_column = find_word_exclusive_end_forward(line, column)
.unwrap_or(line_char_count);
Some((
cursor,
line_start
.saturating_add(byte_offset_for_char_column(line, end_column)),
))
} else if line_end < visible.len() {
Some((cursor, line_end + 1))
} else {
None
}
}
DeleteOperation::BackwardChar | DeleteOperation::ForwardChar => unreachable!(),
}
}
}
}
fn byte_offset_for_char_column(line: &str, column: usize) -> usize {
line.char_indices()
.nth(column)
.map_or(line.len(), |(offset, _)| offset)
}
fn find_word_start_backward(line: &str, start_column: usize) -> Option<usize> {
let byte = byte_offset_for_char_column(line, start_column);
let mut chars = line[..byte].chars().rev().enumerate();
let mut current = WordCharKind::new(chars.next()?.1);
for (index, ch) in chars {
let next = WordCharKind::new(ch);
if current != WordCharKind::Space && next != current {
return Some(start_column - index);
}
current = next;
}
(current != WordCharKind::Space).then_some(0)
}
fn find_word_exclusive_end_forward(line: &str, start_column: usize) -> Option<usize> {
let mut chars = line.chars().enumerate().skip(start_column);
let mut previous = WordCharKind::new(chars.next()?.1);
for (column, ch) in chars {
let current = WordCharKind::new(ch);
if previous != WordCharKind::Space && previous != current {
return Some(column);
}
previous = current;
}
None
}
fn expand_visible_range_with_atoms(
visible: &str,
atoms: &[PasteAtom],
start: usize,
end: usize,
) -> String {
let start = clamp_char_boundary(visible, start);
let end = clamp_char_boundary(visible, end)
.max(start)
.min(visible.len());
if start == end {
return String::new();
}
let mut expanded = String::new();
let mut cursor = start;
for atom in atoms {
if atom.end <= start {
continue;
}
if atom.start >= end {
break;
}
let ordinary_end = atom.start.min(end);
if cursor < ordinary_end {
expanded.push_str(&visible[cursor..ordinary_end]);
}
if atom.start < end && atom.end > start {
expanded.push_str(&atom.payload);
}
cursor = cursor.max(atom.end.min(end));
if cursor >= end {
break;
}
}
if cursor < end {
expanded.push_str(&visible[cursor..end]);
}
expanded
}
pub(crate) struct PromptTextNormalizer {
normalized: String,
max_bytes: usize,
pending_cr: bool,
overflowed: bool,
}
impl PromptTextNormalizer {
pub(crate) fn new() -> Self {
Self::with_limit(MAX_PROMPT_BYTES)
}
fn with_limit(max_bytes: usize) -> Self {
Self {
normalized: String::new(),
max_bytes,
pending_cr: false,
overflowed: false,
}
}
pub(crate) fn push_str(&mut self, text: &str) {
for ch in text.chars() {
self.push_char(ch);
}
}
pub(crate) fn push_char(&mut self, ch: char) {
if self.pending_cr {
self.pending_cr = false;
if ch == '\n' {
return;
}
}
if ch == '\r' {
self.pending_cr = true;
self.push_normalized_char('\n');
} else if ch == '\t'
|| ch == '\n'
|| !(('\u{0}'..='\u{1f}').contains(&ch) || ch == '\u{7f}')
{
self.push_normalized_char(ch);
}
}
fn push_normalized_char(&mut self, ch: char) {
if self.overflowed {
return;
}
let Some(new_len) = self.normalized.len().checked_add(ch.len_utf8()) else {
self.overflowed = true;
return;
};
if new_len > self.max_bytes {
self.overflowed = true;
} else {
self.normalized.push(ch);
}
}
pub(crate) fn finish(self) -> Result<String, PromptEditResult> {
if self.overflowed {
Err(PromptEditResult::RejectedTooLarge)
} else {
Ok(self.normalized)
}
}
}
pub(crate) fn normalize_prompt_text_bounded(text: &str) -> Result<String, PromptEditResult> {
let mut normalizer = PromptTextNormalizer::new();
normalizer.push_str(text);
normalizer.finish()
}
fn normalize_prompt_text(text: &str) -> String {
let mut normalizer = PromptTextNormalizer::with_limit(usize::MAX);
normalizer.push_str(text);
normalizer
.finish()
.expect("unbounded prompt normalization cannot exceed usize::MAX")
}
fn normalized_cursor_offset(original: &str, cursor: usize, normalized: &str) -> usize {
if cursor >= original.len() {
return normalized.len();
}
let cursor = clamp_char_boundary(original, cursor);
normalize_prompt_text(&original[..cursor]).len()
}
fn replacement_fits(current_len: usize, removed_len: usize, replacement_len: usize) -> bool {
current_len
.checked_sub(removed_len)
.and_then(|len| len.checked_add(replacement_len))
.is_some_and(|len| len <= MAX_PROMPT_BYTES)
}
fn textarea_width_for_wrap(wrap_width: u16) -> u16 {
wrap_width.saturating_add(1).max(1)
}
fn move_textarea_cursor_to(
textarea: &mut TextArea<'_>,
target: (usize, usize),
) -> Result<usize, ()> {
let atomic_ranges = textarea.atomic_ranges().to_vec();
let result = move_textarea_cursor_to_inner(textarea, target);
if atomic_ranges.is_empty() {
textarea.clear_atomic_ranges();
} else {
textarea.set_atomic_ranges(atomic_ranges);
}
result
}
fn move_textarea_cursor_to_inner(
textarea: &mut TextArea<'_>,
target: (usize, usize),
) -> Result<usize, ()> {
if textarea.cursor() == target {
return Ok(0);
}
let max = usize::from(u16::MAX);
if target.0 <= max
&& target.1 <= max
&& textarea.atomic_ranges().is_empty()
&& textarea.search_pattern().is_none()
{
textarea.move_cursor(CursorMove::Jump(target.0 as u16, target.1 as u16));
if textarea.cursor() == target {
return Ok(1);
}
}
let snapshot = textarea.clone();
let end = {
let lines = textarea.lines();
(
lines.len().saturating_sub(1),
lines.last().map_or(0, |line| line.chars().count()),
)
};
if target.0 <= max && target.1 > max && textarea.cursor().0 != target.0 {
textarea.move_cursor(CursorMove::Jump(target.0 as u16, u16::MAX));
if textarea.cursor().0 != target.0 {
*textarea = snapshot.clone();
}
}
if target > (max, max) {
let distance_from_end = {
let lines = textarea.lines();
logical_cursor_offset(lines, end).saturating_sub(logical_cursor_offset(lines, target))
};
if distance_from_end <= 16 {
textarea.move_cursor(CursorMove::Bottom);
textarea.move_cursor(CursorMove::End);
let mut remaining = distance_from_end;
while textarea.cursor() != target && remaining > 0 {
let before = textarea.cursor();
textarea.move_cursor(CursorMove::Back);
if textarea.cursor() >= before {
break;
}
remaining -= 1;
}
if textarea.cursor() == target {
return Ok(distance_from_end.saturating_add(2));
}
*textarea = snapshot.clone();
}
}
let current = textarea.cursor();
let forward = current < target;
if textarea.atomic_ranges().is_empty()
&& current.0 == target.0
&& current.1.abs_diff(target.1) > 1
{
let selection = textarea.selection_range();
let was_selecting = textarea.is_selecting();
let selection_collapsed = selection == Some((current, current));
let anchor = match selection {
Some((start, end)) if start != end => {
if current == start {
end
} else if current == end {
start
} else {
return move_textarea_cursor_with_snapshot(textarea, target, snapshot);
}
}
_ => current,
};
let range_start = current.1.min(target.1);
let range_end = current.1.max(target.1);
let anchor_inside = anchor.0 == current.0 && range_start < anchor.1 && anchor.1 < range_end;
let restore_collapsed = was_selecting && !selection_collapsed && target == anchor;
if anchor_inside {
let split_result: Result<usize, ()> = (|| {
let first = move_textarea_cursor_to(textarea, anchor)?;
let second = move_textarea_cursor_to(textarea, target)?;
Ok(first.saturating_add(second))
})();
textarea.clear_atomic_ranges();
if let Ok(calls) = split_result {
return Ok(calls);
}
*textarea = snapshot;
return Err(());
}
if !anchor_inside {
let distance = current.1.abs_diff(target.1);
let atomic_result = (|| {
if forward {
search_one_boundary(textarea, true)?;
textarea
.try_set_atomic_ranges([tui_textarea::AtomicRange {
row: current.0,
start_col: range_start,
end_col: range_end,
}])
.map_err(|_| ())?;
textarea.normalize_cursor_around_atomic_ranges(
tui_textarea::AtomicCursorBias::Forward,
);
} else if was_selecting && selection_collapsed {
search_one_boundary(textarea, false)?;
if distance == 2 {
search_one_boundary(textarea, false)?;
} else {
textarea
.try_set_atomic_ranges([tui_textarea::AtomicRange {
row: current.0,
start_col: target.1,
end_col: current.1 - 1,
}])
.map_err(|_| ())?;
search_one_boundary(textarea, false)?;
textarea.normalize_cursor_around_atomic_ranges(
tui_textarea::AtomicCursorBias::Backward,
);
}
} else {
textarea
.try_set_atomic_ranges([tui_textarea::AtomicRange {
row: current.0,
start_col: range_start,
end_col: range_end,
}])
.map_err(|_| ())?;
search_one_boundary(textarea, false)?;
textarea.normalize_cursor_around_atomic_ranges(
tui_textarea::AtomicCursorBias::Backward,
);
}
(textarea.cursor() == target).then_some(1).ok_or(())
})();
textarea.clear_atomic_ranges();
if atomic_result.is_ok() {
if restore_collapsed {
textarea.start_selection();
}
return atomic_result;
}
*textarea = snapshot.clone();
}
}
move_textarea_cursor_with_snapshot(textarea, target, snapshot)
}
fn move_textarea_cursor_with_snapshot<'a>(
textarea: &mut TextArea<'a>,
target: (usize, usize),
snapshot: TextArea<'a>,
) -> Result<usize, ()> {
let result = move_textarea_cursor_with_retry(textarea, target);
if result.is_err() {
*textarea = snapshot;
}
result
}
fn search_one_boundary(textarea: &mut TextArea<'_>, forward: bool) -> Result<(), ()> {
let before = logical_cursor_offset(textarea.lines(), textarea.cursor());
textarea.set_search_pattern("(?:)").map_err(|_| ())?;
let moved = if forward {
textarea.search_forward(false)
} else {
textarea.search_back(false)
};
let after = logical_cursor_offset(textarea.lines(), textarea.cursor());
let _ = textarea.set_search_pattern("");
if moved && after == before.saturating_add_signed(if forward { 1 } else { -1 }) {
Ok(())
} else {
Err(())
}
}
fn move_textarea_cursor_with_retry(
textarea: &mut TextArea<'_>,
target: (usize, usize),
) -> Result<usize, ()> {
let mut calls = 0;
if move_textarea_cursor_until(textarea, target, &mut calls) {
return Ok(calls);
}
if textarea.set_search_pattern("(?:)").is_err() {
return Err(());
}
let result = (|| {
let target_offset = logical_cursor_offset(textarea.lines(), target);
let mut remaining = target_offset
.abs_diff(logical_cursor_offset(textarea.lines(), textarea.cursor()))
.saturating_add(1);
while textarea.cursor() != target && remaining > 0 {
let before = textarea.cursor();
let forward = before < target;
let moved = if forward {
textarea.search_forward(false)
} else {
textarea.search_back(false)
};
calls += 1;
let after = textarea.cursor();
if !moved
|| after == before
|| (forward && (after <= before || after > target))
|| (!forward && (after >= before || after < target))
{
return Err(());
}
remaining -= 1;
}
(textarea.cursor() == target).then_some(calls).ok_or(())
})();
let _ = textarea.set_search_pattern("");
result
}
fn move_textarea_cursor_until(
textarea: &mut TextArea<'_>,
target: (usize, usize),
calls: &mut usize,
) -> bool {
let current = textarea.cursor();
let max = usize::from(u16::MAX);
if current.0 > max || current.1 > max || target.0 > max || target.1 > max {
return false;
}
let current_offset = logical_cursor_offset(textarea.lines(), current);
let target_offset = logical_cursor_offset(textarea.lines(), target);
let mut remaining = target_offset.abs_diff(current_offset).saturating_add(1);
loop {
let before = textarea.cursor();
if before == target {
return true;
}
if remaining == 0 {
return false;
}
textarea.move_cursor(if before < target {
CursorMove::Forward
} else {
CursorMove::Back
});
*calls += 1;
let after = textarea.cursor();
if after == target {
return true;
}
if after == before
|| (before < target && (after <= before || after > target))
|| (before > target && (after >= before || after < target))
{
return false;
}
remaining -= 1;
}
}
fn byte_offset_for_cursor(lines: &[String], (row, column): (usize, usize)) -> usize {
let row = row.min(lines.len().saturating_sub(1));
let mut offset = lines[..row].iter().map(|line| line.len() + 1).sum();
let line = &lines[row];
offset += line
.char_indices()
.nth(column)
.map(|(index, _)| index)
.unwrap_or(line.len());
offset
}
fn logical_cursor_offset(lines: &[String], (row, column): (usize, usize)) -> usize {
let row = row.min(lines.len().saturating_sub(1));
let preceding = lines[..row]
.iter()
.map(|line| line.chars().count().saturating_add(1))
.sum::<usize>();
preceding.saturating_add(column.min(lines[row].chars().count()))
}
fn cursor_for_byte_offset_lines(lines: &[String], byte: usize) -> (usize, usize) {
let mut line_start: usize = 0;
for (row, line) in lines.iter().enumerate() {
let line_end = line_start.saturating_add(line.len());
if byte <= line_end {
let local = clamp_line_char_boundary(line, byte.saturating_sub(line_start));
return (row, line[..local].chars().count());
}
line_start = line_end.saturating_add(1);
}
let last_row = lines.len().saturating_sub(1);
(last_row, lines[last_row].chars().count())
}
fn clamp_line_char_boundary(line: &str, byte: usize) -> usize {
let mut byte = byte.min(line.len());
while byte > 0 && !line.is_char_boundary(byte) {
byte -= 1;
}
byte
}
fn cursor_for_byte_offset(text: &str, byte: usize) -> (usize, usize) {
let byte = clamp_char_boundary(text, byte);
let mut row = 0;
let mut line_start = 0;
for segment in text.split_inclusive('\n') {
let line_end = line_start + segment.trim_end_matches('\n').len();
if byte <= line_end {
return (row, text[line_start..byte].chars().count());
}
line_start += segment.len();
row += 1;
}
(row, text[line_start..byte].chars().count())
}
pub(crate) fn clamp_char_boundary(text: &str, byte: usize) -> usize {
let mut byte = byte.min(text.len());
while byte > 0 && !text.is_char_boundary(byte) {
byte -= 1;
}
byte
}
#[cfg(test)]
mod tests;