Skip to main content

agg_gui/widgets/rich_text/
model.rs

1//! Rich-text **document model** and structural editing primitives.
2//!
3//! This is the data core of the `rich_text` widget family (see the module
4//! [`super`] header for the phase plan).  It defines the owner-specified
5//! document types — [`InlineStyle`], [`TextRun`], [`ListKind`], [`Block`],
6//! [`RichDoc`] — plus position/range helpers ([`DocPos`], [`DocRange`]) and
7//! the low-level merge / split / normalize operations that every higher layer
8//! (the command engine in [`super::commands`], and phase 2's keyboard editing)
9//! builds on.
10//!
11//! # Position model
12//!
13//! A [`DocPos`] addresses a caret location as `(block, byte)` where `byte` is a
14//! byte offset into the block's **flattened** text (the concatenation of all
15//! its runs' strings).  All offsets are expected to fall on UTF-8 char
16//! boundaries — callers that derive offsets from real text always satisfy this.
17//!
18//! # Normalization invariant
19//!
20//! After any structural edit the affected block is [`Block::normalize`]d:
21//! zero-length runs are dropped and adjacent runs with identical
22//! [`InlineStyle`] are merged.  This keeps the run list canonical so tests and
23//! layout see the minimal representation.
24
25use crate::color::Color;
26use crate::widgets::text_area::TextHAlign;
27
28/// Per-run inline character formatting.
29///
30/// Every `Option` field means "inherit the widget's default" when `None`;
31/// `Some(_)` overrides it.  The four boolean attributes are absolute (a run is
32/// either bold or not).
33#[derive(Clone, Debug, Default, PartialEq)]
34pub struct InlineStyle {
35    /// Bold weight (the resolver picks the matching face).
36    pub bold: bool,
37    /// Italic slant (the resolver picks the matching face).
38    pub italic: bool,
39    /// Draw an underline under the run.
40    pub underline: bool,
41    /// Draw a line through the run.
42    pub strikethrough: bool,
43    /// Font family name, or `None` to inherit the widget default family.
44    pub font_family: Option<String>,
45    /// Font size in points, or `None` to inherit the widget default size.
46    pub font_size: Option<f64>,
47    /// Text colour, or `None` to inherit the theme's text colour.
48    pub text_color: Option<Color>,
49    /// Highlight (background) colour behind the run, or `None` for none.
50    pub highlight: Option<Color>,
51}
52
53/// A contiguous span of text sharing one [`InlineStyle`].
54#[derive(Clone, Debug, PartialEq)]
55pub struct TextRun {
56    /// The run's characters (never contains `\n` — newlines are block breaks).
57    pub text: String,
58    /// The inline formatting shared by every character in the run.
59    pub style: InlineStyle,
60}
61
62impl TextRun {
63    /// A run of `text` in `style`.
64    pub fn new(text: impl Into<String>, style: InlineStyle) -> Self {
65        Self {
66            text: text.into(),
67            style,
68        }
69    }
70
71    /// A run of `text` in the default (fully-inherited) style.
72    pub fn plain(text: impl Into<String>) -> Self {
73        Self::new(text, InlineStyle::default())
74    }
75}
76
77/// List decoration applied to a whole [`Block`].
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
79pub enum ListKind {
80    /// Not a list item — a plain paragraph.
81    #[default]
82    None,
83    /// A bulleted (`•`) list item.
84    Bullet,
85    /// An ordered (`1.`, `2.`, …) list item.
86    Ordered,
87}
88
89/// A paragraph: an ordered list of styled runs plus block-level attributes
90/// (alignment, list decoration, indent depth).
91#[derive(Clone, Debug, PartialEq)]
92pub struct Block {
93    /// The paragraph's styled runs, in order.
94    pub runs: Vec<TextRun>,
95    /// Horizontal text alignment within the block.
96    pub align: TextHAlign,
97    /// List decoration (none / bullet / ordered).
98    pub list: ListKind,
99    /// Indent depth; each level offsets the block by [`INDENT_PX`](super::layout::INDENT_PX).
100    pub indent: u8,
101}
102
103impl Default for Block {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl Block {
110    /// An empty paragraph (no runs) with default attributes.
111    pub fn new() -> Self {
112        Self {
113            runs: Vec::new(),
114            align: TextHAlign::Left,
115            list: ListKind::None,
116            indent: 0,
117        }
118    }
119
120    /// A paragraph containing a single styled run.
121    pub fn from_run(run: TextRun) -> Self {
122        Self {
123            runs: vec![run],
124            ..Self::new()
125        }
126    }
127
128    /// A paragraph of plain (default-style) text.
129    pub fn plain(text: impl Into<String>) -> Self {
130        let text = text.into();
131        if text.is_empty() {
132            Self::new()
133        } else {
134            Self::from_run(TextRun::plain(text))
135        }
136    }
137
138    /// Total byte length of this block's flattened text.
139    pub fn text_len(&self) -> usize {
140        self.runs.iter().map(|r| r.text.len()).sum()
141    }
142
143    /// The block's flattened text (all runs concatenated).
144    pub fn text(&self) -> String {
145        self.runs.iter().map(|r| r.text.as_str()).collect()
146    }
147
148    /// Ensure a run boundary exists at flattened byte offset `byte`, splitting
149    /// the straddling run when necessary.  Returns the index of the run that
150    /// *begins* at `byte` (or `runs.len()` when `byte` is at/after the end).
151    ///
152    /// This is the fundamental primitive for applying a style or removing text
153    /// across an arbitrary sub-range: split at both edges, then operate on the
154    /// runs in between.
155    pub fn ensure_boundary(&mut self, byte: usize) -> usize {
156        let mut acc = 0usize;
157        let mut i = 0usize;
158        while i < self.runs.len() {
159            if byte == acc {
160                return i;
161            }
162            let len = self.runs[i].text.len();
163            if byte < acc + len {
164                let split_at = byte - acc;
165                debug_assert!(
166                    self.runs[i].text.is_char_boundary(split_at),
167                    "DocPos byte offset must land on a UTF-8 char boundary"
168                );
169                let tail = self.runs[i].text.split_off(split_at);
170                let style = self.runs[i].style.clone();
171                self.runs.insert(i + 1, TextRun { text: tail, style });
172                return i + 1;
173            }
174            acc += len;
175            i += 1;
176        }
177        self.runs.len()
178    }
179
180    /// Drop empty runs and merge adjacent runs that share an identical style.
181    pub fn normalize(&mut self) {
182        self.runs.retain(|r| !r.text.is_empty());
183        let mut i = 0;
184        while i + 1 < self.runs.len() {
185            if self.runs[i].style == self.runs[i + 1].style {
186                let next = self.runs.remove(i + 1);
187                self.runs[i].text.push_str(&next.text);
188            } else {
189                i += 1;
190            }
191        }
192    }
193}
194
195/// A whole rich-text document: an ordered list of blocks (paragraphs).
196#[derive(Clone, Debug, PartialEq)]
197pub struct RichDoc {
198    /// The document's paragraphs, in order (always at least one).
199    pub blocks: Vec<Block>,
200}
201
202impl Default for RichDoc {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208impl RichDoc {
209    /// A document with a single empty paragraph — the canonical "blank" state.
210    pub fn new() -> Self {
211        Self {
212            blocks: vec![Block::new()],
213        }
214    }
215
216    /// A document from `blocks`, substituting the blank state when empty.
217    pub fn from_blocks(blocks: Vec<Block>) -> Self {
218        if blocks.is_empty() {
219            Self::new()
220        } else {
221            Self { blocks }
222        }
223    }
224
225    /// Position just past the last character of the last block.
226    pub fn end_pos(&self) -> DocPos {
227        let block = self.blocks.len().saturating_sub(1);
228        DocPos {
229            block,
230            byte: self.blocks.get(block).map(Block::text_len).unwrap_or(0),
231        }
232    }
233
234    /// The document's text with blocks separated by `\n`.
235    pub fn plain_text(&self) -> String {
236        self.blocks
237            .iter()
238            .map(Block::text)
239            .collect::<Vec<_>>()
240            .join("\n")
241    }
242}
243
244// ---------------------------------------------------------------------------
245// Positions and ranges
246// ---------------------------------------------------------------------------
247
248/// A caret position: block index + byte offset into that block's flattened
249/// text.  Derives `Ord` so positions sort by `(block, byte)` — earlier in the
250/// document is "less".
251#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
252pub struct DocPos {
253    /// Index of the block (paragraph) this position lies in.
254    pub block: usize,
255    /// Byte offset into that block's flattened text.
256    pub byte: usize,
257}
258
259impl DocPos {
260    /// A position at `byte` within `block`.
261    pub fn new(block: usize, byte: usize) -> Self {
262        Self { block, byte }
263    }
264}
265
266/// A selection between two positions.  `start`/`end` need not be ordered;
267/// [`DocRange::ordered`] returns them low-to-high.
268#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub struct DocRange {
270    /// One endpoint (not necessarily the earlier one).
271    pub start: DocPos,
272    /// The other endpoint (not necessarily the later one).
273    pub end: DocPos,
274}
275
276impl DocRange {
277    /// A range between `start` and `end` (in either document order).
278    pub fn new(start: DocPos, end: DocPos) -> Self {
279        Self { start, end }
280    }
281
282    /// A zero-width range at a single position.
283    pub fn collapsed(pos: DocPos) -> Self {
284        Self {
285            start: pos,
286            end: pos,
287        }
288    }
289
290    /// `true` when the range selects nothing.
291    pub fn is_empty(&self) -> bool {
292        self.start == self.end
293    }
294
295    /// `(low, high)` — the two endpoints in document order.
296    pub fn ordered(&self) -> (DocPos, DocPos) {
297        if self.start <= self.end {
298            (self.start, self.end)
299        } else {
300            (self.end, self.start)
301        }
302    }
303
304    /// The earlier endpoint in document order.
305    pub fn min(&self) -> DocPos {
306        self.ordered().0
307    }
308
309    /// The later endpoint in document order.
310    pub fn max(&self) -> DocPos {
311        self.ordered().1
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Structural edits
317// ---------------------------------------------------------------------------
318
319/// Insert `text` (all in `style`) at `pos`.  Adjacent identical-style runs are
320/// merged afterward, so passing the surrounding run's style yields a seamless
321/// splice.
322pub fn insert_text(doc: &mut RichDoc, pos: DocPos, text: &str, style: InlineStyle) {
323    if text.is_empty() {
324        return;
325    }
326    let Some(block) = doc.blocks.get_mut(pos.block) else {
327        return;
328    };
329    let idx = block.ensure_boundary(pos.byte);
330    block.runs.insert(
331        idx,
332        TextRun {
333            text: text.to_string(),
334            style,
335        },
336    );
337    block.normalize();
338}
339
340/// Remove everything inside `range`, preserving the styles of the surviving
341/// text.  When the range spans multiple blocks the tail of the last block is
342/// merged into the first (Backspace-across-paragraphs semantics).  Returns the
343/// collapse position (the range's low endpoint).
344pub fn remove_range(doc: &mut RichDoc, range: DocRange) -> DocPos {
345    let (a, b) = range.ordered();
346    if a == b {
347        return a;
348    }
349
350    // Clamp the high endpoint's block into range so the primitive is total — a
351    // range whose `end` names a block past the document (as a select-all to
352    // `end_pos` of a shrunk doc might) never indexes out of bounds.
353    let b = DocPos {
354        block: b.block.min(doc.blocks.len().saturating_sub(1)),
355        byte: b.byte,
356    };
357
358    if a.block == b.block {
359        if let Some(block) = doc.blocks.get_mut(a.block) {
360            let start = block.ensure_boundary(a.byte);
361            let end = block.ensure_boundary(b.byte);
362            block.runs.drain(start..end);
363            block.normalize();
364        }
365        return a;
366    }
367
368    // Multi-block: keep the head of `a.block`, the tail of `b.block`, drop the
369    // interior, then splice the two survivors into one paragraph.
370    if let Some(block) = doc.blocks.get_mut(a.block) {
371        let start = block.ensure_boundary(a.byte);
372        block.runs.truncate(start);
373    }
374    let tail_runs = if let Some(block) = doc.blocks.get_mut(b.block) {
375        let end = block.ensure_boundary(b.byte);
376        block.runs.drain(0..end);
377        std::mem::take(&mut block.runs)
378    } else {
379        Vec::new()
380    };
381    if let Some(block) = doc.blocks.get_mut(a.block) {
382        block.runs.extend(tail_runs);
383        block.normalize();
384    }
385    doc.blocks.drain(a.block + 1..=b.block);
386    a
387}
388
389/// Split the block at `pos` into two paragraphs (Enter semantics).  The new
390/// block inherits the original's alignment, list kind, and indent.  Returns the
391/// position at the start of the new block.
392pub fn split_block(doc: &mut RichDoc, pos: DocPos) -> DocPos {
393    let Some(block) = doc.blocks.get_mut(pos.block) else {
394        return pos;
395    };
396    let idx = block.ensure_boundary(pos.byte);
397    let tail_runs = block.runs.split_off(idx);
398    let (align, list, indent) = (block.align, block.list, block.indent);
399    block.normalize();
400    let mut new_block = Block {
401        runs: tail_runs,
402        align,
403        list,
404        indent,
405    };
406    new_block.normalize();
407    doc.blocks.insert(pos.block + 1, new_block);
408    DocPos {
409        block: pos.block + 1,
410        byte: 0,
411    }
412}
413
414/// Merge block `idx` into block `idx - 1` (Backspace-at-start semantics),
415/// keeping the previous block's paragraph attributes.  Returns the position of
416/// the join point.  A no-op (returns the start of `idx`) when `idx` is 0 or out
417/// of range.
418pub fn merge_block_with_prev(doc: &mut RichDoc, idx: usize) -> DocPos {
419    if idx == 0 || idx >= doc.blocks.len() {
420        return DocPos { block: idx, byte: 0 };
421    }
422    let join_byte = doc.blocks[idx - 1].text_len();
423    let block = doc.blocks.remove(idx);
424    doc.blocks[idx - 1].runs.extend(block.runs);
425    doc.blocks[idx - 1].normalize();
426    DocPos {
427        block: idx - 1,
428        byte: join_byte,
429    }
430}
431
432/// Extract the content within `range` as a standalone list of blocks, keeping
433/// each run's [`InlineStyle`] and (for wholly-selected blocks) the block-level
434/// attributes.  Drives styled Copy / Cut — the inverse shape of
435/// [`splice_fragment`].  Returns an empty vec for a collapsed range.
436pub fn extract_range(doc: &RichDoc, range: DocRange) -> Vec<Block> {
437    let (a, b) = range.ordered();
438    if a == b {
439        return Vec::new();
440    }
441    let b_block = b.block.min(doc.blocks.len().saturating_sub(1));
442    let mut out = Vec::new();
443    for bi in a.block..=b_block {
444        let Some(src) = doc.blocks.get(bi) else {
445            continue;
446        };
447        let mut block = src.clone();
448        let hi = if bi == b_block { b.byte } else { block.text_len() };
449        let lo = if bi == a.block { a.byte } else { 0 };
450        // Trim the tail first so the head boundary offset stays valid.
451        let end = block.ensure_boundary(hi);
452        block.runs.truncate(end);
453        let start = block.ensure_boundary(lo);
454        block.runs.drain(0..start);
455        block.normalize();
456        out.push(block);
457    }
458    out
459}
460
461/// Insert a `fragment` (a list of blocks produced by [`extract_range`]) at
462/// `pos`, preserving run styles.  A single-block fragment splices inline (no new
463/// paragraph); a multi-block fragment splits the target paragraph, appends the
464/// fragment's first block to the head, inserts the interior blocks verbatim, and
465/// merges the fragment's last block with the original tail (which keeps the
466/// target paragraph's block attributes).  Returns the caret position at the end
467/// of the inserted content.
468pub fn splice_fragment(doc: &mut RichDoc, pos: DocPos, fragment: &[Block]) -> DocPos {
469    if fragment.is_empty() {
470        return pos;
471    }
472    let Some(target) = doc.blocks.get_mut(pos.block) else {
473        return pos;
474    };
475    if fragment.len() == 1 {
476        // Pasting one paragraph's worth of runs is an inline splice — it must
477        // not change the target paragraph's block attributes. The exception is
478        // pasting into a pristine empty paragraph (a blank doc, or a fresh line
479        // after Enter): there the fragment's own block attributes (e.g. a bullet
480        // list) should carry over, so a copied list item pastes as a list item.
481        let default = Block::new();
482        let adopt_attrs = target.runs.is_empty()
483            && target.align == default.align
484            && target.list == default.list
485            && target.indent == default.indent;
486        let mut idx = target.ensure_boundary(pos.byte);
487        let mut added = 0usize;
488        for run in &fragment[0].runs {
489            target.runs.insert(idx, run.clone());
490            idx += 1;
491            added += run.text.len();
492        }
493        target.normalize();
494        if adopt_attrs {
495            target.align = fragment[0].align;
496            target.list = fragment[0].list;
497            target.indent = fragment[0].indent;
498        }
499        return DocPos::new(pos.block, pos.byte + added);
500    }
501
502    // Multi-block: split the target paragraph at `pos`, keeping the head in
503    // place and stashing the tail runs to re-attach after the fragment.
504    let split_idx = target.ensure_boundary(pos.byte);
505    let tail_runs = target.runs.split_off(split_idx);
506    let (t_align, t_list, t_indent) = (target.align, target.list, target.indent);
507    target.runs.extend(fragment[0].runs.iter().cloned());
508    target.normalize();
509
510    // Interior blocks verbatim, then the final block: the fragment's last block
511    // merged with the original tail. The merged line keeps the target
512    // paragraph's block attributes (it is a continuation of that paragraph).
513    let last = &fragment[fragment.len() - 1];
514    let end_byte = last.text_len();
515    let mut new_blocks: Vec<Block> = fragment[1..fragment.len() - 1].to_vec();
516    let mut last_block = Block {
517        runs: last.runs.clone(),
518        align: t_align,
519        list: t_list,
520        indent: t_indent,
521    };
522    last_block.runs.extend(tail_runs);
523    last_block.normalize();
524    new_blocks.push(last_block);
525
526    let count = new_blocks.len(); // == fragment.len() - 1
527    for (k, block) in new_blocks.into_iter().enumerate() {
528        doc.blocks.insert(pos.block + 1 + k, block);
529    }
530    DocPos::new(pos.block + count, end_byte)
531}
532
533#[cfg(test)]
534#[path = "model_tests.rs"]
535mod model_tests;