Skip to main content

leaf_core/
counts.rs

1//! Word, character, and paragraph counts over the text a *reader* sees.
2//!
3//! The numbers a status bar shows are a claim about the document, not about
4//! the markup that spells it: `**bold**` is one word and four characters, a
5//! link is its label and not its destination, and a picture is a picture. So
6//! the tally runs over a [`VisualMap`] — the same rendering the WYSIWYG view
7//! paints — rather than over the source string, and cannot drift from what is
8//! on the page the way a second, private markdown walk would.
9//!
10//! What it takes off that map is narrower than what the map draws. A glyph
11//! counts only if it is a caret stop ([`Glyph::stop`]) *and* its
12//! [`Role`] is one a reader would call text. The first half drops the
13//! scaffolding a proportional surface draws but nobody types into — a nested
14//! item's indent, a cell's alignment padding — and, because a stop opens a
15//! grapheme cluster and never sits inside one, makes "one stop" and "one
16//! character" the same statement. The second drops the synthetic furniture:
17//! a bullet, a quote's gutter, a rule, a hidden delimiter revealed under
18//! [`MarkupMode::Full`](crate::MarkupMode::Full), and the `🖼 alt` / `⧉ name`
19//! placeholder a block picture or directive stands in as.
20//!
21//! [`Glyph::stop`]: crate::wysiwyg::Glyph::stop
22
23use std::ops::Range;
24
25use unicode_segmentation::UnicodeSegmentation;
26
27use crate::style::Role;
28use crate::wysiwyg::{Glyph, VisualMap};
29
30/// Statistics over the text a reader sees. See [`Doc::counts`] for the rules
31/// each field is counted by, and [`crate::counts`] for what "sees" means.
32///
33/// [`Doc::counts`]: crate::Doc::counts
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub struct TextCounts {
36    /// Words, by UAX#29 word segmentation: a segment holding at least one
37    /// alphabetic or numeric character. So `don't` is one, `3.14` is one, a
38    /// lone `—` is none — and `well-known` is **two**, which is what the
39    /// algorithm says and what every other UAX#29 counter reports.
40    pub words: usize,
41    /// Characters as a reader counts them — grapheme clusters, whitespace
42    /// included. An emoji family and an accented letter are each one.
43    pub characters: usize,
44    /// The same, less every whitespace grapheme.
45    pub characters_without_spaces: usize,
46    /// Block-level text containers holding at least one non-whitespace
47    /// character: a paragraph, a heading, each item of a list, each paragraph
48    /// inside a blockquote, a whole code block, a whole table.
49    pub paragraphs: usize,
50}
51
52impl TextCounts {
53    /// Fold one block-level container into the tally — its lines, already cut
54    /// down to visible text.
55    ///
56    /// Lines rather than one string because the breaks *between* them are not
57    /// characters: a table's cells and a code block's lines each arrive as
58    /// their own line, and gluing them with a `\n` would both invent a
59    /// character and let the last word of one run into the first of the next.
60    /// The block counts as one paragraph however many lines it has, and as
61    /// none at all when every line is blank — which is what keeps an empty
62    /// paragraph, a rule, and a picture's placeholder out of the count.
63    fn add_block(&mut self, lines: &[String]) {
64        let mut has_text = false;
65        for line in lines {
66            for cluster in line.graphemes(true) {
67                self.characters += 1;
68                if !cluster.chars().all(char::is_whitespace) {
69                    self.characters_without_spaces += 1;
70                    has_text = true;
71                }
72            }
73            self.words += line.unicode_words().count();
74        }
75        if has_text {
76            self.paragraphs += 1;
77        }
78    }
79}
80
81/// Tally `map`, or the part of it whose source offsets fall inside `range`.
82///
83/// The map is expected to be an *unwrapped* one — one row per block — which is
84/// what makes a row and a paragraph the same thing here. [`Doc::counts`]
85/// builds one for the purpose rather than borrowing the frontend's, so that a
86/// narrower window cannot mean more paragraphs.
87///
88/// [`Doc::counts`]: crate::Doc::counts
89pub(crate) fn tally(map: &VisualMap, range: Option<Range<usize>>) -> TextCounts {
90    let range = range.as_ref();
91    let mut counts = TextCounts::default();
92    let mut row = 0;
93    while row < map.rows.len() {
94        // A table is one paragraph, not one per cell — the reader sees a
95        // single object, and a two-column shopping list is not twelve
96        // paragraphs. Its text comes off the structural grid rather than off
97        // the box-drawn picture, which carries borders and column padding the
98        // author never wrote.
99        if let Some(table) = map.tables.iter().find(|t| t.rows_span.contains(&row)) {
100            let cells: Vec<String> = table
101                .grid
102                .iter()
103                .flat_map(|r| r.cells.iter())
104                .map(|c| visible(&c.glyphs, range))
105                .collect();
106            counts.add_block(&cells);
107            row = table.rows_span.end.max(row + 1);
108            continue;
109        }
110        // A code block is one paragraph too, however many lines it holds —
111        // the same call, for the same reason.
112        if let Some(code) = map.code_blocks.iter().find(|c| c.rows_span.contains(&row)) {
113            let lines: Vec<String> = map.rows[code.rows_span.clone()]
114                .iter()
115                .map(|r| visible(&r.glyphs, range))
116                .collect();
117            counts.add_block(&lines);
118            row = code.rows_span.end.max(row + 1);
119            continue;
120        }
121        // The blank gap a block boundary is drawn with is not a block.
122        if !map.rows[row].decoration {
123            counts.add_block(&[visible(&map.rows[row].glyphs, range)]);
124        }
125        row += 1;
126    }
127    counts
128}
129
130/// The text of one drawn line: its stop glyphs, less the synthetic ones, less
131/// anything whose source byte falls outside `range`.
132fn visible(glyphs: &[Glyph], range: Option<&Range<usize>>) -> String {
133    glyphs
134        .iter()
135        .filter(|g| g.stop && is_text(g.style.role))
136        .filter(|g| range.is_none_or(|r| r.contains(&g.src)))
137        .map(|g| g.ch)
138        .collect()
139}
140
141/// Whether a glyph in this role is text the reader is reading, as against
142/// furniture the renderer drew around it.
143///
144/// Spelled out arm by arm rather than as a list of exclusions, so that a new
145/// [`Role`] has to be answered for here instead of quietly joining whichever
146/// side the wildcard fell on.
147fn is_text(role: Role) -> bool {
148    match role {
149        Role::Body | Role::Heading(_) | Role::Code | Role::Link | Role::Mark(_) => true,
150        // A bullet, a quote's `│`, a thematic break's dashes and a table's
151        // borders: drawn by the renderer, not written by the author.
152        Role::ListMarker | Role::QuoteGutter | Role::Rule => false,
153        // Raw markup a revealed line is showing — the source, not the text.
154        // `Doc::counts` builds its map with no revealed line, so this arm is
155        // the statement of intent rather than a live case.
156        Role::Delimiter => false,
157        // The `🖼 alt` / `⧉ name` stand-in for a block picture, movie, sound,
158        // or directive. A reader sees the thing, not the label, and a thing
159        // is not a word. (An *inline* image is different: leaf draws its alt
160        // text as ordinary prose in the line, and so counts it.)
161        Role::Image => false,
162        // A formula's atom or placeholder label, for the same reason: what the
163        // reader sees is a picture, and `Doc::counts` builds its map as a
164        // surface that paints one in a line, so an inline formula is an atom
165        // here and never its TeX. (Its TeX on the revealed line is `Code` and
166        // would count, but the count reveals nothing.)
167        Role::Math => false,
168    }
169}