Skip to main content

rmux_core/formats/
styled_text.rs

1use std::borrow::Cow;
2
3use crate::style::{style_parse, Style};
4use crate::utf8::{text_width, truncate_right_to_width, truncate_to_width, Utf8Config};
5
6use super::scan::format_skip_delimiter;
7
8/// Return the display-cell width of expanded format text, excluding embedded
9/// `#[...]` style clauses.
10#[must_use]
11pub fn styled_text_width(value: &str, utf8: &Utf8Config) -> usize {
12    text_width(&visible_text(&inline_tokens(value)), utf8)
13}
14
15/// Keep the leftmost display cells of expanded format text while preserving
16/// the style and range clauses needed to render them.
17#[must_use]
18pub fn truncate_styled_text_to_width(value: &str, max_width: usize, utf8: &Utf8Config) -> String {
19    if value.is_empty() || max_width == 0 {
20        return String::new();
21    }
22
23    let tokens = inline_tokens(value);
24    let visible = visible_text(&tokens);
25    let clipped = truncate_to_width(&visible, max_width, utf8);
26    if clipped.len() == visible.len() {
27        return value.to_owned();
28    }
29
30    let mut remaining = clipped.len();
31    let mut output = String::with_capacity(value.len().min(remaining + 32));
32    for token in tokens {
33        if token.visible.is_empty() {
34            if remaining > 0 {
35                output.push_str(token.source);
36            }
37            continue;
38        }
39
40        if remaining >= token.visible.len() {
41            output.push_str(token.source);
42            remaining -= token.visible.len();
43            continue;
44        }
45
46        push_partial_visible(&mut output, &token, 0, remaining);
47        break;
48    }
49    output
50}
51
52/// Keep the rightmost display cells of expanded format text. Style clauses
53/// before the retained suffix are preserved so its first cell keeps the style
54/// it had in the untruncated value, matching tmux's format modifiers.
55#[must_use]
56pub(super) fn truncate_styled_text_right_to_width(
57    value: &str,
58    max_width: usize,
59    utf8: &Utf8Config,
60) -> String {
61    if value.is_empty() || max_width == 0 {
62        return String::new();
63    }
64
65    let tokens = inline_tokens(value);
66    let visible = visible_text(&tokens);
67    let clipped = truncate_right_to_width(&visible, max_width, utf8);
68    if clipped.len() == visible.len() {
69        return value.to_owned();
70    }
71
72    let mut skip = visible.len().saturating_sub(clipped.len());
73    let mut output = String::with_capacity(value.len().min(clipped.len() + 32));
74    for token in tokens {
75        if token.visible.is_empty() {
76            output.push_str(token.source);
77            continue;
78        }
79
80        if skip >= token.visible.len() {
81            skip -= token.visible.len();
82            continue;
83        }
84
85        if skip > 0 {
86            push_partial_visible(&mut output, &token, skip, token.visible.len());
87            skip = 0;
88        } else {
89            output.push_str(token.source);
90        }
91    }
92    output
93}
94
95fn visible_text(tokens: &[InlineToken<'_>]) -> String {
96    let capacity = tokens.iter().map(|token| token.visible.len()).sum();
97    let mut visible = String::with_capacity(capacity);
98    for token in tokens {
99        visible.push_str(token.visible.as_ref());
100    }
101    visible
102}
103
104fn push_partial_visible(output: &mut String, token: &InlineToken<'_>, start: usize, end: usize) {
105    let partial = &token.visible[start..end];
106    if matches!(token.visible, Cow::Borrowed(_)) {
107        output.push_str(partial);
108    } else {
109        // Owned token text came from tmux hash-doubling. Escape each retained
110        // hash again so the later format-draw pass renders it literally.
111        output.push_str(&partial.replace('#', "##"));
112    }
113}
114
115#[derive(Debug)]
116struct InlineToken<'a> {
117    source: &'a str,
118    visible: Cow<'a, str>,
119}
120
121fn inline_tokens(expanded: &str) -> Vec<InlineToken<'_>> {
122    let bytes = expanded.as_bytes();
123    let mut tokens = Vec::new();
124    let mut index = 0_usize;
125    let mut style = Style::default();
126    let default = style.cell;
127
128    while index < bytes.len() {
129        // Match format_draw's hash-doubling rules. Odd runs before `[` leave
130        // the final `#[` for the style-clause path; even runs render `#[`.
131        if bytes[index] == b'#' && index + 1 < bytes.len() && bytes[index + 1] != b'[' {
132            let mut count = 1_usize;
133            while index + count < bytes.len() && bytes[index + count] == b'#' {
134                count += 1;
135            }
136
137            let followed_by_bracket = bytes.get(index + count).copied() == Some(b'[');
138            let (consumed, mut visible) = if followed_by_bracket && count.is_multiple_of(2) {
139                let mut visible = "#".repeat(count / 2);
140                visible.push('[');
141                (count + 1, visible)
142            } else if followed_by_bracket {
143                (count - 1, "#".repeat(count / 2))
144            } else {
145                (count, "#".repeat(count.div_ceil(2)))
146            };
147
148            if style.ignore {
149                visible.clear();
150            }
151            tokens.push(InlineToken {
152                source: &expanded[index..index + consumed],
153                visible: Cow::Owned(visible),
154            });
155            index += consumed;
156            continue;
157        }
158
159        if bytes[index] == b'#' && bytes.get(index + 1).copied() == Some(b'[') && !style.ignore {
160            let Some(offset) = format_skip_delimiter(&expanded[index + 2..], b"]") else {
161                // format_draw stops at an unterminated style clause.
162                break;
163            };
164            let end = index + 2 + offset;
165            let source = &expanded[index..=end];
166            let clause = &expanded[index + 2..end];
167            let _ = style_parse(&mut style, &default, clause);
168            tokens.push(InlineToken {
169                source,
170                visible: Cow::Borrowed(""),
171            });
172            index = end + 1;
173            continue;
174        }
175
176        let start = index;
177        while index < bytes.len() && bytes[index] != b'#' {
178            let Some(character) = expanded[index..].chars().next() else {
179                break;
180            };
181            index += character.len_utf8();
182        }
183        if start == index {
184            let character = expanded[index..]
185                .chars()
186                .next()
187                .expect("index is inside expanded text");
188            index += character.len_utf8();
189        }
190        let source = &expanded[start..index];
191        tokens.push(InlineToken {
192            source,
193            visible: Cow::Borrowed(source),
194        });
195    }
196
197    tokens
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{
203        styled_text_width, truncate_styled_text_right_to_width, truncate_styled_text_to_width,
204    };
205    use crate::Utf8Config;
206
207    #[test]
208    fn width_ignores_style_and_range_clauses() {
209        assert_eq!(
210            styled_text_width(
211                "#[fg=red]AB#[range=control|7]CD#[norange]",
212                &Utf8Config::default(),
213            ),
214            4
215        );
216    }
217
218    #[test]
219    fn nested_format_syntax_inside_a_style_clause_stays_zero_width() {
220        let value = "#[fg=#{?client_prefix,red,blue},bold]ABCD";
221
222        assert_eq!(styled_text_width(value, &Utf8Config::default()), 4);
223        assert_eq!(
224            truncate_styled_text_to_width(value, 2, &Utf8Config::default()),
225            "#[fg=#{?client_prefix,red,blue},bold]AB"
226        );
227    }
228
229    #[test]
230    fn left_truncation_preserves_clauses_and_unicode_cells() {
231        assert_eq!(
232            truncate_styled_text_to_width(
233                "#[fg=red]่กจA#[range=control|7]๐Ÿ‘‹๐ŸฝB",
234                5,
235                &Utf8Config::default(),
236            ),
237            "#[fg=red]่กจA#[range=control|7]๐Ÿ‘‹๐Ÿฝ"
238        );
239    }
240
241    #[test]
242    fn right_truncation_preserves_style_before_the_retained_suffix() {
243        assert_eq!(
244            truncate_styled_text_right_to_width(
245                "#[fg=red]AB#[fg=blue]CDEF",
246                3,
247                &Utf8Config::default(),
248            ),
249            "#[fg=red]#[fg=blue]DEF"
250        );
251    }
252
253    #[test]
254    fn hash_doubling_is_measured_as_rendered_text() {
255        assert_eq!(
256            truncate_styled_text_to_width("##[ABCD", 3, &Utf8Config::default()),
257            "##[A"
258        );
259        assert_eq!(
260            truncate_styled_text_right_to_width("####[AB", 4, &Utf8Config::default()),
261            "##[AB"
262        );
263    }
264}