Skip to main content

kimun_notes/ropetext/
width.rs

1//! How many terminal cells a piece of text occupies.
2
3use unicode_width::UnicodeWidthStr;
4
5/// Cell measurements a layout needs, so a caller that renders differently can
6/// say so instead of the layout assuming.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct Metrics {
9    /// Cells between tab stops. A tab advances to the next multiple of this.
10    pub tab_width: usize,
11}
12
13impl Default for Metrics {
14    fn default() -> Self {
15        Self {
16            tab_width: Metrics::DEFAULT_TAB_WIDTH,
17        }
18    }
19}
20
21impl Metrics {
22    /// Cells between tab stops, absent a caller saying otherwise.
23    ///
24    /// Public because a caller that draws the text this crate wrapped has to
25    /// measure a tab the same way, and a second `4` written down beside this one
26    /// agrees only by luck. Anything that expands or paints a tab should derive
27    /// its stop from here rather than declare its own.
28    pub const DEFAULT_TAB_WIDTH: usize = 4;
29
30    /// Cells `cluster` occupies when drawn starting at cell `column`.
31    ///
32    /// Position-dependent, because a tab's width is the distance to the next tab
33    /// stop and nothing else. Measuring a tab as a fixed width — or, as
34    /// `unicode-width` alone does, as zero — makes wrapping disagree with the
35    /// renderer about where a row ends, and every column derived from either is
36    /// then wrong by the difference.
37    pub fn width_at(&self, cluster: &str, column: usize) -> usize {
38        // The byte check rather than `cluster == "\t"`: this runs once per cluster
39        // per wrap, so a full wrap of a large note runs it hundreds of thousands of
40        // times, and a length test that fails immediately beats a string compare.
41        if cluster.len() == 1 && cluster.as_bytes()[0] == b'\t' {
42            let stop = self.tab_width.max(1);
43            stop - (column % stop)
44        } else {
45            cluster.width()
46        }
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn a_cluster_is_measured_whole() {
56        let m = Metrics::default();
57        // The first codepoint of each of these is narrow; the cluster is not.
58        assert_eq!(m.width_at("\u{1F1EA}\u{1F1F8}", 0), 2, "flag");
59        assert_eq!(m.width_at("\u{2764}\u{FE0F}", 0), 2, "heart with VS16");
60        assert_eq!(m.width_at("1\u{FE0F}\u{20E3}", 0), 2, "keycap");
61        assert_eq!(m.width_at("\u{3042}", 0), 2, "CJK");
62        assert_eq!(m.width_at("a", 0), 1);
63        assert_eq!(m.width_at("e\u{301}", 0), 1, "e plus combining acute");
64    }
65
66    #[test]
67    fn zero_width_clusters_measure_zero() {
68        let m = Metrics::default();
69        for zero in ["\u{200B}", "\u{00AD}", "\u{200C}", "\u{FEFF}", "\u{301}"] {
70            assert_eq!(m.width_at(zero, 0), 0, "{zero:?}");
71        }
72    }
73
74    #[test]
75    fn a_tab_advances_to_the_next_stop() {
76        let m = Metrics::default();
77        assert_eq!(m.width_at("\t", 0), 4);
78        assert_eq!(m.width_at("\t", 1), 3);
79        assert_eq!(m.width_at("\t", 3), 1);
80        assert_eq!(m.width_at("\t", 4), 4);
81    }
82
83    #[test]
84    fn a_tab_width_of_zero_still_advances() {
85        let m = Metrics { tab_width: 0 };
86        assert_eq!(m.width_at("\t", 0), 1, "forward progress is not optional");
87    }
88}