1use unicode_width::UnicodeWidthChar;
2
3pub fn visible_width(s: &str) -> usize {
8 let mut width = 0;
9 let mut chars = s.chars().peekable();
10 while let Some(ch) = chars.next() {
11 if ch == '\x1b' {
12 match chars.peek() {
13 | Some(&'[') => {
14 chars.next();
15 while let Some(&c) = chars.peek() {
16 chars.next();
17 if c.is_alphabetic() {
18 break;
19 }
20 }
21 continue;
22 },
23 | Some(&']') => {
24 chars.next();
25 while let Some(&c) = chars.peek() {
26 chars.next();
27 if c == '\x07' {
28 break;
29 }
30 if c == '\x1b' {
31 if let Some(&'\\') = chars.peek() {
32 chars.next();
33 break;
34 }
35 }
36 }
37 continue;
38 },
39 | _ => {},
40 }
41 }
42 width += ch.width().unwrap_or(0);
43 }
44 width
45}
46
47pub fn byte_index_at_visual_pos(s: &str, target_pos: usize) -> usize {
54 let mut width = 0;
55 let mut byte_idx = 0;
56 let mut chars = s.chars().peekable();
57
58 while let Some(&ch) = chars.peek() {
59 let ch_len = ch.len_utf8();
60 if ch == '\x1b' {
61 chars.next();
62 byte_idx += ch_len;
63 match chars.peek() {
64 | Some(&'[') => {
65 chars.next();
66 byte_idx += '['.len_utf8();
67 while let Some(&c) = chars.peek() {
68 chars.next();
69 byte_idx += c.len_utf8();
70 if c.is_alphabetic() {
71 break;
72 }
73 }
74 },
75 | Some(&']') => {
76 chars.next();
77 byte_idx += ']'.len_utf8();
78 while let Some(&c) = chars.peek() {
79 chars.next();
80 byte_idx += c.len_utf8();
81 if c == '\x07' {
82 break;
83 }
84 if c == '\x1b' {
85 if let Some(&'\\') = chars.peek() {
86 chars.next();
87 byte_idx += '\\'.len_utf8();
88 break;
89 }
90 }
91 }
92 },
93 | _ => {},
94 }
95 continue;
96 }
97 if width >= target_pos {
98 return byte_idx;
99 }
100 chars.next();
101 width += ch.width().unwrap_or(0);
102 byte_idx += ch_len;
103 if width >= target_pos {
104 return byte_idx;
105 }
106 }
107 byte_idx
108}
109
110pub fn truncate_to_width(s: &str, max_width: u16, ellipsis: &str) -> String {
124 let max = max_width as usize;
125 let ellip_width = visible_width(ellipsis);
126 let total = visible_width(s);
127 if total <= max {
128 return s.to_string();
129 }
130 let target = max.saturating_sub(ellip_width);
131 let mut result = String::new();
132 let mut w = 0;
133 let mut chars = s.chars().peekable();
134 while let Some(ch) = chars.next() {
135 if ch == '\x1b' {
137 match chars.peek() {
138 | Some(&'[') => {
139 result.push(ch);
140 chars.next(); result.push('[');
142 while let Some(&c) = chars.peek() {
143 chars.next();
144 result.push(c);
145 if c.is_alphabetic() {
146 break;
147 }
148 }
149 continue;
150 },
151 | Some(&']') => {
152 result.push(ch);
153 chars.next(); result.push(']');
155 while let Some(&c) = chars.peek() {
156 chars.next();
157 result.push(c);
158 if c == '\x07' {
159 break;
160 }
161 if c == '\x1b' {
162 if let Some(&'\\') = chars.peek() {
163 chars.next();
164 result.push('\\');
165 break;
166 }
167 }
168 }
169 continue;
170 },
171 | _ => {},
172 }
173 }
174 let cw = ch.width().unwrap_or(0);
175 if w + cw > target {
176 break;
177 }
178 result.push(ch);
179 w += cw;
180 }
181 result.push_str(ellipsis);
182 if s.contains('\x1b') {
186 result.push_str("\x1b[0m");
187 }
188 result
189}
190
191#[derive(Debug, Clone, PartialEq)]
193pub struct ActiveHyperlink {
194 pub params: String,
196 pub url: String,
198 pub terminator: String,
200}
201
202#[derive(Debug, Default, Clone, PartialEq)]
220pub struct AnsiCodeTracker {
221 pub bold: bool,
223 pub italic: bool,
225 pub underline: bool,
227 pub fg_color: Option<String>,
229 pub bg_color: Option<String>,
231 pub hyperlink: Option<ActiveHyperlink>,
233}
234
235impl AnsiCodeTracker {
236 pub fn new() -> Self {
238 Self::default()
239 }
240
241 fn parse_osc8(seq: &str) -> Option<Option<ActiveHyperlink>> {
246 let body = seq.strip_prefix("\x1b]")?;
247 let (body, terminator) = if body.ends_with("\x1b\\") {
248 (&body[..body.len() - 2], "\x1b\\".to_string())
249 } else if body.ends_with('\x07') {
250 (&body[..body.len() - 1], "\x07".to_string())
251 } else {
252 return None;
253 };
254 let rest = body.strip_prefix("8;")?;
255 let sep = rest.find(';')?;
256 let params = rest[..sep].to_string();
257 let url = rest[sep + 1..].to_string();
258 if url.is_empty() {
259 Some(None)
260 } else {
261 Some(Some(ActiveHyperlink {
262 params,
263 url,
264 terminator,
265 }))
266 }
267 }
268
269 pub fn process(&mut self, seq: &str) {
275 if let Some(parsed) = Self::parse_osc8(seq) {
276 self.hyperlink = parsed;
277 return;
278 }
279
280 let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
281 let body = body.strip_suffix('m').unwrap_or(body);
282 for code in body.split(';') {
283 match code {
284 | "1" => self.bold = true,
285 | "3" => self.italic = true,
286 | "4" => self.underline = true,
287 | "22" => self.bold = false,
288 | "23" => self.italic = false,
289 | "24" => self.underline = false,
290 | "39" => self.fg_color = None,
291 | "49" => self.bg_color = None,
292 | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
293 | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
294 | _ => {},
295 }
296 }
297 }
298
299 pub fn current_codes(&self) -> String {
303 let mut parts = Vec::new();
304 if self.bold {
305 parts.push("1");
306 }
307 if self.italic {
308 parts.push("3");
309 }
310 if self.underline {
311 parts.push("4");
312 }
313 if let Some(ref fg) = self.fg_color {
314 parts.push(fg.as_str());
315 }
316 if let Some(ref bg) = self.bg_color {
317 parts.push(bg.as_str());
318 }
319 let mut result = if parts.is_empty() {
320 String::new()
321 } else {
322 format!("\x1b[{}m", parts.join(";"))
323 };
324 if let Some(ref link) = self.hyperlink {
325 result.push_str(&format!(
326 "\x1b]8;{};{}{}",
327 link.params, link.url, link.terminator
328 ));
329 }
330 result
331 }
332
333 pub fn line_end_reset(&self) -> String {
339 let mut result = String::new();
340 if self.underline {
341 result.push_str("\x1b[24m");
342 }
343 if let Some(ref link) = self.hyperlink {
344 result.push_str(&format!("\x1b]8;;{}", link.terminator));
345 }
346 result
347 }
348
349 pub fn has_active_codes(&self) -> bool {
351 self.bold ||
352 self.italic ||
353 self.underline ||
354 self.fg_color.is_some() ||
355 self.bg_color.is_some() ||
356 self.hyperlink.is_some()
357 }
358}
359
360pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
375 let w = width as usize;
376 let mut lines: Vec<String> = Vec::new();
377 let mut current = String::new();
378 let mut current_width = 0;
379 let mut tracker = AnsiCodeTracker::new();
380
381 let mut chars = text.chars().peekable();
382 while let Some(ch) = chars.next() {
383 if ch == '\x1b' {
384 match chars.peek() {
385 | Some(&'[') => {
386 chars.next();
387 let mut seq = String::from("\x1b[");
388 while let Some(&c) = chars.peek() {
389 seq.push(c);
390 chars.next();
391 if c.is_alphabetic() {
392 break;
393 }
394 }
395 tracker.process(&seq);
396 current.push_str(&seq);
397 continue;
398 },
399 | Some(&']') => {
400 chars.next();
401 let mut seq = String::from("\x1b]");
402 while let Some(&c) = chars.peek() {
403 seq.push(c);
404 chars.next();
405 if c == '\x07' {
406 break;
407 }
408 if c == '\x1b' {
409 if let Some(&'\\') = chars.peek() {
410 seq.push('\\');
411 chars.next();
412 break;
413 }
414 }
415 }
416 tracker.process(&seq);
417 current.push_str(&seq);
418 continue;
419 },
420 | _ => {},
421 }
422 }
423
424 if ch == '\n' {
425 if tracker.bold ||
426 tracker.italic ||
427 tracker.underline ||
428 tracker.fg_color.is_some() ||
429 tracker.bg_color.is_some()
430 {
431 current.push_str("\x1b[0m");
432 }
433 let reset = tracker.line_end_reset();
434 if !reset.is_empty() {
435 current.push_str(&reset);
436 }
437 lines.push(current);
438 current = tracker.current_codes();
439 current_width = 0;
440 continue;
441 }
442
443 let cw = ch.width().unwrap_or(0);
444 if current_width + cw > w && !current.is_empty() {
445 if tracker.bold ||
446 tracker.italic ||
447 tracker.underline ||
448 tracker.fg_color.is_some() ||
449 tracker.bg_color.is_some()
450 {
451 current.push_str("\x1b[0m");
452 }
453 let reset = tracker.line_end_reset();
454 if !reset.is_empty() {
455 current.push_str(&reset);
456 }
457 lines.push(current);
458 current = tracker.current_codes();
459 current_width = 0;
460 }
461 current.push(ch);
462 current_width += cw;
463 }
464
465 if !current.is_empty() {
466 lines.push(current);
467 }
468 lines
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn tracker_tracks_hyperlink() {
477 let mut tracker = AnsiCodeTracker::new();
478 tracker.process("\x1b]8;;https://example.com\x1b\\");
479 assert!(tracker.hyperlink.is_some());
480 assert_eq!(
481 tracker.hyperlink.as_ref().unwrap().url,
482 "https://example.com"
483 );
484 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
485 }
486
487 #[test]
488 fn tracker_hyperlink_bel_terminator() {
489 let mut tracker = AnsiCodeTracker::new();
490 tracker.process("\x1b]8;;https://example.com\x07");
491 assert!(tracker.hyperlink.is_some());
492 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
493 }
494
495 #[test]
496 fn tracker_hyperlink_close() {
497 let mut tracker = AnsiCodeTracker::new();
498 tracker.process("\x1b]8;;https://example.com\x1b\\");
499 assert!(tracker.hyperlink.is_some());
500 tracker.process("\x1b]8;;\x1b\\");
501 assert!(tracker.hyperlink.is_none());
502 }
503
504 #[test]
505 fn current_codes_includes_hyperlink() {
506 let mut tracker = AnsiCodeTracker::new();
507 tracker.process("\x1b]8;;https://example.com\x1b\\");
508 let codes = tracker.current_codes();
509 assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
510 }
511
512 #[test]
513 fn line_end_reset_closes_hyperlink() {
514 let mut tracker = AnsiCodeTracker::new();
515 tracker.process("\x1b]8;;https://example.com\x1b\\");
516 let reset = tracker.line_end_reset();
517 assert!(reset.contains("\x1b]8;;\x1b\\"));
518 }
519
520 #[test]
521 fn wrap_preserves_hyperlink_across_lines() {
522 let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
523 let lines = wrap_text_with_ansi(text, 6);
524 assert_eq!(lines.len(), 2);
525 assert!(lines[0].contains("\x1b]8;;\x1b\\"));
527 assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
529 }
530
531 #[test]
532 fn has_active_codes_with_hyperlink() {
533 let mut tracker = AnsiCodeTracker::new();
534 assert!(!tracker.has_active_codes());
535 tracker.process("\x1b]8;;https://example.com\x1b\\");
536 assert!(tracker.has_active_codes());
537 }
538
539 #[test]
540 fn line_end_reset_with_underline() {
541 let mut tracker = AnsiCodeTracker::new();
542 tracker.process("\x1b[4m");
543 let reset = tracker.line_end_reset();
544 assert!(reset.contains("\x1b[24m"));
545 }
546
547 #[test]
548 fn wrap_hyperlink_bel_terminator() {
549 let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
550 let lines = wrap_text_with_ansi(text, 6);
551 assert_eq!(lines.len(), 2);
552 assert!(lines[0].contains("\x1b]8;;\x07"));
553 assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
554 }
555
556 #[test]
557 fn wrap_newline_with_active_sgr() {
558 let text = "\x1b[31mhello\nworld\x1b[0m";
559 let lines = wrap_text_with_ansi(text, 20);
560 assert_eq!(lines.len(), 2);
561 assert!(lines[0].contains("\x1b[0m"));
563 assert!(lines[1].starts_with("\x1b[31m"));
565 }
566
567 #[test]
568 fn tracker_invalid_osc_ignored() {
569 let mut tracker = AnsiCodeTracker::new();
570 tracker.process("\x1b]8;;url");
571 assert!(tracker.hyperlink.is_none());
572 }
573
574 #[test]
575 fn tracker_invalid_osc_no_prefix() {
576 let mut tracker = AnsiCodeTracker::new();
577 tracker.process("\x1b]9;;url\x1b\\");
578 assert!(tracker.hyperlink.is_none());
579 }
580
581 #[test]
582 fn has_active_codes_with_sgr() {
583 let mut tracker = AnsiCodeTracker::new();
584 tracker.process("\x1b[1m");
585 assert!(tracker.has_active_codes());
586 }
587
588 #[test]
589 fn truncate_jk_text_demo() {
590 let text = " j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit";
591 let truncated = truncate_to_width(text, 80, "…");
592 let vw = visible_width(&truncated);
593 eprintln!("original vw: {}", visible_width(text));
594 eprintln!("truncated: {:?}", truncated);
595 eprintln!("truncated vw: {}", vw);
596 assert!(vw <= 80, "truncated width {} exceeds 80", vw);
597 assert!(truncated.ends_with("…"));
598 }
599
600 #[test]
601 fn truncate_to_width_preserves_ansi_prefix() {
602 let s = "\x1b[44mhello\x1b[0m";
603 let truncated = truncate_to_width(s, 3, "…");
604 assert!(truncated.starts_with("\x1b[44m"));
607 assert!(truncated.contains("…"));
608 assert!(truncated.ends_with("\x1b[0m"));
609 assert_eq!(visible_width(&truncated), 3);
610 }
611
612 #[test]
613 fn truncate_to_width_preserves_ansi_infix() {
614 let s = "hi\x1b[31mred\x1b[0mlo";
615 let truncated = truncate_to_width(s, 4, "…");
616 assert_eq!(visible_width(&truncated), 4);
617 assert!(truncated.contains("\x1b[31m"));
619 assert!(truncated.contains("\x1b[0m"));
620 }
621
622 #[test]
623 fn truncate_to_width_no_truncation_when_fits() {
624 let s = "\x1b[44mhi\x1b[0m";
625 let truncated = truncate_to_width(s, 5, "…");
626 assert_eq!(truncated, s);
628 }
629
630 #[test]
631 fn byte_index_at_visual_pos_plain() {
632 assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
633 assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
634 assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
635 assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
636 }
637
638 #[test]
639 fn byte_index_at_visual_pos_with_ansi_prefix() {
640 let s = "\x1b[31mhello\x1b[0m";
641 assert_eq!(byte_index_at_visual_pos(s, 0), 5);
643 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
644 assert_eq!(byte_index_at_visual_pos(s, 5), 10);
645 assert_eq!(byte_index_at_visual_pos(s, 10), 14);
647 }
648
649 #[test]
650 fn byte_index_at_visual_pos_with_ansi_infix() {
651 let s = "hi\x1b[31mred\x1b[0mlo";
652 assert_eq!(byte_index_at_visual_pos(s, 0), 0);
654 assert_eq!(byte_index_at_visual_pos(s, 2), 2);
655 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
657 assert_eq!(byte_index_at_visual_pos(s, 7), 16);
659 }
660
661 #[test]
662 fn byte_index_at_visual_pos_with_hyperlink() {
663 let s = "\x1b]8;;https://example.com\x07hello";
664 assert_eq!(byte_index_at_visual_pos(s, 0), 25);
666 assert_eq!(byte_index_at_visual_pos(s, 3), 28);
667 }
668}