Skip to main content

densa_paragraph/
editor.rs

1use std::ops::Range;
2
3use densa_core::Rect;
4
5use crate::history::{Edit, History, Kind};
6use crate::selection::backspace_start;
7use crate::style::{is_char_range, ParagraphError, ParagraphStyle};
8use crate::{Affinity, Motion, Paragraph, TextSelection, TextSystem};
9
10/// Editable text: a [`Paragraph`] with a [`TextSelection`], typing, deletion and input-method
11/// composition.
12///
13/// Every change to the text (typing, deleting, composing, a new style, undo and redo) lays
14/// the text out again, which allocates; moving the caret, drawing
15/// [`paragraph`](Self::paragraph) and the geometry queries do not (except the move that
16/// keeps a preedit as text, an edit). A change that fails leaves the editor as it was.
17///
18/// Input methods (IME) compose text before committing it: forward their preedit to
19/// [`set_composition`](Self::set_composition) (an empty preedit removes it) and the committed
20/// text to [`insert`](Self::insert); underline [`for_each_composition_rect`] and report
21/// [`ime_area`](Self::ime_area) to the window so the candidate list appears next to the text.
22/// Typing replaces an active preedit, deleting cancels it (restoring any selection it
23/// replaced), and moving the caret keeps it as ordinary text.
24///
25/// Edits can be undone and redone ([`undo`](Self::undo), [`redo`](Self::redo)): typing is one
26/// step per word, and Backspace or Delete pressed repeatedly one step, until the caret
27/// moves. A preedit is not an edit until it is committed or kept. Up to 100 steps are kept.
28///
29/// Not yet supported: styled spans, a maximum length.
30///
31/// [`for_each_composition_rect`]: Self::for_each_composition_rect
32#[derive(Debug)]
33pub struct TextEditor {
34    text: String,
35    style: ParagraphStyle,
36    paragraph: Paragraph,
37    selection: TextSelection,
38    composition: Option<Composition>,
39    shows_caret: bool,
40    history: History,
41}
42
43/// Preedit text an input method is composing.
44#[derive(Debug, Clone)]
45struct Composition {
46    /// Byte range of the preedit text.
47    range: Range<usize>,
48    /// The selected text the preedit replaced, put back if the input method cancels.
49    replaced: String,
50    /// The selection (anchor, focus) before composing started.
51    before: (usize, usize),
52}
53
54/// Characters after which the caret belongs to the next line: the hard line breaks of
55/// Unicode's line breaking algorithm (UAX #14 classes BK, CR, LF and NL).
56const LINE_BREAKS: [char; 7] = [
57    '\n', '\r', '\u{0B}', '\u{0C}', '\u{85}', '\u{2028}', '\u{2029}',
58];
59
60impl TextEditor {
61    /// An editor holding `text` set in `style`, with the caret at the end.
62    ///
63    /// # Errors
64    /// [`ParagraphError`] if `style` is out of range or a font cannot be read.
65    pub fn new(
66        system: &mut TextSystem,
67        text: impl Into<String>,
68        style: ParagraphStyle,
69    ) -> Result<Self, ParagraphError> {
70        let text = text.into();
71        let paragraph = system.paragraph(&text, &style, &[])?;
72        let selection = TextSelection::caret_at(&paragraph, text.len());
73        Ok(Self {
74            text,
75            style,
76            paragraph,
77            selection,
78            composition: None,
79            shows_caret: true,
80            history: History::default(),
81        })
82    }
83
84    /// The text, including any preedit text being composed.
85    pub fn text(&self) -> &str {
86        &self.text
87    }
88
89    /// The style the text is set in.
90    pub fn style(&self) -> &ParagraphStyle {
91        &self.style
92    }
93
94    /// The laid-out text, to draw and to hit-test.
95    pub fn paragraph(&self) -> &Paragraph {
96        &self.paragraph
97    }
98
99    /// The caret or selected range.
100    pub fn selection(&self) -> TextSelection {
101        self.selection
102    }
103
104    /// The selected text (empty for a caret), for copying.
105    pub fn selected_text(&self) -> &str {
106        &self.text[self.selection.range()]
107    }
108
109    /// The byte range of the preedit text an input method is composing, if any.
110    pub fn composition(&self) -> Option<Range<usize>> {
111        self.composition.as_ref().map(|c| c.range.clone())
112    }
113
114    /// Whether to draw the caret: an input method can hide it while it composes.
115    pub fn shows_caret(&self) -> bool {
116        self.shows_caret
117    }
118
119    /// Selects `selection` (clamped to this text), for example from a click or a drag. A
120    /// preedit being composed stays as ordinary text.
121    pub fn set_selection(&mut self, selection: TextSelection) {
122        self.keep_composition();
123        self.history.seal();
124        self.selection = selection.refreshed(&self.paragraph);
125    }
126
127    /// Moves the caret by `motion`, or with `extend` moves the selection's focus (shift +
128    /// navigation keys). A preedit being composed stays as ordinary text.
129    pub fn move_caret(&mut self, motion: Motion, extend: bool) {
130        self.keep_composition();
131        self.history.seal();
132        self.selection = self.selection.moved(&self.paragraph, motion, extend);
133    }
134
135    /// Types `text` (a key press, a paste or committed input-method text): it replaces the
136    /// preedit being composed if there is one, otherwise the selection, and the caret
137    /// follows it.
138    ///
139    /// # Errors
140    /// [`ParagraphError::UnreadableFont`] if a font chosen for the new text cannot be read.
141    pub fn insert(&mut self, system: &mut TextSystem, text: &str) -> Result<(), ParagraphError> {
142        self.insert_as(system, text, Kind::Typing)
143    }
144
145    /// Inserts `text` as [`insert`](Self::insert) does, as an undo step of its own: for a
146    /// paste or a drop, rather than typing.
147    ///
148    /// # Errors
149    /// [`ParagraphError::UnreadableFont`] if a font chosen for the new text cannot be read.
150    pub fn paste(&mut self, system: &mut TextSystem, text: &str) -> Result<(), ParagraphError> {
151        self.insert_as(system, text, Kind::Other)
152    }
153
154    fn insert_as(
155        &mut self,
156        system: &mut TextSystem,
157        text: &str,
158        kind: Kind,
159    ) -> Result<(), ParagraphError> {
160        let (range, removed, before) = match &self.composition {
161            Some(c) => (c.range.clone(), c.replaced.clone(), c.before),
162            None => (
163                self.selection.range(),
164                self.selected_text().to_owned(),
165                self.ends(),
166            ),
167        };
168        let at = range.start;
169        let caret = range.start + text.len();
170        // After a line break the caret belongs to the new line; otherwise it stays at the
171        // end of the line it typed on, even where that line wraps.
172        let affinity = if text.ends_with(LINE_BREAKS) {
173            Affinity::Downstream
174        } else {
175            Affinity::Upstream
176        };
177        self.replace(system, range, text, |p| {
178            let at = TextSelection::caret_with(p, caret, affinity);
179            // A caret inside a character snaps to its start: typing a letter before a
180            // combining accent forms one character, and the caret belongs after it.
181            if at.focus() < caret {
182                at.moved(p, Motion::Next, false)
183            } else {
184                at
185            }
186        })?;
187        self.composition = None;
188        self.shows_caret = true;
189        self.record(at, removed, text, before, kind);
190        Ok(())
191    }
192
193    /// Deletes the selection, or with a caret the text between it and where `motion` would
194    /// move it: `Previous` for Backspace (one character, so a combining accent comes off its
195    /// letter; a line break or emoji goes whole), `Next` for Delete, `PreviousWord` and
196    /// `NextWord` with Ctrl, `LineStart` to delete to the start of the line. A preedit being
197    /// composed is removed instead.
198    ///
199    /// # Errors
200    /// [`ParagraphError::UnreadableFont`] if a font chosen for the remaining text cannot be
201    /// read.
202    pub fn delete(
203        &mut self,
204        system: &mut TextSystem,
205        motion: Motion,
206    ) -> Result<(), ParagraphError> {
207        if self.composition.is_some() {
208            return self.set_composition(system, "", None);
209        }
210        let range = self.deletion_range(motion);
211        if range.is_empty() {
212            return Ok(());
213        }
214        let kind = match motion {
215            _ if !self.selection.is_caret() => Kind::Other,
216            Motion::Previous | Motion::PreviousWord => Kind::Backspace,
217            Motion::Next | Motion::NextWord => Kind::ForwardDelete,
218            _ => Kind::Other,
219        };
220        let (before, removed) = (self.ends(), self.text[range.clone()].to_owned());
221        let caret = range.start;
222        self.replace(system, range, "", |p| TextSelection::caret_at(p, caret))?;
223        self.record(caret, removed, "", before, kind);
224        Ok(())
225    }
226
227    /// Takes back the last edit, selecting what was selected before it; `false` if there
228    /// was none. A preedit being composed is removed first.
229    ///
230    /// # Errors
231    /// [`ParagraphError::UnreadableFont`] if a font cannot be read; the edit stays undoable.
232    pub fn undo(&mut self, system: &mut TextSystem) -> Result<bool, ParagraphError> {
233        self.cancel_composition(system)?;
234        let Some(edit) = self.history.take_undo() else {
235            return Ok(false);
236        };
237        let range = edit.at..edit.at + edit.inserted.len();
238        let (anchor, focus) = edit.before;
239        let undone = self.replace(system, range, &edit.removed, |p| {
240            TextSelection::from_range(p, anchor, focus)
241        });
242        match undone {
243            Ok(()) => self.history.push_redo(edit),
244            Err(_) => self.history.push_undo(edit),
245        }
246        undone.map(|()| true)
247    }
248
249    /// Makes the last undone edit again, selecting what it left selected; `false` if there
250    /// was none. A new edit makes undone ones unavailable.
251    ///
252    /// # Errors
253    /// [`ParagraphError::UnreadableFont`] if a font cannot be read; the edit stays redoable.
254    pub fn redo(&mut self, system: &mut TextSystem) -> Result<bool, ParagraphError> {
255        self.cancel_composition(system)?;
256        let Some(edit) = self.history.take_redo() else {
257            return Ok(false);
258        };
259        let range = edit.at..edit.at + edit.removed.len();
260        let (anchor, focus) = edit.after;
261        let redone = self.replace(system, range, &edit.inserted, |p| {
262            TextSelection::from_range(p, anchor, focus)
263        });
264        match redone {
265            Ok(()) => self.history.push_undo(edit),
266            Err(_) => self.history.push_redo(edit),
267        }
268        redone.map(|()| true)
269    }
270
271    /// Whether there is an edit to [`undo`](Self::undo).
272    pub fn can_undo(&self) -> bool {
273        self.history.can_undo()
274    }
275
276    /// Whether there is an edit to [`redo`](Self::redo).
277    pub fn can_redo(&self) -> bool {
278        self.history.can_redo()
279    }
280
281    /// Replaces the whole text with `text`, the caret at its end, and forgets the edits to
282    /// undo and redo (as loading a new document does).
283    ///
284    /// # Errors
285    /// [`ParagraphError::UnreadableFont`] if a font chosen for `text` cannot be read.
286    pub fn set_text(&mut self, system: &mut TextSystem, text: &str) -> Result<(), ParagraphError> {
287        let all = 0..self.text.len();
288        let caret = text.len();
289        self.replace(system, all, text, |p| TextSelection::caret_at(p, caret))?;
290        self.composition = None;
291        self.shows_caret = true;
292        self.history.clear();
293        Ok(())
294    }
295
296    /// Sets the text in `style`, keeping the text and the selection.
297    ///
298    /// # Errors
299    /// [`ParagraphError`] if `style` is out of range or a font cannot be read; the editor
300    /// keeps its previous style.
301    pub fn set_style(
302        &mut self,
303        system: &mut TextSystem,
304        style: ParagraphStyle,
305    ) -> Result<(), ParagraphError> {
306        let paragraph = system.paragraph(&self.text, &style, &[])?;
307        self.selection = self.selection.refreshed(&paragraph);
308        self.paragraph = paragraph;
309        self.style = style;
310        Ok(())
311    }
312
313    /// Wraps the text at `max_width` pixels (`None`: only at line breaks) without shaping
314    /// it again: call when the editor is resized.
315    ///
316    /// # Errors
317    /// [`ParagraphError::InvalidWidth`] if `max_width` is negative or not finite; nothing
318    /// changes.
319    pub fn set_width(&mut self, max_width: Option<f32>) -> Result<(), ParagraphError> {
320        self.paragraph.reflow(max_width)?;
321        self.style.max_width = max_width;
322        self.selection = self.selection.refreshed(&self.paragraph);
323        Ok(())
324    }
325
326    /// Shows an input method's preedit `text` in place of the selection (or of the previous
327    /// preedit), with the input method's `cursor` (a byte range within `text`) selected, or
328    /// the caret hidden for `None`. An empty `text` removes the preedit and restores what it
329    /// replaced: the selected text, selected again, or the caret where it was.
330    ///
331    /// # Errors
332    /// [`ParagraphError::InvalidRange`] if `cursor` is reversed, outside `text` or splits a
333    /// character; [`ParagraphError::UnreadableFont`] if a font cannot be read. Nothing
334    /// changes on error.
335    pub fn set_composition(
336        &mut self,
337        system: &mut TextSystem,
338        text: &str,
339        cursor: Option<Range<usize>>,
340    ) -> Result<(), ParagraphError> {
341        if let Some(cursor) = cursor.as_ref().filter(|c| !is_char_range(text, c)) {
342            return Err(ParagraphError::InvalidRange(cursor.clone()));
343        }
344        if text.is_empty() {
345            return self.cancel_composition(system);
346        }
347        let (range, replaced, before) = match &self.composition {
348            Some(c) => (c.range.clone(), c.replaced.clone(), c.before),
349            None => (
350                self.selection.range(),
351                self.selected_text().to_owned(),
352                self.ends(),
353            ),
354        };
355        let start = range.start;
356        let selected = cursor.clone().unwrap_or(0..0);
357        self.replace(system, range, text, |p| {
358            TextSelection::from_range(p, start + selected.start, start + selected.end)
359        })?;
360        self.composition = Some(Composition {
361            range: start..start + text.len(),
362            replaced,
363            before,
364        });
365        self.shows_caret = cursor.is_some();
366        Ok(())
367    }
368
369    /// Calls `f` with each rectangle covering the preedit text (one per line it spans),
370    /// relative to the paragraph's top-left corner: draw an underline along their bottoms.
371    pub fn for_each_composition_rect(&self, f: impl FnMut(Rect)) {
372        if let Some(range) = self.composition() {
373            TextSelection::from_range(&self.paragraph, range.start, range.end)
374                .for_each_rect(&self.paragraph, f);
375        }
376    }
377
378    /// Where an input method should show its candidate window, relative to the paragraph's
379    /// top-left corner: around the preedit text while composing, otherwise the caret.
380    pub fn ime_area(&self) -> Rect {
381        let caret = self.selection.caret_rect(&self.paragraph, 0.0);
382        let mut area: Option<Rect> = None;
383        self.for_each_composition_rect(|rect| {
384            area = Some(area.map_or(rect, |a| union(a, rect)));
385        });
386        area.unwrap_or(caret)
387    }
388
389    /// The byte range `delete(motion)` removes.
390    fn deletion_range(&self, motion: Motion) -> Range<usize> {
391        if !self.selection.is_caret() {
392            return self.selection.range();
393        }
394        let caret = self.selection.focus();
395        if motion == Motion::Previous {
396            return backspace_start(&self.paragraph, &self.text, caret)..caret;
397        }
398        self.selection.moved(&self.paragraph, motion, true).range()
399    }
400
401    /// Removes the preedit text and puts back what it replaced, selected.
402    fn cancel_composition(&mut self, system: &mut TextSystem) -> Result<(), ParagraphError> {
403        if let Some(Composition {
404            range, replaced, ..
405        }) = self.composition.clone()
406        {
407            let (start, end) = (range.start, range.start + replaced.len());
408            self.replace(system, range, &replaced, |p| {
409                TextSelection::from_range(p, start, end)
410            })?;
411        }
412        self.composition = None;
413        self.shows_caret = true;
414        Ok(())
415    }
416
417    /// Keeps the preedit text as ordinary text: an edit from then on.
418    fn keep_composition(&mut self) {
419        if let Some(c) = self.composition.take() {
420            let kept = self.text[c.range.clone()].to_owned();
421            self.record(c.range.start, c.replaced, &kept, c.before, Kind::Other);
422        }
423        self.shows_caret = true;
424    }
425
426    /// The selection's ends: (anchor, focus).
427    fn ends(&self) -> (usize, usize) {
428        (self.selection.anchor(), self.selection.focus())
429    }
430
431    /// Records that `removed` at `at` became `inserted`, leaving the current selection.
432    fn record(
433        &mut self,
434        at: usize,
435        removed: String,
436        inserted: &str,
437        before: (usize, usize),
438        kind: Kind,
439    ) {
440        if removed.is_empty() && inserted.is_empty() {
441            return;
442        }
443        self.history.record(Edit {
444            at,
445            removed,
446            inserted: inserted.to_owned(),
447            before,
448            after: self.ends(),
449            kind,
450        });
451    }
452
453    /// Replaces `range` of the text with `with`, lays it out again and selects what
454    /// `select` returns for the new layout; on error nothing changes.
455    fn replace(
456        &mut self,
457        system: &mut TextSystem,
458        range: Range<usize>,
459        with: &str,
460        select: impl FnOnce(&Paragraph) -> TextSelection,
461    ) -> Result<(), ParagraphError> {
462        let mut text = String::with_capacity(self.text.len() - range.len() + with.len());
463        text.push_str(&self.text[..range.start]);
464        text.push_str(with);
465        text.push_str(&self.text[range.end..]);
466        let paragraph = system.paragraph(&text, &self.style, &[])?;
467        self.selection = select(&paragraph);
468        self.paragraph = paragraph;
469        self.text = text;
470        Ok(())
471    }
472}
473
474/// The smallest rectangle containing `a` and `b`.
475fn union(a: Rect, b: Rect) -> Rect {
476    Rect::from_edges(
477        a.left().min(b.left()),
478        a.top().min(b.top()),
479        a.right().max(b.right()),
480        a.bottom().max(b.bottom()),
481    )
482}