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:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View 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;

View 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,
}
}

View 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)));
}

View 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(), "");
}
}

View 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)
}

View 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");
}

View 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);
}
}

View file

@ -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,