rmux_core/formats/
styled_text.rs1use 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#[must_use]
11pub fn styled_text_width(value: &str, utf8: &Utf8Config) -> usize {
12 text_width(&visible_text(&inline_tokens(value)), utf8)
13}
14
15#[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#[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 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 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 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}