Skip to main content

day_spec/
styled.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Styled text: the document `label().runs(…)` renders and `day-piece-text-editor` edits
5//! (docs/texteditor.md).
6//!
7//! [`StyledText`] is plain text plus two range vectors over it: [`TextRun`]s for character
8//! attributes and [`ParagraphRun`]s for paragraph ones. Text and ranges travel together by
9//! construction, because a range only means something against a particular string.
10//!
11//! Everything an editor's toolbar needs is a pure function here rather than a round trip into the
12//! toolkit: [`StyledText::style_of`] answers "what is the selection's style" (with a mixed state
13//! where the runs disagree), [`StyledText::apply`] changes it, and [`StyledText::reflow`] moves
14//! the ranges over an edit the native editor already made. That is what lets the same logic run
15//! on nine backends and be tested on the headless one.
16
17use crate::{Color, Font, FontSpec, FontWeight, TextRun, runs_are_valid};
18
19// ---------------------------------------------------------------------------
20// Character attributes
21// ---------------------------------------------------------------------------
22
23/// A line under a run.
24///
25/// An enum rather than a `bool` because every toolkit distinguishes at least single from double,
26/// and because a wavy underline is how an app draws its diagnostics (a spelling or grammar
27/// mark of its own making) without fighting the platform's checker for the same pixels.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
29pub enum Underline {
30    #[default]
31    None,
32    Single,
33    Double,
34    Dotted,
35    /// The squiggle. A backend with no wavy style draws `Single`.
36    Wavy,
37}
38
39impl Underline {
40    pub fn is_on(self) -> bool {
41        self != Underline::None
42    }
43    /// The toolbar's on/off toggle: `Single` when turning it on, `None` when off.
44    pub fn toggled(self) -> Self {
45        if self.is_on() {
46            Underline::None
47        } else {
48            Underline::Single
49        }
50    }
51}
52
53/// A [`TextRun`]'s attributes with no range attached: what a toolbar toggles, what an editor
54/// applies to a selection, and what it styles the next typed character with.
55#[derive(Clone, Debug, Default, PartialEq)]
56pub struct RunStyle {
57    pub font: FontSpec,
58    pub color: Option<Color>,
59    pub background: Option<Color>,
60    pub underline: Underline,
61    pub strikethrough: bool,
62    pub link: Option<String>,
63}
64
65impl RunStyle {
66    /// The style of unstyled text in a document whose base font is `base`.
67    pub fn plain(base: Font) -> Self {
68        RunStyle {
69            font: FontSpec::new(base),
70            ..RunStyle::default()
71        }
72    }
73    /// Bold in the sense a toolbar button means it: semibold or heavier.
74    pub fn bold(&self) -> bool {
75        self.font.weight.is_some_and(|w| w >= FontWeight::Semibold)
76    }
77    pub fn set_bold(&mut self, on: bool) {
78        self.font.weight = on.then_some(FontWeight::Bold);
79    }
80    pub fn italic(&self) -> bool {
81        self.font.italic
82    }
83    pub fn set_italic(&mut self, on: bool) {
84        self.font.italic = on;
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Paragraph attributes
90// ---------------------------------------------------------------------------
91
92/// How a paragraph's lines sit in the width they are given.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
94pub enum ParagraphAlign {
95    /// The reading direction's start edge: left under LTR, right under RTL.
96    #[default]
97    Natural,
98    Center,
99    /// The reading direction's end edge.
100    Trailing,
101    Justified,
102}
103
104/// A paragraph's list decoration, if it is a list item.
105#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
106pub enum ListStyle {
107    #[default]
108    None,
109    /// A bullet; the glyph is the platform's.
110    Bullet,
111    /// A number, which the app computes. Nothing here renumbers a list, because nothing here
112    /// knows where the list began or whether the paragraph above belongs to it.
113    Ordered(u32),
114}
115
116/// A [`ParagraphRun`]'s attributes with no range attached.
117#[derive(Clone, Copy, Debug, Default, PartialEq)]
118pub struct ParagraphStyle {
119    pub align: ParagraphAlign,
120    /// Leading indent in points, from the reading direction's start edge. A list item's total
121    /// indent is this plus whatever the platform reserves for its marker.
122    pub indent: f64,
123    pub space_before: f64,
124    pub space_after: f64,
125    pub list: ListStyle,
126    /// Nesting depth for a list item (0 = outermost). Ignored when `list` is `None`.
127    pub list_level: u8,
128}
129
130/// One styled paragraph.
131///
132/// A second range vector alongside [`TextRun`] rather than more fields on one, because these
133/// attributes apply to whole paragraphs: folding alignment into a character run would let a
134/// program say "center these three words", which no text system can honor and every one of them
135/// would resolve differently.
136///
137/// `range` is a byte range into the same string the runs address and covers whole paragraphs. A
138/// gap is a paragraph with the document's own defaults.
139#[derive(Clone, Debug, Default, PartialEq)]
140pub struct ParagraphRun {
141    pub range: std::ops::Range<usize>,
142    pub align: ParagraphAlign,
143    pub indent: f64,
144    pub space_before: f64,
145    pub space_after: f64,
146    pub list: ListStyle,
147    pub list_level: u8,
148}
149
150impl ParagraphRun {
151    pub fn new(range: std::ops::Range<usize>, style: ParagraphStyle) -> Self {
152        ParagraphRun {
153            range,
154            align: style.align,
155            indent: style.indent,
156            space_before: style.space_before,
157            space_after: style.space_after,
158            list: style.list,
159            list_level: style.list_level,
160        }
161    }
162    pub fn style(&self) -> ParagraphStyle {
163        ParagraphStyle {
164            align: self.align,
165            indent: self.indent,
166            space_before: self.space_before,
167            space_after: self.space_after,
168            list: self.list,
169            list_level: self.list_level,
170        }
171    }
172}
173
174// ---------------------------------------------------------------------------
175// The document
176// ---------------------------------------------------------------------------
177
178/// A styled document: plain text, the character runs over it, and the paragraph attributes over
179/// the same offsets.
180#[derive(Clone, Debug, Default, PartialEq)]
181pub struct StyledText {
182    pub text: String,
183    pub runs: Vec<TextRun>,
184    pub paragraphs: Vec<ParagraphRun>,
185}
186
187impl From<String> for StyledText {
188    fn from(text: String) -> Self {
189        StyledText::plain(text)
190    }
191}
192
193impl From<&str> for StyledText {
194    fn from(text: &str) -> Self {
195        StyledText::plain(text)
196    }
197}
198
199impl StyledText {
200    /// Unstyled text.
201    pub fn plain(text: impl Into<String>) -> Self {
202        StyledText {
203            text: text.into(),
204            runs: Vec::new(),
205            paragraphs: Vec::new(),
206        }
207    }
208
209    /// Parse markdown, the ergonomic way to seed a document
210    /// ([`styled_codec`](crate::styled_codec)).
211    pub fn markdown(md: &str, base: Font) -> Self {
212        crate::styled_codec::markdown_to_styled(
213            md,
214            crate::styled_codec::DocStyle {
215                base,
216                ..Default::default()
217            },
218        )
219    }
220
221    /// Parse an HTML fragment.
222    pub fn html(html: &str, base: Font) -> Self {
223        crate::styled_codec::html_to_styled(
224            html,
225            crate::styled_codec::DocStyle {
226                base,
227                ..Default::default()
228            },
229        )
230    }
231
232    /// Parse the RTF subset Day reads (docs/texteditor.md).
233    pub fn rtf(rtf: &str, base: Font) -> Self {
234        crate::styled_codec::rtf_to_styled(
235            rtf,
236            crate::styled_codec::DocStyle {
237                base,
238                ..Default::default()
239            },
240        )
241    }
242
243    /// Write this document as markdown / HTML / RTF. Each is lossy in the ways
244    /// [`styled_codec`](crate::styled_codec) documents.
245    pub fn to_markdown(&self, base: Font) -> String {
246        crate::styled_codec::styled_to_markdown(
247            self,
248            crate::styled_codec::DocStyle {
249                base,
250                ..Default::default()
251            },
252        )
253    }
254
255    pub fn to_html(&self, base: Font) -> String {
256        crate::styled_codec::styled_to_html(
257            self,
258            crate::styled_codec::DocStyle {
259                base,
260                ..Default::default()
261            },
262        )
263    }
264
265    pub fn to_rtf(&self, base: Font) -> String {
266        crate::styled_codec::styled_to_rtf(
267            self,
268            crate::styled_codec::DocStyle {
269                base,
270                ..Default::default()
271            },
272        )
273    }
274
275    /// Text with character runs and no paragraph attributes, which is what a label carries today.
276    pub fn new(text: impl Into<String>, runs: Vec<TextRun>) -> Self {
277        StyledText {
278            text: text.into(),
279            runs,
280            paragraphs: Vec::new(),
281        }
282    }
283
284    pub fn is_empty(&self) -> bool {
285        self.text.is_empty()
286    }
287
288    /// Are both range vectors well formed for the text (ascending, non-overlapping, in bounds,
289    /// on character boundaries)?
290    pub fn validate(&self) -> Result<(), String> {
291        runs_are_valid(&self.text, &self.runs)?;
292        paragraphs_are_valid(&self.text, &self.paragraphs)
293    }
294
295    /// The whole text as consecutive `(range, style)` segments, unstyled gaps included as `base`.
296    ///
297    /// One view that the query and the mutation below are both written against, so neither has to
298    /// reason about where a run ends and a gap begins.
299    pub fn segments(&self, base: Font) -> Vec<(std::ops::Range<usize>, RunStyle)> {
300        let plain = RunStyle::plain(base);
301        let mut out = Vec::with_capacity(self.runs.len() * 2 + 1);
302        let mut at = 0usize;
303        for r in &self.runs {
304            if r.range.start > at {
305                out.push((at..r.range.start, plain.clone()));
306            }
307            if !r.range.is_empty() {
308                out.push((r.range.clone(), r.style()));
309            }
310            at = at.max(r.range.end);
311        }
312        if at < self.text.len() {
313            out.push((at..self.text.len(), plain));
314        }
315        out
316    }
317
318    /// The style in effect across `range`.
319    ///
320    /// Where the segments under it disagree, the differing attribute comes back as its default
321    /// rather than as one of the values. That is what a toolbar renders as its mixed state, and
322    /// it is what makes pressing the button set the whole selection instead of toggling half of
323    /// it.
324    ///
325    /// A collapsed caret takes the style of the segment that ENDS at it, preferring that over the
326    /// one it starts: typing after a bold word continues bold, which is what every editor does.
327    pub fn style_of(&self, range: std::ops::Range<usize>, base: Font) -> RunStyle {
328        let segs = self.segments(base);
329        if range.is_empty() {
330            let at = range.start;
331            return segs
332                .iter()
333                .find(|(r, _)| r.start < at && r.end >= at)
334                .or_else(|| segs.iter().find(|(r, _)| r.contains(&at)))
335                .map(|(_, s)| s.clone())
336                .unwrap_or_else(|| RunStyle::plain(base));
337        }
338        let mut acc: Option<RunStyle> = None;
339        for (r, s) in &segs {
340            if r.end <= range.start || r.start >= range.end {
341                continue;
342            }
343            acc = Some(merge_style(acc, s));
344        }
345        acc.unwrap_or_else(|| RunStyle::plain(base))
346    }
347
348    /// Apply `f` to the style of every segment overlapping `range`, splitting at its boundaries so
349    /// only the selected text changes, then coalescing what came out.
350    pub fn apply(&mut self, range: std::ops::Range<usize>, base: Font, f: impl Fn(&mut RunStyle)) {
351        if range.start >= range.end || range.start >= self.text.len() {
352            return;
353        }
354        let range = range.start..range.end.min(self.text.len());
355        let mut out: Vec<TextRun> = Vec::new();
356        for (r, s) in self.segments(base) {
357            // Up to three pieces per segment: before the selection, inside it, after it.
358            for (piece, inside) in [
359                (r.start..r.end.min(range.start), false),
360                (r.start.max(range.start)..r.end.min(range.end), true),
361                (r.start.max(range.end)..r.end, false),
362            ] {
363                if piece.start >= piece.end {
364                    continue;
365                }
366                let mut style = s.clone();
367                if inside {
368                    f(&mut style);
369                }
370                out.push(TextRun::styled(piece, style));
371            }
372        }
373        self.runs = coalesce_runs(out, base);
374    }
375
376    /// The paragraph style in effect across `range`, by the same mixed-state rule as
377    /// [`StyledText::style_of`].
378    pub fn paragraph_style_of(&self, range: std::ops::Range<usize>) -> ParagraphStyle {
379        let mut acc: Option<ParagraphStyle> = None;
380        for (start, end) in paragraph_bounds(&self.text) {
381            if !touches(start, end, &range) {
382                continue;
383            }
384            let style = self.paragraph_at(start, end);
385            acc = Some(match acc {
386                None => style,
387                Some(a) if a == style => a,
388                Some(_) => ParagraphStyle::default(),
389            });
390        }
391        acc.unwrap_or_default()
392    }
393
394    /// Apply `f` to every paragraph the selection touches. Paragraph boundaries are `\n`, so a
395    /// caret anywhere inside a paragraph styles the whole of it, which is what every editor does.
396    pub fn apply_paragraph(
397        &mut self,
398        range: std::ops::Range<usize>,
399        f: impl Fn(&mut ParagraphStyle),
400    ) {
401        let mut out: Vec<ParagraphRun> = Vec::new();
402        for (start, end) in paragraph_bounds(&self.text) {
403            let mut style = self.paragraph_at(start, end);
404            if touches(start, end, &range) {
405                f(&mut style);
406            }
407            if style != ParagraphStyle::default() {
408                out.push(ParagraphRun::new(start..end, style));
409            }
410        }
411        self.paragraphs = out;
412    }
413
414    /// The style recorded for the paragraph spanning `start..end`, or the default.
415    fn paragraph_at(&self, start: usize, end: usize) -> ParagraphStyle {
416        self.paragraphs
417            .iter()
418            .find(|p| p.range.start <= start && p.range.end >= end.min(p.range.end).max(start + 1))
419            .map(|p| p.style())
420            .unwrap_or_default()
421    }
422
423    /// Move the ranges over a character edit the native editor already made: `removed` bytes at
424    /// `offset` replaced by `inserted` bytes.
425    ///
426    /// This is why a keystroke does not have to ship the whole document back. Ranges before the
427    /// edit are untouched, ranges after it shift, a range containing it grows or shrinks, and a
428    /// range the edit emptied is dropped. That is the rule every attributed-string implementation
429    /// uses, exact, and O(runs) rather than O(document).
430    ///
431    /// `self.text` must already be the new text; this only moves ranges.
432    pub fn reflow(&mut self, offset: usize, removed: usize, inserted: usize) {
433        let shift = |p: usize| -> usize {
434            if p <= offset {
435                p
436            } else if p >= offset + removed {
437                p - removed + inserted
438            } else {
439                offset // inside the deleted span: collapse to the edit point
440            }
441        };
442        for r in &mut self.runs {
443            r.range = shift(r.range.start)..shift(r.range.end);
444        }
445        for p in &mut self.paragraphs {
446            p.range = shift(p.range.start)..shift(p.range.end);
447        }
448        // A run the edit ended inside grows over the insertion, so typing at the end of a bold
449        // word stays bold. Applied after the shift so the comparison is in new coordinates.
450        if inserted > 0 {
451            for r in &mut self.runs {
452                if r.range.end == offset && r.range.start < offset {
453                    r.range.end = offset + inserted;
454                }
455            }
456            for p in &mut self.paragraphs {
457                if p.range.end == offset && p.range.start < offset {
458                    p.range.end = offset + inserted;
459                }
460            }
461        }
462        self.clamp();
463    }
464
465    /// Clamp every range into the text and onto character boundaries, dropping what cannot be
466    /// saved. The last defense before a range reaches a backend and panics an `str` slice.
467    pub fn clamp(&mut self) {
468        let text = std::mem::take(&mut self.text);
469        let fix = |p: usize| -> usize {
470            let mut p = p.min(text.len());
471            while p > 0 && !text.is_char_boundary(p) {
472                p -= 1;
473            }
474            p
475        };
476        for r in &mut self.runs {
477            r.range = fix(r.range.start)..fix(r.range.end);
478        }
479        for p in &mut self.paragraphs {
480            p.range = fix(p.range.start)..fix(p.range.end);
481        }
482        self.text = text;
483        self.runs.retain(|r| r.range.start < r.range.end);
484        self.paragraphs.retain(|p| p.range.start < p.range.end);
485    }
486
487    /// Replace `range` with `text`, moving every other range over the edit. The app-side twin of
488    /// what a keystroke does, for a programmatic insert or delete.
489    pub fn splice(&mut self, range: std::ops::Range<usize>, text: &str) {
490        let range = range.start.min(self.text.len())..range.end.min(self.text.len());
491        if !self.text.is_char_boundary(range.start) || !self.text.is_char_boundary(range.end) {
492            return;
493        }
494        let removed = range.end.saturating_sub(range.start);
495        self.text.replace_range(range.clone(), text);
496        self.reflow(range.start, removed, text.len());
497    }
498}
499
500/// Fold `b` into the accumulated selection style: an attribute both agree on survives, one they
501/// differ on resets to its default (the toolbar's mixed state).
502fn merge_style(acc: Option<RunStyle>, b: &RunStyle) -> RunStyle {
503    let Some(a) = acc else { return b.clone() };
504    RunStyle {
505        font: FontSpec {
506            style: if a.font.style == b.font.style {
507                a.font.style
508            } else {
509                Font::Body
510            },
511            weight: if a.font.weight == b.font.weight {
512                a.font.weight
513            } else {
514                None
515            },
516            italic: a.font.italic && b.font.italic,
517            tabular: a.font.tabular && b.font.tabular,
518            monospace: a.font.monospace && b.font.monospace,
519            scale: if a.font.scale == b.font.scale {
520                a.font.scale
521            } else {
522                1.0
523            },
524        },
525        color: if a.color == b.color { a.color } else { None },
526        background: if a.background == b.background {
527            a.background
528        } else {
529            None
530        },
531        underline: if a.underline == b.underline {
532            a.underline
533        } else {
534            Underline::None
535        },
536        strikethrough: a.strikethrough && b.strikethrough,
537        link: if a.link == b.link { a.link } else { None },
538    }
539}
540
541/// Does the paragraph `start..end` fall under `sel`? A collapsed caret counts against the
542/// paragraph it sits in, including at its very end.
543fn touches(start: usize, end: usize, sel: &std::ops::Range<usize>) -> bool {
544    if sel.start == sel.end {
545        return sel.start >= start && sel.start <= end;
546    }
547    start < sel.end && end > sel.start
548}
549
550/// Merge adjacent runs whose styles came out equal, and drop runs that say nothing beyond `base`.
551///
552/// Every `apply` produces fragments; without this a document accumulates one run per edit until
553/// the run vector is longer than the text it describes.
554pub fn coalesce_runs(runs: Vec<TextRun>, base: Font) -> Vec<TextRun> {
555    let plain = RunStyle::plain(base);
556    let mut out: Vec<TextRun> = Vec::with_capacity(runs.len());
557    for r in runs {
558        if r.range.is_empty() {
559            continue;
560        }
561        match out.last_mut() {
562            Some(prev) if prev.range.end == r.range.start && prev.style() == r.style() => {
563                prev.range.end = r.range.end;
564            }
565            _ => out.push(r),
566        }
567    }
568    out.retain(|r| r.style() != plain);
569    out
570}
571
572/// The `\n`-delimited paragraphs of `text`, as byte ranges INCLUDING the terminator.
573///
574/// An empty string has no paragraphs. A trailing newline makes an empty final paragraph, the way
575/// an editor shows a blank last line, so `"a\n"` is two paragraphs, not one.
576pub fn paragraph_bounds(text: &str) -> Vec<(usize, usize)> {
577    if text.is_empty() {
578        return Vec::new();
579    }
580    let mut out = Vec::new();
581    let mut start = 0usize;
582    for (i, b) in text.bytes().enumerate() {
583        if b == b'\n' {
584            out.push((start, i + 1));
585            start = i + 1;
586        }
587    }
588    out.push((start, text.len()));
589    out
590}
591
592/// Whether these paragraph runs are well formed for `text`: [`runs_are_valid`]'s rules, applied
593/// to the second range vector.
594pub fn paragraphs_are_valid(text: &str, paragraphs: &[ParagraphRun]) -> Result<(), String> {
595    let mut prev_end = 0usize;
596    for (i, p) in paragraphs.iter().enumerate() {
597        if p.range.start < prev_end {
598            return Err(format!(
599                "paragraph {i} starts at {} but the previous ends at {prev_end} — paragraph runs \
600                 must be ascending and non-overlapping",
601                p.range.start
602            ));
603        }
604        if p.range.end > text.len() {
605            return Err(format!(
606                "paragraph {i} ends at {} but the text is {} bytes",
607                p.range.end,
608                text.len()
609            ));
610        }
611        if p.range.start > p.range.end {
612            return Err(format!("paragraph {i} has an inverted range {:?}", p.range));
613        }
614        if !text.is_char_boundary(p.range.start) || !text.is_char_boundary(p.range.end) {
615            return Err(format!(
616                "paragraph {i}'s range {:?} splits a multi-byte character",
617                p.range
618            ));
619        }
620        prev_end = p.range.end;
621    }
622    Ok(())
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    fn bold_run(range: std::ops::Range<usize>) -> TextRun {
630        TextRun::styled(range, {
631            let mut s = RunStyle::plain(Font::Body);
632            s.set_bold(true);
633            s
634        })
635    }
636
637    fn doc() -> StyledText {
638        // "hello world" with "hello" bold.
639        StyledText::new("hello world", vec![bold_run(0..5)])
640    }
641
642    #[test]
643    fn segments_cover_the_whole_text_including_gaps() {
644        let segs = doc().segments(Font::Body);
645        assert_eq!(
646            segs.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>(),
647            vec![0..5, 5..11]
648        );
649        assert!(segs[0].1.bold());
650        assert!(!segs[1].1.bold());
651    }
652
653    #[test]
654    fn style_of_a_uniform_selection_is_that_style() {
655        assert!(doc().style_of(0..5, Font::Body).bold());
656        assert!(!doc().style_of(5..11, Font::Body).bold());
657    }
658
659    #[test]
660    fn style_of_a_mixed_selection_resets_what_differs() {
661        // Half bold, half not: the toolbar shows "not bold", so pressing it bolds everything.
662        let s = doc().style_of(0..11, Font::Body);
663        assert!(!s.bold(), "a mixed weight comes back as the default");
664    }
665
666    #[test]
667    fn a_caret_takes_the_style_of_the_run_it_ends() {
668        // Typing at offset 5, right after "hello", continues bold.
669        assert!(doc().style_of(5..5, Font::Body).bold());
670        // And at 11, after the plain tail, it does not.
671        assert!(!doc().style_of(11..11, Font::Body).bold());
672        // At offset 0 there is nothing behind the caret, so it takes the run ahead instead:
673        // typing at the very start of a document that begins bold gives bold, which is what
674        // TextEdit, Word and every web editor do.
675        assert!(doc().style_of(0..0, Font::Body).bold());
676    }
677
678    #[test]
679    fn apply_splits_runs_at_the_selection_boundaries() {
680        let mut d = doc();
681        d.apply(3..8, Font::Body, |s| s.set_italic(true));
682        // bold 0..3, bold+italic 3..5, italic 5..8, plain 8..11 (dropped as plain).
683        let got: Vec<_> = d
684            .runs
685            .iter()
686            .map(|r| (r.range.clone(), r.style().bold(), r.style().italic()))
687            .collect();
688        assert_eq!(
689            got,
690            vec![(0..3, true, false), (3..5, true, true), (5..8, false, true),]
691        );
692    }
693
694    #[test]
695    fn apply_coalesces_and_drops_plain() {
696        let mut d = doc();
697        d.apply(0..11, Font::Body, |s| s.set_bold(false));
698        assert!(
699            d.runs.is_empty(),
700            "unbolding everything leaves nothing to say: {:?}",
701            d.runs
702        );
703    }
704
705    #[test]
706    fn reflow_moves_ranges_over_an_insert() {
707        let mut d = doc();
708        // Type "XX" at offset 8 (inside " world").
709        d.text = "hello woXXrld".into();
710        d.reflow(8, 0, 2);
711        assert_eq!(d.runs[0].range, 0..5, "a run before the edit is untouched");
712
713        let mut d = doc();
714        // Type at 2, inside the bold run: it grows.
715        d.text = "heXXllo world".into();
716        d.reflow(2, 0, 2);
717        assert_eq!(d.runs[0].range, 0..7);
718    }
719
720    #[test]
721    fn typing_at_the_end_of_a_run_continues_it() {
722        let mut d = doc();
723        d.text = "helloX world".into();
724        d.reflow(5, 0, 1);
725        assert_eq!(d.runs[0].range, 0..6, "the bold run took the new character");
726    }
727
728    #[test]
729    fn reflow_shrinks_and_drops_over_a_delete() {
730        let mut d = doc();
731        // Delete "ello " (1..6): the bold run shrinks to 0..1.
732        d.text = "hworld".into();
733        d.reflow(1, 5, 0);
734        assert_eq!(d.runs[0].range, 0..1);
735
736        let mut d = doc();
737        // Delete the whole bold word: the run goes away rather than becoming empty.
738        d.text = " world".into();
739        d.reflow(0, 5, 0);
740        assert!(d.runs.is_empty(), "{:?}", d.runs);
741    }
742
743    #[test]
744    fn splice_edits_text_and_ranges_together() {
745        let mut d = doc();
746        d.splice(5..5, ",");
747        assert_eq!(d.text, "hello, world");
748        assert_eq!(d.runs[0].range, 0..6);
749        assert!(d.validate().is_ok());
750    }
751
752    #[test]
753    fn clamp_pulls_ranges_off_multi_byte_boundaries() {
754        let mut d = StyledText::new("héllo", vec![bold_run(0..2)]);
755        d.text = "h".into(); // as if the tail were deleted without a reflow
756        d.clamp();
757        assert!(d.validate().is_ok());
758        assert!(d.runs.iter().all(|r| r.range.end <= 1));
759    }
760
761    #[test]
762    fn paragraph_bounds_include_the_terminator_and_the_blank_last_line() {
763        assert_eq!(paragraph_bounds(""), Vec::new());
764        assert_eq!(paragraph_bounds("a"), vec![(0, 1)]);
765        assert_eq!(paragraph_bounds("a\n"), vec![(0, 2), (2, 2)]);
766        assert_eq!(paragraph_bounds("a\nbb\nc"), vec![(0, 2), (2, 5), (5, 6)]);
767    }
768
769    #[test]
770    fn apply_paragraph_styles_the_whole_paragraph_a_caret_sits_in() {
771        let mut d = StyledText::plain("one\ntwo\nthree");
772        d.apply_paragraph(5..5, |p| p.align = ParagraphAlign::Center);
773        assert_eq!(d.paragraphs.len(), 1);
774        assert_eq!(d.paragraphs[0].range, 4..8, "the whole second paragraph");
775        assert_eq!(d.paragraphs[0].align, ParagraphAlign::Center);
776        assert_eq!(d.paragraph_style_of(5..5).align, ParagraphAlign::Center);
777        assert_eq!(
778            d.paragraph_style_of(0..13).align,
779            ParagraphAlign::Natural,
780            "a mixed selection reports the default"
781        );
782        assert!(d.validate().is_ok());
783    }
784
785    #[test]
786    fn paragraph_ranges_reflow_with_the_text() {
787        let mut d = StyledText::plain("one\ntwo");
788        d.apply_paragraph(5..5, |p| p.list = ListStyle::Bullet);
789        assert_eq!(d.paragraphs[0].range, 4..7);
790        d.splice(0..0, "XY");
791        assert_eq!(d.paragraphs[0].range, 6..9);
792        assert!(d.validate().is_ok());
793    }
794
795    #[test]
796    fn font_scale_resolves_against_the_platforms_style_size() {
797        let s = FontSpec::new(Font::Body).scaled(1.5);
798        assert_eq!(s.resolved_points(16.0), 24.0);
799        // An absolute style carries its own size and ignores the platform's.
800        let abs = FontSpec::new(Font::System(14.0));
801        assert_eq!(abs.resolved_points(16.0), 14.0);
802        assert_eq!(abs.scaled(2.0).resolved_points(16.0), 28.0);
803    }
804}