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 let Some(&'\\') = chars.peek()
32 {
33 chars.next();
34 break;
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 let Some(&'\\') = chars.peek()
86 {
87 chars.next();
88 byte_idx += '\\'.len_utf8();
89 break;
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
110fn sgr_reset(bold_or_faint: bool) -> &'static str {
117 if bold_or_faint {
118 "\x1b[22m\x1b[0m"
119 } else {
120 "\x1b[0m"
121 }
122}
123
124pub fn truncate_to_width(s: &str, max_width: u16, ellipsis: &str) -> String {
138 let max = max_width as usize;
139 let ellip_width = visible_width(ellipsis);
140 let total = visible_width(s);
141 if total <= max {
142 return s.to_string();
143 }
144 let target = max.saturating_sub(ellip_width);
145 let mut result = String::new();
146 let mut w = 0;
147 let mut chars = s.chars().peekable();
148 let mut tracker = AnsiCodeTracker::new();
149 while let Some(ch) = chars.next() {
150 if ch == '\x1b' {
152 match chars.peek() {
153 | Some(&'[') => {
154 result.push(ch);
155 chars.next(); result.push('[');
157 let mut seq = String::from("\x1b[");
158 while let Some(&c) = chars.peek() {
159 chars.next();
160 result.push(c);
161 seq.push(c);
162 if c.is_alphabetic() {
163 break;
164 }
165 }
166 tracker.process(&seq);
167 continue;
168 },
169 | Some(&']') => {
170 result.push(ch);
171 chars.next(); result.push(']');
173 while let Some(&c) = chars.peek() {
174 chars.next();
175 result.push(c);
176 if c == '\x07' {
177 break;
178 }
179 if c == '\x1b' &&
180 let Some(&'\\') = chars.peek()
181 {
182 chars.next();
183 result.push('\\');
184 break;
185 }
186 }
187 continue;
188 },
189 | _ => {},
190 }
191 }
192 let cw = ch.width().unwrap_or(0);
193 if w + cw > target {
194 break;
195 }
196 result.push(ch);
197 w += cw;
198 }
199 result.push_str(ellipsis);
200 if s.contains('\x1b') {
205 result.push_str(sgr_reset(tracker.bold || tracker.faint));
206 }
207 result
208}
209
210#[derive(Debug, Clone, PartialEq)]
212pub struct ActiveHyperlink {
213 pub params: String,
215 pub url: String,
217 pub terminator: String,
219}
220
221#[derive(Debug, Default, Clone, PartialEq)]
239pub struct AnsiCodeTracker {
240 pub bold: bool,
242 pub italic: bool,
244 pub underline: bool,
246 pub faint: bool,
248 pub reverse: bool,
250 pub fg_color: Option<String>,
252 pub bg_color: Option<String>,
254 pub hyperlink: Option<ActiveHyperlink>,
256}
257
258impl AnsiCodeTracker {
259 pub fn new() -> Self {
261 Self::default()
262 }
263
264 fn parse_osc8(seq: &str) -> Option<Option<ActiveHyperlink>> {
269 let body = match seq.strip_prefix("\x1b]") {
270 | Some(b) => b,
271 | None => return None,
272 };
273 let (body, terminator) = if let Some(body) = body.strip_suffix("\x1b\\") {
274 (body, "\x1b\\".to_string())
275 } else if let Some(body) = body.strip_suffix('\x07') {
276 (body, "\x07".to_string())
277 } else {
278 return None;
279 };
280 let rest = match body.strip_prefix("8;") {
281 | Some(r) => r,
282 | None => return None,
283 };
284 let sep = match rest.find(';') {
285 | Some(s) => s,
286 | None => return None,
287 };
288 let params = rest[..sep].to_string();
289 let url = rest[sep + 1..].to_string();
290 if url.is_empty() {
291 Some(None)
292 } else {
293 Some(Some(ActiveHyperlink {
294 params,
295 url,
296 terminator,
297 }))
298 }
299 }
300
301 pub fn process(&mut self, seq: &str) {
309 if let Some(parsed) = Self::parse_osc8(seq) {
310 self.hyperlink = parsed;
311 return;
312 }
313
314 let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
315 let body = body.strip_suffix('m').unwrap_or(body);
316 let codes: Vec<&str> = body.split(';').collect();
317 let mut i = 0;
318 while i < codes.len() {
319 let code = codes[i];
320 match code {
321 | "0" => *self = Self::default(),
322 | "1" => self.bold = true,
323 | "2" => self.faint = true,
324 | "3" => self.italic = true,
325 | "4" => self.underline = true,
326 | "7" => self.reverse = true,
327 | "22" => {
328 self.bold = false;
329 self.faint = false;
330 },
331 | "23" => self.italic = false,
332 | "24" => self.underline = false,
333 | "27" => self.reverse = false,
334 | "39" => self.fg_color = None,
335 | "49" => self.bg_color = None,
336 | "38" => {
337 self.fg_color = Self::parse_extended_color(&codes, &mut i, "38");
338 continue;
339 },
340 | "48" => {
341 self.bg_color = Self::parse_extended_color(&codes, &mut i, "48");
342 continue;
343 },
344 | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
345 | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
346 | _ => {},
347 }
348 i += 1;
349 }
350 }
351
352 fn parse_extended_color(codes: &[&str], i: &mut usize, prefix: &str) -> Option<String> {
359 *i += 1;
360 if *i >= codes.len() {
361 return None;
362 }
363 match codes[*i] {
364 | "5" => {
365 *i += 1;
366 if *i >= codes.len() {
367 return None;
368 }
369 let idx = codes[*i];
370 *i += 1;
371 Some(format!("{};5;{}", prefix, idx))
372 },
373 | "2" => {
374 *i += 1;
375 if *i + 2 >= codes.len() {
376 return None;
377 }
378 let r = codes[*i];
379 let g = codes[*i + 1];
380 let b = codes[*i + 2];
381 *i += 3;
382 Some(format!("{};2;{};{};{}", prefix, r, g, b))
383 },
384 | _ => None,
385 }
386 }
387
388 pub fn current_codes(&self) -> String {
392 let mut parts = Vec::new();
393 if self.bold {
394 parts.push("1");
395 }
396 if self.faint {
397 parts.push("2");
398 }
399 if self.italic {
400 parts.push("3");
401 }
402 if self.underline {
403 parts.push("4");
404 }
405 if self.reverse {
406 parts.push("7");
407 }
408 if let Some(ref fg) = self.fg_color {
409 parts.push(fg.as_str());
410 }
411 if let Some(ref bg) = self.bg_color {
412 parts.push(bg.as_str());
413 }
414 let mut result = if parts.is_empty() {
415 String::new()
416 } else {
417 format!("\x1b[{}m", parts.join(";"))
418 };
419 if let Some(ref link) = self.hyperlink {
420 result.push_str(&format!(
421 "\x1b]8;{};{}{}",
422 link.params, link.url, link.terminator
423 ));
424 }
425 result
426 }
427
428 pub fn line_end_reset(&self) -> String {
434 let mut result = String::new();
435 if self.underline {
436 result.push_str("\x1b[24m");
437 }
438 if self.reverse {
439 result.push_str("\x1b[27m");
440 }
441 if let Some(ref link) = self.hyperlink {
442 result.push_str(&format!("\x1b]8;;{}", link.terminator));
443 }
444 result
445 }
446
447 pub fn has_active_codes(&self) -> bool {
449 self.bold ||
450 self.faint ||
451 self.italic ||
452 self.underline ||
453 self.reverse ||
454 self.fg_color.is_some() ||
455 self.bg_color.is_some() ||
456 self.hyperlink.is_some()
457 }
458}
459
460pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
475 let w = width as usize;
476 let mut lines: Vec<String> = Vec::new();
477 let mut current = String::new();
478 let mut current_width = 0;
479 let mut tracker = AnsiCodeTracker::new();
480
481 let mut chars = text.chars().peekable();
482 while let Some(ch) = chars.next() {
483 if ch == '\x1b' {
484 match chars.peek() {
485 | Some(&'[') => {
486 chars.next();
487 let mut seq = String::from("\x1b[");
488 while let Some(&c) = chars.peek() {
489 seq.push(c);
490 chars.next();
491 if c.is_alphabetic() {
492 break;
493 }
494 }
495 tracker.process(&seq);
496 current.push_str(&seq);
497 continue;
498 },
499 | Some(&']') => {
500 chars.next();
501 let mut seq = String::from("\x1b]");
502 while let Some(&c) = chars.peek() {
503 seq.push(c);
504 chars.next();
505 if c == '\x07' {
506 break;
507 }
508 if c == '\x1b' &&
509 let Some(&'\\') = chars.peek()
510 {
511 seq.push('\\');
512 chars.next();
513 break;
514 }
515 }
516 tracker.process(&seq);
517 current.push_str(&seq);
518 continue;
519 },
520 | _ => {},
521 }
522 }
523
524 if ch == '\n' {
525 if tracker.bold ||
526 tracker.faint ||
527 tracker.italic ||
528 tracker.underline ||
529 tracker.fg_color.is_some() ||
530 tracker.bg_color.is_some()
531 {
532 current.push_str(sgr_reset(tracker.bold || tracker.faint));
533 }
534 let reset = tracker.line_end_reset();
535 if !reset.is_empty() {
536 current.push_str(&reset);
537 }
538 lines.push(current);
539 current = tracker.current_codes();
540 current_width = 0;
541 continue;
542 }
543
544 let cw = ch.width().unwrap_or(0);
545 if current_width + cw > w && !current.is_empty() {
546 if tracker.bold ||
547 tracker.faint ||
548 tracker.italic ||
549 tracker.underline ||
550 tracker.fg_color.is_some() ||
551 tracker.bg_color.is_some()
552 {
553 current.push_str(sgr_reset(tracker.bold || tracker.faint));
554 }
555 let reset = tracker.line_end_reset();
556 if !reset.is_empty() {
557 current.push_str(&reset);
558 }
559 lines.push(current);
560 current = tracker.current_codes();
561 current_width = 0;
562 }
563 current.push(ch);
564 current_width += cw;
565 }
566
567 if !current.is_empty() {
568 lines.push(current);
569 }
570 lines
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn tracker_tracks_hyperlink() {
579 let mut tracker = AnsiCodeTracker::new();
580 tracker.process("\x1b]8;;https://example.com\x1b\\");
581 assert!(tracker.hyperlink.is_some());
582 assert_eq!(
583 tracker.hyperlink.as_ref().unwrap().url,
584 "https://example.com"
585 );
586 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
587 }
588
589 #[test]
590 fn tracker_hyperlink_bel_terminator() {
591 let mut tracker = AnsiCodeTracker::new();
592 tracker.process("\x1b]8;;https://example.com\x07");
593 assert!(tracker.hyperlink.is_some());
594 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
595 }
596
597 #[test]
598 fn tracker_hyperlink_close() {
599 let mut tracker = AnsiCodeTracker::new();
600 tracker.process("\x1b]8;;https://example.com\x1b\\");
601 assert!(tracker.hyperlink.is_some());
602 tracker.process("\x1b]8;;\x1b\\");
603 assert!(tracker.hyperlink.is_none());
604 }
605
606 #[test]
607 fn current_codes_includes_hyperlink() {
608 let mut tracker = AnsiCodeTracker::new();
609 tracker.process("\x1b]8;;https://example.com\x1b\\");
610 let codes = tracker.current_codes();
611 assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
612 }
613
614 #[test]
615 fn line_end_reset_closes_hyperlink() {
616 let mut tracker = AnsiCodeTracker::new();
617 tracker.process("\x1b]8;;https://example.com\x1b\\");
618 let reset = tracker.line_end_reset();
619 assert!(reset.contains("\x1b]8;;\x1b\\"));
620 }
621
622 #[test]
623 fn wrap_preserves_hyperlink_across_lines() {
624 let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
625 let lines = wrap_text_with_ansi(text, 6);
626 assert_eq!(lines.len(), 2);
627 assert!(lines[0].contains("\x1b]8;;\x1b\\"));
629 assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
631 }
632
633 #[test]
634 fn has_active_codes_with_hyperlink() {
635 let mut tracker = AnsiCodeTracker::new();
636 assert!(!tracker.has_active_codes());
637 tracker.process("\x1b]8;;https://example.com\x1b\\");
638 assert!(tracker.has_active_codes());
639 }
640
641 #[test]
642 fn line_end_reset_with_underline() {
643 let mut tracker = AnsiCodeTracker::new();
644 tracker.process("\x1b[4m");
645 let reset = tracker.line_end_reset();
646 assert!(reset.contains("\x1b[24m"));
647 }
648
649 #[test]
650 fn wrap_hyperlink_bel_terminator() {
651 let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
652 let lines = wrap_text_with_ansi(text, 6);
653 assert_eq!(lines.len(), 2);
654 assert!(lines[0].contains("\x1b]8;;\x07"));
655 assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
656 }
657
658 #[test]
659 fn wrap_newline_with_active_sgr() {
660 let text = "\x1b[31mhello\nworld\x1b[0m";
661 let lines = wrap_text_with_ansi(text, 20);
662 assert_eq!(lines.len(), 2);
663 assert!(lines[0].contains("\x1b[0m"));
665 assert!(lines[1].starts_with("\x1b[31m"));
667 }
668
669 #[test]
670 fn tracker_invalid_osc_ignored() {
671 let mut tracker = AnsiCodeTracker::new();
672 tracker.process("\x1b]8;;url");
673 assert!(tracker.hyperlink.is_none());
674 }
675
676 #[test]
677 fn tracker_invalid_osc_no_prefix() {
678 let mut tracker = AnsiCodeTracker::new();
679 tracker.process("\x1b]9;;url\x1b\\");
680 assert!(tracker.hyperlink.is_none());
681 }
682
683 #[test]
684 fn has_active_codes_with_sgr() {
685 let mut tracker = AnsiCodeTracker::new();
686 tracker.process("\x1b[1m");
687 assert!(tracker.has_active_codes());
688 }
689
690 #[test]
692 fn tracker_preserves_truecolor_foreground() {
693 let mut tracker = AnsiCodeTracker::new();
694 tracker.process("\x1b[38;2;250;82;15m");
695 assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
696 assert_eq!(tracker.current_codes(), "\x1b[38;2;250;82;15m");
697 }
698
699 #[test]
701 fn tracker_preserves_truecolor_background() {
702 let mut tracker = AnsiCodeTracker::new();
703 tracker.process("\x1b[48;2;42;42;42m");
704 assert_eq!(tracker.bg_color, Some("48;2;42;42;42".to_string()));
705 assert_eq!(tracker.current_codes(), "\x1b[48;2;42;42;42m");
706 }
707
708 #[test]
710 fn tracker_preserves_256_foreground() {
711 let mut tracker = AnsiCodeTracker::new();
712 tracker.process("\x1b[38;5;196m");
713 assert_eq!(tracker.fg_color, Some("38;5;196".to_string()));
714 }
715
716 #[test]
718 fn tracker_mixed_truecolor_and_attributes() {
719 let mut tracker = AnsiCodeTracker::new();
720 tracker.process("\x1b[1;38;2;250;82;15;48;2;0;0;0m");
721 assert!(tracker.bold);
722 assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
723 assert_eq!(tracker.bg_color, Some("48;2;0;0;0".to_string()));
724 assert_eq!(tracker.current_codes(), "\x1b[1;38;2;250;82;15;48;2;0;0;0m");
725 }
726
727 #[test]
729 fn tracker_default_colors_clear_state() {
730 let mut tracker = AnsiCodeTracker::new();
731 tracker.process("\x1b[38;2;250;82;15;48;2;0;0;0m");
732 tracker.process("\x1b[39;49m");
733 assert!(tracker.fg_color.is_none());
734 assert!(tracker.bg_color.is_none());
735 }
736
737 #[test]
738 fn truncate_jk_text_demo() {
739 let text = " j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit";
740 let truncated = truncate_to_width(text, 80, "…");
741 let vw = visible_width(&truncated);
742 eprintln!("original vw: {}", visible_width(text));
743 eprintln!("truncated: {:?}", truncated);
744 eprintln!("truncated vw: {}", vw);
745 assert!(vw <= 80, "truncated width {} exceeds 80", vw);
746 assert!(truncated.ends_with("…"));
747 }
748
749 #[test]
750 fn truncate_to_width_preserves_ansi_prefix() {
751 let s = "\x1b[44mhello\x1b[0m";
752 let truncated = truncate_to_width(s, 3, "…");
753 assert!(truncated.starts_with("\x1b[44m"));
756 assert!(truncated.contains("…"));
757 assert!(truncated.ends_with("\x1b[0m"));
758 assert_eq!(visible_width(&truncated), 3);
759 }
760
761 #[test]
762 fn truncate_to_width_preserves_ansi_infix() {
763 let s = "hi\x1b[31mred\x1b[0mlo";
764 let truncated = truncate_to_width(s, 4, "…");
765 assert_eq!(visible_width(&truncated), 4);
766 assert!(truncated.contains("\x1b[31m"));
768 assert!(truncated.contains("\x1b[0m"));
769 }
770
771 #[test]
772 fn truncate_to_width_no_truncation_when_fits() {
773 let s = "\x1b[44mhi\x1b[0m";
774 let truncated = truncate_to_width(s, 5, "…");
775 assert_eq!(truncated, s);
777 }
778
779 #[test]
780 fn byte_index_at_visual_pos_plain() {
781 assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
782 assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
783 assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
784 assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
785 }
786
787 #[test]
788 fn byte_index_at_visual_pos_with_ansi_prefix() {
789 let s = "\x1b[31mhello\x1b[0m";
790 assert_eq!(byte_index_at_visual_pos(s, 0), 5);
792 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
793 assert_eq!(byte_index_at_visual_pos(s, 5), 10);
794 assert_eq!(byte_index_at_visual_pos(s, 10), 14);
796 }
797
798 #[test]
799 fn byte_index_at_visual_pos_with_ansi_infix() {
800 let s = "hi\x1b[31mred\x1b[0mlo";
801 assert_eq!(byte_index_at_visual_pos(s, 0), 0);
803 assert_eq!(byte_index_at_visual_pos(s, 2), 2);
804 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
806 assert_eq!(byte_index_at_visual_pos(s, 7), 16);
808 }
809
810 #[test]
811 fn byte_index_at_visual_pos_with_hyperlink() {
812 let s = "\x1b]8;;https://example.com\x07hello";
813 assert_eq!(byte_index_at_visual_pos(s, 0), 25);
815 assert_eq!(byte_index_at_visual_pos(s, 3), 28);
816 }
817}