1use std::borrow::Cow;
2use std::io::Cursor;
3use std::sync::Arc;
4
5use ratatui::text::Line;
6use rootcause::report;
7use skim::DisplayContext;
8use skim::ItemPreview;
9use skim::PreviewContext;
10use skim::SkimItem;
11use skim::options::SkimOptions;
12use skim::prelude::SkimItemReader;
13use skim::prelude::SkimItemReaderOption;
14
15const PREVIEW_SEPARATOR_WIDTH: usize = 1;
16
17#[derive(Debug)]
18pub struct IndexedSkimItem {
19 pub output: String,
20 pub display_item: Arc<dyn SkimItem>,
21 pub visible_text: String,
22 pub preview_text: Option<String>,
23 pub search_corpus: String,
24}
25
26impl SkimItem for IndexedSkimItem {
27 fn text(&self) -> Cow<'_, str> {
28 Cow::Borrowed(&self.visible_text)
29 }
30
31 fn display(&self, context: DisplayContext) -> Line<'_> {
32 self.display_item.display(context)
33 }
34
35 fn preview(&self, context: PreviewContext) -> ItemPreview {
36 self.preview_text.as_ref().map_or(ItemPreview::Global, |text| {
37 ItemPreview::AnsiText(wrap_ansi_text(
38 text,
39 context.width.saturating_sub(PREVIEW_SEPARATOR_WIDTH),
40 ))
41 })
42 }
43
44 fn output(&self) -> Cow<'_, str> {
45 Cow::Borrowed(&self.output)
46 }
47}
48
49pub fn configure_options(options: &mut SkimOptions) {
50 options.preview = Some(String::new());
53 options.preview_window = "right:45%:wrap".into();
54 options
55 .bind
56 .extend(["ctrl-d:preview-page-down".into(), "ctrl-u:preview-page-up".into()]);
57}
58
59pub fn build_ansi_display_items(display_texts: &[String]) -> rootcause::Result<Vec<Arc<dyn SkimItem>>> {
60 let input = display_texts.join("\n");
61
62 let reader_options = SkimItemReaderOption::default().ansi(true).build();
63 let receiver = SkimItemReader::new(reader_options).of_bufread(Cursor::new(input));
64 let mut items = Vec::with_capacity(display_texts.len());
65 while let Ok(batch) = receiver.recv() {
66 items.extend(batch);
67 }
68
69 if items.len() != display_texts.len() {
70 return Err(report!("failed to build ANSI display items")
71 .attach(format!("expected={}", display_texts.len()))
72 .attach(format!("actual={}", items.len())));
73 }
74 Ok(items)
75}
76
77fn wrap_ansi_text(text: &str, width: usize) -> String {
78 if width == 0 {
79 return text.to_owned();
80 }
81
82 let mut wrapped = String::with_capacity(text.len());
83 let mut line_width: usize = 0;
84 let mut index = 0;
85 while index < text.len() {
86 if text.as_bytes().get(index) == Some(&b'\x1b') {
87 let escape_end = ansi_escape_end(text, index);
88 if let Some(escape) = text.get(index..escape_end) {
89 wrapped.push_str(escape);
90 }
91 index = escape_end;
92 continue;
93 }
94
95 let Some(character) = text.get(index..).and_then(|suffix| suffix.chars().next()) else {
96 break;
97 };
98 index = index.saturating_add(character.len_utf8());
99
100 if character == '\n' {
101 wrapped.push(character);
102 line_width = 0;
103 continue;
104 }
105
106 let character_width = Line::raw(character.to_string()).width();
107 if line_width > 0 && line_width.saturating_add(character_width) > width {
108 wrapped.push('\n');
109 line_width = 0;
110 }
111 wrapped.push(character);
112 line_width = line_width.saturating_add(character_width);
113 }
114
115 wrapped
116}
117
118fn ansi_escape_end(text: &str, start: usize) -> usize {
119 let bytes = text.as_bytes();
120 let mut index = start.saturating_add(1);
121 match bytes.get(index).copied() {
122 Some(b'[') => {
123 index = index.saturating_add(1);
124 while let Some(byte) = bytes.get(index).copied() {
125 index = index.saturating_add(1);
126 if (b'@'..=b'~').contains(&byte) {
127 break;
128 }
129 }
130 }
131 Some(b']') => {
132 index = index.saturating_add(1);
133 while let Some(byte) = bytes.get(index).copied() {
134 index = index.saturating_add(1);
135 if byte == b'\x07' {
136 break;
137 }
138 if byte == b'\x1b' && bytes.get(index) == Some(&b'\\') {
139 index = index.saturating_add(1);
140 break;
141 }
142 }
143 }
144 Some(_) => {
145 if let Some(character) = text.get(index..).and_then(|suffix| suffix.chars().next()) {
146 index = index.saturating_add(character.len_utf8());
147 }
148 }
149 None => {}
150 }
151 index
152}
153
154#[cfg(test)]
155mod tests {
156 use std::sync::Arc;
157
158 use skim::DisplayContext;
159 use skim::ItemPreview;
160 use skim::PreviewContext;
161 use skim::SkimItem;
162 use test_that::prelude::*;
163
164 use super::*;
165
166 #[test]
167 fn test_wrap_ansi_text_when_content_exceeds_width_inserts_scrollable_lines() {
168 let text = "\u{1b}[31mabcdef\u{1b}[0m";
169
170 assert_that!(wrap_ansi_text(text, 3), eq("\u{1b}[31mabc\ndef\u{1b}[0m"));
171 }
172
173 #[test]
174 fn test_indexed_skim_item_when_display_and_preview_are_ansi_preserves_both_values() {
175 let normalize = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" ");
176 let display = normalize("\u{1b}[31mvisible\tvalue\nnext\u{1b}[0m");
177 let hidden_search = normalize("hidden\rvalue");
178 let display_items_result = build_ansi_display_items(std::slice::from_ref(&display));
179 assert_that!(display_items_result.as_ref().map(|_| ()), ok(eq(())));
180 let mut display_items = display_items_result.expect("display item should build");
181 let display_item = display_items.swap_remove(0);
182 let match_text = format!("{} {hidden_search}", display_item.text());
183
184 let item = IndexedSkimItem {
185 output: "3".to_owned(),
186 display_item,
187 visible_text: "visible value next".to_owned(),
188 preview_text: Some("\u{1b}[1mformatted preview\u{1b}[0m".to_owned()),
189 search_corpus: match_text,
190 };
191
192 assert_that!(item.output(), eq("3"));
193 assert_that!(item.text(), eq("visible value next"));
194 assert_that!(
195 matches!(
196 item.preview(PreviewContext {
197 query: "",
198 cmd_query: "",
199 width: 0,
200 height: 0,
201 current_index: 0,
202 current_selection: "",
203 selected_indices: &[],
204 selections: &[],
205 }),
206 ItemPreview::AnsiText(text) if text == "\u{1b}[1mformatted preview\u{1b}[0m"
207 ),
208 eq(true)
209 );
210 assert_that!(
211 item.display(DisplayContext::default())
212 .spans
213 .first()
214 .map(|span| span.content.as_ref()),
215 eq(Some("visible value next"))
216 );
217 }
218
219 #[test]
220 fn test_indexed_skim_item_when_preview_width_includes_one_separator_wraps_at_inner_width() {
221 let item = IndexedSkimItem {
222 output: "3".to_owned(),
223 display_item: Arc::new("visible".to_owned()),
224 visible_text: "visible".to_owned(),
225 preview_text: Some("\u{1b}[31mabcdefghijklmnopq\u{1b}[0m".to_owned()),
226 search_corpus: "visible".to_owned(),
227 };
228
229 assert_that!(
230 matches!(
231 item.preview(PreviewContext {
232 query: "",
233 cmd_query: "",
234 width: 10,
235 height: 2,
236 current_index: 0,
237 current_selection: "",
238 selected_indices: &[],
239 selections: &[],
240 }),
241 ItemPreview::AnsiText(text) if text == "\u{1b}[31mabcdefghi\njklmnopq\u{1b}[0m"
242 ),
243 eq(true)
244 );
245 }
246}