supercode_frontend_tui/foundation/
text_formatting.rs1use unicode_segmentation::UnicodeSegmentation;
8use unicode_width::UnicodeWidthChar;
9use unicode_width::UnicodeWidthStr;
10
11pub fn capitalize_first(input: &str) -> String {
12 let mut chars = input.chars();
13 match chars.next() {
14 Some(first) => {
15 let mut capitalized = first.to_uppercase().collect::<String>();
16 capitalized.push_str(chars.as_str());
17 capitalized
18 }
19 None => String::new(),
20 }
21}
22
23pub fn format_and_truncate_tool_result(text: &str, max_lines: usize, line_width: usize) -> String {
26 let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
30
31 if let Some(formatted_json) = format_json_compact(text) {
32 truncate_text(&formatted_json, max_graphemes)
33 } else {
34 truncate_text(text, max_graphemes)
35 }
36}
37
38pub fn format_json_compact(text: &str) -> Option<String> {
47 let json = serde_json::from_str::<serde_json::Value>(text).ok()?;
48 let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
49
50 let mut result = String::new();
52 let mut chars = json_pretty.chars().peekable();
53 let mut in_string = false;
54 let mut escape_next = false;
55
56 while let Some(ch) = chars.next() {
58 match ch {
59 '"' if !escape_next => {
60 in_string = !in_string;
61 result.push(ch);
62 }
63 '\\' if in_string => {
64 escape_next = !escape_next;
65 result.push(ch);
66 }
67 '\n' | '\r' if !in_string => {
68 }
70 ' ' | '\t' if !in_string => {
71 if let (Some(&next_ch), Some(last_ch)) = (chars.peek(), result.chars().last()) {
73 if (last_ch == ':' || last_ch == ',') && !matches!(next_ch, '}' | ']') {
74 result.push(' ');
75 }
76 }
77 }
78 _ => {
79 if escape_next && in_string {
80 escape_next = false;
81 }
82 result.push(ch);
83 }
84 }
85 }
86
87 Some(result)
88}
89
90pub fn truncate_text(text: &str, max_graphemes: usize) -> String {
92 let mut graphemes = text.grapheme_indices(true);
93
94 if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
96 if max_graphemes >= 3 {
98 let mut truncate_graphemes = text.grapheme_indices(true);
100 if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) {
101 let truncated = &text[..truncate_byte_index];
102 format!("{truncated}...")
103 } else {
104 text.to_string()
105 }
106 } else {
107 let truncated = &text[..byte_index];
109 truncated.to_string()
110 }
111 } else {
112 text.to_string()
114 }
115}
116
117pub fn center_truncate_path(path: &str, max_width: usize) -> String {
121 if max_width == 0 {
122 return String::new();
123 }
124 if UnicodeWidthStr::width(path) <= max_width {
125 return path.to_string();
126 }
127
128 let sep = std::path::MAIN_SEPARATOR;
129 let has_leading_sep = path.starts_with(sep);
130 let has_trailing_sep = path.ends_with(sep);
131 let mut raw_segments: Vec<&str> = path.split(sep).collect();
132 if has_leading_sep && !raw_segments.is_empty() && raw_segments[0].is_empty() {
133 raw_segments.remove(0);
134 }
135 if has_trailing_sep
136 && !raw_segments.is_empty()
137 && raw_segments.last().is_some_and(|last| last.is_empty())
138 {
139 raw_segments.pop();
140 }
141
142 if raw_segments.is_empty() {
143 if has_leading_sep {
144 let root = sep.to_string();
145 if UnicodeWidthStr::width(root.as_str()) <= max_width {
146 return root;
147 }
148 }
149 return "…".to_string();
150 }
151
152 struct Segment<'a> {
153 original: &'a str,
154 text: String,
155 truncatable: bool,
156 is_suffix: bool,
157 }
158
159 let assemble = |leading: bool, segments: &[Segment<'_>]| -> String {
160 let mut result = String::new();
161 if leading {
162 result.push(sep);
163 }
164 for segment in segments {
165 if !result.is_empty() && !result.ends_with(sep) {
166 result.push(sep);
167 }
168 result.push_str(segment.text.as_str());
169 }
170 result
171 };
172
173 let front_truncate = |original: &str, allowed_width: usize| -> String {
174 if allowed_width == 0 {
175 return String::new();
176 }
177 if UnicodeWidthStr::width(original) <= allowed_width {
178 return original.to_string();
179 }
180 if allowed_width == 1 {
181 return "…".to_string();
182 }
183
184 let mut kept: Vec<char> = Vec::new();
185 let mut used_width = 1; for ch in original.chars().rev() {
187 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
188 if used_width + ch_width > allowed_width {
189 break;
190 }
191 used_width += ch_width;
192 kept.push(ch);
193 }
194 kept.reverse();
195 let mut truncated = String::from("…");
196 for ch in kept {
197 truncated.push(ch);
198 }
199 truncated
200 };
201
202 let mut combos: Vec<(usize, usize)> = Vec::new();
203 let segment_count = raw_segments.len();
204 for left in 1..=segment_count {
205 let min_right = if left == segment_count { 0 } else { 1 };
206 for right in min_right..=(segment_count - left) {
207 combos.push((left, right));
208 }
209 }
210 let desired_suffix = if segment_count > 1 {
211 std::cmp::min(2, segment_count - 1)
212 } else {
213 0
214 };
215 let mut prioritized: Vec<(usize, usize)> = Vec::new();
216 let mut fallback: Vec<(usize, usize)> = Vec::new();
217 for combo in combos {
218 if combo.1 >= desired_suffix {
219 prioritized.push(combo);
220 } else {
221 fallback.push(combo);
222 }
223 }
224 let sort_combos = |items: &mut Vec<(usize, usize)>| {
225 items.sort_by(|(left_a, right_a), (left_b, right_b)| {
226 left_b
227 .cmp(left_a)
228 .then_with(|| right_b.cmp(right_a))
229 .then_with(|| (left_b + right_b).cmp(&(left_a + right_a)))
230 });
231 };
232 sort_combos(&mut prioritized);
233 sort_combos(&mut fallback);
234
235 let fit_segments =
236 |segments: &mut Vec<Segment<'_>>, allow_front_truncate: bool| -> Option<String> {
237 loop {
238 let candidate = assemble(has_leading_sep, segments);
239 let width = UnicodeWidthStr::width(candidate.as_str());
240 if width <= max_width {
241 return Some(candidate);
242 }
243
244 if !allow_front_truncate {
245 return None;
246 }
247
248 let mut indices: Vec<usize> = Vec::new();
249 for (idx, seg) in segments.iter().enumerate().rev() {
250 if seg.truncatable && seg.is_suffix {
251 indices.push(idx);
252 }
253 }
254 for (idx, seg) in segments.iter().enumerate().rev() {
255 if seg.truncatable && !seg.is_suffix {
256 indices.push(idx);
257 }
258 }
259
260 if indices.is_empty() {
261 return None;
262 }
263
264 let mut changed = false;
265 for idx in indices {
266 let original_width = UnicodeWidthStr::width(segments[idx].original);
267 if original_width <= max_width && segment_count > 2 {
268 continue;
269 }
270 let seg_width = UnicodeWidthStr::width(segments[idx].text.as_str());
271 let other_width = width.saturating_sub(seg_width);
272 let allowed_width = max_width.saturating_sub(other_width).max(1);
273 let new_text = front_truncate(segments[idx].original, allowed_width);
274 if new_text != segments[idx].text {
275 segments[idx].text = new_text;
276 changed = true;
277 break;
278 }
279 }
280
281 if !changed {
282 return None;
283 }
284 }
285 };
286
287 for (left_count, right_count) in prioritized.into_iter().chain(fallback) {
288 let mut segments: Vec<Segment<'_>> = raw_segments[..left_count]
289 .iter()
290 .map(|seg| Segment {
291 original: seg,
292 text: (*seg).to_string(),
293 truncatable: true,
294 is_suffix: false,
295 })
296 .collect();
297
298 let need_ellipsis = left_count + right_count < segment_count;
299 if need_ellipsis {
300 segments.push(Segment {
301 original: "…",
302 text: "…".to_string(),
303 truncatable: false,
304 is_suffix: false,
305 });
306 }
307
308 if right_count > 0 {
309 segments.extend(
310 raw_segments[segment_count - right_count..]
311 .iter()
312 .map(|seg| Segment {
313 original: seg,
314 text: (*seg).to_string(),
315 truncatable: true,
316 is_suffix: true,
317 }),
318 );
319 }
320
321 let allow_front_truncate = need_ellipsis || segment_count <= 2;
322 if let Some(candidate) = fit_segments(&mut segments, allow_front_truncate) {
323 return candidate;
324 }
325 }
326
327 front_truncate(path, max_width)
328}
329
330pub fn proper_join<T: AsRef<str>>(items: &[T]) -> String {
337 match items.len() {
338 0 => String::new(),
339 1 => items[0].as_ref().to_string(),
340 2 => format!("{} and {}", items[0].as_ref(), items[1].as_ref()),
341 _ => {
342 let last = items[items.len() - 1].as_ref();
343 let mut result = String::new();
344
345 for (i, item) in items.iter().take(items.len() - 1).enumerate() {
346 if i > 0 {
347 result.push_str(", ");
348 }
349 result.push_str(item.as_ref());
350 }
351
352 format!("{result} and {last}")
353 }
354 }
355}