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