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) {
288 if let Some(parsed) = Self::parse_osc8(seq) {
289 self.hyperlink = parsed;
290 return;
291 }
292
293 let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
294 let body = body.strip_suffix('m').unwrap_or(body);
295 for code in body.split(';') {
296 match code {
297 | "1" => self.bold = true,
298 | "2" => self.faint = true,
299 | "3" => self.italic = true,
300 | "4" => self.underline = true,
301 | "7" => self.reverse = true,
302 | "22" => {
303 self.bold = false;
304 self.faint = false;
305 },
306 | "23" => self.italic = false,
307 | "24" => self.underline = false,
308 | "27" => self.reverse = false,
309 | "39" => self.fg_color = None,
310 | "49" => self.bg_color = None,
311 | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
312 | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
313 | _ => {},
314 }
315 }
316 }
317
318 pub fn current_codes(&self) -> String {
322 let mut parts = Vec::new();
323 if self.bold {
324 parts.push("1");
325 }
326 if self.faint {
327 parts.push("2");
328 }
329 if self.italic {
330 parts.push("3");
331 }
332 if self.underline {
333 parts.push("4");
334 }
335 if self.reverse {
336 parts.push("7");
337 }
338 if let Some(ref fg) = self.fg_color {
339 parts.push(fg.as_str());
340 }
341 if let Some(ref bg) = self.bg_color {
342 parts.push(bg.as_str());
343 }
344 let mut result = if parts.is_empty() {
345 String::new()
346 } else {
347 format!("\x1b[{}m", parts.join(";"))
348 };
349 if let Some(ref link) = self.hyperlink {
350 result.push_str(&format!(
351 "\x1b]8;{};{}{}",
352 link.params, link.url, link.terminator
353 ));
354 }
355 result
356 }
357
358 pub fn line_end_reset(&self) -> String {
364 let mut result = String::new();
365 if self.underline {
366 result.push_str("\x1b[24m");
367 }
368 if self.reverse {
369 result.push_str("\x1b[27m");
370 }
371 if let Some(ref link) = self.hyperlink {
372 result.push_str(&format!("\x1b]8;;{}", link.terminator));
373 }
374 result
375 }
376
377 pub fn has_active_codes(&self) -> bool {
379 self.bold ||
380 self.faint ||
381 self.italic ||
382 self.underline ||
383 self.reverse ||
384 self.fg_color.is_some() ||
385 self.bg_color.is_some() ||
386 self.hyperlink.is_some()
387 }
388}
389
390pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
405 let w = width as usize;
406 let mut lines: Vec<String> = Vec::new();
407 let mut current = String::new();
408 let mut current_width = 0;
409 let mut tracker = AnsiCodeTracker::new();
410
411 let mut chars = text.chars().peekable();
412 while let Some(ch) = chars.next() {
413 if ch == '\x1b' {
414 match chars.peek() {
415 | Some(&'[') => {
416 chars.next();
417 let mut seq = String::from("\x1b[");
418 while let Some(&c) = chars.peek() {
419 seq.push(c);
420 chars.next();
421 if c.is_alphabetic() {
422 break;
423 }
424 }
425 tracker.process(&seq);
426 current.push_str(&seq);
427 continue;
428 },
429 | Some(&']') => {
430 chars.next();
431 let mut seq = String::from("\x1b]");
432 while let Some(&c) = chars.peek() {
433 seq.push(c);
434 chars.next();
435 if c == '\x07' {
436 break;
437 }
438 if c == '\x1b' &&
439 let Some(&'\\') = chars.peek()
440 {
441 seq.push('\\');
442 chars.next();
443 break;
444 }
445 }
446 tracker.process(&seq);
447 current.push_str(&seq);
448 continue;
449 },
450 | _ => {},
451 }
452 }
453
454 if ch == '\n' {
455 if tracker.bold ||
456 tracker.italic ||
457 tracker.underline ||
458 tracker.fg_color.is_some() ||
459 tracker.bg_color.is_some()
460 {
461 current.push_str("\x1b[0m");
462 }
463 let reset = tracker.line_end_reset();
464 if !reset.is_empty() {
465 current.push_str(&reset);
466 }
467 lines.push(current);
468 current = tracker.current_codes();
469 current_width = 0;
470 continue;
471 }
472
473 let cw = ch.width().unwrap_or(0);
474 if current_width + cw > w && !current.is_empty() {
475 if tracker.bold ||
476 tracker.italic ||
477 tracker.underline ||
478 tracker.fg_color.is_some() ||
479 tracker.bg_color.is_some()
480 {
481 current.push_str("\x1b[0m");
482 }
483 let reset = tracker.line_end_reset();
484 if !reset.is_empty() {
485 current.push_str(&reset);
486 }
487 lines.push(current);
488 current = tracker.current_codes();
489 current_width = 0;
490 }
491 current.push(ch);
492 current_width += cw;
493 }
494
495 if !current.is_empty() {
496 lines.push(current);
497 }
498 lines
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn tracker_tracks_hyperlink() {
507 let mut tracker = AnsiCodeTracker::new();
508 tracker.process("\x1b]8;;https://example.com\x1b\\");
509 assert!(tracker.hyperlink.is_some());
510 assert_eq!(
511 tracker.hyperlink.as_ref().unwrap().url,
512 "https://example.com"
513 );
514 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
515 }
516
517 #[test]
518 fn tracker_hyperlink_bel_terminator() {
519 let mut tracker = AnsiCodeTracker::new();
520 tracker.process("\x1b]8;;https://example.com\x07");
521 assert!(tracker.hyperlink.is_some());
522 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
523 }
524
525 #[test]
526 fn tracker_hyperlink_close() {
527 let mut tracker = AnsiCodeTracker::new();
528 tracker.process("\x1b]8;;https://example.com\x1b\\");
529 assert!(tracker.hyperlink.is_some());
530 tracker.process("\x1b]8;;\x1b\\");
531 assert!(tracker.hyperlink.is_none());
532 }
533
534 #[test]
535 fn current_codes_includes_hyperlink() {
536 let mut tracker = AnsiCodeTracker::new();
537 tracker.process("\x1b]8;;https://example.com\x1b\\");
538 let codes = tracker.current_codes();
539 assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
540 }
541
542 #[test]
543 fn line_end_reset_closes_hyperlink() {
544 let mut tracker = AnsiCodeTracker::new();
545 tracker.process("\x1b]8;;https://example.com\x1b\\");
546 let reset = tracker.line_end_reset();
547 assert!(reset.contains("\x1b]8;;\x1b\\"));
548 }
549
550 #[test]
551 fn wrap_preserves_hyperlink_across_lines() {
552 let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
553 let lines = wrap_text_with_ansi(text, 6);
554 assert_eq!(lines.len(), 2);
555 assert!(lines[0].contains("\x1b]8;;\x1b\\"));
557 assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
559 }
560
561 #[test]
562 fn has_active_codes_with_hyperlink() {
563 let mut tracker = AnsiCodeTracker::new();
564 assert!(!tracker.has_active_codes());
565 tracker.process("\x1b]8;;https://example.com\x1b\\");
566 assert!(tracker.has_active_codes());
567 }
568
569 #[test]
570 fn line_end_reset_with_underline() {
571 let mut tracker = AnsiCodeTracker::new();
572 tracker.process("\x1b[4m");
573 let reset = tracker.line_end_reset();
574 assert!(reset.contains("\x1b[24m"));
575 }
576
577 #[test]
578 fn wrap_hyperlink_bel_terminator() {
579 let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
580 let lines = wrap_text_with_ansi(text, 6);
581 assert_eq!(lines.len(), 2);
582 assert!(lines[0].contains("\x1b]8;;\x07"));
583 assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
584 }
585
586 #[test]
587 fn wrap_newline_with_active_sgr() {
588 let text = "\x1b[31mhello\nworld\x1b[0m";
589 let lines = wrap_text_with_ansi(text, 20);
590 assert_eq!(lines.len(), 2);
591 assert!(lines[0].contains("\x1b[0m"));
593 assert!(lines[1].starts_with("\x1b[31m"));
595 }
596
597 #[test]
598 fn tracker_invalid_osc_ignored() {
599 let mut tracker = AnsiCodeTracker::new();
600 tracker.process("\x1b]8;;url");
601 assert!(tracker.hyperlink.is_none());
602 }
603
604 #[test]
605 fn tracker_invalid_osc_no_prefix() {
606 let mut tracker = AnsiCodeTracker::new();
607 tracker.process("\x1b]9;;url\x1b\\");
608 assert!(tracker.hyperlink.is_none());
609 }
610
611 #[test]
612 fn has_active_codes_with_sgr() {
613 let mut tracker = AnsiCodeTracker::new();
614 tracker.process("\x1b[1m");
615 assert!(tracker.has_active_codes());
616 }
617
618 #[test]
619 fn truncate_jk_text_demo() {
620 let text = " j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit";
621 let truncated = truncate_to_width(text, 80, "…");
622 let vw = visible_width(&truncated);
623 eprintln!("original vw: {}", visible_width(text));
624 eprintln!("truncated: {:?}", truncated);
625 eprintln!("truncated vw: {}", vw);
626 assert!(vw <= 80, "truncated width {} exceeds 80", vw);
627 assert!(truncated.ends_with("…"));
628 }
629
630 #[test]
631 fn truncate_to_width_preserves_ansi_prefix() {
632 let s = "\x1b[44mhello\x1b[0m";
633 let truncated = truncate_to_width(s, 3, "…");
634 assert!(truncated.starts_with("\x1b[44m"));
637 assert!(truncated.contains("…"));
638 assert!(truncated.ends_with("\x1b[0m"));
639 assert_eq!(visible_width(&truncated), 3);
640 }
641
642 #[test]
643 fn truncate_to_width_preserves_ansi_infix() {
644 let s = "hi\x1b[31mred\x1b[0mlo";
645 let truncated = truncate_to_width(s, 4, "…");
646 assert_eq!(visible_width(&truncated), 4);
647 assert!(truncated.contains("\x1b[31m"));
649 assert!(truncated.contains("\x1b[0m"));
650 }
651
652 #[test]
653 fn truncate_to_width_no_truncation_when_fits() {
654 let s = "\x1b[44mhi\x1b[0m";
655 let truncated = truncate_to_width(s, 5, "…");
656 assert_eq!(truncated, s);
658 }
659
660 #[test]
661 fn byte_index_at_visual_pos_plain() {
662 assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
663 assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
664 assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
665 assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
666 }
667
668 #[test]
669 fn byte_index_at_visual_pos_with_ansi_prefix() {
670 let s = "\x1b[31mhello\x1b[0m";
671 assert_eq!(byte_index_at_visual_pos(s, 0), 5);
673 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
674 assert_eq!(byte_index_at_visual_pos(s, 5), 10);
675 assert_eq!(byte_index_at_visual_pos(s, 10), 14);
677 }
678
679 #[test]
680 fn byte_index_at_visual_pos_with_ansi_infix() {
681 let s = "hi\x1b[31mred\x1b[0mlo";
682 assert_eq!(byte_index_at_visual_pos(s, 0), 0);
684 assert_eq!(byte_index_at_visual_pos(s, 2), 2);
685 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
687 assert_eq!(byte_index_at_visual_pos(s, 7), 16);
689 }
690
691 #[test]
692 fn byte_index_at_visual_pos_with_hyperlink() {
693 let s = "\x1b]8;;https://example.com\x07hello";
694 assert_eq!(byte_index_at_visual_pos(s, 0), 25);
696 assert_eq!(byte_index_at_visual_pos(s, 3), 28);
697 }
698}