1#![allow(missing_docs)]
20
21use crate::TextGeometry;
22use crate::undo::UndoStack;
23
24use bevy_ecs::message::{MessageReader, MessageWriter};
25use bevy_ecs::prelude::*;
26use lumen_core::prelude::*;
27use lumen_core::text_events::AppliedKind;
28use lumen_core::time::Instant;
29use std::ops::Range;
30use std::sync::Arc;
31use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
32
33type UnattachedTextInputData<'a> = (Entity, &'a TextContent, &'a TextInput);
37type UnattachedTextInputFilter = (With<TextInput>, Without<TextBuffer>);
39
40pub fn text_attach_buffer(
46 mut commands: Commands,
47 q: Query<UnattachedTextInputData, UnattachedTextInputFilter>,
48) {
49 for (e, tc, ti) in &q {
50 let kind = if ti.multiline {
51 TextBufferKind::MultiLine
52 } else {
53 TextBufferKind::SingleLine
54 };
55 let mut buf = TextBuffer {
56 rope: ropey::Rope::from_str(&tc.0),
57 version: 0,
58 kind,
59 };
60 let _ = &mut buf; let cursor = {
62 let head = TextPos::from_byte(&tc.0, ti.cursor);
63 let anchor = match ti.selection_anchor {
64 Some(a) => TextPos::from_byte(&tc.0, a),
65 None => head,
66 };
67 TextCursor {
68 head,
69 anchor,
70 affinity: Affinity::default(),
71 goal_x: None,
72 }
73 };
74 commands
75 .entity(e)
76 .insert((buf, cursor, UndoStack::default(), TextEditable));
77 }
78}
79
80#[allow(clippy::too_many_arguments)]
95pub fn text_apply_edits(
96 mut requests: MessageReader<TextEditRequest>,
97 mut applied: MessageWriter<TextEditApplied>,
98 mut rejected: MessageWriter<TextEditRejected>,
99 mut q: Query<(
100 &mut TextBuffer,
101 &mut TextCursor,
102 &mut UndoStack,
103 Option<&mut ImePreedit>,
104 )>,
105 mut commands: Commands,
106) {
107 for req in requests.read() {
108 let entity = req.entity();
109 let Ok((mut buf, mut cur, mut undo, mut preedit)) = q.get_mut(entity) else {
110 rejected.write(TextEditRejected {
111 entity,
112 reason: RejectReason::BadTarget,
113 });
114 continue;
115 };
116 let before_byte = cur.head.byte;
117 let outcome = apply_one(
118 req,
119 entity,
120 &mut buf,
121 &mut cur,
122 &mut undo,
123 preedit.as_deref_mut(),
124 &mut commands,
125 );
126 match outcome {
127 ApplyOutcome::Applied(kind) => {
128 applied.write(TextEditApplied {
129 entity,
130 version: buf.version,
131 kind,
132 before_byte,
133 after_byte: cur.head.byte,
134 });
135 }
136 ApplyOutcome::Rejected(reason) => {
137 rejected.write(TextEditRejected { entity, reason });
138 }
139 }
140 }
141}
142
143enum ApplyOutcome {
144 Applied(AppliedKind),
145 Rejected(RejectReason),
146}
147
148#[allow(clippy::too_many_arguments)]
149fn apply_one(
150 req: &TextEditRequest,
151 entity: Entity,
152 buf: &mut TextBuffer,
153 cur: &mut TextCursor,
154 undo: &mut UndoStack,
155 preedit: Option<&mut ImePreedit>,
156 commands: &mut Commands,
157) -> ApplyOutcome {
158 use TextEditRequest::*;
159 match req {
160 Insert { at, text, .. } => {
161 let sanitized = sanitize_for_buffer(buf, text);
162 if let Some(reason) = sanitized.reject {
163 return ApplyOutcome::Rejected(reason);
164 }
165 let pos = resolve_pos(buf, Anchor::Position(*at));
166 insert_text(buf, cur, undo, pos.byte, &sanitized.text);
167 ApplyOutcome::Applied(AppliedKind::Insert)
168 }
169 Delete { range, .. } => {
170 let r = clamp_range(buf, range.clone());
171 if r.is_empty() {
172 return ApplyOutcome::Rejected(RejectReason::BadTarget);
173 }
174 delete_range(buf, cur, undo, r);
175 ApplyOutcome::Applied(AppliedKind::Delete)
176 }
177 Replace { range, text, .. } => {
178 let sanitized = sanitize_for_buffer(buf, text);
179 if let Some(reason) = sanitized.reject {
180 return ApplyOutcome::Rejected(reason);
181 }
182 let r = clamp_range(buf, range.clone());
183 replace_range(buf, cur, undo, r, &sanitized.text);
184 ApplyOutcome::Applied(AppliedKind::Replace)
185 }
186 MoveCursor { motion, mode, .. } => {
187 let new_head = move_cursor(buf, cur.head, *motion);
188 let keep_anchor = matches!(mode, MoveMode::KeepAnchor);
189 cur.move_head(new_head, keep_anchor);
190 ApplyOutcome::Applied(AppliedKind::CursorMove)
191 }
192 Select { range, .. } => {
193 let r = clamp_range(buf, range.clone());
194 cur.anchor = TextPos::from_buffer_byte(buf, r.start);
195 cur.head = TextPos::from_buffer_byte(buf, r.end);
196 ApplyOutcome::Applied(AppliedKind::CursorMove)
197 }
198 SetCursor { pos, .. } => {
199 let p = resolve_pos(buf, Anchor::Position(*pos));
200 cur.head = p;
201 cur.anchor = p;
202 ApplyOutcome::Applied(AppliedKind::CursorMove)
203 }
204 ExtendSelection { pos, .. } => {
205 let p = resolve_pos(buf, Anchor::Position(*pos));
206 cur.move_head(p, true);
207 ApplyOutcome::Applied(AppliedKind::CursorMove)
208 }
209 SelectAll { .. } => {
210 cur.anchor = TextPos::ZERO;
211 cur.head = TextPos::from_buffer_byte(buf, buf.len_bytes());
212 ApplyOutcome::Applied(AppliedKind::CursorMove)
213 }
214 Undo { .. } => {
215 if !undo.undo(buf, cur) {
216 return ApplyOutcome::Rejected(RejectReason::NothingToUndo);
217 }
218 ApplyOutcome::Applied(AppliedKind::UndoRedo)
219 }
220 Redo { .. } => {
221 if !undo.redo(buf, cur) {
222 return ApplyOutcome::Rejected(RejectReason::NothingToRedo);
223 }
224 ApplyOutcome::Applied(AppliedKind::UndoRedo)
225 }
226 ImeBegin { .. } => {
227 if preedit.is_none() {
228 commands.entity(entity).insert(ImePreedit::default());
229 }
230 ApplyOutcome::Applied(AppliedKind::CursorMove)
231 }
232 ImeUpdate {
233 text,
234 caret_in_preedit,
235 ..
236 } => {
237 let caret = (*caret_in_preedit).min(text.len());
238 match preedit {
239 Some(p) => {
240 p.text = text.to_string();
241 p.caret_in_preedit = caret;
242 }
243 None => {
244 commands.entity(entity).insert(ImePreedit {
245 text: text.to_string(),
246 caret_in_preedit: caret,
247 });
248 }
249 }
250 ApplyOutcome::Applied(AppliedKind::CursorMove)
251 }
252 ImeCommit {
253 text,
254 replace_range,
255 ..
256 } => {
257 let sanitized = sanitize_for_buffer(buf, text);
258 if let Some(reason) = sanitized.reject {
259 commands.entity(entity).remove::<ImePreedit>();
261 return ApplyOutcome::Rejected(reason);
262 }
263 let kind = if let Some(rr) = replace_range {
265 let r = clamp_range(buf, rr.clone());
266 replace_range_op(buf, cur, undo, r, &sanitized.text);
267 AppliedKind::Replace
268 } else if let Some(sel) = cur.selection_range() {
269 replace_range_op(buf, cur, undo, sel, &sanitized.text);
270 AppliedKind::Replace
271 } else {
272 insert_text(buf, cur, undo, cur.head.byte, &sanitized.text);
273 AppliedKind::Insert
274 };
275 commands.entity(entity).remove::<ImePreedit>();
276 ApplyOutcome::Applied(kind)
277 }
278 ImeCancel { .. } => {
279 commands.entity(entity).remove::<ImePreedit>();
280 ApplyOutcome::Applied(AppliedKind::CursorMove)
281 }
282 }
283}
284
285struct Sanitized {
286 text: String,
287 reject: Option<RejectReason>,
288}
289
290fn sanitize_for_buffer(buf: &TextBuffer, text: &str) -> Sanitized {
291 if buf.is_single_line() && text.contains('\n') {
292 let stripped: String = text.replace('\n', " ");
297 return Sanitized {
298 text: stripped,
299 reject: None,
300 };
301 }
302 Sanitized {
303 text: text.to_string(),
304 reject: None,
305 }
306}
307
308fn resolve_pos(buf: &TextBuffer, anchor: Anchor) -> TextPos {
309 match anchor {
310 Anchor::Cursor => TextPos::from_buffer_byte(buf, buf.len_bytes()),
311 Anchor::Position(p) => {
312 TextPos::from_buffer_byte(buf, p.byte)
314 }
315 Anchor::SelectionStart | Anchor::DocumentStart => TextPos::ZERO,
316 Anchor::SelectionEnd | Anchor::DocumentEnd => {
317 TextPos::from_buffer_byte(buf, buf.len_bytes())
318 }
319 }
320}
321
322fn clamp_range(buf: &TextBuffer, mut r: Range<usize>) -> Range<usize> {
323 let len = buf.len_bytes();
324 if r.start > len {
325 r.start = len;
326 }
327 if r.end > len {
328 r.end = len;
329 }
330 if r.start > r.end {
331 std::mem::swap(&mut r.start, &mut r.end);
332 }
333 let s = buf.rope.to_string();
335 let mut start = r.start;
336 while start > 0 && !s.is_char_boundary(start) {
337 start -= 1;
338 }
339 let mut end = r.end;
340 while end < s.len() && !s.is_char_boundary(end) {
341 end += 1;
342 }
343 start..end
344}
345
346pub fn insert_text(
352 buf: &mut TextBuffer,
353 cur: &mut TextCursor,
354 undo: &mut UndoStack,
355 at_byte: usize,
356 text: &str,
357) {
358 if text.is_empty() {
359 return;
360 }
361 let char_idx = buf.rope.byte_to_char(at_byte);
362 let before_pos = cur.head;
363 buf.rope.insert(char_idx, text);
364 buf.version = buf.version.wrapping_add(1);
365 let new_byte = at_byte + text.len();
366 let new_head = TextPos::from_buffer_byte(buf, new_byte);
367 cur.head = new_head;
368 cur.anchor = new_head;
369 undo.record_insert(at_byte, text, before_pos);
370}
371
372pub fn delete_range(
376 buf: &mut TextBuffer,
377 cur: &mut TextCursor,
378 undo: &mut UndoStack,
379 range: Range<usize>,
380) {
381 if range.is_empty() {
382 return;
383 }
384 let removed = {
385 let s_char = buf.rope.byte_to_char(range.start);
386 let e_char = buf.rope.byte_to_char(range.end);
387 let removed = buf.rope.slice(s_char..e_char).to_string();
388 buf.rope.remove(s_char..e_char);
389 removed
390 };
391 buf.version = buf.version.wrapping_add(1);
392 let new_head = TextPos::from_buffer_byte(buf, range.start);
393 let before_pos = cur.head;
394 cur.head = new_head;
395 cur.anchor = new_head;
396 undo.record_delete(range.start, &removed, before_pos);
397}
398
399fn replace_range_op(
400 buf: &mut TextBuffer,
401 cur: &mut TextCursor,
402 undo: &mut UndoStack,
403 range: Range<usize>,
404 text: &str,
405) {
406 let removed = {
407 let s_char = buf.rope.byte_to_char(range.start);
408 let e_char = buf.rope.byte_to_char(range.end);
409 let removed = buf.rope.slice(s_char..e_char).to_string();
410 if !removed.is_empty() {
411 buf.rope.remove(s_char..e_char);
412 }
413 removed
414 };
415 let s_char = buf.rope.byte_to_char(range.start);
416 if !text.is_empty() {
417 buf.rope.insert(s_char, text);
418 }
419 buf.version = buf.version.wrapping_add(1);
420 let new_byte = range.start + text.len();
421 let before_pos = cur.head;
422 let new_head = TextPos::from_buffer_byte(buf, new_byte);
423 cur.head = new_head;
424 cur.anchor = new_head;
425 undo.record_replace(range.start, &removed, text, before_pos);
426}
427
428pub fn replace_range(
432 buf: &mut TextBuffer,
433 cur: &mut TextCursor,
434 undo: &mut UndoStack,
435 range: Range<usize>,
436 text: &str,
437) {
438 replace_range_op(buf, cur, undo, range, text);
439}
440
441pub fn move_cursor(buf: &TextBuffer, from: TextPos, motion: CursorMotion) -> TextPos {
446 let text = buf.rope.to_string();
447 let byte = from.byte.min(text.len());
448 let new_byte = match motion {
449 CursorMotion::CharLeft => prev_grapheme_boundary(&text, byte),
450 CursorMotion::CharRight => next_grapheme_boundary(&text, byte),
451 CursorMotion::WordLeft => prev_word_boundary(&text, byte),
452 CursorMotion::WordRight => next_word_boundary(&text, byte),
453 CursorMotion::LineStart => line_start(&text, byte),
454 CursorMotion::LineEnd => line_end(&text, byte),
455 CursorMotion::LineUp => {
456 let ls = line_start(&text, byte);
458 if ls == 0 {
459 0
460 } else {
461 let prev_end = ls - 1;
462 let prev_start = line_start(&text, prev_end);
463 let col = byte - ls;
464 (prev_start + col).min(prev_end)
465 }
466 }
467 CursorMotion::LineDown => {
468 let le = line_end(&text, byte);
469 if le == text.len() {
470 text.len()
471 } else {
472 let next_start = le + 1;
473 let next_end = line_end(&text, next_start);
474 let col = byte - line_start(&text, byte);
475 (next_start + col).min(next_end)
476 }
477 }
478 CursorMotion::DocStart => 0,
479 CursorMotion::DocEnd => text.len(),
480 };
481 TextPos::from_byte(&text, new_byte)
482}
483
484pub fn move_cursor_visual(
502 geom: &TextGeometry,
503 buf: &TextBuffer,
504 cur: TextCursor,
505 motion: CursorMotion,
506) -> (TextPos, Option<f32>) {
507 let text = buf.rope.to_string();
508 let head = cur.head.byte.min(text.len());
509 match motion {
510 CursorMotion::LineUp | CursorMotion::LineDown => {
511 let count = geom.line_count();
512 if count == 0 {
513 return (move_cursor(buf, cur.head, motion), None);
514 }
515 let line = geom.visual_line_of_byte(head);
516 let target = match motion {
517 CursorMotion::LineUp => line.saturating_sub(1),
518 _ => (line + 1).min(count - 1),
519 };
520 if target == line {
521 return (move_cursor(buf, cur.head, motion), cur.goal_x);
525 }
526 let goal = cur.goal_x.unwrap_or_else(|| geom.byte_to_caret(head).x);
527 let landed = geom.byte_at_line_x(target, goal);
528 (TextPos::from_byte(&text, landed), Some(goal))
529 }
530 CursorMotion::LineStart | CursorMotion::LineEnd => {
531 let line = geom.visual_line_of_byte(head);
532 let (s, e) = geom.visual_line_bounds(line);
533 let b = if matches!(motion, CursorMotion::LineStart) {
534 s
535 } else {
536 e
537 };
538 (TextPos::from_byte(&text, b), None)
539 }
540 _ => (move_cursor(buf, cur.head, motion), None),
541 }
542}
543
544fn snap_char_boundary(s: &str, from: usize) -> usize {
546 let mut i = from.min(s.len());
547 while i > 0 && !s.is_char_boundary(i) {
548 i -= 1;
549 }
550 i
551}
552
553pub fn prev_grapheme_boundary(s: &str, from: usize) -> usize {
560 let from = snap_char_boundary(s, from);
561 let mut cursor = GraphemeCursor::new(from, s.len(), true);
562 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
563}
564
565pub fn next_grapheme_boundary(s: &str, from: usize) -> usize {
570 let from = snap_char_boundary(s, from);
571 let mut cursor = GraphemeCursor::new(from, s.len(), true);
572 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
573}
574
575pub fn prev_code_point_boundary(s: &str, from: usize) -> usize {
582 let from = snap_char_boundary(s, from);
583 if from == 0 {
584 return 0;
585 }
586 snap_char_boundary(s, from - 1)
587}
588
589fn prev_word_boundary(s: &str, from: usize) -> usize {
590 let mut prev = 0usize;
591 for (off, _) in s.split_word_bound_indices() {
592 if off >= from {
593 return prev;
594 }
595 prev = off;
596 }
597 prev
598}
599
600fn next_word_boundary(s: &str, from: usize) -> usize {
601 for (off, w) in s.split_word_bound_indices() {
602 let end = off + w.len();
603 if end > from {
604 return end;
605 }
606 }
607 s.len()
608}
609
610fn line_start(s: &str, from: usize) -> usize {
611 s[..from].rfind('\n').map(|i| i + 1).unwrap_or(0)
612}
613
614fn line_end(s: &str, from: usize) -> usize {
615 s[from..].find('\n').map(|i| from + i).unwrap_or(s.len())
616}
617
618#[allow(clippy::type_complexity)]
625pub fn text_mirror_buffer_to_content(
626 mut applied: MessageReader<TextEditApplied>,
627 mut q: Query<(
628 &TextBuffer,
629 &TextCursor,
630 &mut TextContent,
631 Option<&mut TextInput>,
632 )>,
633) {
634 for ev in applied.read() {
635 let Ok((buf, cur, mut tc, ti)) = q.get_mut(ev.entity) else {
636 continue;
637 };
638 let s = buf.rope.to_string();
639 if tc.0 != s {
640 tc.0 = s;
641 }
642 if let Some(mut ti) = ti {
643 ti.cursor = cur.head.byte;
644 ti.selection_anchor = if cur.is_empty() {
645 None
646 } else {
647 Some(cur.anchor.byte)
648 };
649 }
650 }
651}
652
653#[allow(clippy::type_complexity)]
663pub fn text_reflect_content_to_buffer(
664 mut q: Query<
665 (
666 &TextContent,
667 &mut TextBuffer,
668 Option<&TextInput>,
669 &mut TextCursor,
670 Option<&mut UndoStack>,
671 ),
672 Changed<TextContent>,
673 >,
674) {
675 for (tc, mut buf, ti, mut cur, undo) in &mut q {
676 let rope_str = buf.rope.to_string();
677 if rope_str == tc.0 {
678 continue;
679 }
680 buf.rope = ropey::Rope::from_str(&tc.0);
684 buf.version = buf.version.wrapping_add(1);
685 let byte = match ti {
686 Some(ti) => ti.cursor.min(tc.0.len()),
687 None => tc.0.len(),
688 };
689 let p = TextPos::from_byte(&tc.0, byte);
690 cur.head = p;
691 cur.anchor = p;
692 if let Some(mut undo) = undo {
697 undo.clear();
698 }
699 }
700}
701
702pub fn caret_blink(
720 tracker: Res<lumen_core::input::FocusTracker>,
721 inputs: Query<(), With<TextInput>>,
722 changed_inputs: Query<(), Changed<TextInput>>,
723 mut blink: ResMut<lumen_core::components::CaretBlink>,
724 animations: Option<Res<lumen_core::render_world::AnimationsActive>>,
725 frame_dirty: Option<ResMut<lumen_core::render_world::FrameDirty>>,
726 mut last_focused: Local<Option<Entity>>,
727) {
728 let focused_input = tracker.0.filter(|e| inputs.contains(*e));
729 let Some(entity) = focused_input else {
730 if !blink.visible {
733 blink.reset();
734 }
735 *last_focused = None;
736 return;
737 };
738 let was_visible = blink.visible;
739 let fresh_focus = *last_focused != Some(entity);
740 if fresh_focus || changed_inputs.contains(entity) {
741 blink.reset();
744 *last_focused = Some(entity);
745 } else {
746 let periods = blink
747 .phase
748 .elapsed()
749 .as_millis()
750 .checked_div(blink.period.as_millis())
751 .unwrap_or(0);
752 blink.visible = periods.is_multiple_of(2);
753 }
754 if (blink.visible != was_visible || fresh_focus)
758 && let Some(mut fd) = frame_dirty
759 {
760 fd.dirty = true;
761 }
762 if let Some(anims) = animations {
766 anims.request();
767 }
768}
769
770pub fn make_insert(entity: Entity, at: TextPos, text: impl Into<Arc<str>>) -> TextEditRequest {
773 TextEditRequest::Insert {
774 entity,
775 at,
776 text: text.into(),
777 }
778}
779
780pub struct TextEditPlugin;
802
803impl Plugin for TextEditPlugin {
804 fn build(self, app: &mut lumen_core::app::App) {
805 app.world
806 .init_resource::<bevy_ecs::message::Messages<TextEditRequest>>();
807 app.world
808 .init_resource::<bevy_ecs::message::Messages<TextEditApplied>>();
809 app.world
810 .init_resource::<bevy_ecs::message::Messages<TextEditRejected>>();
811 app.world
812 .init_resource::<bevy_ecs::message::Messages<ImeSurroundingRequested>>();
813 app.world
814 .init_resource::<bevy_ecs::message::Messages<ImeSurroundingResponse>>();
815 app.world
816 .init_resource::<lumen_core::components::CaretBlink>();
817 app.add_systems(TickStage::Systems, text_attach_buffer);
819 app.add_systems(
820 TickStage::Systems,
821 text_apply_edits
822 .in_set(lumen_core::text_events::TextEditSet::Apply)
823 .after(text_attach_buffer)
824 .after(lumen_core::text_events::TextEditSet::Producers),
825 );
826 app.add_systems(
827 TickStage::Systems,
828 text_mirror_buffer_to_content
829 .in_set(lumen_core::text_events::TextEditSet::Mirror)
830 .after(text_apply_edits),
831 );
832 app.add_systems(
833 TickStage::Systems,
834 text_reflect_content_to_buffer.after(text_mirror_buffer_to_content),
835 );
836 app.add_systems(
837 TickStage::Systems,
838 caret_blink.after(text_mirror_buffer_to_content),
839 );
840 }
841}
842
843impl Default for TextEditPlugin {
845 fn default() -> Self {
846 Self
847 }
848}
849
850#[allow(dead_code)]
853fn _suppress_unused_warnings() {
854 let _ = Instant::now;
855 let _: &dyn Fn(&str) -> Vec<&str> = &|s| s.unicode_words().collect();
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861 use bevy_ecs::message::Messages;
862 use lumen_core::app::App;
863
864 fn build_app() -> App {
865 let mut app = App::new();
866 app.world.init_resource::<Messages<TextEditRequest>>();
867 app.world.init_resource::<Messages<TextEditApplied>>();
868 app.world.init_resource::<Messages<TextEditRejected>>();
869 app
870 }
871
872 fn spawn_editable(app: &mut App, text: &str) -> Entity {
873 let buf = TextBuffer::multi_line(text);
874 let cursor = {
875 let p = TextPos::from_byte(text, text.len());
876 TextCursor {
877 head: p,
878 anchor: p,
879 affinity: Affinity::default(),
880 goal_x: None,
881 }
882 };
883 app.world
884 .spawn((
885 TextContent(text.to_string()),
886 TextInput {
887 placeholder: String::new(),
888 cursor: text.len(),
889 selection_anchor: None,
890 multiline: true,
891 },
892 buf,
893 cursor,
894 UndoStack::default(),
895 TextEditable,
896 ))
897 .id()
898 }
899
900 #[test]
901 fn insert_appends_text_and_bumps_version() {
902 let mut app = build_app();
903 let e = spawn_editable(&mut app, "hello");
904 app.world
905 .resource_mut::<Messages<TextEditRequest>>()
906 .write(TextEditRequest::Insert {
907 entity: e,
908 at: TextPos {
909 byte: 5,
910 grapheme: 5,
911 },
912 text: Arc::from(" world"),
913 });
914 let mut sched = bevy_ecs::schedule::Schedule::default();
915 sched.add_systems(text_apply_edits);
916 sched.run(&mut app.world);
917 let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
918 assert_eq!(buf.to_string(), "hello world");
919 assert_eq!(buf.version, 1);
920 }
921
922 #[test]
923 fn delete_range_clamps_to_boundary() {
924 let mut app = build_app();
925 let e = spawn_editable(&mut app, "h\u{e9}llo"); app.world
927 .resource_mut::<Messages<TextEditRequest>>()
928 .write(TextEditRequest::Delete {
929 entity: e,
930 range: 1..2, });
932 let mut sched = bevy_ecs::schedule::Schedule::default();
933 sched.add_systems(text_apply_edits);
934 sched.run(&mut app.world);
935 let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
936 assert_eq!(buf.to_string(), "hllo");
937 }
938
939 #[test]
940 fn replace_with_selection_uses_replace_kind() {
941 let mut app = build_app();
942 let e = spawn_editable(&mut app, "hello");
943 app.world
944 .resource_mut::<Messages<TextEditRequest>>()
945 .write(TextEditRequest::Replace {
946 entity: e,
947 range: 1..4,
948 text: Arc::from("XYZ"),
949 });
950 let mut sched = bevy_ecs::schedule::Schedule::default();
951 sched.add_systems(text_apply_edits);
952 sched.run(&mut app.world);
953 let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
954 assert_eq!(buf.to_string(), "hXYZo");
955 }
956
957 #[test]
958 fn cursor_motion_word_right() {
959 let mut app = build_app();
960 let e = spawn_editable(&mut app, "hello world");
961 {
963 let mut em = app.world.entity_mut(e);
964 let mut c = em.get_mut::<TextCursor>().unwrap();
965 c.head = TextPos::ZERO;
966 c.anchor = TextPos::ZERO;
967 }
968 app.world
969 .resource_mut::<Messages<TextEditRequest>>()
970 .write(TextEditRequest::MoveCursor {
971 entity: e,
972 motion: CursorMotion::WordRight,
973 mode: MoveMode::MoveAnchor,
974 });
975 let mut sched = bevy_ecs::schedule::Schedule::default();
976 sched.add_systems(text_apply_edits);
977 sched.run(&mut app.world);
978 let c = app.world.entity(e).get::<TextCursor>().unwrap();
979 assert_eq!(c.head.byte, 5);
980 }
981
982 #[test]
983 fn ime_commit_with_replace_range_replaces() {
984 let mut app = build_app();
985 let e = spawn_editable(&mut app, "abcdef");
986 app.world
987 .resource_mut::<Messages<TextEditRequest>>()
988 .write(TextEditRequest::ImeCommit {
989 entity: e,
990 text: Arc::from("XX"),
991 replace_range: Some(2..4),
992 });
993 let mut sched = bevy_ecs::schedule::Schedule::default();
994 sched.add_systems(text_apply_edits);
995 sched.run(&mut app.world);
996 let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
997 assert_eq!(buf.to_string(), "abXXef");
998 }
999
1000 #[test]
1001 fn single_line_strips_newlines_on_insert() {
1002 let mut app = build_app();
1003 let buf = TextBuffer::single_line("hi");
1004 let p = TextPos::from_byte("hi", 2);
1005 let e = app
1006 .world
1007 .spawn((
1008 TextContent(String::from("hi")),
1009 TextInput {
1010 placeholder: String::new(),
1011 cursor: 2,
1012 selection_anchor: None,
1013 multiline: false,
1014 },
1015 buf,
1016 TextCursor {
1017 head: p,
1018 anchor: p,
1019 affinity: Affinity::default(),
1020 goal_x: None,
1021 },
1022 UndoStack::default(),
1023 ))
1024 .id();
1025 app.world
1026 .resource_mut::<Messages<TextEditRequest>>()
1027 .write(TextEditRequest::Insert {
1028 entity: e,
1029 at: p,
1030 text: Arc::from("a\nb"),
1031 });
1032 let mut sched = bevy_ecs::schedule::Schedule::default();
1033 sched.add_systems(text_apply_edits);
1034 sched.run(&mut app.world);
1035 let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
1036 assert_eq!(buf.to_string(), "hia b");
1037 }
1038
1039 #[test]
1040 fn extend_selection_keeps_anchor() {
1041 let mut app = build_app();
1042 let e = spawn_editable(&mut app, "hello world");
1043 let mut sched = bevy_ecs::schedule::Schedule::default();
1044 sched.add_systems(text_apply_edits);
1045 app.world
1048 .resource_mut::<Messages<TextEditRequest>>()
1049 .write(TextEditRequest::SetCursor {
1050 entity: e,
1051 pos: TextPos::from_byte("hello world", 7),
1052 });
1053 sched.run(&mut app.world);
1054 app.world.resource_mut::<Messages<TextEditRequest>>().write(
1055 TextEditRequest::ExtendSelection {
1056 entity: e,
1057 pos: TextPos::from_byte("hello world", 2),
1058 },
1059 );
1060 sched.run(&mut app.world);
1061 let c = app.world.entity(e).get::<TextCursor>().unwrap();
1062 assert_eq!(c.anchor.byte, 7);
1063 assert_eq!(c.head.byte, 2);
1064 assert_eq!(c.selection_range(), Some(2..7));
1065 }
1066
1067 #[test]
1068 fn undo_then_redo_restores_state() {
1069 let mut app = build_app();
1070 let e = spawn_editable(&mut app, "hello");
1071 app.world
1073 .resource_mut::<Messages<TextEditRequest>>()
1074 .write(TextEditRequest::Insert {
1075 entity: e,
1076 at: TextPos {
1077 byte: 5,
1078 grapheme: 5,
1079 },
1080 text: Arc::from("!"),
1081 });
1082 let mut sched = bevy_ecs::schedule::Schedule::default();
1083 sched.add_systems(text_apply_edits);
1084 sched.run(&mut app.world);
1085 assert_eq!(
1086 app.world.entity(e).get::<TextBuffer>().unwrap().to_string(),
1087 "hello!"
1088 );
1089 app.world
1091 .resource_mut::<Messages<TextEditRequest>>()
1092 .write(TextEditRequest::Undo { entity: e });
1093 sched.run(&mut app.world);
1094 assert_eq!(
1095 app.world.entity(e).get::<TextBuffer>().unwrap().to_string(),
1096 "hello"
1097 );
1098 app.world
1100 .resource_mut::<Messages<TextEditRequest>>()
1101 .write(TextEditRequest::Redo { entity: e });
1102 sched.run(&mut app.world);
1103 assert_eq!(
1104 app.world.entity(e).get::<TextBuffer>().unwrap().to_string(),
1105 "hello!"
1106 );
1107 }
1108
1109 use crate::{GlyphPosition, ShapedRun, ShapedSegment, TextGeometry};
1112
1113 fn vglyph(bs: u32, be: u32, x: f32, y: f32) -> GlyphPosition {
1114 GlyphPosition {
1115 id: 0,
1116 x,
1117 y,
1118 advance: 10.0,
1119 byte_start: bs,
1120 byte_end: be,
1121 }
1122 }
1123
1124 fn geom_of(glyphs: Vec<GlyphPosition>, width: f32) -> TextGeometry {
1125 let seg = ShapedSegment {
1126 font_id: 1,
1127 font_data: std::sync::Arc::new(Vec::new()),
1128 font_index: 0,
1129 normalized_coords: Vec::new(),
1130 level: 0,
1131 glyphs: glyphs.clone(),
1132 width,
1133 };
1134 let run = ShapedRun {
1135 font_data: seg.font_data.clone(),
1136 font_index: 0,
1137 glyphs,
1138 segments: vec![seg],
1139 width,
1140 };
1141 TextGeometry::from(&run)
1142 }
1143
1144 fn three_line_geom() -> TextGeometry {
1146 geom_of(
1147 vec![
1148 vglyph(0, 1, 0.0, 0.0),
1149 vglyph(1, 2, 10.0, 0.0),
1150 vglyph(2, 3, 20.0, 0.0),
1151 vglyph(3, 4, 30.0, 0.0),
1152 vglyph(4, 5, 40.0, 0.0),
1153 vglyph(5, 6, 0.0, 19.2),
1154 vglyph(6, 7, 10.0, 19.2),
1155 vglyph(7, 8, 20.0, 19.2),
1156 vglyph(8, 9, 0.0, 38.4),
1157 vglyph(9, 10, 10.0, 38.4),
1158 vglyph(10, 11, 20.0, 38.4),
1159 vglyph(11, 12, 30.0, 38.4),
1160 vglyph(12, 13, 40.0, 38.4),
1161 ],
1162 50.0,
1163 )
1164 }
1165
1166 #[test]
1169 fn goal_x_preserved_across_vertical_moves() {
1170 let g = three_line_geom();
1171 let buf = TextBuffer::multi_line("aaaaabbbccccc"); let cur = TextCursor {
1174 head: TextPos::from_byte("aaaaabbbccccc", 4),
1175 ..Default::default()
1176 };
1177 let (p1, goal1) = move_cursor_visual(&g, &buf, cur, CursorMotion::LineDown);
1178 assert_eq!(goal1, Some(40.0));
1180 let cur2 = TextCursor {
1182 head: p1,
1183 goal_x: goal1,
1184 ..Default::default()
1185 };
1186 let (p2, goal2) = move_cursor_visual(&g, &buf, cur2, CursorMotion::LineDown);
1187 assert_eq!(goal2, Some(40.0));
1188 assert_eq!(p2.byte, 12);
1189 assert_eq!(g.caret_xy(p2.byte).0, 40.0);
1190 }
1191
1192 #[test]
1196 fn goal_x_reset_reseeds_from_current_column() {
1197 let g = three_line_geom();
1198 let buf = TextBuffer::multi_line("aaaaabbbccccc");
1199 let at2 = TextPos::from_byte("aaaaabbbccccc", 2); let cleared = TextCursor {
1202 head: at2,
1203 goal_x: None,
1204 ..Default::default()
1205 };
1206 let (p_reset, _) = move_cursor_visual(&g, &buf, cleared, CursorMotion::LineDown);
1207 let stale = TextCursor {
1209 head: at2,
1210 goal_x: Some(40.0),
1211 ..Default::default()
1212 };
1213 let (p_stale, _) = move_cursor_visual(&g, &buf, stale, CursorMotion::LineDown);
1214 assert_eq!(p_reset.byte, 7);
1215 assert_eq!(p_stale.byte, 8);
1216 assert_ne!(p_reset.byte, p_stale.byte);
1217 }
1218
1219 fn wrapped_geom() -> TextGeometry {
1221 geom_of(
1222 vec![
1223 vglyph(0, 1, 0.0, 0.0),
1224 vglyph(1, 2, 10.0, 0.0),
1225 vglyph(2, 3, 20.0, 0.0),
1226 vglyph(3, 4, 30.0, 0.0),
1227 vglyph(4, 5, 40.0, 0.0),
1228 vglyph(5, 6, 0.0, 19.2),
1229 vglyph(6, 7, 10.0, 19.2),
1230 vglyph(7, 8, 20.0, 19.2),
1231 vglyph(8, 9, 30.0, 19.2),
1232 vglyph(9, 10, 40.0, 19.2),
1233 ],
1234 50.0,
1235 )
1236 }
1237
1238 #[test]
1241 fn visual_line_bounds_and_cross_wrap() {
1242 let g = wrapped_geom();
1243 let buf = TextBuffer::multi_line("aaaaabbbbb"); let text = "aaaaabbbbb";
1245 let (end0, _) = move_cursor_visual(
1248 &g,
1249 &buf,
1250 TextCursor {
1251 head: TextPos::from_byte(text, 2),
1252 ..Default::default()
1253 },
1254 CursorMotion::LineEnd,
1255 );
1256 assert_eq!(end0.byte, 5);
1257 let (home1, _) = move_cursor_visual(
1259 &g,
1260 &buf,
1261 TextCursor {
1262 head: TextPos::from_byte(text, 7),
1263 ..Default::default()
1264 },
1265 CursorMotion::LineStart,
1266 );
1267 assert_eq!(home1.byte, 5);
1268 let (up, _) = move_cursor_visual(
1271 &g,
1272 &buf,
1273 TextCursor {
1274 head: TextPos::from_byte(text, 7),
1275 ..Default::default()
1276 },
1277 CursorMotion::LineUp,
1278 );
1279 assert_eq!(g.visual_line_of_byte(up.byte), 0);
1280 assert_eq!(up.byte, 2);
1281 }
1282}
1283
1284#[cfg(test)]
1285mod grapheme_motion_tests {
1286 use super::*;
1292 use lumen_core::text_model::{TextBuffer, TextCursor, TextPos};
1293
1294 const COMBINING: &str = "e\u{0301}";
1296 const FLAG: &str = "\u{1F1E6}\u{1F1E7}";
1298 const ZWJ: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
1300 const DEVANAGARI: &str = "\u{0915}\u{093F}";
1302
1303 fn buf_of(s: &str) -> TextBuffer {
1304 TextBuffer::from(s)
1305 }
1306
1307 fn right(text: &str, from: usize) -> usize {
1308 move_cursor(
1309 &buf_of(text),
1310 TextPos::from_byte(text, from),
1311 CursorMotion::CharRight,
1312 )
1313 .byte
1314 }
1315
1316 fn left(text: &str, from: usize) -> usize {
1317 move_cursor(
1318 &buf_of(text),
1319 TextPos::from_byte(text, from),
1320 CursorMotion::CharLeft,
1321 )
1322 .byte
1323 }
1324
1325 #[test]
1326 fn arrow_right_clears_a_whole_cluster() {
1327 for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1328 assert_eq!(right(s, 0), s.len(), "Right must cross all of {s:?}");
1329 }
1330 }
1331
1332 #[test]
1333 fn arrow_left_clears_a_whole_cluster() {
1334 for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1335 assert_eq!(left(s, s.len()), 0, "Left must cross all of {s:?}");
1336 }
1337 }
1338
1339 #[test]
1340 fn arrow_round_trip_returns_to_the_start_byte() {
1341 for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1342 let text = format!("a{s}b");
1343 let after_a = 1;
1344 let crossed = right(&text, after_a);
1345 assert_eq!(crossed, 1 + s.len());
1346 assert_eq!(left(&text, crossed), after_a);
1347 }
1348 }
1349
1350 #[test]
1351 fn arrow_never_parks_inside_a_cluster() {
1352 let text = format!("{ZWJ}x");
1353 assert_eq!(right(&text, 0), ZWJ.len());
1354 assert_eq!(right(&text, ZWJ.len()), text.len());
1355 }
1356
1357 #[test]
1358 fn forward_delete_removes_the_whole_cluster() {
1359 for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1360 let text = format!("{s}z");
1361 let mut buf = buf_of(&text);
1362 let mut cur = TextCursor::default();
1363 let mut undo = UndoStack::default();
1364 let end = move_cursor(&buf, cur.head, CursorMotion::CharRight).byte;
1366 delete_range(&mut buf, &mut cur, &mut undo, 0..end);
1367 assert_eq!(buf.to_string(), "z", "Delete left a remnant of {s:?}");
1368 }
1369 }
1370
1371 #[test]
1372 fn backspace_still_peels_one_code_point() {
1373 assert_eq!(prev_code_point_boundary(COMBINING, COMBINING.len()), 1);
1377 assert_eq!(prev_code_point_boundary(ZWJ, ZWJ.len()), ZWJ.len() - 4);
1378 assert_eq!(prev_code_point_boundary(FLAG, FLAG.len()), 4);
1379 }
1380
1381 #[test]
1382 fn shift_arrow_extends_by_the_same_cluster_boundaries() {
1383 let text = format!("{FLAG}{COMBINING}");
1384 let buf = buf_of(&text);
1385 let mut cur = TextCursor::default();
1386 for expected in [FLAG.len(), text.len()] {
1389 let p = move_cursor(&buf, cur.head, CursorMotion::CharRight);
1390 cur.move_head(p, true);
1391 assert_eq!(cur.head.byte, expected);
1392 assert_eq!(cur.anchor.byte, 0);
1393 }
1394 assert_eq!(cur.selection_range(), Some(0..text.len()));
1395 for expected in [FLAG.len(), 0] {
1397 let p = move_cursor(&buf, cur.head, CursorMotion::CharLeft);
1398 cur.move_head(p, true);
1399 assert_eq!(cur.head.byte, expected);
1400 }
1401 assert_eq!(cur.selection_range(), None);
1402 }
1403
1404 #[test]
1409 fn ascii_steps_one_byte_at_a_time() {
1410 let text = "hello world";
1411 let mut at = 0usize;
1412 for expected in 1..=text.len() {
1413 at = right(text, at);
1414 assert_eq!(at, expected, "Right over ASCII skipped a character");
1415 }
1416 for expected in (0..text.len()).rev() {
1417 at = left(text, at);
1418 assert_eq!(at, expected, "Left over ASCII skipped a character");
1419 }
1420 }
1421
1422 #[test]
1425 fn a_newline_is_one_step() {
1426 let text = "ab\ncd";
1427 assert_eq!(right(text, 2), 3, "Right at the line end skipped the \\n");
1428 assert_eq!(right(text, 3), 4);
1429 assert_eq!(left(text, 3), 2);
1430 }
1431
1432 #[test]
1433 fn motion_snaps_a_mid_cluster_start_byte() {
1434 let b = next_grapheme_boundary(ZWJ, 4);
1437 assert_eq!(b, ZWJ.len());
1438 assert_eq!(prev_grapheme_boundary(ZWJ, 4), 0);
1439 }
1440}