Skip to main content

ytil_tui/
preview.rs

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    // Skim only creates a preview pane when a global preview is configured,
51    // even when every item provides inline preview text.
52    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}