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