1use unicode_width::UnicodeWidthChar;
2
3use crate::theme::{
4 Palette,
5 Style,
6 stylize,
7};
8
9pub fn visible_width(s: &str) -> usize {
14 let mut width = 0;
15 let mut chars = s.chars().peekable();
16 while let Some(ch) = chars.next() {
17 if ch == '\x1b' {
18 match chars.peek() {
19 | Some(&'[') => {
20 chars.next();
21 while let Some(&c) = chars.peek() {
22 chars.next();
23 if c.is_alphabetic() {
24 break;
25 }
26 }
27 continue;
28 },
29 | Some(&']') => {
30 chars.next();
31 while let Some(&c) = chars.peek() {
32 chars.next();
33 if c == '\x07' {
34 break;
35 }
36 if c == '\x1b' &&
37 let Some(&'\\') = chars.peek()
38 {
39 chars.next();
40 break;
41 }
42 }
43 continue;
44 },
45 | _ => {},
46 }
47 }
48 width += ch.width().unwrap_or(0);
49 }
50 width
51}
52
53pub fn byte_index_at_visual_pos(s: &str, target_pos: usize) -> usize {
60 let mut width = 0;
61 let mut byte_idx = 0;
62 let mut chars = s.chars().peekable();
63
64 while let Some(&ch) = chars.peek() {
65 let ch_len = ch.len_utf8();
66 if ch == '\x1b' {
67 chars.next();
68 byte_idx += ch_len;
69 match chars.peek() {
70 | Some(&'[') => {
71 chars.next();
72 byte_idx += '['.len_utf8();
73 while let Some(&c) = chars.peek() {
74 chars.next();
75 byte_idx += c.len_utf8();
76 if c.is_alphabetic() {
77 break;
78 }
79 }
80 },
81 | Some(&']') => {
82 chars.next();
83 byte_idx += ']'.len_utf8();
84 while let Some(&c) = chars.peek() {
85 chars.next();
86 byte_idx += c.len_utf8();
87 if c == '\x07' {
88 break;
89 }
90 if c == '\x1b' &&
91 let Some(&'\\') = chars.peek()
92 {
93 chars.next();
94 byte_idx += '\\'.len_utf8();
95 break;
96 }
97 }
98 },
99 | _ => {},
100 }
101 continue;
102 }
103 if width >= target_pos {
104 return byte_idx;
105 }
106 chars.next();
107 width += ch.width().unwrap_or(0);
108 byte_idx += ch_len;
109 if width >= target_pos {
110 return byte_idx;
111 }
112 }
113 byte_idx
114}
115
116fn sgr_reset(bold_or_faint: bool) -> &'static str {
123 if bold_or_faint {
124 "\x1b[22m\x1b[0m"
125 } else {
126 "\x1b[0m"
127 }
128}
129
130pub fn truncate_to_width(s: &str, max_width: u16, ellipsis: &str) -> String {
144 let max = max_width as usize;
145 let ellip_width = visible_width(ellipsis);
146 let total = visible_width(s);
147 if total <= max {
148 return s.to_string();
149 }
150 let target = max.saturating_sub(ellip_width);
151 let mut result = String::new();
152 let mut w = 0;
153 let mut chars = s.chars().peekable();
154 let mut tracker = AnsiCodeTracker::new();
155 while let Some(ch) = chars.next() {
156 if ch == '\x1b' {
158 match chars.peek() {
159 | Some(&'[') => {
160 result.push(ch);
161 chars.next(); result.push('[');
163 let mut seq = String::from("\x1b[");
164 while let Some(&c) = chars.peek() {
165 chars.next();
166 result.push(c);
167 seq.push(c);
168 if c.is_alphabetic() {
169 break;
170 }
171 }
172 tracker.process(&seq);
173 continue;
174 },
175 | Some(&']') => {
176 result.push(ch);
177 chars.next(); result.push(']');
179 while let Some(&c) = chars.peek() {
180 chars.next();
181 result.push(c);
182 if c == '\x07' {
183 break;
184 }
185 if c == '\x1b' &&
186 let Some(&'\\') = chars.peek()
187 {
188 chars.next();
189 result.push('\\');
190 break;
191 }
192 }
193 continue;
194 },
195 | _ => {},
196 }
197 }
198 let cw = ch.width().unwrap_or(0);
199 if w + cw > target {
200 break;
201 }
202 result.push(ch);
203 w += cw;
204 }
205 result.push_str(ellipsis);
206 if s.contains('\x1b') {
211 result.push_str(sgr_reset(tracker.bold || tracker.faint));
212 }
213 result
214}
215
216#[derive(Debug, Clone, PartialEq)]
218pub struct ActiveHyperlink {
219 pub params: String,
221 pub url: String,
223 pub terminator: String,
225}
226
227#[derive(Debug, Default, Clone, PartialEq)]
245pub struct AnsiCodeTracker {
246 pub bold: bool,
248 pub italic: bool,
250 pub underline: bool,
252 pub faint: bool,
254 pub reverse: bool,
256 pub fg_color: Option<String>,
258 pub bg_color: Option<String>,
260 pub hyperlink: Option<ActiveHyperlink>,
262}
263
264impl AnsiCodeTracker {
265 pub fn new() -> Self {
267 Self::default()
268 }
269
270 fn parse_osc8(seq: &str) -> Option<Option<ActiveHyperlink>> {
275 let body = match seq.strip_prefix("\x1b]") {
276 | Some(b) => b,
277 | None => return None,
278 };
279 let (body, terminator) = if let Some(body) = body.strip_suffix("\x1b\\") {
280 (body, "\x1b\\".to_string())
281 } else if let Some(body) = body.strip_suffix('\x07') {
282 (body, "\x07".to_string())
283 } else {
284 return None;
285 };
286 let rest = match body.strip_prefix("8;") {
287 | Some(r) => r,
288 | None => return None,
289 };
290 let sep = match rest.find(';') {
291 | Some(s) => s,
292 | None => return None,
293 };
294 let params = rest[..sep].to_string();
295 let url = rest[sep + 1..].to_string();
296 if url.is_empty() {
297 Some(None)
298 } else {
299 Some(Some(ActiveHyperlink {
300 params,
301 url,
302 terminator,
303 }))
304 }
305 }
306
307 pub fn process(&mut self, seq: &str) {
315 if let Some(parsed) = Self::parse_osc8(seq) {
316 self.hyperlink = parsed;
317 return;
318 }
319
320 let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
321 let body = body.strip_suffix('m').unwrap_or(body);
322 let codes: Vec<&str> = body.split(';').collect();
323 let mut i = 0;
324 while i < codes.len() {
325 let code = codes[i];
326 match code {
327 | "0" => *self = Self::default(),
328 | "1" => self.bold = true,
329 | "2" => self.faint = true,
330 | "3" => self.italic = true,
331 | "4" => self.underline = true,
332 | "7" => self.reverse = true,
333 | "22" => {
334 self.bold = false;
335 self.faint = false;
336 },
337 | "23" => self.italic = false,
338 | "24" => self.underline = false,
339 | "27" => self.reverse = false,
340 | "39" => self.fg_color = None,
341 | "49" => self.bg_color = None,
342 | "38" => {
343 self.fg_color = Self::parse_extended_color(&codes, &mut i, "38");
344 continue;
345 },
346 | "48" => {
347 self.bg_color = Self::parse_extended_color(&codes, &mut i, "48");
348 continue;
349 },
350 | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
351 | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
352 | _ => {},
353 }
354 i += 1;
355 }
356 }
357
358 fn parse_extended_color(codes: &[&str], i: &mut usize, prefix: &str) -> Option<String> {
365 *i += 1;
366 if *i >= codes.len() {
367 return None;
368 }
369 match codes[*i] {
370 | "5" => {
371 *i += 1;
372 if *i >= codes.len() {
373 return None;
374 }
375 let idx = codes[*i];
376 *i += 1;
377 Some(format!("{};5;{}", prefix, idx))
378 },
379 | "2" => {
380 *i += 1;
381 if *i + 2 >= codes.len() {
382 return None;
383 }
384 let r = codes[*i];
385 let g = codes[*i + 1];
386 let b = codes[*i + 2];
387 *i += 3;
388 Some(format!("{};2;{};{};{}", prefix, r, g, b))
389 },
390 | _ => None,
391 }
392 }
393
394 pub fn current_codes(&self) -> String {
398 let mut parts = Vec::new();
399 if self.bold {
400 parts.push("1");
401 }
402 if self.faint {
403 parts.push("2");
404 }
405 if self.italic {
406 parts.push("3");
407 }
408 if self.underline {
409 parts.push("4");
410 }
411 if self.reverse {
412 parts.push("7");
413 }
414 if let Some(ref fg) = self.fg_color {
415 parts.push(fg.as_str());
416 }
417 if let Some(ref bg) = self.bg_color {
418 parts.push(bg.as_str());
419 }
420 let mut result = if parts.is_empty() {
421 String::new()
422 } else {
423 format!("\x1b[{}m", parts.join(";"))
424 };
425 if let Some(ref link) = self.hyperlink {
426 result.push_str(&format!(
427 "\x1b]8;{};{}{}",
428 link.params, link.url, link.terminator
429 ));
430 }
431 result
432 }
433
434 pub fn line_end_reset(&self) -> String {
440 let mut result = String::new();
441 if self.underline {
442 result.push_str("\x1b[24m");
443 }
444 if self.reverse {
445 result.push_str("\x1b[27m");
446 }
447 if let Some(ref link) = self.hyperlink {
448 result.push_str(&format!("\x1b]8;;{}", link.terminator));
449 }
450 result
451 }
452
453 pub fn has_active_codes(&self) -> bool {
455 self.bold ||
456 self.faint ||
457 self.italic ||
458 self.underline ||
459 self.reverse ||
460 self.fg_color.is_some() ||
461 self.bg_color.is_some() ||
462 self.hyperlink.is_some()
463 }
464}
465
466pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
481 let w = width as usize;
482 let mut lines: Vec<String> = Vec::new();
483 let mut current = String::new();
484 let mut current_width = 0;
485 let mut tracker = AnsiCodeTracker::new();
486
487 let mut chars = text.chars().peekable();
488 while let Some(ch) = chars.next() {
489 if ch == '\x1b' {
490 match chars.peek() {
491 | Some(&'[') => {
492 chars.next();
493 let mut seq = String::from("\x1b[");
494 while let Some(&c) = chars.peek() {
495 seq.push(c);
496 chars.next();
497 if c.is_alphabetic() {
498 break;
499 }
500 }
501 tracker.process(&seq);
502 current.push_str(&seq);
503 continue;
504 },
505 | Some(&']') => {
506 chars.next();
507 let mut seq = String::from("\x1b]");
508 while let Some(&c) = chars.peek() {
509 seq.push(c);
510 chars.next();
511 if c == '\x07' {
512 break;
513 }
514 if c == '\x1b' &&
515 let Some(&'\\') = chars.peek()
516 {
517 seq.push('\\');
518 chars.next();
519 break;
520 }
521 }
522 tracker.process(&seq);
523 current.push_str(&seq);
524 continue;
525 },
526 | _ => {},
527 }
528 }
529
530 if ch == '\n' {
531 if tracker.bold ||
532 tracker.faint ||
533 tracker.italic ||
534 tracker.underline ||
535 tracker.fg_color.is_some() ||
536 tracker.bg_color.is_some()
537 {
538 current.push_str(sgr_reset(tracker.bold || tracker.faint));
539 }
540 let reset = tracker.line_end_reset();
541 if !reset.is_empty() {
542 current.push_str(&reset);
543 }
544 lines.push(current);
545 current = tracker.current_codes();
546 current_width = 0;
547 continue;
548 }
549
550 let cw = ch.width().unwrap_or(0);
551 if current_width + cw > w && !current.is_empty() {
552 if tracker.bold ||
553 tracker.faint ||
554 tracker.italic ||
555 tracker.underline ||
556 tracker.fg_color.is_some() ||
557 tracker.bg_color.is_some()
558 {
559 current.push_str(sgr_reset(tracker.bold || tracker.faint));
560 }
561 let reset = tracker.line_end_reset();
562 if !reset.is_empty() {
563 current.push_str(&reset);
564 }
565 lines.push(current);
566 current = tracker.current_codes();
567 current_width = 0;
568 }
569 current.push(ch);
570 current_width += cw;
571 }
572
573 if !current.is_empty() {
574 lines.push(current);
575 }
576 lines
577}
578
579pub const EDIT_CURSOR: char = '█';
581
582pub fn pad_to_width(s: &str, width: u16) -> String {
586 let w = width as usize;
587 let vw = visible_width(s);
588 match vw.cmp(&w) {
589 | std::cmp::Ordering::Less => format!("{}{}", s, " ".repeat(w - vw)),
590 | _ => s.to_string(),
591 }
592}
593
594pub fn render_line_with_cursor(
601 line: &str,
602 cursor_col: usize,
603 width: u16,
604 edit_style: &Style,
605 cursor_style: &Style,
606) -> String {
607 let before_idx = byte_index_at_visual_pos(line, cursor_col);
608 let before = &line[..before_idx];
609
610 let after_idx = if before_idx >= line.len() {
611 before_idx
612 } else {
613 let mut chars = line[before_idx..].chars();
614 match chars.next() {
615 | Some(ch) => before_idx + ch.len_utf8(),
616 | None => before_idx,
617 }
618 };
619 let after = &line[after_idx..];
620
621 let inner = format!("{}{}{}", before, EDIT_CURSOR, after);
622 let inner_width = visible_width(&inner);
623 let w = width as usize;
624 let pad = w.saturating_sub(inner_width);
625 let padded = format!("{}{}", inner, " ".repeat(pad));
626
627 let cursor_byte_len = EDIT_CURSOR.len_utf8();
628 let before_part = &padded[..before_idx];
629 let cursor_end = before_idx + cursor_byte_len;
630 let cursor_part = &padded[before_idx..cursor_end];
631 let rest_part = &padded[cursor_end..];
632
633 format!(
634 "{}{}{}",
635 stylize(before_part, edit_style),
636 stylize(cursor_part, cursor_style),
637 stylize(rest_part, edit_style)
638 )
639}
640
641pub fn render_editable_line(prefix: &str, buffer: &str, width: u16, theme: &dyn Palette) -> String {
648 let prefix_style = Style::new().fg(theme.text_muted());
649 let edit_style = Style::new().fg(theme.text()).bg(theme.surface());
650 let cursor_style = Style::new().fg(theme.cursor()).bg(theme.surface());
651
652 let prefix_styled = stylize(prefix, &prefix_style);
653 let prefix_width = visible_width(prefix);
654 let available = (width as usize).saturating_sub(prefix_width);
655 if available == 0 {
656 return prefix_styled;
657 }
658
659 let buffer_width = visible_width(buffer);
660 let (buffer_display, pad) = if buffer_width < available {
661 (buffer.to_string(), available - buffer_width - 1)
662 } else {
663 let truncated = truncate_to_width(buffer, (available - 1) as u16, "");
664 let truncated_width = visible_width(&truncated);
665 (truncated, available - truncated_width - 1)
666 };
667
668 let content = format!("{}{}{}", buffer_display, EDIT_CURSOR, " ".repeat(pad));
669 let buffer_end = buffer_display.len();
670 let cursor_end = buffer_end + EDIT_CURSOR.len_utf8();
671
672 let buffer_part = &content[..buffer_end];
673 let cursor_part = &content[buffer_end..cursor_end];
674 let pad_part = &content[cursor_end..];
675
676 format!(
677 "{}{}{}{}",
678 prefix_styled,
679 stylize(buffer_part, &edit_style),
680 stylize(cursor_part, &cursor_style),
681 stylize(pad_part, &edit_style)
682 )
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn tracker_tracks_hyperlink() {
691 let mut tracker = AnsiCodeTracker::new();
692 tracker.process("\x1b]8;;https://example.com\x1b\\");
693 assert!(tracker.hyperlink.is_some());
694 assert_eq!(
695 tracker.hyperlink.as_ref().unwrap().url,
696 "https://example.com"
697 );
698 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
699 }
700
701 #[test]
702 fn tracker_hyperlink_bel_terminator() {
703 let mut tracker = AnsiCodeTracker::new();
704 tracker.process("\x1b]8;;https://example.com\x07");
705 assert!(tracker.hyperlink.is_some());
706 assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
707 }
708
709 #[test]
710 fn tracker_hyperlink_close() {
711 let mut tracker = AnsiCodeTracker::new();
712 tracker.process("\x1b]8;;https://example.com\x1b\\");
713 assert!(tracker.hyperlink.is_some());
714 tracker.process("\x1b]8;;\x1b\\");
715 assert!(tracker.hyperlink.is_none());
716 }
717
718 #[test]
719 fn current_codes_includes_hyperlink() {
720 let mut tracker = AnsiCodeTracker::new();
721 tracker.process("\x1b]8;;https://example.com\x1b\\");
722 let codes = tracker.current_codes();
723 assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
724 }
725
726 #[test]
727 fn line_end_reset_closes_hyperlink() {
728 let mut tracker = AnsiCodeTracker::new();
729 tracker.process("\x1b]8;;https://example.com\x1b\\");
730 let reset = tracker.line_end_reset();
731 assert!(reset.contains("\x1b]8;;\x1b\\"));
732 }
733
734 #[test]
735 fn wrap_preserves_hyperlink_across_lines() {
736 let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
737 let lines = wrap_text_with_ansi(text, 6);
738 assert_eq!(lines.len(), 2);
739 assert!(lines[0].contains("\x1b]8;;\x1b\\"));
741 assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
743 }
744
745 #[test]
746 fn has_active_codes_with_hyperlink() {
747 let mut tracker = AnsiCodeTracker::new();
748 assert!(!tracker.has_active_codes());
749 tracker.process("\x1b]8;;https://example.com\x1b\\");
750 assert!(tracker.has_active_codes());
751 }
752
753 #[test]
754 fn line_end_reset_with_underline() {
755 let mut tracker = AnsiCodeTracker::new();
756 tracker.process("\x1b[4m");
757 let reset = tracker.line_end_reset();
758 assert!(reset.contains("\x1b[24m"));
759 }
760
761 #[test]
762 fn wrap_hyperlink_bel_terminator() {
763 let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
764 let lines = wrap_text_with_ansi(text, 6);
765 assert_eq!(lines.len(), 2);
766 assert!(lines[0].contains("\x1b]8;;\x07"));
767 assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
768 }
769
770 #[test]
771 fn wrap_newline_with_active_sgr() {
772 let text = "\x1b[31mhello\nworld\x1b[0m";
773 let lines = wrap_text_with_ansi(text, 20);
774 assert_eq!(lines.len(), 2);
775 assert!(lines[0].contains("\x1b[0m"));
777 assert!(lines[1].starts_with("\x1b[31m"));
779 }
780
781 #[test]
782 fn tracker_invalid_osc_ignored() {
783 let mut tracker = AnsiCodeTracker::new();
784 tracker.process("\x1b]8;;url");
785 assert!(tracker.hyperlink.is_none());
786 }
787
788 #[test]
789 fn tracker_invalid_osc_no_prefix() {
790 let mut tracker = AnsiCodeTracker::new();
791 tracker.process("\x1b]9;;url\x1b\\");
792 assert!(tracker.hyperlink.is_none());
793 }
794
795 #[test]
796 fn has_active_codes_with_sgr() {
797 let mut tracker = AnsiCodeTracker::new();
798 tracker.process("\x1b[1m");
799 assert!(tracker.has_active_codes());
800 }
801
802 #[test]
804 fn tracker_preserves_truecolor_foreground() {
805 let mut tracker = AnsiCodeTracker::new();
806 tracker.process("\x1b[38;2;250;82;15m");
807 assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
808 assert_eq!(tracker.current_codes(), "\x1b[38;2;250;82;15m");
809 }
810
811 #[test]
813 fn tracker_preserves_truecolor_background() {
814 let mut tracker = AnsiCodeTracker::new();
815 tracker.process("\x1b[48;2;42;42;42m");
816 assert_eq!(tracker.bg_color, Some("48;2;42;42;42".to_string()));
817 assert_eq!(tracker.current_codes(), "\x1b[48;2;42;42;42m");
818 }
819
820 #[test]
822 fn tracker_preserves_256_foreground() {
823 let mut tracker = AnsiCodeTracker::new();
824 tracker.process("\x1b[38;5;196m");
825 assert_eq!(tracker.fg_color, Some("38;5;196".to_string()));
826 }
827
828 #[test]
830 fn tracker_mixed_truecolor_and_attributes() {
831 let mut tracker = AnsiCodeTracker::new();
832 tracker.process("\x1b[1;38;2;250;82;15;48;2;0;0;0m");
833 assert!(tracker.bold);
834 assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
835 assert_eq!(tracker.bg_color, Some("48;2;0;0;0".to_string()));
836 assert_eq!(tracker.current_codes(), "\x1b[1;38;2;250;82;15;48;2;0;0;0m");
837 }
838
839 #[test]
841 fn tracker_default_colors_clear_state() {
842 let mut tracker = AnsiCodeTracker::new();
843 tracker.process("\x1b[38;2;250;82;15;48;2;0;0;0m");
844 tracker.process("\x1b[39;49m");
845 assert!(tracker.fg_color.is_none());
846 assert!(tracker.bg_color.is_none());
847 }
848
849 #[test]
850 fn truncate_jk_text_demo() {
851 let text = " j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit";
852 let truncated = truncate_to_width(text, 80, "…");
853 let vw = visible_width(&truncated);
854 eprintln!("original vw: {}", visible_width(text));
855 eprintln!("truncated: {:?}", truncated);
856 eprintln!("truncated vw: {}", vw);
857 assert!(vw <= 80, "truncated width {} exceeds 80", vw);
858 assert!(truncated.ends_with("…"));
859 }
860
861 #[test]
862 fn truncate_to_width_preserves_ansi_prefix() {
863 let s = "\x1b[44mhello\x1b[0m";
864 let truncated = truncate_to_width(s, 3, "…");
865 assert!(truncated.starts_with("\x1b[44m"));
868 assert!(truncated.contains("…"));
869 assert!(truncated.ends_with("\x1b[0m"));
870 assert_eq!(visible_width(&truncated), 3);
871 }
872
873 #[test]
874 fn truncate_to_width_preserves_ansi_infix() {
875 let s = "hi\x1b[31mred\x1b[0mlo";
876 let truncated = truncate_to_width(s, 4, "…");
877 assert_eq!(visible_width(&truncated), 4);
878 assert!(truncated.contains("\x1b[31m"));
880 assert!(truncated.contains("\x1b[0m"));
881 }
882
883 #[test]
884 fn truncate_to_width_no_truncation_when_fits() {
885 let s = "\x1b[44mhi\x1b[0m";
886 let truncated = truncate_to_width(s, 5, "…");
887 assert_eq!(truncated, s);
889 }
890
891 #[test]
892 fn byte_index_at_visual_pos_plain() {
893 assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
894 assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
895 assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
896 assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
897 }
898
899 #[test]
900 fn byte_index_at_visual_pos_with_ansi_prefix() {
901 let s = "\x1b[31mhello\x1b[0m";
902 assert_eq!(byte_index_at_visual_pos(s, 0), 5);
904 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
905 assert_eq!(byte_index_at_visual_pos(s, 5), 10);
906 assert_eq!(byte_index_at_visual_pos(s, 10), 14);
908 }
909
910 #[test]
911 fn byte_index_at_visual_pos_with_ansi_infix() {
912 let s = "hi\x1b[31mred\x1b[0mlo";
913 assert_eq!(byte_index_at_visual_pos(s, 0), 0);
915 assert_eq!(byte_index_at_visual_pos(s, 2), 2);
916 assert_eq!(byte_index_at_visual_pos(s, 3), 8);
918 assert_eq!(byte_index_at_visual_pos(s, 7), 16);
920 }
921
922 #[test]
923 fn byte_index_at_visual_pos_with_hyperlink() {
924 let s = "\x1b]8;;https://example.com\x07hello";
925 assert_eq!(byte_index_at_visual_pos(s, 0), 25);
927 assert_eq!(byte_index_at_visual_pos(s, 3), 28);
928 }
929
930 #[test]
931 fn pad_to_width_adds_spaces() {
932 assert_eq!(pad_to_width("hi", 5), "hi ");
933 }
934
935 #[test]
936 fn pad_to_width_ignores_ansi() {
937 let s = "\x1b[31mhi\x1b[0m";
938 assert_eq!(pad_to_width(s, 5), "\x1b[31mhi\x1b[0m ");
939 }
940
941 #[test]
942 fn render_line_with_cursor_inserts_block() {
943 use crate::theme::{
944 Style,
945 Theme,
946 };
947 Theme::with(Theme::Light, || {
948 let theme = Theme::palette();
949 let edit = Style::new().fg(theme.text()).bg(theme.surface());
950 let cursor = Style::new().fg(theme.cursor()).bg(theme.surface());
951 let line = render_line_with_cursor("hello", 2, 8, &edit, &cursor);
952 assert!(line.contains(EDIT_CURSOR));
953 assert!(line.contains("he"));
954 assert!(line.contains("lo"));
955 assert!(line.contains("\x1b[48;"));
956 assert!(line.contains("\x1b[38;"));
957 assert_eq!(visible_width(&line), 8);
958 });
959 }
960
961 #[test]
962 fn render_line_with_cursor_at_end() {
963 use crate::theme::{
964 Style,
965 Theme,
966 };
967 Theme::with(Theme::Light, || {
968 let theme = Theme::palette();
969 let edit = Style::new().fg(theme.text()).bg(theme.surface());
970 let cursor = Style::new().fg(theme.cursor()).bg(theme.surface());
971 let line = render_line_with_cursor("hi", 5, 6, &edit, &cursor);
972 assert!(line.contains("hi"));
973 assert!(line.contains(EDIT_CURSOR));
974 assert_eq!(visible_width(&line), 6);
975 });
976 }
977
978 #[test]
979 fn render_editable_line_shows_prefix_buffer_and_cursor() {
980 use crate::theme::Theme;
981 Theme::with(Theme::Light, || {
982 let theme = Theme::palette();
983 let line = render_editable_line("/", "abc", 10, &*theme);
984 assert!(line.contains('/'));
985 assert!(line.contains("abc"));
986 assert!(line.contains(EDIT_CURSOR));
987 assert!(line.contains("\x1b[48;"));
988 assert_eq!(visible_width(&line), 10);
989 });
990 }
991
992 #[test]
993 fn render_editable_line_cursor_visible_when_empty() {
994 use crate::theme::Theme;
995 Theme::with(Theme::Light, || {
996 let theme = Theme::palette();
997 let line = render_editable_line("/", "", 10, &*theme);
998 assert!(line.contains('/'));
999 assert!(line.contains(EDIT_CURSOR));
1000 assert_eq!(visible_width(&line), 10);
1001 });
1002 }
1003
1004 #[test]
1005 fn render_editable_line_respects_custom_cursor_colour() {
1006 use std::sync::Arc;
1007
1008 use crate::theme::{
1009 Color,
1010 Palette,
1011 Theme,
1012 };
1013
1014 struct CyanCursor;
1015 impl Palette for CyanCursor {
1016 fn background(&self) -> Color {
1017 Color::SUNBEAM_BLACK
1018 }
1019
1020 fn surface(&self) -> Color {
1021 Color::CARD_DARK
1022 }
1023
1024 fn field(&self) -> Color {
1025 Color::CARD_DARK
1026 }
1027
1028 fn text(&self) -> Color {
1029 Color::WHITE
1030 }
1031
1032 fn text_muted(&self) -> Color {
1033 Color(0xbb, 0xbb, 0xbb)
1034 }
1035
1036 fn text_on_accent(&self) -> Color {
1037 Color::WHITE
1038 }
1039
1040 fn accent(&self) -> Color {
1041 Color::SUNBEAM_ORANGE
1042 }
1043
1044 fn accent_hover(&self) -> Color {
1045 Color::SUNBEAM_FLAME
1046 }
1047
1048 fn border(&self) -> Color {
1049 Color(0x55, 0x55, 0x55)
1050 }
1051
1052 fn border_muted(&self) -> Color {
1053 Color(0x44, 0x44, 0x44)
1054 }
1055
1056 fn focus(&self) -> Color {
1057 Color::BEAM_ORANGE
1058 }
1059
1060 fn success(&self) -> Color {
1061 Color(0x22, 0x99, 0x55)
1062 }
1063
1064 fn warning(&self) -> Color {
1065 Color::SUNSHINE_900
1066 }
1067
1068 fn error(&self) -> Color {
1069 Color(0xdd, 0x33, 0x33)
1070 }
1071
1072 fn info(&self) -> Color {
1073 Color(0x33, 0x77, 0xcc)
1074 }
1075
1076 fn cursor(&self) -> Color {
1077 Color(0x00, 0xff, 0xff)
1078 }
1079 }
1080
1081 Theme::set_palette(Arc::new(CyanCursor));
1082 let theme = Theme::palette();
1083 let line = render_editable_line("/", "x", 10, &*theme);
1084 Theme::clear_palette();
1085 assert!(line.contains(EDIT_CURSOR));
1086 assert!(line.contains("\x1b[38;2;0;255;255m"));
1088 }
1089}