lumen_core/text_model.rs
1//! W3.1: rope-backed text model components.
2//!
3//! These components REPLACE the byte-offset `TextInput.cursor` model for the
4//! W3 rewrite, but coexist with the existing `TextContent` / `TextInput` pair
5//! so the bd23f51 surgical fix keeps working through the rewrite landing.
6//!
7//! Component layering:
8//! - `TextContent` (existing) - rendered text for non-editable labels.
9//! - `TextBuffer` (new) - rope-backed authoritative text for editable
10//! entities (`<input>` / `<textarea>`). Mirrored into `TextContent` after
11//! each edit so the existing renderer / binding path keeps working
12//! unchanged.
13//! - `TextCursor` (new) - caret + selection anchor + affinity. Supplants
14//! the byte-offset fields on `TextInput`; the mirror system also writes
15//! the byte offset back into `TextInput.cursor` for legacy code paths
16//! (W3 stage; later waves remove the legacy fields).
17//! - `ImePreedit` (new) - replaces `ImeState`; carries the in-progress
18//! composition string plus the caret position inside it.
19//!
20//! The `From`/`Into` impls follow the project-memory rule: convert between
21//! types via trait impls, never bespoke `convert_x_to_y` helpers.
22
23use bevy_ecs::prelude::*;
24use ropey::Rope;
25use std::ops::Range;
26use std::sync::Arc;
27use unicode_segmentation::UnicodeSegmentation;
28
29/// Rope-backed authoritative text buffer for editable entities (W3.1).
30///
31/// - Attached alongside (and mirrored to) [`crate::components::TextContent`]
32/// on `<input>` / `<textarea>` spawn (see the bootstrap system in
33/// `lumen-text-edit`).
34/// - `version` bumps on every successful edit so derived caches (shape,
35/// validation, syntax) can be invalidated without `Changed<T>` overuse.
36/// - `kind` selects single-line vs multi-line semantics - single-line
37/// buffers reject `\n` at insert time (matches Qt's `QLineEdit::setText`).
38#[derive(Component, Clone, Debug)]
39pub struct TextBuffer {
40 /// The rope. Use [`Self::as_str`] / [`Self::slice`] for read access;
41 /// mutate only through `lumen-text-edit::text_apply_edits`.
42 pub rope: Rope,
43 /// Monotonic edit counter. Bump on every mutation.
44 pub version: u64,
45 /// Single-line vs multiline policy.
46 pub kind: TextBufferKind,
47}
48
49impl Default for TextBuffer {
50 fn default() -> Self {
51 Self {
52 rope: Rope::new(),
53 version: 0,
54 kind: TextBufferKind::default(),
55 }
56 }
57}
58
59impl TextBuffer {
60 /// Build a single-line buffer from an initial string.
61 pub fn single_line(s: &str) -> Self {
62 Self {
63 rope: Rope::from_str(s),
64 version: 0,
65 kind: TextBufferKind::SingleLine,
66 }
67 }
68
69 /// Build a multi-line buffer from an initial string.
70 pub fn multi_line(s: &str) -> Self {
71 Self {
72 rope: Rope::from_str(s),
73 version: 0,
74 kind: TextBufferKind::MultiLine,
75 }
76 }
77
78 /// Length in bytes.
79 pub fn len_bytes(&self) -> usize {
80 self.rope.len_bytes()
81 }
82
83 /// `true` when the rope is empty.
84 pub fn is_empty(&self) -> bool {
85 self.rope.len_bytes() == 0
86 }
87
88 /// Slice as a string. `range` is in bytes; out-of-range / non-boundary
89 /// inputs are clamped to the nearest valid char boundary.
90 pub fn slice(&self, range: Range<usize>) -> String {
91 let len = self.rope.len_bytes();
92 let s = range.start.min(len);
93 let e = range.end.min(len);
94 if s >= e {
95 return String::new();
96 }
97 // ropey slicing is by char index. Convert through byte_to_char.
98 let s_char = self.rope.byte_to_char(s);
99 let e_char = self.rope.byte_to_char(e);
100 self.rope.slice(s_char..e_char).to_string()
101 }
102
103 /// `true` when the buffer is single-line (rejects `\n` at insert time).
104 pub fn is_single_line(&self) -> bool {
105 matches!(self.kind, TextBufferKind::SingleLine)
106 }
107}
108
109impl std::fmt::Display for TextBuffer {
110 /// Materialise the rope as a `String`. Allocates; prefer
111 /// [`Self::slice`] when only a sub-range is needed.
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 write!(f, "{}", self.rope)
114 }
115}
116
117/// `Arc<str>` snapshot of the buffer for binding-push (`buffer -> signal`).
118impl From<&TextBuffer> for Arc<str> {
119 fn from(buf: &TextBuffer) -> Self {
120 Arc::<str>::from(buf.rope.to_string())
121 }
122}
123
124/// `String` snapshot.
125impl From<&TextBuffer> for String {
126 fn from(buf: &TextBuffer) -> Self {
127 buf.rope.to_string()
128 }
129}
130
131/// Build a multi-line buffer from a string.
132impl From<&str> for TextBuffer {
133 fn from(s: &str) -> Self {
134 Self::multi_line(s)
135 }
136}
137
138/// Build a multi-line buffer from an owned `String`.
139impl From<String> for TextBuffer {
140 fn from(s: String) -> Self {
141 Self::multi_line(&s)
142 }
143}
144
145/// Single-line vs multi-line policy for [`TextBuffer`].
146#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
147pub enum TextBufferKind {
148 /// Single line; insert paths strip `\n`. Default for `<input>`.
149 #[default]
150 SingleLine,
151 /// Multi-line; `\n` preserved. Default for `<textarea>`.
152 MultiLine,
153}
154
155/// Caret behaviour at line-wrap boundaries (Qt's `Affinity`).
156///
157/// - `Upstream`: caret renders at the end of the wrapped-from line.
158/// - `Downstream`: caret renders at the start of the wrapped-to line.
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub enum Affinity {
161 /// End-of-previous-line behaviour.
162 Upstream,
163 /// Start-of-next-line behaviour (default).
164 #[default]
165 Downstream,
166}
167
168/// Position inside a [`TextBuffer`]; carries both byte (for slicing) and
169/// grapheme (for cursor display) axes.
170#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
171pub struct TextPos {
172 /// Byte offset into the buffer.
173 pub byte: usize,
174 /// Grapheme-cluster offset, computed from `byte` against the live
175 /// buffer (use [`TextPos::from_byte`]).
176 pub grapheme: usize,
177}
178
179impl TextPos {
180 /// Origin.
181 pub const ZERO: Self = Self {
182 byte: 0,
183 grapheme: 0,
184 };
185
186 /// Build a position from a byte offset into `text`. Clamps to the
187 /// nearest char boundary, then counts graphemes up to that boundary.
188 pub fn from_byte(text: &str, byte: usize) -> Self {
189 let byte = clamp_to_char_boundary(text, byte);
190 let grapheme = text[..byte].graphemes(true).count();
191 Self { byte, grapheme }
192 }
193
194 /// Build a position from a byte offset into a [`TextBuffer`].
195 pub fn from_buffer_byte(buf: &TextBuffer, byte: usize) -> Self {
196 let s = buf.rope.to_string();
197 Self::from_byte(&s, byte)
198 }
199}
200
201impl From<&TextBuffer> for TextPos {
202 /// End-of-buffer.
203 fn from(buf: &TextBuffer) -> Self {
204 let s = buf.rope.to_string();
205 Self::from_byte(&s, s.len())
206 }
207}
208
209fn clamp_to_char_boundary(s: &str, mut at: usize) -> usize {
210 if at > s.len() {
211 at = s.len();
212 }
213 while at > 0 && !s.is_char_boundary(at) {
214 at -= 1;
215 }
216 at
217}
218
219/// Caret + selection state on an editable entity (W3.1).
220#[derive(Component, Clone, Copy, Debug, Default)]
221pub struct TextCursor {
222 /// Caret position (the moving end of the selection).
223 pub head: TextPos,
224 /// Selection anchor (the fixed end). Equal to `head` => no selection.
225 pub anchor: TextPos,
226 /// Affinity at the head.
227 pub affinity: Affinity,
228 /// D5: sticky visual x for vertical motion (Qt `verticalMovementX`).
229 /// `None` means "recompute from the caret's current x on the next
230 /// vertical motion"; any horizontal motion or edit resets it to `None`.
231 /// A pixel x (not a byte column) so it tracks through proportional
232 /// glyphs and wrapped lines.
233 pub goal_x: Option<f32>,
234}
235
236impl TextCursor {
237 /// `true` when there is no selection (head == anchor).
238 pub fn is_empty(&self) -> bool {
239 self.head.byte == self.anchor.byte
240 }
241
242 /// Selection byte range, sorted low -> high. `None` when empty.
243 pub fn selection_range(&self) -> Option<Range<usize>> {
244 if self.is_empty() {
245 return None;
246 }
247 let lo = self.head.byte.min(self.anchor.byte);
248 let hi = self.head.byte.max(self.anchor.byte);
249 Some(lo..hi)
250 }
251
252 /// Collapse selection to the head.
253 pub fn collapse(&mut self) {
254 self.anchor = self.head;
255 }
256
257 /// Move head to `pos`; if `keep_anchor` is false, anchor follows.
258 pub fn move_head(&mut self, pos: TextPos, keep_anchor: bool) {
259 self.head = pos;
260 if !keep_anchor {
261 self.anchor = pos;
262 }
263 }
264}
265
266/// Stable identifier for a [`TextMark`]. Marks survive edits.
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
268pub struct MarkId(pub u64);
269
270/// Insertion-at-mark bias (Qt's `QTextCursor::MoveMode::KeepAnchor` for
271/// marks): when an insertion lands AT a mark's byte position, should the
272/// mark stay before (`Backward`) or after (`Forward`) the new text?
273#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
274pub enum Bias {
275 /// Mark stays before inserted-at-mark text.
276 #[default]
277 Backward,
278 /// Mark moves to the end of inserted-at-mark text.
279 Forward,
280}
281
282/// A point inside a [`TextBuffer`] that survives edits (GTK's
283/// `GtkTextMark`). The `text_apply_edits` mutator shifts marks during
284/// insert/delete according to [`Self::bias`].
285#[derive(Clone, Copy, Debug)]
286pub struct TextMark {
287 /// Stable id.
288 pub id: MarkId,
289 /// Current byte offset; updated by the edit mutator.
290 pub byte: usize,
291 /// Insertion-at-mark policy.
292 pub bias: Bias,
293}
294
295/// Tag flavours carried by [`TextTagRange`].
296#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
297pub enum TagKind {
298 /// IME preedit underline (uncommitted composition).
299 PreeditUnderline,
300 /// IME preedit converted segment (committed-but-uncommitted in CJK).
301 PreeditConverted,
302 /// User selection highlight (renderer paints translucent background).
303 SelectedHighlight,
304 /// Syntax-colour overlay (paint with carried `u32` colour packed RGBA).
305 SyntaxColour(u32),
306 /// Generic highlight (link / search hit).
307 Highlight(u32),
308}
309
310/// A tagged byte range inside a [`TextBuffer`]; survives edits via two
311/// [`TextMark`]s at the endpoints.
312#[derive(Clone, Copy, Debug)]
313pub struct TextTagRange {
314 /// Range start.
315 pub start: TextMark,
316 /// Range end (inclusive byte; convention: `end.byte > start.byte`).
317 pub end: TextMark,
318 /// Tag flavour.
319 pub tag: TagKind,
320}
321
322/// IME preedit (composition) state on a focused editable. Replaces the
323/// pre-W3 `ImeState` for entities with a [`TextBuffer`]; the legacy
324/// `ImeState` stays for backwards compatibility with the old `TextInput`
325/// path until the W3 migration completes.
326#[derive(Component, Clone, Debug, Default)]
327pub struct ImePreedit {
328 /// In-progress composition string.
329 pub text: String,
330 /// Caret byte offset INSIDE [`Self::text`].
331 pub caret_in_preedit: usize,
332}
333
334/// Marker: this entity is editable text (W3.1).
335///
336/// - Attached alongside `TextInput` by the bootstrap system in
337/// `lumen-text-edit` so spawn-side compatibility holds without touching
338/// `crates/lumenc/src/spawn.rs`.
339/// - Future migration: replace `TextInput` with this + a policy bundle.
340#[derive(Component, Clone, Copy, Debug, Default)]
341pub struct TextEditable;
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn text_pos_from_byte_clamps_to_boundary() {
349 let s = "h\u{e9}llo"; // '\u{e9}' = 2 bytes
350 // byte 2 lands mid-'\u{e9}'? no, 'h'=1, '\u{e9}'=2 -> 1..3, so byte 2 is mid.
351 let p = TextPos::from_byte(s, 2);
352 assert_eq!(p.byte, 1);
353 assert_eq!(p.grapheme, 1);
354 }
355
356 #[test]
357 fn from_str_makes_multiline_buffer() {
358 let buf: TextBuffer = "hi".into();
359 assert!(!buf.is_single_line());
360 assert_eq!(buf.to_string(), "hi");
361 }
362
363 #[test]
364 fn arc_str_snapshot_roundtrip() {
365 let buf = TextBuffer::single_line("hi");
366 let s: Arc<str> = (&buf).into();
367 assert_eq!(&*s, "hi");
368 }
369
370 #[test]
371 fn cursor_selection_range_sorted() {
372 let c = TextCursor {
373 head: TextPos {
374 byte: 5,
375 grapheme: 5,
376 },
377 anchor: TextPos {
378 byte: 2,
379 grapheme: 2,
380 },
381 ..Default::default()
382 };
383 assert_eq!(c.selection_range(), Some(2..5));
384 }
385
386 #[test]
387 fn cursor_collapse_clears_selection() {
388 let mut c = TextCursor {
389 head: TextPos {
390 byte: 5,
391 grapheme: 5,
392 },
393 anchor: TextPos {
394 byte: 2,
395 grapheme: 2,
396 },
397 ..Default::default()
398 };
399 c.collapse();
400 assert!(c.is_empty());
401 }
402
403 #[test]
404 fn buffer_slice_clamps() {
405 let buf = TextBuffer::multi_line("hello");
406 // Past-end clamps.
407 assert_eq!(buf.slice(0..100), "hello");
408 // Mid-range works.
409 assert_eq!(buf.slice(1..4), "ell");
410 // Inverted range yields empty.
411 #[allow(clippy::reversed_empty_ranges)]
412 let inverted = 4..1;
413 assert_eq!(buf.slice(inverted), "");
414 }
415}