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;