Skip to main content

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}