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
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 let Some(&'\\') = chars.peek()
163 {
164 chars.next();
165 result.push('\\');
166 break;
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 faint: bool,
229 pub reverse: bool,
231 pub fg_color: Option<String>,
233 pub bg_color: Option<String>,
235 pub hyperlink: Option<ActiveHyperlink>,
237}
238
239impl AnsiCodeTracker {
240 pub fn new() -> Self {
242 Self::default()
243 }
244
245 fn parse_osc8(seq: &str) -> Option<Option<ActiveHyperlink>> {
250 let body = match seq.strip_prefix("\x1b]") {
251 | Some(b) => b,
252 | None => return None,
253 };
254 let (body, terminator) = if let Some(body) = body.strip_suffix("\x1b\\") {
255 (body, "\x1b\\".to_string())
256 } else if let Some(body) = body.strip_suffix('\x07') {
257 (body, "\x07".to_string())
258 } else {
259 return None;
260 };
261 let rest = match body.strip_prefix("8;") {
262 | Some(r) => r,
263 | None => return None,
264 };
265 let sep = match rest.find(';') {
266 | Some(s) => s,
267 | None => return None,
268 };
269 let params = rest[..sep].to_string();
270 let url = rest[sep + 1..].to_string();
271 if url.is_empty() {
272 Some(None)
273 } else {
274 Some(Some(ActiveHyperlink {
275 params,
276 url,
277 terminator,
278 }))
279 }
280 }
281
282 pub fn process(&mut self, seq: &str) {
290 if let Some(parsed) = Self::parse_osc8(seq) {
291 self.hyperlink = parsed;
292 return;
293 }
294
295 let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
296 let body = body.strip_suffix('m').unwrap_or(body);
297 let codes: Vec<&str> = body.split(';').collect();
298 let mut i = 0;
299 while i < codes.len() {
300 let code = codes[i];
301 match code {
302 | "1" => self.bold = true,
303 | "2" => self.faint = true,
304 | "3" => self.italic = true,
305 | "4" => self.underline = true,
306 | "7" => self.reverse = true,
307 | "22" => {
308 self.bold = false;
309 self.faint = false;
310 },
311 | "23" => self.italic = false,
312 | "24" => self.underline = false,
313 | "27" => self.reverse = false,
314 | "39" => self.fg_color = None,
315 | "49" => self.bg_color = None,
316 | "38" => {
317 self.fg_color = Self::parse_extended_color(&codes, &mut i, "38");
318 continue;
319 },
320 | "48" => {
321 self.bg_color = Self::parse_extended_color(&codes, &mut i, "48");
322 continue;
323 },
324 | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
325 | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
326 | _ => {},
327 }
328 i += 1;
329 }
330 }
331
332 fn parse_extended_color(codes: &[&str], i: &mut usize, prefix: &str) -> Option<String> {
339 *i += 1;
340 if *i >= codes.len() {
341 return None;
342 }
343 match codes[*i] {
344 | "5" => {
345 *i += 1;
346 if *i >= codes.len() {
347 return None;
348 }
349 let idx = codes[*i];
350 *i += 1;
351 Some(format!("{};5;{}", prefix, idx))
352 },
353 | "2" => {
354 *i += 1;
355 if *i + 2 >= codes.len() {
356 return None;
357 }
358 let r = codes[*i];
359 let g = codes[*i + 1];
360 let b = codes[*i + 2];
361 *i += 3;
362 Some(format!("{};2;{};{};{}", prefix, r, g, b))
363 },
364 | _ => None,
365 }
366 }
367
368 pub fn current_codes(&self) -> String {
372 let mut parts = Vec::new();
373 if self.bold {
374 parts.push("1");
375 }
376 if self.faint {
377 parts.push("2");
378 }
379 if self.italic {
380 parts.push("3");
381 }
382 if self.underline {
383 parts.push("4");
384 }
385 if self.reverse {
386 parts.push("7");
387 }
388 if let Some(ref fg) = self.fg_color {
389 parts.push(fg.as_str());
390 }
391 if let Some(ref bg) = self.bg_color {
392 parts.push(bg.as_str());
393 }
394 let mut result = if parts.is_empty() {
395 String::new()
396 } else {
397 format!("\x1b[{}m", parts.join(";"))
398 };
399 if let Some(ref link) = self.hyperlink {
400 result.push_str(&format!(
401 "\x1b]8;{};{}{}",
402 link.params, link.url, link.terminator
403 ));
404 }
405 result
406 }
407
408 pub fn line_end_reset(&self) -> String {
414 let mut result = String::new();
415 if self.underline {
416 result.push_str("\x1b[24m");
417 }
418 if self.reverse {
419 result.push_str("\x1b[27m");
420 }
421 if let Some(ref link) = self.hyperlink {
422 result.push_str(&format!("\x1b]8;;{}", link.terminator));
423 }
424 result
425 }
426
427 pub fn has_active_codes(&self) -> bool {
429 self.bold ||
430 self.faint ||
431 self.italic ||
432 self.underline ||
433 self.reverse ||
434 self.fg_color.is_some() ||
435 self.bg_color.is_some() ||
436 self.hyperlink.is_some()
437 }
438}
439
440pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
455 let w = width as usize;
456 let mut lines: Vec<String> = Vec::new();
457 let mut current = String::new();
458 let mut current_width = 0;
459 let mut tracker = AnsiCodeTracker::new();
460
461 let mut chars = text.chars().peekable();
462 while let Some(ch) = chars.next() {
463 if ch == '\x1b' {
464 match chars.peek() {
465 | Some(&'[') => {
466 chars.next();
467 let mut seq = String::from("\x1b[");
468 while let Some(&c) = chars.peek() {
469 seq.push(c);
470 chars.next();
471 if c.is_alphabetic() {
472 break;
473 }
474 }
475 tracker.process(&seq);
476 current.push_str(&seq);
477 continue;
478 },
479 | Some(&']') => {
480 chars.next();
481 let mut seq = String::from("\x1b]");
482 while let Some(&c) = chars.peek() {
483 seq.push(c);
484 chars.next();
485 if c == '\x07' {
486 break;
487 }
488 if c == '\x1b' &&
489 let Some(&'\\') = chars.peek()
490 {
491 seq.push('\\');
492 chars.next();
493 break;
494 }
495 }
496 tracker.process(&seq);
497 current.push_str(&seq);
498 continue;
499 },
500 | _ => {},
501 }
502 }
503
504 if ch == '\n' {
505 if tracker.bold ||
506 tracker.italic ||
507 tracker.underline ||
508 tracker.fg_color.is_some() ||
509 tracker.bg_color.is_some()
510 {
511 current.push_str("\x1b[0m");
512 }
513 let reset = tracker.line_end_reset();
514 if !reset.is_empty() {
515 current.push_str(&reset);
516 }
517 lines.push(current);
518 current = tracker.current_codes();
519 current_width = 0;
520 continue;
521 }
522
523 let cw = ch.width().unwrap_or(0);
524 if current_width + cw > w && !current.is_empty() {
525 if tracker.bold ||
526 tracker.italic ||
527 tracker.underline ||
528 tracker.fg_color.is_some() ||
529 tracker.bg_color.is_some()
530 {
531 current.push_str("\x1b[0m");
532 }
533 let reset = tracker.line_end_reset();
534 if !reset.is_empty() {
535 current.push_str(&reset);
536 }
537 lines.push(current);
538 current = tracker.current_codes();
539 current_width = 0;
540 }
541 current.push(ch);
542 current_width += cw;
543 }
544
545 if !current.is_empty() {
546 lines.push(current);
547 }
548 lines
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn tracker_tracks_hyperlink() {
557 let mut tracker = AnsiCodeTracker::new();
558 tracker.process("\x1b]8;;https://example.com\x1b\\");
559 assert!(tracker.hyperlink.is_some());
560 assert_eq!(
561 tracker.hyperlink.as_ref().unwrap().url,
562 "https://example.com"
563 );
564 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
565 }
566
567 #[test]
568 fn tracker_hyperlink_bel_terminator() {
569 let mut tracker = AnsiCodeTracker::new();
570 tracker.process("\x1b]8;;https://example.com\x07");
571 assert!(tracker.hyperlink.is_some());
572 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
573 }
574
575 #[test]
576 fn tracker_hyperlink_close() {
577 let mut tracker = AnsiCodeTracker::new();
578 tracker.process("\x1b]8;;https://example.com\x1b\\");
579 assert!(tracker.hyperlink.is_some());
580 tracker.process("\x1b]8;;\x1b\\");
581 assert!(tracker.hyperlink.is_none());
582 }
583
584 #[test]
585 fn current_codes_includes_hyperlink() {
586 let mut tracker = AnsiCodeTracker::new();
587 tracker.process("\x1b]8;;https://example.com\x1b\\");
588 let codes = tracker.current_codes();
589 assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
590 }
591
592 #[test]
593 fn line_end_reset_closes_hyperlink() {
594 let mut tracker = AnsiCodeTracker::new();
595 tracker.process("\x1b]8;;https://example.com\x1b\\");
596 let reset = tracker.line_end_reset();
597 assert!(reset.contains("\x1b]8;;\x1b\\"));
598 }
599
600 #[test]
601 fn wrap_preserves_hyperlink_across_lines() {
602 let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
603 let lines = wrap_text_with_ansi(text, 6);
604 assert_eq!(lines.len(), 2);
605 assert!(lines[0].contains("\x1b]8;;\x1b\\"));
607 assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
609 }
610
611 #[test]
612 fn has_active_codes_with_hyperlink() {
613 let mut tracker = AnsiCodeTracker::new();
614 assert!(!tracker.has_active_codes());
615 tracker.process("\x1b]8;;https://example.com\x1b\\");
616 assert!(tracker.has_active_codes());
617 }
618
619 #[test]
620 fn line_end_reset_with_underline() {
621 let mut tracker = AnsiCodeTracker::new();
622 tracker.process("\x1b[4m");
623 let reset = tracker.line_end_reset();
624 assert!(reset.contains("\x1b[24m"));
625 }
626
627 #[test]
628 fn wrap_hyperlink_bel_terminator() {
629 let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
630 let lines = wrap_text_with_ansi(text, 6);
631 assert_eq!(lines.len(), 2);
632 assert!(lines[0].contains("\x1b]8;;\x07"));
633 assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
634 }
635
636 #[test]
637 fn wrap_newline_with_active_sgr() {
638 let text = "\x1b[31mhello\nworld\x1b[0m";
639 let lines = wrap_text_with_ansi(text, 20);
640 assert_eq!(lines.len(), 2);
641 assert!(lines[0].contains("\x1b[0m"));
643 assert!(lines[1].starts_with("\x1b[31m"));
645 }
646
647 #[test]
648 fn tracker_invalid_osc_ignored() {
649 let mut tracker = AnsiCodeTracker::new();
650 tracker.process("\x1b]8;;url");
651 assert!(tracker.hyperlink.is_none());
652 }
653
654 #[test]
655 fn tracker_invalid_osc_no_prefix() {
656 let mut tracker = AnsiCodeTracker::new();
657 tracker.process("\x1b]9;;url\x1b\\");
658 assert!(tracker.hyperlink.is_none());
659 }
660
661 #[test]
662 fn has_active_codes_with_sgr() {
663 let mut tracker = AnsiCodeTracker::new();
664 tracker.process("\x1b[1m");
665 assert!(tracker.has_active_codes());
666 }
667
668 #[test]
670 fn tracker_preserves_truecolor_foreground() {
671 let mut tracker = AnsiCodeTracker::new();
672 tracker.process("\x1b[38;2;250;82;15m");
673 assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
674 assert_eq!(tracker.current_codes(), "\x1b[38;2;250;82;15m");
675 }
676
677 #[test]
679 fn tracker_preserves_truecolor_background() {
680 let mut tracker = AnsiCodeTracker::new();
681 tracker.process("\x1b[48;2;42;42;42m");
682 assert_eq!(tracker.bg_color, Some("48;2;42;42;42".to_string()));
683 assert_eq!(tracker.current_codes(), "\x1b[48;2;42;42;42m");
684 }
685
686 #[test]
688 fn tracker_preserves_256_foreground() {
689 let mut tracker = AnsiCodeTracker::new();
690 tracker.process("\x1b[38;5;196m");
691 assert_eq!(tracker.fg_color, Some("38;5;196".to_string()));
692 }
693
694 #[test]
696 fn tracker_mixed_truecolor_and_attributes() {
697 let mut tracker = AnsiCodeTracker::new();
698 tracker.process("\x1b[1;38;2;250;82;15;48;2;0;0;0m");
699 assert!(tracker.bold);
700 assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
701 assert_eq!(tracker.bg_color, Some("48;2;0;0;0".to_string()));
702 assert_eq!(tracker.current_codes(), "\x1b[1;38;2;250;82;15;48;2;0;0;0m");
703 }
704
705 #[test]
707 fn tracker_default_colors_clear_state() {
708 let mut tracker = AnsiCodeTracker::new();
709 tracker.process("\x1b[38;2;250;82;15;48;2;0;0;0m");
710 tracker.process("\x1b[39;49m");
711 assert!(tracker.fg_color.is_none());
712 assert!(tracker.bg_color.is_none());
713 }
714
715 #[test]
716 fn truncate_jk_text_demo() {
717 let text = " j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit";
718 let truncated = truncate_to_width(text, 80, "…");
719 let vw = visible_width(&truncated);
720 eprintln!("original vw: {}", visible_width(text));
721 eprintln!("truncated: {:?}", truncated);
722 eprintln!("truncated vw: {}", vw);
723 assert!(vw <= 80, "truncated width {} exceeds 80", vw);
724 assert!(truncated.ends_with("…"));
725 }
726
727 #[test]
728 fn truncate_to_width_preserves_ansi_prefix() {
729 let s = "\x1b[44mhello\x1b[0m";
730 let truncated = truncate_to_width(s, 3, "…");
731 assert!(truncated.starts_with("\x1b[44m"));
734 assert!(truncated.contains("…"));
735 assert!(truncated.ends_with("\x1b[0m"));
736 assert_eq!(visible_width(&truncated), 3);
737 }
738
739 #[test]
740 fn truncate_to_width_preserves_ansi_infix() {
741 let s = "hi\x1b[31mred\x1b[0mlo";
742 let truncated = truncate_to_width(s, 4, "…");
743 assert_eq!(visible_width(&truncated), 4);
744 assert!(truncated.contains("\x1b[31m"));
746 assert!(truncated.contains("\x1b[0m"));
747 }
748
749 #[test]
750 fn truncate_to_width_no_truncation_when_fits() {
751 let s = "\x1b[44mhi\x1b[0m";
752 let truncated = truncate_to_width(s, 5, "…");
753 assert_eq!(truncated, s);
755 }
756
757 #[test]
758 fn byte_index_at_visual_pos_plain() {
759 assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
760 assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
761 assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
762 assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
763 }
764
765 #[test]
766 fn byte_index_at_visual_pos_with_ansi_prefix() {
767 let s = "\x1b[31mhello\x1b[0m";
768 assert_eq!(byte_index_at_visual_pos(s, 0), 5);
770 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
771 assert_eq!(byte_index_at_visual_pos(s, 5), 10);
772 assert_eq!(byte_index_at_visual_pos(s, 10), 14);
774 }
775
776 #[test]
777 fn byte_index_at_visual_pos_with_ansi_infix() {
778 let s = "hi\x1b[31mred\x1b[0mlo";
779 assert_eq!(byte_index_at_visual_pos(s, 0), 0);
781 assert_eq!(byte_index_at_visual_pos(s, 2), 2);
782 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
784 assert_eq!(byte_index_at_visual_pos(s, 7), 16);
786 }
787
788 #[test]
789 fn byte_index_at_visual_pos_with_hyperlink() {
790 let s = "\x1b]8;;https://example.com\x07hello";
791 assert_eq!(byte_index_at_visual_pos(s, 0), 25);
793 assert_eq!(byte_index_at_visual_pos(s, 3), 28);
794 }
795}