Synced from monorepo
Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
903
crates/codegen/xai-ratatui-textarea/src/editor.rs
Normal file
903
crates/codegen/xai-ratatui-textarea/src/editor.rs
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation as _};
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
|
||||
#[path = "editor_keys.rs"]
|
||||
mod keys;
|
||||
|
||||
pub use keys::classify_key_event;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WordStyle {
|
||||
Small,
|
||||
WhitespaceDelimited,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EditCommand {
|
||||
Insert(char),
|
||||
MoveGraphemeLeft,
|
||||
MoveGraphemeRight,
|
||||
MoveWordLeft(WordStyle),
|
||||
MoveWordRight(WordStyle),
|
||||
MoveLogicalLineStart,
|
||||
MoveLogicalLineEnd,
|
||||
DeleteGraphemeBackward,
|
||||
DeleteGraphemeForward,
|
||||
DeleteWordBackward(WordStyle),
|
||||
DeleteWordForward(WordStyle),
|
||||
DeleteToLineStart,
|
||||
DeleteToLineEnd,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EditDelta {
|
||||
pub replaced_byte_range: Range<usize>,
|
||||
pub inserted_byte_range: Range<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditOutcome {
|
||||
Unchanged,
|
||||
CursorOnly,
|
||||
TextOnly(EditDelta),
|
||||
TextAndCursor(EditDelta),
|
||||
}
|
||||
|
||||
impl EditOutcome {
|
||||
fn from_changes(delta: Option<EditDelta>, cursor_changed: bool) -> Self {
|
||||
match (delta, cursor_changed) {
|
||||
(None, false) => Self::Unchanged,
|
||||
(None, true) => Self::CursorOnly,
|
||||
(Some(delta), false) => Self::TextOnly(delta),
|
||||
(Some(delta), true) => Self::TextAndCursor(delta),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PostEditCursorAffinity {
|
||||
Exact,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditPlan {
|
||||
replaced_byte_range: Range<usize>,
|
||||
replacement: String,
|
||||
removed_text: String,
|
||||
cursor_byte: usize,
|
||||
cursor_affinity: PostEditCursorAffinity,
|
||||
source_identity: Arc<BufferIdentity>,
|
||||
source_generation: u64,
|
||||
}
|
||||
|
||||
impl EditPlan {
|
||||
pub fn replaced_byte_range(&self) -> Range<usize> {
|
||||
self.replaced_byte_range.clone()
|
||||
}
|
||||
|
||||
pub fn replacement(&self) -> &str {
|
||||
&self.replacement
|
||||
}
|
||||
|
||||
pub fn removed_text(&self) -> &str {
|
||||
&self.removed_text
|
||||
}
|
||||
|
||||
pub fn cursor_byte(&self) -> usize {
|
||||
self.cursor_byte
|
||||
}
|
||||
|
||||
pub fn cursor_affinity(&self) -> PostEditCursorAffinity {
|
||||
self.cursor_affinity
|
||||
}
|
||||
|
||||
pub fn into_removed_text(self) -> String {
|
||||
self.removed_text
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApplyEditPlanError {
|
||||
StalePlan,
|
||||
InvalidRange,
|
||||
RemovedTextMismatch,
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SingleLineViewport {
|
||||
pub visible_byte_range: Range<usize>,
|
||||
pub cursor_display_column: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BufferIdentity;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EditBuffer {
|
||||
text: String,
|
||||
cursor_byte: usize,
|
||||
identity: Arc<BufferIdentity>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Default for EditBuffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
cursor_byte: 0,
|
||||
identity: Arc::new(BufferIdentity),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for EditBuffer {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
text: self.text.clone(),
|
||||
cursor_byte: self.cursor_byte,
|
||||
identity: Arc::new(BufferIdentity),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for EditBuffer {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.text == other.text && self.cursor_byte == other.cursor_byte
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for EditBuffer {}
|
||||
|
||||
impl EditBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_text(text: impl Into<String>) -> Self {
|
||||
let text = text.into();
|
||||
let cursor_byte = text.len();
|
||||
Self {
|
||||
text,
|
||||
cursor_byte,
|
||||
identity: Arc::new(BufferIdentity),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// External cursor requests use nearest grapheme boundaries; ties go left for determinism.
|
||||
pub fn from_parts(text: impl Into<String>, cursor_byte: usize) -> Self {
|
||||
let text = text.into();
|
||||
let cursor_byte = normalize_external_cursor(&text, cursor_byte);
|
||||
Self {
|
||||
text,
|
||||
cursor_byte,
|
||||
identity: Arc::new(BufferIdentity),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn into_text(self) -> String {
|
||||
self.text
|
||||
}
|
||||
|
||||
pub fn cursor_byte(&self) -> usize {
|
||||
self.cursor_byte
|
||||
}
|
||||
|
||||
/// External cursor requests use nearest grapheme boundaries; ties go left for determinism.
|
||||
#[must_use]
|
||||
pub fn set_cursor_byte(&mut self, cursor_byte: usize) -> EditOutcome {
|
||||
let old_cursor = self.cursor_byte;
|
||||
self.cursor_byte = normalize_external_cursor(&self.text, cursor_byte);
|
||||
let cursor_changed = self.cursor_byte != old_cursor;
|
||||
if cursor_changed {
|
||||
self.advance_generation();
|
||||
}
|
||||
EditOutcome::from_changes(None, cursor_changed)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn insert_str(&mut self, text: &str) -> EditOutcome {
|
||||
let plan = self.plan_replace_byte_range(self.cursor_byte..self.cursor_byte, text, &[]);
|
||||
self.apply_valid_plan(&plan)
|
||||
}
|
||||
|
||||
/// Edit-result cursors keep right affinity when adjacent text merges into one grapheme.
|
||||
#[must_use]
|
||||
pub fn replace_byte_range(&mut self, range: Range<usize>, replacement: &str) -> EditOutcome {
|
||||
let plan = self.plan_replace_byte_range(range, replacement, &[]);
|
||||
self.apply_valid_plan(&plan)
|
||||
}
|
||||
|
||||
pub fn plan_replace_byte_range(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
replacement: &str,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> EditPlan {
|
||||
let atomic_byte_ranges = normalize_atomic_ranges(&self.text, atomic_byte_ranges);
|
||||
let range = normalize_replacement_range(&self.text, range, &atomic_byte_ranges);
|
||||
let cursor_byte = normalize_cursor_for_atomic_ranges(self.cursor_byte, &atomic_byte_ranges);
|
||||
let next_cursor = if cursor_byte < range.start {
|
||||
cursor_byte
|
||||
} else if cursor_byte <= range.end {
|
||||
range.start + replacement.len()
|
||||
} else {
|
||||
cursor_byte - (range.end - range.start) + replacement.len()
|
||||
};
|
||||
self.make_plan(
|
||||
range,
|
||||
replacement.to_owned(),
|
||||
next_cursor,
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn plan_command(
|
||||
&self,
|
||||
command: EditCommand,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> EditPlan {
|
||||
let atomic_byte_ranges = normalize_atomic_ranges(&self.text, atomic_byte_ranges);
|
||||
let cursor_byte = normalize_cursor_for_atomic_ranges(self.cursor_byte, &atomic_byte_ranges);
|
||||
match command {
|
||||
EditCommand::Insert(character) => {
|
||||
let replacement = character.to_string();
|
||||
self.make_plan(
|
||||
cursor_byte..cursor_byte,
|
||||
replacement,
|
||||
cursor_byte + character.len_utf8(),
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
EditCommand::MoveGraphemeLeft => self.make_plan(
|
||||
cursor_byte..cursor_byte,
|
||||
String::new(),
|
||||
previous_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges),
|
||||
PostEditCursorAffinity::Exact,
|
||||
),
|
||||
EditCommand::MoveGraphemeRight => self.make_plan(
|
||||
cursor_byte..cursor_byte,
|
||||
String::new(),
|
||||
next_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges),
|
||||
PostEditCursorAffinity::Exact,
|
||||
),
|
||||
EditCommand::MoveWordLeft(style) => {
|
||||
let target = self.previous_word_boundary(style, cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
cursor_byte..cursor_byte,
|
||||
String::new(),
|
||||
target,
|
||||
PostEditCursorAffinity::Exact,
|
||||
)
|
||||
}
|
||||
EditCommand::MoveWordRight(style) => {
|
||||
let target = self.next_word_boundary(style, cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
cursor_byte..cursor_byte,
|
||||
String::new(),
|
||||
target,
|
||||
PostEditCursorAffinity::Exact,
|
||||
)
|
||||
}
|
||||
EditCommand::MoveLogicalLineStart => {
|
||||
let target = self.logical_line_start_target(cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
cursor_byte..cursor_byte,
|
||||
String::new(),
|
||||
target,
|
||||
PostEditCursorAffinity::Exact,
|
||||
)
|
||||
}
|
||||
EditCommand::MoveLogicalLineEnd => {
|
||||
let target = self.logical_line_end_target(cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
cursor_byte..cursor_byte,
|
||||
String::new(),
|
||||
target,
|
||||
PostEditCursorAffinity::Exact,
|
||||
)
|
||||
}
|
||||
EditCommand::DeleteGraphemeBackward => {
|
||||
let start = previous_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
start..cursor_byte,
|
||||
String::new(),
|
||||
start,
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
EditCommand::DeleteGraphemeForward => {
|
||||
let end = next_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
cursor_byte..end,
|
||||
String::new(),
|
||||
cursor_byte,
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
EditCommand::DeleteWordBackward(style) => {
|
||||
let start = self.previous_word_boundary(style, cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
start..cursor_byte,
|
||||
String::new(),
|
||||
start,
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
EditCommand::DeleteWordForward(style) => {
|
||||
let end = self.next_word_boundary(style, cursor_byte, &atomic_byte_ranges);
|
||||
self.make_plan(
|
||||
cursor_byte..end,
|
||||
String::new(),
|
||||
cursor_byte,
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
EditCommand::DeleteToLineStart => {
|
||||
let line_start = self.line_start_at(cursor_byte, &atomic_byte_ranges);
|
||||
let start = if cursor_byte == line_start {
|
||||
previous_atomic_boundary(&self.text, line_start, &atomic_byte_ranges)
|
||||
} else {
|
||||
line_start
|
||||
};
|
||||
self.make_plan(
|
||||
start..cursor_byte,
|
||||
String::new(),
|
||||
start,
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
EditCommand::DeleteToLineEnd => {
|
||||
let line_end = self.line_end_from(cursor_byte, &atomic_byte_ranges);
|
||||
let start = cursor_byte.min(line_end);
|
||||
let end = if cursor_byte >= line_end {
|
||||
self.line_ending_at(line_end)
|
||||
.map_or(line_end, |range| range.end)
|
||||
} else {
|
||||
line_end
|
||||
};
|
||||
self.make_plan(
|
||||
start..end,
|
||||
String::new(),
|
||||
start,
|
||||
PostEditCursorAffinity::Right,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_plan(&mut self, plan: &EditPlan) -> Result<EditOutcome, ApplyEditPlanError> {
|
||||
self.validate_plan(plan)?;
|
||||
Ok(self.apply_valid_plan(plan))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn apply(&mut self, command: EditCommand) -> EditOutcome {
|
||||
let plan = self.plan_command(command, &[]);
|
||||
self.apply_valid_plan(&plan)
|
||||
}
|
||||
|
||||
fn make_plan(
|
||||
&self,
|
||||
replaced_byte_range: Range<usize>,
|
||||
replacement: String,
|
||||
cursor_byte: usize,
|
||||
cursor_affinity: PostEditCursorAffinity,
|
||||
) -> EditPlan {
|
||||
let removed_text = self.text[replaced_byte_range.clone()].to_owned();
|
||||
EditPlan {
|
||||
replaced_byte_range,
|
||||
replacement,
|
||||
removed_text,
|
||||
cursor_byte,
|
||||
cursor_affinity,
|
||||
source_identity: Arc::clone(&self.identity),
|
||||
source_generation: self.generation,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_plan(&self, plan: &EditPlan) -> Result<(), ApplyEditPlanError> {
|
||||
if !Arc::ptr_eq(&plan.source_identity, &self.identity)
|
||||
|| plan.source_generation != self.generation
|
||||
{
|
||||
return Err(ApplyEditPlanError::StalePlan);
|
||||
}
|
||||
let range = &plan.replaced_byte_range;
|
||||
if range.start > range.end
|
||||
|| range.end > self.text.len()
|
||||
|| !self.text.is_char_boundary(range.start)
|
||||
|| !self.text.is_char_boundary(range.end)
|
||||
|| !is_grapheme_boundary(&self.text, range.start)
|
||||
|| !is_grapheme_boundary(&self.text, range.end)
|
||||
{
|
||||
return Err(ApplyEditPlanError::InvalidRange);
|
||||
}
|
||||
if self.text.get(range.clone()) != Some(plan.removed_text.as_str()) {
|
||||
return Err(ApplyEditPlanError::RemovedTextMismatch);
|
||||
}
|
||||
let Some(resulting_len) = self
|
||||
.text
|
||||
.len()
|
||||
.checked_sub(range.end - range.start)
|
||||
.and_then(|len| len.checked_add(plan.replacement.len()))
|
||||
else {
|
||||
return Err(ApplyEditPlanError::InvalidCursor);
|
||||
};
|
||||
if plan.cursor_byte > resulting_len {
|
||||
return Err(ApplyEditPlanError::InvalidCursor);
|
||||
}
|
||||
if plan.cursor_affinity == PostEditCursorAffinity::Exact
|
||||
&& (plan.replacement != plan.removed_text
|
||||
|| !is_grapheme_boundary(&self.text, plan.cursor_byte))
|
||||
{
|
||||
return Err(ApplyEditPlanError::InvalidCursor);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_valid_plan(&mut self, plan: &EditPlan) -> EditOutcome {
|
||||
let old_cursor = self.cursor_byte;
|
||||
let text_changed = plan.removed_text != plan.replacement;
|
||||
let inserted_len = plan.replacement.len();
|
||||
if text_changed {
|
||||
self.text
|
||||
.replace_range(plan.replaced_byte_range.clone(), &plan.replacement);
|
||||
}
|
||||
self.cursor_byte = match plan.cursor_affinity {
|
||||
PostEditCursorAffinity::Exact => plan.cursor_byte,
|
||||
PostEditCursorAffinity::Right => ceil_grapheme_boundary(&self.text, plan.cursor_byte),
|
||||
};
|
||||
let cursor_changed = self.cursor_byte != old_cursor;
|
||||
if text_changed || cursor_changed {
|
||||
self.advance_generation();
|
||||
}
|
||||
let delta = text_changed.then_some(EditDelta {
|
||||
inserted_byte_range: plan.replaced_byte_range.start
|
||||
..(plan.replaced_byte_range.start + inserted_len),
|
||||
replaced_byte_range: plan.replaced_byte_range.clone(),
|
||||
});
|
||||
EditOutcome::from_changes(delta, cursor_changed)
|
||||
}
|
||||
|
||||
fn advance_generation(&mut self) {
|
||||
if let Some(generation) = self.generation.checked_add(1) {
|
||||
self.generation = generation;
|
||||
} else {
|
||||
self.identity = Arc::new(BufferIdentity);
|
||||
self.generation = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn single_line_viewport(&self, display_width: usize) -> SingleLineViewport {
|
||||
self.single_line_viewport_with_atomic_ranges(display_width, &[])
|
||||
}
|
||||
|
||||
pub fn single_line_viewport_with_atomic_ranges(
|
||||
&self,
|
||||
display_width: usize,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> SingleLineViewport {
|
||||
let atomic_byte_ranges = normalize_atomic_ranges(&self.text, atomic_byte_ranges);
|
||||
let cursor_byte = self.cursor_byte;
|
||||
if display_width == 0 {
|
||||
return SingleLineViewport {
|
||||
visible_byte_range: cursor_byte..cursor_byte,
|
||||
cursor_display_column: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let line_start = self.line_start_at(cursor_byte, &atomic_byte_ranges);
|
||||
let line_end = self.line_end_from(cursor_byte, &atomic_byte_ranges);
|
||||
let left_budget = display_width - 1;
|
||||
let mut start = cursor_byte;
|
||||
let mut left_width = 0usize;
|
||||
while start > line_start {
|
||||
let previous = previous_atomic_boundary(&self.text, start, &atomic_byte_ranges);
|
||||
let grapheme_width = self.text[previous..start].width();
|
||||
let next_width = left_width.saturating_add(grapheme_width);
|
||||
if next_width > left_budget {
|
||||
break;
|
||||
}
|
||||
start = previous;
|
||||
left_width = next_width;
|
||||
}
|
||||
|
||||
let mut end = start;
|
||||
let mut visible_width = 0usize;
|
||||
while end < line_end {
|
||||
let next = next_atomic_boundary(&self.text, end, &atomic_byte_ranges);
|
||||
let grapheme_width = self.text[end..next].width();
|
||||
let next_width = visible_width.saturating_add(grapheme_width);
|
||||
if next_width > display_width {
|
||||
if end < cursor_byte {
|
||||
end = next;
|
||||
}
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
visible_width = next_width;
|
||||
}
|
||||
|
||||
SingleLineViewport {
|
||||
visible_byte_range: start..end,
|
||||
cursor_display_column: self.text[start..cursor_byte].width(),
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_word_boundary(
|
||||
&self,
|
||||
style: WordStyle,
|
||||
cursor_byte: usize,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> usize {
|
||||
let mut position = cursor_byte;
|
||||
while position > 0 {
|
||||
let previous = previous_atomic_boundary(&self.text, position, atomic_byte_ranges);
|
||||
if atomic_word_class(&self.text, previous, position, style, atomic_byte_ranges)
|
||||
== Some(WordClass::Whitespace)
|
||||
{
|
||||
position = previous;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if position == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let previous = previous_atomic_boundary(&self.text, position, atomic_byte_ranges);
|
||||
let target_class =
|
||||
atomic_word_class(&self.text, previous, position, style, atomic_byte_ranges);
|
||||
while position > 0 {
|
||||
let previous = previous_atomic_boundary(&self.text, position, atomic_byte_ranges);
|
||||
if atomic_word_class(&self.text, previous, position, style, atomic_byte_ranges)
|
||||
!= target_class
|
||||
{
|
||||
break;
|
||||
}
|
||||
position = previous;
|
||||
}
|
||||
position
|
||||
}
|
||||
|
||||
fn next_word_boundary(
|
||||
&self,
|
||||
style: WordStyle,
|
||||
cursor_byte: usize,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> usize {
|
||||
let mut position = cursor_byte;
|
||||
while position < self.text.len() {
|
||||
let next = next_atomic_boundary(&self.text, position, atomic_byte_ranges);
|
||||
if atomic_word_class(&self.text, position, next, style, atomic_byte_ranges)
|
||||
== Some(WordClass::Whitespace)
|
||||
{
|
||||
position = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if position == self.text.len() {
|
||||
return position;
|
||||
}
|
||||
|
||||
let next = next_atomic_boundary(&self.text, position, atomic_byte_ranges);
|
||||
let target_class = atomic_word_class(&self.text, position, next, style, atomic_byte_ranges);
|
||||
while position < self.text.len() {
|
||||
let next = next_atomic_boundary(&self.text, position, atomic_byte_ranges);
|
||||
if atomic_word_class(&self.text, position, next, style, atomic_byte_ranges)
|
||||
!= target_class
|
||||
{
|
||||
break;
|
||||
}
|
||||
position = next;
|
||||
}
|
||||
position
|
||||
}
|
||||
|
||||
fn logical_line_start_target(
|
||||
&self,
|
||||
cursor_byte: usize,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> usize {
|
||||
let line_start = self.line_start_at(cursor_byte, atomic_byte_ranges);
|
||||
if cursor_byte == line_start && line_start > 0 {
|
||||
let previous_line_end =
|
||||
previous_atomic_boundary(&self.text, line_start, atomic_byte_ranges);
|
||||
self.line_start_at(previous_line_end, atomic_byte_ranges)
|
||||
} else {
|
||||
line_start
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_line_end_target(
|
||||
&self,
|
||||
cursor_byte: usize,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> usize {
|
||||
let line_end = self.line_end_from(cursor_byte, atomic_byte_ranges);
|
||||
if cursor_byte == line_end {
|
||||
self.line_ending_at(line_end).map_or(line_end, |range| {
|
||||
self.line_end_from(range.end, atomic_byte_ranges)
|
||||
})
|
||||
} else {
|
||||
line_end
|
||||
}
|
||||
}
|
||||
|
||||
fn line_start_at(&self, cursor_byte: usize, atomic_byte_ranges: &[Range<usize>]) -> usize {
|
||||
let cursor_byte = cursor_byte.min(self.text.len());
|
||||
(0..cursor_byte)
|
||||
.rev()
|
||||
.find(|position| {
|
||||
self.text.as_bytes()[*position] == b'\n'
|
||||
&& !byte_is_inside_atomic_range(*position, atomic_byte_ranges)
|
||||
})
|
||||
.map_or(0, |position| position + 1)
|
||||
}
|
||||
|
||||
fn line_end_from(&self, cursor_byte: usize, atomic_byte_ranges: &[Range<usize>]) -> usize {
|
||||
let cursor_byte = cursor_byte.min(self.text.len());
|
||||
(cursor_byte..self.text.len())
|
||||
.find(|position| {
|
||||
self.text.as_bytes()[*position] == b'\n'
|
||||
&& !byte_is_inside_atomic_range(*position, atomic_byte_ranges)
|
||||
})
|
||||
.map_or(self.text.len(), |line_feed| {
|
||||
if line_feed > 0 && self.text.as_bytes()[line_feed - 1] == b'\r' {
|
||||
line_feed - 1
|
||||
} else {
|
||||
line_feed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn line_ending_at(&self, line_end: usize) -> Option<Range<usize>> {
|
||||
let remaining = self.text.get(line_end..)?;
|
||||
if remaining.starts_with("\r\n") {
|
||||
Some(line_end..line_end + 2)
|
||||
} else if remaining.starts_with('\n') {
|
||||
Some(line_end..line_end + 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WordClass {
|
||||
Whitespace,
|
||||
Word,
|
||||
Punctuation,
|
||||
Atomic(usize),
|
||||
}
|
||||
|
||||
fn word_class(grapheme: &str, style: WordStyle) -> Option<WordClass> {
|
||||
let character = grapheme.chars().next()?;
|
||||
if character.is_whitespace() {
|
||||
Some(WordClass::Whitespace)
|
||||
} else if style == WordStyle::WhitespaceDelimited
|
||||
|| character.is_alphanumeric()
|
||||
|| character == '_'
|
||||
{
|
||||
Some(WordClass::Word)
|
||||
} else {
|
||||
Some(WordClass::Punctuation)
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_word_class(
|
||||
text: &str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
style: WordStyle,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> Option<WordClass> {
|
||||
if let Some(index) = atomic_byte_ranges
|
||||
.iter()
|
||||
.position(|range| range.start == start && range.end == end)
|
||||
{
|
||||
match style {
|
||||
WordStyle::Small => Some(WordClass::Atomic(index)),
|
||||
WordStyle::WhitespaceDelimited => Some(WordClass::Word),
|
||||
}
|
||||
} else {
|
||||
word_class(&text[start..end], style)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_atomic_ranges(text: &str, ranges: &[Range<usize>]) -> Vec<Range<usize>> {
|
||||
let mut normalized = ranges
|
||||
.iter()
|
||||
.filter_map(|range| {
|
||||
let raw_start = range.start.min(range.end).min(text.len());
|
||||
let raw_end = range.start.max(range.end).min(text.len());
|
||||
if raw_start == raw_end {
|
||||
return None;
|
||||
}
|
||||
let start = floor_grapheme_boundary(text, raw_start);
|
||||
let end = ceil_grapheme_boundary(text, raw_end);
|
||||
(start < end).then_some(start..end)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort_by_key(|range| (range.start, range.end));
|
||||
|
||||
let mut merged: Vec<Range<usize>> = Vec::with_capacity(normalized.len());
|
||||
for range in normalized {
|
||||
if let Some(previous) = merged.last_mut()
|
||||
&& range.start < previous.end
|
||||
{
|
||||
previous.end = previous.end.max(range.end);
|
||||
} else {
|
||||
merged.push(range);
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn normalize_replacement_range(
|
||||
text: &str,
|
||||
range: Range<usize>,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> Range<usize> {
|
||||
let raw_start = range.start.min(range.end).min(text.len());
|
||||
let raw_end = range.start.max(range.end).min(text.len());
|
||||
if raw_start == raw_end {
|
||||
let cursor = normalize_external_cursor(text, raw_start);
|
||||
let cursor = normalize_cursor_for_atomic_ranges(cursor, atomic_byte_ranges);
|
||||
return cursor..cursor;
|
||||
}
|
||||
|
||||
let mut normalized =
|
||||
floor_grapheme_boundary(text, raw_start)..ceil_grapheme_boundary(text, raw_end);
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for atomic in atomic_byte_ranges {
|
||||
if atomic.start < normalized.end && atomic.end > normalized.start {
|
||||
let start = normalized.start.min(atomic.start);
|
||||
let end = normalized.end.max(atomic.end);
|
||||
changed |= start != normalized.start || end != normalized.end;
|
||||
normalized = start..end;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_cursor_for_atomic_ranges(
|
||||
cursor_byte: usize,
|
||||
atomic_byte_ranges: &[Range<usize>],
|
||||
) -> usize {
|
||||
let Some(range) = atomic_byte_ranges
|
||||
.iter()
|
||||
.find(|range| cursor_byte > range.start && cursor_byte < range.end)
|
||||
else {
|
||||
return cursor_byte;
|
||||
};
|
||||
if cursor_byte - range.start <= range.end - cursor_byte {
|
||||
range.start
|
||||
} else {
|
||||
range.end
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_atomic_boundary(text: &str, byte: usize, atomic_byte_ranges: &[Range<usize>]) -> usize {
|
||||
if let Some(range) = atomic_byte_ranges
|
||||
.iter()
|
||||
.find(|range| byte > range.start && byte <= range.end)
|
||||
{
|
||||
return range.start;
|
||||
}
|
||||
let boundary = previous_grapheme_boundary(text, byte);
|
||||
atomic_byte_ranges
|
||||
.iter()
|
||||
.find(|range| boundary > range.start && boundary < range.end)
|
||||
.map_or(boundary, |range| range.start)
|
||||
}
|
||||
|
||||
fn next_atomic_boundary(text: &str, byte: usize, atomic_byte_ranges: &[Range<usize>]) -> usize {
|
||||
if let Some(range) = atomic_byte_ranges
|
||||
.iter()
|
||||
.find(|range| byte >= range.start && byte < range.end)
|
||||
{
|
||||
return range.end;
|
||||
}
|
||||
let boundary = next_grapheme_boundary(text, byte);
|
||||
atomic_byte_ranges
|
||||
.iter()
|
||||
.find(|range| boundary > range.start && boundary < range.end)
|
||||
.map_or(boundary, |range| range.end)
|
||||
}
|
||||
|
||||
fn byte_is_inside_atomic_range(byte: usize, atomic_byte_ranges: &[Range<usize>]) -> bool {
|
||||
atomic_byte_ranges
|
||||
.iter()
|
||||
.any(|range| byte >= range.start && byte < range.end)
|
||||
}
|
||||
|
||||
fn is_grapheme_boundary(text: &str, byte: usize) -> bool {
|
||||
byte == text.len()
|
||||
|| text
|
||||
.grapheme_indices(true)
|
||||
.any(|(boundary, _)| boundary == byte)
|
||||
}
|
||||
|
||||
fn floor_grapheme_boundary(text: &str, byte: usize) -> usize {
|
||||
let byte = byte.min(text.len());
|
||||
if byte == text.len() {
|
||||
return byte;
|
||||
}
|
||||
text.grapheme_indices(true)
|
||||
.map(|(index, _)| index)
|
||||
.take_while(|index| *index <= byte)
|
||||
.last()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn ceil_grapheme_boundary(text: &str, byte: usize) -> usize {
|
||||
let byte = byte.min(text.len());
|
||||
if byte == text.len() {
|
||||
return byte;
|
||||
}
|
||||
text.grapheme_indices(true)
|
||||
.map(|(index, _)| index)
|
||||
.find(|index| *index >= byte)
|
||||
.unwrap_or(text.len())
|
||||
}
|
||||
|
||||
fn normalize_external_cursor(text: &str, byte: usize) -> usize {
|
||||
let byte = byte.min(text.len());
|
||||
let before = floor_grapheme_boundary(text, byte);
|
||||
let after = ceil_grapheme_boundary(text, byte);
|
||||
if byte - before <= after - byte {
|
||||
before
|
||||
} else {
|
||||
after
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_grapheme_boundary(text: &str, byte: usize) -> usize {
|
||||
let byte = byte.min(text.len());
|
||||
if byte == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut cursor = GraphemeCursor::new(byte, text.len(), true);
|
||||
match cursor.prev_boundary(text, 0) {
|
||||
Ok(Some(boundary)) => boundary,
|
||||
Ok(None) => 0,
|
||||
Err(_) => floor_grapheme_boundary(text, byte.saturating_sub(1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
|
||||
let byte = byte.min(text.len());
|
||||
if byte == text.len() {
|
||||
return byte;
|
||||
}
|
||||
let mut cursor = GraphemeCursor::new(byte, text.len(), true);
|
||||
match cursor.next_boundary(text, 0) {
|
||||
Ok(Some(boundary)) => boundary,
|
||||
Ok(None) => text.len(),
|
||||
Err(_) => ceil_grapheme_boundary(text, byte.saturating_add(1)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "editor_tests/mod.rs"]
|
||||
mod tests;
|
||||
159
crates/codegen/xai-ratatui-textarea/src/editor_keys.rs
Normal file
159
crates/codegen/xai-ratatui-textarea/src/editor_keys.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::{EditCommand, WordStyle};
|
||||
|
||||
pub fn classify_key_event(event: &KeyEvent) -> Option<EditCommand> {
|
||||
match event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0002}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(EditCommand::MoveGraphemeLeft),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0006}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(EditCommand::MoveGraphemeRight),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers,
|
||||
..
|
||||
} if *modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
Some(EditCommand::DeleteWordBackward(WordStyle::Small))
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace | KeyCode::Char('\u{0008}' | '\u{007f}'),
|
||||
modifiers,
|
||||
..
|
||||
} => Some(backspace_command(*modifiers)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::DeleteWordForward(WordStyle::Small)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
..
|
||||
} => Some(EditCommand::DeleteGraphemeForward),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('w'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::DeleteWordBackward(
|
||||
WordStyle::WhitespaceDelimited,
|
||||
)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::MoveWordLeft(WordStyle::Small)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::MoveWordRight(WordStyle::Small)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::MoveLogicalLineStart),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::MoveLogicalLineEnd),
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::MoveGraphemeLeft),
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::MoveGraphemeRight),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => Some(EditCommand::MoveWordLeft(WordStyle::Small)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => Some(EditCommand::MoveWordRight(WordStyle::Small)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('u'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::DeleteToLineStart),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::DeleteToLineEnd),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::DeleteGraphemeBackward),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => Some(EditCommand::DeleteGraphemeForward),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::ALT | KeyModifiers::SUPER,
|
||||
..
|
||||
} => Some(EditCommand::DeleteWordForward(WordStyle::Small)),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(character),
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} if !character.is_control() => {
|
||||
let character = if event.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
shifted_char(*character)
|
||||
} else {
|
||||
*character
|
||||
};
|
||||
Some(EditCommand::Insert(character))
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(character),
|
||||
modifiers,
|
||||
..
|
||||
} if crate::is_altgr(*modifiers) && !character.is_control() => {
|
||||
Some(EditCommand::Insert(*character))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn shifted_char(character: char) -> char {
|
||||
if character.is_ascii_lowercase() {
|
||||
character.to_ascii_uppercase()
|
||||
} else {
|
||||
character
|
||||
}
|
||||
}
|
||||
|
||||
fn backspace_command(modifiers: KeyModifiers) -> EditCommand {
|
||||
match modifiers {
|
||||
KeyModifiers::ALT | KeyModifiers::CONTROL => {
|
||||
EditCommand::DeleteWordBackward(WordStyle::Small)
|
||||
}
|
||||
KeyModifiers::SUPER => EditCommand::DeleteToLineStart,
|
||||
_ => EditCommand::DeleteGraphemeBackward,
|
||||
}
|
||||
}
|
||||
278
crates/codegen/xai-ratatui-textarea/src/editor_tests/editing.rs
Normal file
278
crates/codegen/xai-ratatui-textarea/src/editor_tests/editing.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
use super::super::*;
|
||||
use super::{delta, is_extended_grapheme_boundary};
|
||||
|
||||
#[test]
|
||||
fn edit_outcome_is_closed_over_cursor_and_text_changes() {
|
||||
let mut buffer = EditBuffer::new();
|
||||
assert_eq!(
|
||||
buffer.apply(EditCommand::MoveGraphemeLeft),
|
||||
EditOutcome::Unchanged
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
buffer.apply(EditCommand::Insert('é')),
|
||||
EditOutcome::TextAndCursor(delta(0..0, 0.."é".len()))
|
||||
);
|
||||
assert_eq!(buffer.text(), "é");
|
||||
assert_eq!(buffer.cursor_byte(), "é".len());
|
||||
|
||||
assert_eq!(
|
||||
buffer.apply(EditCommand::MoveGraphemeLeft),
|
||||
EditOutcome::CursorOnly
|
||||
);
|
||||
assert_eq!(buffer.cursor_byte(), 0);
|
||||
|
||||
assert_eq!(
|
||||
buffer.apply(EditCommand::DeleteGraphemeForward),
|
||||
EditOutcome::TextOnly(delta(0.."é".len(), 0..0))
|
||||
);
|
||||
assert_eq!(buffer.text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_motion_treats_combining_zwj_flags_and_cjk_atomically() {
|
||||
let graphemes = ["e\u{301}", "👩🏽\u{200d}💻", "🇺🇸", "界"];
|
||||
let text = graphemes.concat();
|
||||
let mut boundaries = vec![0];
|
||||
for grapheme in graphemes {
|
||||
boundaries.push(boundaries.last().copied().unwrap_or(0) + grapheme.len());
|
||||
}
|
||||
|
||||
let mut buffer = EditBuffer::from_parts(text, usize::MAX);
|
||||
for expected in boundaries.iter().rev().skip(1) {
|
||||
let _ = buffer.apply(EditCommand::MoveGraphemeLeft);
|
||||
assert_eq!(buffer.cursor_byte(), *expected);
|
||||
}
|
||||
for expected in boundaries.iter().skip(1) {
|
||||
let _ = buffer.apply(EditCommand::MoveGraphemeRight);
|
||||
assert_eq!(buffer.cursor_byte(), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_deletion_and_replacement_never_split_clusters() {
|
||||
let combining = "e\u{301}";
|
||||
let zwj = "👩🏽\u{200d}💻";
|
||||
let flag = "🇺🇸";
|
||||
let text = format!("{combining}{zwj}{flag}");
|
||||
let mut buffer = EditBuffer::from_parts(text.as_str(), combining.len() + zwj.len());
|
||||
|
||||
let _ = buffer.apply(EditCommand::DeleteGraphemeBackward);
|
||||
let expected = format!("{combining}{flag}");
|
||||
assert_eq!(buffer.text(), expected.as_str());
|
||||
assert_eq!(buffer.cursor_byte(), combining.len());
|
||||
|
||||
let _ = buffer.apply(EditCommand::DeleteGraphemeForward);
|
||||
assert_eq!(buffer.text(), combining);
|
||||
assert_eq!(buffer.cursor_byte(), combining.len());
|
||||
|
||||
let text = format!("a{zwj}b");
|
||||
let mut buffer = EditBuffer::from_parts(text.as_str(), text.len());
|
||||
let outcome = buffer.replace_byte_range(2..(1 + zwj.len() - 1), "X");
|
||||
assert_eq!(buffer.text(), "aXb");
|
||||
assert_eq!(buffer.cursor_byte(), 3);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
EditOutcome::TextAndCursor(delta(1..(1 + zwj.len()), 1..2))
|
||||
);
|
||||
|
||||
let mut combining_insert = EditBuffer::from_text("e");
|
||||
let _ = combining_insert.insert_str("\u{301}");
|
||||
assert_eq!(combining_insert.text(), combining);
|
||||
let _ = combining_insert.apply(EditCommand::DeleteGraphemeBackward);
|
||||
assert_eq!(combining_insert.text(), "");
|
||||
|
||||
let base = "👩🏽";
|
||||
let laptop = "💻";
|
||||
let text = format!("{base}{laptop}");
|
||||
let mut zwj_insert = EditBuffer::from_parts(text, base.len());
|
||||
let _ = zwj_insert.insert_str("\u{200d}");
|
||||
assert_eq!(zwj_insert.text(), zwj);
|
||||
assert_eq!(zwj_insert.cursor_byte(), zwj.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_created_grapheme_merges_keep_right_cursor_affinity() {
|
||||
let woman = "👩";
|
||||
let tail = "👩🏽\u{200d}💻";
|
||||
let text = format!("{woman}{tail}");
|
||||
let mut zwj_insert = EditBuffer::from_parts(text, woman.len());
|
||||
let outcome = zwj_insert.insert_str("\u{200d}");
|
||||
let inserted_end = woman.len() + "\u{200d}".len();
|
||||
assert_eq!(zwj_insert.text().graphemes(true).count(), 1);
|
||||
assert_eq!(zwj_insert.cursor_byte(), zwj_insert.text().len());
|
||||
assert_eq!(
|
||||
outcome,
|
||||
EditOutcome::TextAndCursor(delta(woman.len()..woman.len(), woman.len()..inserted_end,))
|
||||
);
|
||||
|
||||
let mut flag_insert = EditBuffer::from_parts("🇺", 0);
|
||||
let outcome = flag_insert.insert_str("🇨");
|
||||
assert_eq!(flag_insert.text(), "🇨🇺");
|
||||
assert_eq!(flag_insert.cursor_byte(), flag_insert.text().len());
|
||||
assert_eq!(
|
||||
outcome,
|
||||
EditOutcome::TextAndCursor(delta(0..0, 0.."🇨".len()))
|
||||
);
|
||||
|
||||
let mut flag_replace = EditBuffer::from_parts("x🇺", 0);
|
||||
let outcome = flag_replace.replace_byte_range(0..1, "🇨");
|
||||
assert_eq!(flag_replace.text(), "🇨🇺");
|
||||
assert_eq!(flag_replace.cursor_byte(), flag_replace.text().len());
|
||||
assert_eq!(
|
||||
outcome,
|
||||
EditOutcome::TextAndCursor(delta(0..1, 0.."🇨".len()))
|
||||
);
|
||||
|
||||
let regional_indicator_len = "🇨".len();
|
||||
let mut flag_delete = EditBuffer::from_parts("🇨x🇺", regional_indicator_len + "x".len());
|
||||
let outcome = flag_delete.apply(EditCommand::DeleteGraphemeBackward);
|
||||
assert_eq!(flag_delete.text(), "🇨🇺");
|
||||
assert_eq!(flag_delete.cursor_byte(), flag_delete.text().len());
|
||||
assert_eq!(
|
||||
outcome,
|
||||
EditOutcome::TextAndCursor(delta(
|
||||
regional_indicator_len..(regional_indicator_len + "x".len()),
|
||||
regional_indicator_len..regional_indicator_len,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cursor_bytes_normalize_to_the_nearest_grapheme_boundary() {
|
||||
let mut buffer = EditBuffer::from_parts("e\u{301}x", 1);
|
||||
assert_eq!(buffer.cursor_byte(), 0);
|
||||
|
||||
let outcome = buffer.set_cursor_byte(2);
|
||||
assert_eq!(buffer.cursor_byte(), "e\u{301}".len());
|
||||
assert_eq!(outcome, EditOutcome::CursorOnly);
|
||||
|
||||
let _ = buffer.set_cursor_byte(usize::MAX);
|
||||
assert_eq!(buffer.cursor_byte(), buffer.text().len());
|
||||
assert!(is_extended_grapheme_boundary(
|
||||
buffer.text(),
|
||||
buffer.cursor_byte()
|
||||
));
|
||||
|
||||
let tied = EditBuffer::from_parts("🇨🇺", "🇨".len());
|
||||
assert_eq!(tied.cursor_byte(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_replacement_tracks_a_cursor_before_inside_or_after_the_edit() {
|
||||
let mut before = EditBuffer::from_parts("alpha beta", 1);
|
||||
let outcome = before.replace_byte_range(6..10, "B");
|
||||
assert_eq!(before.text(), "alpha B");
|
||||
assert_eq!(before.cursor_byte(), 1);
|
||||
assert_eq!(outcome, EditOutcome::TextOnly(delta(6..10, 6..7)));
|
||||
|
||||
let mut inside = EditBuffer::from_parts("alpha beta", 8);
|
||||
let outcome = inside.replace_byte_range(6..10, "B");
|
||||
assert_eq!(inside.cursor_byte(), 7);
|
||||
assert_eq!(outcome, EditOutcome::TextAndCursor(delta(6..10, 6..7)));
|
||||
|
||||
let mut after = EditBuffer::from_parts("alpha beta", 10);
|
||||
let outcome = after.replace_byte_range(0..5, "A");
|
||||
assert_eq!(after.text(), "A beta");
|
||||
assert_eq!(after.cursor_byte(), 6);
|
||||
assert_eq!(outcome, EditOutcome::TextAndCursor(delta(0..5, 0..1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_words_keep_textarea_punctuation_classes() {
|
||||
let mut buffer = EditBuffer::from_parts("hello-world", 0);
|
||||
for expected in [5, 6, 11] {
|
||||
let _ = buffer.apply(EditCommand::MoveWordRight(WordStyle::Small));
|
||||
assert_eq!(buffer.cursor_byte(), expected);
|
||||
}
|
||||
for expected in [6, 5, 0] {
|
||||
let _ = buffer.apply(EditCommand::MoveWordLeft(WordStyle::Small));
|
||||
assert_eq!(buffer.cursor_byte(), expected);
|
||||
}
|
||||
|
||||
let mut buffer = EditBuffer::from_text("hello-world");
|
||||
let _ = buffer.apply(EditCommand::DeleteWordBackward(WordStyle::Small));
|
||||
assert_eq!(buffer.text(), "hello-");
|
||||
|
||||
let mut buffer = EditBuffer::from_parts("hello-world", 0);
|
||||
let _ = buffer.apply(EditCommand::DeleteWordForward(WordStyle::Small));
|
||||
assert_eq!(buffer.text(), "-world");
|
||||
|
||||
let mut buffer = EditBuffer::from_parts("hello-world", 0);
|
||||
let _ = buffer.apply(EditCommand::MoveWordRight(WordStyle::WhitespaceDelimited));
|
||||
assert_eq!(buffer.cursor_byte(), buffer.text().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_line_commands_chain_at_line_boundaries() {
|
||||
let mut buffer = EditBuffer::from_parts("one\ntwo\nthree", 6);
|
||||
let _ = buffer.apply(EditCommand::MoveLogicalLineStart);
|
||||
assert_eq!(buffer.cursor_byte(), 4);
|
||||
let _ = buffer.apply(EditCommand::MoveLogicalLineEnd);
|
||||
assert_eq!(buffer.cursor_byte(), 7);
|
||||
|
||||
let _ = buffer.set_cursor_byte(4);
|
||||
let _ = buffer.apply(EditCommand::MoveLogicalLineStart);
|
||||
assert_eq!(buffer.cursor_byte(), 0);
|
||||
|
||||
let _ = buffer.set_cursor_byte(3);
|
||||
let _ = buffer.apply(EditCommand::MoveLogicalLineEnd);
|
||||
assert_eq!(buffer.cursor_byte(), 7);
|
||||
|
||||
let _ = buffer.set_cursor_byte(6);
|
||||
let _ = buffer.apply(EditCommand::DeleteToLineStart);
|
||||
assert_eq!(buffer.text(), "one\no\nthree");
|
||||
assert_eq!(buffer.cursor_byte(), 4);
|
||||
|
||||
let _ = buffer.apply(EditCommand::DeleteToLineStart);
|
||||
assert_eq!(buffer.text(), "oneo\nthree");
|
||||
assert_eq!(buffer.cursor_byte(), 3);
|
||||
|
||||
let mut buffer = EditBuffer::from_parts("one\ntwo\nthree", 7);
|
||||
let _ = buffer.apply(EditCommand::DeleteToLineEnd);
|
||||
assert_eq!(buffer.text(), "one\ntwothree");
|
||||
assert_eq!(buffer.cursor_byte(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_line_motion_and_deletion_keep_the_line_ending_atomic() {
|
||||
let mut motion = EditBuffer::from_parts("ab\r\ncd", 1);
|
||||
let _ = motion.apply(EditCommand::MoveLogicalLineEnd);
|
||||
assert_eq!(motion.cursor_byte(), 2);
|
||||
let _ = motion.apply(EditCommand::MoveLogicalLineEnd);
|
||||
assert_eq!(motion.cursor_byte(), 6);
|
||||
let _ = motion.set_cursor_byte(4);
|
||||
let _ = motion.apply(EditCommand::MoveLogicalLineStart);
|
||||
assert_eq!(motion.cursor_byte(), 0);
|
||||
|
||||
let mut midline = EditBuffer::from_parts("ab\r\ncd", 1);
|
||||
let outcome = midline.apply(EditCommand::DeleteToLineEnd);
|
||||
assert_eq!(midline.text(), "a\r\ncd");
|
||||
assert_eq!(midline.cursor_byte(), 1);
|
||||
assert_eq!(outcome, EditOutcome::TextOnly(delta(1..2, 1..1)));
|
||||
|
||||
let mut at_eol = EditBuffer::from_parts("ab\r\ncd", 2);
|
||||
let outcome = at_eol.apply(EditCommand::DeleteToLineEnd);
|
||||
assert_eq!(at_eol.text(), "abcd");
|
||||
assert_eq!(at_eol.cursor_byte(), 2);
|
||||
assert_eq!(outcome, EditOutcome::TextOnly(delta(2..4, 2..2)));
|
||||
|
||||
let mut on_lf = EditBuffer::from_parts("ab\r\ncd", 3);
|
||||
let outcome = on_lf.apply(EditCommand::DeleteToLineEnd);
|
||||
assert_eq!(on_lf.text(), "abcd");
|
||||
assert_eq!(outcome, EditOutcome::TextOnly(delta(2..4, 2..2)));
|
||||
|
||||
let mut second_line = EditBuffer::from_parts("ab\r\ncd", 5);
|
||||
let outcome = second_line.apply(EditCommand::DeleteToLineStart);
|
||||
assert_eq!(second_line.text(), "ab\r\nd");
|
||||
assert_eq!(second_line.cursor_byte(), 4);
|
||||
assert_eq!(outcome, EditOutcome::TextAndCursor(delta(4..5, 4..4)));
|
||||
|
||||
let mut at_bol = EditBuffer::from_parts("ab\r\ncd", 4);
|
||||
let outcome = at_bol.apply(EditCommand::DeleteToLineStart);
|
||||
assert_eq!(at_bol.text(), "abcd");
|
||||
assert_eq!(at_bol.cursor_byte(), 2);
|
||||
assert_eq!(outcome, EditOutcome::TextAndCursor(delta(2..4, 2..2)));
|
||||
}
|
||||
313
crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs
Normal file
313
crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::super::*;
|
||||
|
||||
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent::new(code, modifiers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_uses_whitespace_delimited_word_deletion() {
|
||||
let command = classify_key_event(&key(KeyCode::Char('w'), KeyModifiers::CONTROL))
|
||||
.expect("Ctrl+W must classify");
|
||||
assert_eq!(
|
||||
command,
|
||||
EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited)
|
||||
);
|
||||
|
||||
let mut buffer = EditBuffer::from_text("git commit -m hello-world");
|
||||
let _ = buffer.apply(command);
|
||||
assert_eq!(buffer.text(), "git commit -m ");
|
||||
|
||||
let mut small = EditBuffer::from_text("git commit -m hello-world");
|
||||
let _ = small.apply(EditCommand::DeleteWordBackward(WordStyle::Small));
|
||||
assert_eq!(small.text(), "git commit -m hello-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_editing_keys_classify_to_semantic_commands() {
|
||||
let cases = [
|
||||
(
|
||||
key(KeyCode::Char('a'), KeyModifiers::NONE),
|
||||
EditCommand::Insert('a'),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('a'), KeyModifiers::SHIFT),
|
||||
EditCommand::Insert('A'),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Left, KeyModifiers::NONE),
|
||||
EditCommand::MoveGraphemeLeft,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Right, KeyModifiers::NONE),
|
||||
EditCommand::MoveGraphemeRight,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Left, KeyModifiers::ALT),
|
||||
EditCommand::MoveWordLeft(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Left, KeyModifiers::CONTROL),
|
||||
EditCommand::MoveWordLeft(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Right, KeyModifiers::ALT),
|
||||
EditCommand::MoveWordRight(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||||
EditCommand::MoveWordRight(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('a'), KeyModifiers::CONTROL),
|
||||
EditCommand::MoveLogicalLineStart,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('e'), KeyModifiers::CONTROL),
|
||||
EditCommand::MoveLogicalLineEnd,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('u'), KeyModifiers::CONTROL),
|
||||
EditCommand::DeleteToLineStart,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('k'), KeyModifiers::CONTROL),
|
||||
EditCommand::DeleteToLineEnd,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||||
EditCommand::MoveGraphemeLeft,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('f'), KeyModifiers::CONTROL),
|
||||
EditCommand::MoveGraphemeRight,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('d'), KeyModifiers::CONTROL),
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('h'), KeyModifiers::CONTROL),
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('b'), KeyModifiers::ALT),
|
||||
EditCommand::MoveWordLeft(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('f'), KeyModifiers::ALT),
|
||||
EditCommand::MoveWordRight(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('d'), KeyModifiers::ALT),
|
||||
EditCommand::DeleteWordForward(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('d'), KeyModifiers::SUPER),
|
||||
EditCommand::DeleteWordForward(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('\u{0002}'), KeyModifiers::NONE),
|
||||
EditCommand::MoveGraphemeLeft,
|
||||
),
|
||||
(
|
||||
key(KeyCode::Char('\u{0006}'), KeyModifiers::NONE),
|
||||
EditCommand::MoveGraphemeRight,
|
||||
),
|
||||
];
|
||||
|
||||
for (event, expected) in cases {
|
||||
assert_eq!(classify_key_event(&event), Some(expected), "{event:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_row_keys_and_modified_home_end_remain_adapter_owned() {
|
||||
let events = [
|
||||
key(KeyCode::Home, KeyModifiers::NONE),
|
||||
key(KeyCode::Home, KeyModifiers::SHIFT),
|
||||
key(KeyCode::Home, KeyModifiers::CONTROL),
|
||||
key(KeyCode::Home, KeyModifiers::ALT),
|
||||
key(KeyCode::Home, KeyModifiers::SUPER),
|
||||
key(KeyCode::Home, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
|
||||
key(KeyCode::End, KeyModifiers::NONE),
|
||||
key(KeyCode::End, KeyModifiers::SHIFT),
|
||||
key(KeyCode::End, KeyModifiers::CONTROL),
|
||||
key(KeyCode::End, KeyModifiers::ALT),
|
||||
key(KeyCode::End, KeyModifiers::SUPER),
|
||||
key(KeyCode::End, KeyModifiers::ALT | KeyModifiers::SUPER),
|
||||
key(KeyCode::Left, KeyModifiers::SUPER),
|
||||
key(KeyCode::Right, KeyModifiers::SUPER),
|
||||
];
|
||||
|
||||
for event in events {
|
||||
assert_eq!(classify_key_event(&event), None, "{event:?}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
classify_key_event(&key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
|
||||
Some(EditCommand::MoveLogicalLineStart)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_key_event(&key(KeyCode::Char('e'), KeyModifiers::CONTROL)),
|
||||
Some(EditCommand::MoveLogicalLineEnd)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_key_event(&key(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_and_host_owned_keys_remain_unclassified() {
|
||||
let events = [
|
||||
key(KeyCode::Esc, KeyModifiers::NONE),
|
||||
key(KeyCode::Enter, KeyModifiers::NONE),
|
||||
key(KeyCode::Tab, KeyModifiers::NONE),
|
||||
key(KeyCode::BackTab, KeyModifiers::SHIFT),
|
||||
key(KeyCode::Up, KeyModifiers::NONE),
|
||||
key(KeyCode::Down, KeyModifiers::NONE),
|
||||
key(KeyCode::Char('j'), KeyModifiers::CONTROL),
|
||||
key(KeyCode::Char('m'), KeyModifiers::CONTROL),
|
||||
key(KeyCode::Char('v'), KeyModifiers::CONTROL),
|
||||
key(KeyCode::Char('z'), KeyModifiers::CONTROL),
|
||||
key(KeyCode::Char('\n'), KeyModifiers::NONE),
|
||||
];
|
||||
|
||||
for event in events {
|
||||
assert_eq!(classify_key_event(&event), None, "{event:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_delete_and_raw_encodings_have_modifier_parity() {
|
||||
let cases = [
|
||||
(
|
||||
KeyModifiers::NONE,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::SHIFT,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::ALT,
|
||||
EditCommand::DeleteWordBackward(WordStyle::Small),
|
||||
EditCommand::DeleteWordForward(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
KeyModifiers::CONTROL,
|
||||
EditCommand::DeleteWordBackward(WordStyle::Small),
|
||||
EditCommand::DeleteWordForward(WordStyle::Small),
|
||||
),
|
||||
(
|
||||
KeyModifiers::SUPER,
|
||||
EditCommand::DeleteToLineStart,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::ALT | KeyModifiers::SHIFT,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::SUPER | KeyModifiers::SHIFT,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::CONTROL | KeyModifiers::SUPER,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::ALT | KeyModifiers::SUPER,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::META,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::META | KeyModifiers::SHIFT,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
(
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
),
|
||||
];
|
||||
|
||||
for (modifiers, expected_backspace, expected_delete) in cases {
|
||||
let backspace = key(KeyCode::Backspace, modifiers);
|
||||
let delete = key(KeyCode::Delete, modifiers);
|
||||
let raw_bs = key(KeyCode::Char('\u{0008}'), modifiers);
|
||||
let raw_del = key(KeyCode::Char('\u{007f}'), modifiers);
|
||||
let backspace_command = classify_key_event(&backspace);
|
||||
assert_eq!(backspace_command, Some(expected_backspace), "{backspace:?}");
|
||||
assert_eq!(
|
||||
classify_key_event(&delete),
|
||||
Some(expected_delete),
|
||||
"{delete:?}"
|
||||
);
|
||||
assert_eq!(classify_key_event(&raw_bs), backspace_command, "{raw_bs:?}");
|
||||
assert_eq!(
|
||||
classify_key_event(&raw_del),
|
||||
backspace_command,
|
||||
"{raw_del:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn altgr_insertion_and_ctrl_alt_h_precedence_follow_platform_encoding() {
|
||||
let ctrl_alt = KeyModifiers::CONTROL | KeyModifiers::ALT;
|
||||
assert_eq!(
|
||||
classify_key_event(&key(KeyCode::Char('h'), ctrl_alt)),
|
||||
Some(EditCommand::DeleteWordBackward(WordStyle::Small))
|
||||
);
|
||||
|
||||
for (character, modifiers) in [
|
||||
('q', ctrl_alt),
|
||||
('€', ctrl_alt | KeyModifiers::SHIFT),
|
||||
('h', ctrl_alt | KeyModifiers::SHIFT),
|
||||
] {
|
||||
let expected = if cfg!(target_os = "windows") {
|
||||
Some(EditCommand::Insert(character))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
assert_eq!(
|
||||
classify_key_event(&key(KeyCode::Char(character), modifiers)),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
let mut buffer = EditBuffer::new();
|
||||
let command = classify_key_event(&key(KeyCode::Char('€'), ctrl_alt | KeyModifiers::SHIFT))
|
||||
.expect("shifted AltGr must classify on Windows");
|
||||
let _ = buffer.apply(command);
|
||||
assert_eq!(buffer.text(), "€");
|
||||
}
|
||||
}
|
||||
21
crates/codegen/xai-ratatui-textarea/src/editor_tests/mod.rs
Normal file
21
crates/codegen/xai-ratatui-textarea/src/editor_tests/mod.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use std::ops::Range;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
use super::EditDelta;
|
||||
|
||||
mod editing;
|
||||
mod keys;
|
||||
mod planning;
|
||||
mod viewport;
|
||||
|
||||
fn delta(replaced_byte_range: Range<usize>, inserted_byte_range: Range<usize>) -> EditDelta {
|
||||
EditDelta {
|
||||
replaced_byte_range,
|
||||
inserted_byte_range,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_extended_grapheme_boundary(text: &str, byte: usize) -> bool {
|
||||
byte == text.len() || text.grapheme_indices(true).any(|(index, _)| index == byte)
|
||||
}
|
||||
278
crates/codegen/xai-ratatui-textarea/src/editor_tests/planning.rs
Normal file
278
crates/codegen/xai-ratatui-textarea/src/editor_tests/planning.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use super::super::*;
|
||||
use super::delta;
|
||||
|
||||
#[test]
|
||||
fn edit_plan_exposes_removed_text_before_apply_and_delta_matches() {
|
||||
let mut buffer = EditBuffer::from_text("say hello-world");
|
||||
let plan = buffer.plan_command(
|
||||
EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited),
|
||||
&[],
|
||||
);
|
||||
let replaced = "say ".len()..buffer.text().len();
|
||||
assert_eq!(plan.replaced_byte_range(), replaced);
|
||||
assert_eq!(plan.replacement(), "");
|
||||
assert_eq!(plan.removed_text(), "hello-world");
|
||||
assert_eq!(plan.cursor_byte(), replaced.start);
|
||||
assert_eq!(plan.cursor_affinity(), PostEditCursorAffinity::Right);
|
||||
assert_eq!(buffer.text(), "say hello-world");
|
||||
|
||||
let expected_delta = delta(replaced.clone(), replaced.start..replaced.start);
|
||||
let outcome = buffer.apply_plan(&plan);
|
||||
assert_eq!(outcome, Ok(EditOutcome::TextAndCursor(expected_delta)));
|
||||
assert_eq!(buffer.text(), "say ");
|
||||
assert_eq!(buffer.cursor_byte(), replaced.start);
|
||||
assert_eq!(buffer.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan));
|
||||
assert_eq!(plan.into_removed_text(), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_edit_plan_is_rejected_without_mutation() {
|
||||
let mut buffer = EditBuffer::from_text("abc");
|
||||
let plan = buffer.plan_command(EditCommand::DeleteGraphemeBackward, &[]);
|
||||
let _ = buffer.set_cursor_byte(0);
|
||||
assert_eq!(buffer.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan));
|
||||
assert_eq!(buffer.text(), "abc");
|
||||
assert_eq!(buffer.cursor_byte(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_plans_are_bound_to_buffer_identity_and_generation() {
|
||||
let source = EditBuffer::from_text("x");
|
||||
let plan = source.plan_command(EditCommand::DeleteGraphemeBackward, &[]);
|
||||
|
||||
let mut same_value = EditBuffer::from_text("x");
|
||||
assert_eq!(same_value, source);
|
||||
assert_eq!(
|
||||
same_value.apply_plan(&plan),
|
||||
Err(ApplyEditPlanError::StalePlan)
|
||||
);
|
||||
|
||||
let mut other = EditBuffer::from_text("x\u{301}");
|
||||
assert_eq!(other.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan));
|
||||
assert_eq!(other.text(), "x\u{301}");
|
||||
|
||||
let mut cloned = source.clone();
|
||||
assert_eq!(cloned, source);
|
||||
assert_eq!(cloned.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan));
|
||||
assert_eq!(cloned, source);
|
||||
|
||||
let mut changed_text = EditBuffer::from_text("x");
|
||||
let stale = changed_text.plan_command(EditCommand::DeleteGraphemeBackward, &[]);
|
||||
let _ = changed_text.insert_str("y");
|
||||
assert_eq!(
|
||||
changed_text.apply_plan(&stale),
|
||||
Err(ApplyEditPlanError::StalePlan)
|
||||
);
|
||||
assert_eq!(changed_text.text(), "xy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_state_changes_advance_generation_without_rotating_identity() {
|
||||
let mut buffer = EditBuffer::from_text("ab");
|
||||
let identity = Arc::clone(&buffer.identity);
|
||||
assert_eq!(buffer.generation, 0);
|
||||
|
||||
let _ = buffer.apply(EditCommand::MoveGraphemeLeft);
|
||||
assert_eq!(buffer.generation, 1);
|
||||
assert!(Arc::ptr_eq(&identity, &buffer.identity));
|
||||
|
||||
let _ = buffer.insert_str("x");
|
||||
assert_eq!(buffer.generation, 2);
|
||||
assert!(Arc::ptr_eq(&identity, &buffer.identity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_overflow_rotates_identity_and_invalidates_old_plans() {
|
||||
let mut buffer = EditBuffer::from_text("ab");
|
||||
buffer.generation = u64::MAX;
|
||||
let identity = Arc::clone(&buffer.identity);
|
||||
let plan = buffer.plan_command(EditCommand::DeleteGraphemeBackward, &[]);
|
||||
|
||||
assert_eq!(
|
||||
buffer.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextAndCursor(delta(1..2, 1..1)))
|
||||
);
|
||||
assert_eq!(buffer.generation, 0);
|
||||
assert!(!Arc::ptr_eq(&identity, &buffer.identity));
|
||||
assert_eq!(buffer.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_plan_validation_rejects_grapheme_splitting_ranges() {
|
||||
let mut buffer = EditBuffer::from_text("x\u{301}");
|
||||
let mut plan = buffer.plan_command(EditCommand::DeleteGraphemeBackward, &[]);
|
||||
plan.replaced_byte_range = 0..1;
|
||||
plan.removed_text = "x".to_owned();
|
||||
plan.cursor_byte = 0;
|
||||
|
||||
assert_eq!(
|
||||
buffer.apply_plan(&plan),
|
||||
Err(ApplyEditPlanError::InvalidRange)
|
||||
);
|
||||
assert_eq!(buffer.text(), "x\u{301}");
|
||||
assert_eq!(buffer.cursor_byte(), "x\u{301}".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_ranges_are_indivisible_for_grapheme_plans() {
|
||||
let atomic = 1..6;
|
||||
|
||||
let mut backward = EditBuffer::from_parts("aTOKENb", atomic.end);
|
||||
let plan = backward.plan_command(
|
||||
EditCommand::DeleteGraphemeBackward,
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(plan.replaced_byte_range(), atomic);
|
||||
assert_eq!(plan.removed_text(), "TOKEN");
|
||||
assert_eq!(
|
||||
backward.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextAndCursor(delta(1..6, 1..1)))
|
||||
);
|
||||
assert_eq!(backward.text(), "ab");
|
||||
assert_eq!(backward.cursor_byte(), 1);
|
||||
|
||||
let mut forward = EditBuffer::from_parts("aTOKENb", atomic.start);
|
||||
let plan = forward.plan_command(
|
||||
EditCommand::DeleteGraphemeForward,
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(plan.replaced_byte_range(), atomic);
|
||||
assert_eq!(
|
||||
forward.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextOnly(delta(1..6, 1..1)))
|
||||
);
|
||||
assert_eq!(forward.text(), "ab");
|
||||
assert_eq!(forward.cursor_byte(), 1);
|
||||
|
||||
let mut motion = EditBuffer::from_parts("aTOKENb", atomic.end);
|
||||
let left = motion.plan_command(EditCommand::MoveGraphemeLeft, std::slice::from_ref(&atomic));
|
||||
assert_eq!(left.cursor_byte(), atomic.start);
|
||||
assert_eq!(motion.apply_plan(&left), Ok(EditOutcome::CursorOnly));
|
||||
let right = motion.plan_command(EditCommand::MoveGraphemeRight, &[atomic]);
|
||||
assert_eq!(right.cursor_byte(), 6);
|
||||
assert_eq!(motion.apply_plan(&right), Ok(EditOutcome::CursorOnly));
|
||||
|
||||
let mut replacement = EditBuffer::from_parts("aTOKENb", 3);
|
||||
let plan = replacement.plan_replace_byte_range(3..4, "x", &[1..6]);
|
||||
assert_eq!(plan.replaced_byte_range(), 1..6);
|
||||
assert_eq!(plan.replacement(), "x");
|
||||
assert_eq!(plan.removed_text(), "TOKEN");
|
||||
assert_eq!(
|
||||
replacement.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextAndCursor(delta(1..6, 1..2)))
|
||||
);
|
||||
assert_eq!(replacement.text(), "axb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_word_classes_follow_the_selected_word_style() {
|
||||
let text = "fooTOKENbar";
|
||||
let atomic = 3..8;
|
||||
|
||||
let small_forward = EditBuffer::from_parts(text, 0).plan_command(
|
||||
EditCommand::MoveWordRight(WordStyle::Small),
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(small_forward.cursor_byte(), atomic.start);
|
||||
let small_backward = EditBuffer::from_text(text).plan_command(
|
||||
EditCommand::MoveWordLeft(WordStyle::Small),
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(small_backward.cursor_byte(), atomic.end);
|
||||
|
||||
let mut small_delete_forward = EditBuffer::from_parts(text, atomic.start);
|
||||
let plan = small_delete_forward.plan_command(
|
||||
EditCommand::DeleteWordForward(WordStyle::Small),
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(plan.replaced_byte_range(), atomic);
|
||||
assert_eq!(
|
||||
small_delete_forward.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextOnly(delta(3..8, 3..3)))
|
||||
);
|
||||
assert_eq!(small_delete_forward.text(), "foobar");
|
||||
|
||||
let mut small_delete_backward = EditBuffer::from_parts(text, atomic.end);
|
||||
let plan = small_delete_backward.plan_command(
|
||||
EditCommand::DeleteWordBackward(WordStyle::Small),
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(plan.replaced_byte_range(), atomic);
|
||||
assert_eq!(
|
||||
small_delete_backward.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextAndCursor(delta(3..8, 3..3)))
|
||||
);
|
||||
assert_eq!(small_delete_backward.text(), "foobar");
|
||||
|
||||
let word_forward = EditBuffer::from_parts(text, 0).plan_command(
|
||||
EditCommand::MoveWordRight(WordStyle::WhitespaceDelimited),
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(word_forward.cursor_byte(), text.len());
|
||||
let word_backward = EditBuffer::from_text(text).plan_command(
|
||||
EditCommand::MoveWordLeft(WordStyle::WhitespaceDelimited),
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(word_backward.cursor_byte(), 0);
|
||||
|
||||
let mut word_delete_forward = EditBuffer::from_parts(text, 0);
|
||||
let plan = word_delete_forward.plan_command(
|
||||
EditCommand::DeleteWordForward(WordStyle::WhitespaceDelimited),
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(plan.replaced_byte_range(), 0..text.len());
|
||||
assert_eq!(
|
||||
word_delete_forward.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextOnly(delta(0..text.len(), 0..0)))
|
||||
);
|
||||
assert_eq!(word_delete_forward.text(), "");
|
||||
|
||||
let mut word_delete_backward = EditBuffer::from_text(text);
|
||||
let plan = word_delete_backward.plan_command(
|
||||
EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited),
|
||||
&[atomic],
|
||||
);
|
||||
assert_eq!(plan.replaced_byte_range(), 0..text.len());
|
||||
assert_eq!(
|
||||
word_delete_backward.apply_plan(&plan),
|
||||
Ok(EditOutcome::TextAndCursor(delta(0..text.len(), 0..0)))
|
||||
);
|
||||
assert_eq!(word_delete_backward.text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_line_plans_ignore_newlines_inside_atomic_ranges() {
|
||||
let atomic = 1..4;
|
||||
let text = "aX\nYb\nc";
|
||||
|
||||
let motion = EditBuffer::from_parts(text, 0);
|
||||
let to_end = motion.plan_command(
|
||||
EditCommand::MoveLogicalLineEnd,
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(to_end.cursor_byte(), 5);
|
||||
|
||||
let mut deletion = EditBuffer::from_parts(text, 0);
|
||||
let to_end = deletion.plan_command(EditCommand::DeleteToLineEnd, std::slice::from_ref(&atomic));
|
||||
assert_eq!(to_end.replaced_byte_range(), 0..5);
|
||||
assert_eq!(to_end.removed_text(), "aX\nYb");
|
||||
assert_eq!(
|
||||
deletion.apply_plan(&to_end),
|
||||
Ok(EditOutcome::TextOnly(delta(0..5, 0..0)))
|
||||
);
|
||||
assert_eq!(deletion.text(), "\nc");
|
||||
|
||||
let from_bol = EditBuffer::from_parts(text, 6);
|
||||
let to_start = from_bol.plan_command(
|
||||
EditCommand::MoveLogicalLineStart,
|
||||
std::slice::from_ref(&atomic),
|
||||
);
|
||||
assert_eq!(to_start.cursor_byte(), 0);
|
||||
|
||||
let deletion = EditBuffer::from_parts(text, atomic.end);
|
||||
let to_start = deletion.plan_command(EditCommand::DeleteToLineStart, &[atomic]);
|
||||
assert_eq!(to_start.replaced_byte_range(), 0..4);
|
||||
assert_eq!(to_start.removed_text(), "aX\nY");
|
||||
}
|
||||
247
crates/codegen/xai-ratatui-textarea/src/editor_tests/viewport.rs
Normal file
247
crates/codegen/xai-ratatui-textarea/src/editor_tests/viewport.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
use rand::{Rng as _, SeedableRng as _};
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
|
||||
use super::super::*;
|
||||
use super::is_extended_grapheme_boundary;
|
||||
|
||||
fn assert_viewport_invariants(
|
||||
buffer: &EditBuffer,
|
||||
viewport: &SingleLineViewport,
|
||||
display_width: usize,
|
||||
) {
|
||||
let visible = &buffer.text()[viewport.visible_byte_range.clone()];
|
||||
let prefix = &buffer.text()[viewport.visible_byte_range.start..buffer.cursor_byte()];
|
||||
assert!(!visible.contains('\n'));
|
||||
assert!(!visible.contains('\r'));
|
||||
assert!(visible.width() <= display_width);
|
||||
assert_eq!(prefix.width(), viewport.cursor_display_column);
|
||||
if display_width == 0 {
|
||||
assert_eq!(viewport.cursor_display_column, 0);
|
||||
} else {
|
||||
assert!(viewport.cursor_display_column < display_width);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_viewport_clips_only_at_grapheme_boundaries() {
|
||||
let zwj = "👩🏽\u{200d}💻";
|
||||
let flag = "🇺🇸";
|
||||
let text = format!("a{zwj}b{flag}界");
|
||||
let after_b = 1 + zwj.len() + 1;
|
||||
let mut buffer = EditBuffer::from_parts(text.as_str(), after_b);
|
||||
|
||||
let viewport = buffer.single_line_viewport(4);
|
||||
let expected = format!("{zwj}b");
|
||||
assert_eq!(
|
||||
&buffer.text()[viewport.visible_byte_range.clone()],
|
||||
expected.as_str()
|
||||
);
|
||||
assert_eq!(viewport.cursor_display_column, 3);
|
||||
assert_viewport_invariants(&buffer, &viewport, 4);
|
||||
|
||||
let _ = buffer.set_cursor_byte(0);
|
||||
let viewport = buffer.single_line_viewport(4);
|
||||
let expected = format!("a{zwj}b");
|
||||
assert_eq!(
|
||||
&buffer.text()[viewport.visible_byte_range.clone()],
|
||||
expected.as_str()
|
||||
);
|
||||
assert_eq!(viewport.cursor_display_column, 0);
|
||||
assert_viewport_invariants(&buffer, &viewport, 4);
|
||||
|
||||
let _ = buffer.set_cursor_byte(buffer.text().len());
|
||||
let viewport = buffer.single_line_viewport(4);
|
||||
assert_eq!(&buffer.text()[viewport.visible_byte_range.clone()], "界");
|
||||
assert_eq!(viewport.cursor_display_column, 2);
|
||||
assert_viewport_invariants(&buffer, &viewport, 4);
|
||||
|
||||
let viewport = buffer.single_line_viewport(0);
|
||||
assert_eq!(
|
||||
viewport.visible_byte_range,
|
||||
buffer.cursor_byte()..buffer.cursor_byte()
|
||||
);
|
||||
assert_eq!(viewport.cursor_display_column, 0);
|
||||
assert_viewport_invariants(&buffer, &viewport, 0);
|
||||
|
||||
let combining = EditBuffer::from_parts("e\u{301}x", 0);
|
||||
let viewport = combining.single_line_viewport(1);
|
||||
assert_eq!(
|
||||
&combining.text()[viewport.visible_byte_range.clone()],
|
||||
"e\u{301}"
|
||||
);
|
||||
assert_viewport_invariants(&combining, &viewport, 1);
|
||||
|
||||
let narrow_zwj = EditBuffer::from_parts(zwj, 0);
|
||||
let viewport = narrow_zwj.single_line_viewport(1);
|
||||
assert!(viewport.visible_byte_range.is_empty());
|
||||
assert_viewport_invariants(&narrow_zwj, &viewport, 1);
|
||||
|
||||
let zero_width = "\u{200b}";
|
||||
assert_eq!(zero_width.width(), 0);
|
||||
let text = format!("a{zero_width}b");
|
||||
let zero_width_buffer = EditBuffer::from_parts(text.as_str(), 1 + zero_width.len());
|
||||
let viewport = zero_width_buffer.single_line_viewport(2);
|
||||
assert_eq!(
|
||||
&zero_width_buffer.text()[viewport.visible_byte_range.clone()],
|
||||
text.as_str()
|
||||
);
|
||||
assert_eq!(viewport.cursor_display_column, 1);
|
||||
assert_viewport_invariants(&zero_width_buffer, &viewport, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_viewport_stays_within_lf_and_crlf_logical_lines() {
|
||||
for (text, cursor_byte, expected) in [
|
||||
("a\nb", "a\nb".len(), "b"),
|
||||
("ab\r\ncd", "ab\r\ncd".len(), "cd"),
|
||||
("ab\r\ncd", 2, "ab"),
|
||||
] {
|
||||
let buffer = EditBuffer::from_parts(text, cursor_byte);
|
||||
let viewport = buffer.single_line_viewport(4);
|
||||
assert_eq!(
|
||||
&buffer.text()[viewport.visible_byte_range.clone()],
|
||||
expected
|
||||
);
|
||||
assert_viewport_invariants(&buffer, &viewport, 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_line_break_stays_inside_single_line_viewport() {
|
||||
let text = "aaX\nYbb";
|
||||
let cursor = text.find('Y').expect("Y") + 1;
|
||||
let buffer = EditBuffer::from_parts(text, cursor);
|
||||
let atomic = text.find('X').expect("X")..text.find('b').expect("b");
|
||||
|
||||
let physical = buffer.single_line_viewport(16);
|
||||
assert_eq!(&buffer.text()[physical.visible_byte_range], "Ybb");
|
||||
|
||||
let logical = buffer.single_line_viewport_with_atomic_ranges(16, &[atomic]);
|
||||
assert_eq!(&buffer.text()[logical.visible_byte_range], text);
|
||||
assert_eq!(logical.cursor_display_column, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_viewport_preserves_raw_cursor_and_whole_spans() {
|
||||
let text = "aaTOKENbb";
|
||||
let atom = 2..7;
|
||||
let buffer = EditBuffer {
|
||||
text: text.to_string(),
|
||||
cursor_byte: 5,
|
||||
..EditBuffer::default()
|
||||
};
|
||||
|
||||
let viewport = buffer.single_line_viewport_with_atomic_ranges(4, std::slice::from_ref(&atom));
|
||||
assert!(viewport.visible_byte_range.start <= buffer.cursor_byte());
|
||||
assert!(buffer.cursor_byte() <= viewport.visible_byte_range.end);
|
||||
assert!(
|
||||
viewport.visible_byte_range.is_empty()
|
||||
|| viewport.visible_byte_range.start <= atom.start
|
||||
|| viewport.visible_byte_range.start >= atom.end
|
||||
);
|
||||
assert!(
|
||||
viewport.visible_byte_range.is_empty()
|
||||
|| viewport.visible_byte_range.end <= atom.start
|
||||
|| viewport.visible_byte_range.end >= atom.end
|
||||
);
|
||||
assert!(viewport.visible_byte_range.start <= buffer.cursor_byte());
|
||||
assert!(buffer.cursor_byte() <= viewport.visible_byte_range.end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_seed_edit_sequence_preserves_cursor_and_viewport_invariants() {
|
||||
let atoms = [
|
||||
"",
|
||||
"a",
|
||||
"_",
|
||||
"-",
|
||||
" ",
|
||||
"\n",
|
||||
"\r\n",
|
||||
"\u{200b}",
|
||||
"e\u{301}",
|
||||
"👩🏽\u{200d}💻",
|
||||
"🇺🇸",
|
||||
"界",
|
||||
];
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(0x5eed_ed17);
|
||||
let mut buffer = EditBuffer::new();
|
||||
|
||||
for _ in 0..2_000 {
|
||||
match rng.random_range(0..13) {
|
||||
0 => {
|
||||
let atom = atoms[rng.random_range(0..atoms.len())];
|
||||
let _ = buffer.insert_str(atom);
|
||||
}
|
||||
1 => {
|
||||
let _ = buffer.apply(EditCommand::MoveGraphemeLeft);
|
||||
}
|
||||
2 => {
|
||||
let _ = buffer.apply(EditCommand::MoveGraphemeRight);
|
||||
}
|
||||
3 => {
|
||||
let _ = buffer.apply(EditCommand::DeleteGraphemeBackward);
|
||||
}
|
||||
4 => {
|
||||
let _ = buffer.apply(EditCommand::DeleteGraphemeForward);
|
||||
}
|
||||
5 => {
|
||||
let _ = buffer.apply(EditCommand::DeleteWordBackward(WordStyle::Small));
|
||||
}
|
||||
6 => {
|
||||
let _ = buffer.apply(EditCommand::DeleteWordBackward(
|
||||
WordStyle::WhitespaceDelimited,
|
||||
));
|
||||
}
|
||||
7 => {
|
||||
let _ = buffer.apply(EditCommand::DeleteWordForward(WordStyle::Small));
|
||||
}
|
||||
8 => {
|
||||
let _ = buffer.apply(EditCommand::MoveWordLeft(WordStyle::Small));
|
||||
}
|
||||
9 => {
|
||||
let _ = buffer.apply(EditCommand::MoveWordRight(WordStyle::Small));
|
||||
}
|
||||
10 => {
|
||||
let byte = rng.random_range(0..=buffer.text().len().saturating_add(3));
|
||||
let _ = buffer.set_cursor_byte(byte);
|
||||
}
|
||||
11 => {
|
||||
let max = buffer.text().len().saturating_add(2);
|
||||
let start = rng.random_range(0..=max);
|
||||
let end = rng.random_range(0..=max);
|
||||
let replacement = atoms[rng.random_range(0..atoms.len())];
|
||||
let _ = buffer.replace_byte_range(start..end, replacement);
|
||||
}
|
||||
12 => {
|
||||
let command = if rng.random() {
|
||||
EditCommand::MoveLogicalLineStart
|
||||
} else {
|
||||
EditCommand::MoveLogicalLineEnd
|
||||
};
|
||||
let _ = buffer.apply(command);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
assert!(buffer.cursor_byte() <= buffer.text().len());
|
||||
assert!(is_extended_grapheme_boundary(
|
||||
buffer.text(),
|
||||
buffer.cursor_byte()
|
||||
));
|
||||
|
||||
let width = rng.random_range(0..8);
|
||||
let viewport = buffer.single_line_viewport(width);
|
||||
assert!(viewport.visible_byte_range.start <= buffer.cursor_byte());
|
||||
assert!(buffer.cursor_byte() <= viewport.visible_byte_range.end);
|
||||
assert!(is_extended_grapheme_boundary(
|
||||
buffer.text(),
|
||||
viewport.visible_byte_range.start
|
||||
));
|
||||
assert!(is_extended_grapheme_boundary(
|
||||
buffer.text(),
|
||||
viewport.visible_byte_range.end
|
||||
));
|
||||
assert_viewport_invariants(&buffer, &viewport, width);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
#![allow(clippy::new_without_default)]
|
||||
|
||||
pub mod editor;
|
||||
pub mod render;
|
||||
pub mod textarea;
|
||||
pub mod wrapping;
|
||||
|
||||
pub use editor::{
|
||||
ApplyEditPlanError, EditBuffer, EditCommand, EditDelta, EditOutcome, EditPlan,
|
||||
PostEditCursorAffinity, SingleLineViewport, WordStyle, classify_key_event,
|
||||
};
|
||||
pub use textarea::{
|
||||
ClipboardProvider, ElementId, ElementKind, InternalClipboard, MouseAction, TextArea,
|
||||
TextAreaState, TextElement, TextElementEvent, TextElementEventKind, is_undo_input,
|
||||
|
|
|
|||
Loading…
Reference in a new issue