Skip to main content

flake_edit/tui/components/list/
view.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, List as RatatuiList, ListItem, ListState, StatefulWidget, Widget},
7};
8
9use super::model::ListState as SelectionState;
10use crate::tui::components::footer::Footer;
11use crate::tui::helpers::{checkbox_line, context_span, diff_toggle_style, layouts};
12use crate::tui::style::{BORDER_STYLE, HIGHLIGHT_STYLE, HIGHLIGHT_SYMBOL};
13
14/// Parse an item string that may contain a follows indicator.
15/// Format: "path\tfollows_target" or just "path"
16/// Returns (path, Option<follows_target>)
17fn parse_follows_item(item: &str) -> (&str, Option<&str>) {
18    if let Some((path, follows)) = item.split_once('\t') {
19        (path, Some(follows))
20    } else {
21        (item, None)
22    }
23}
24
25/// Create a styled line for an item that may have a follows indicator.
26fn styled_item_line(item: &str) -> Line<'_> {
27    let (path, follows) = parse_follows_item(item);
28    if let Some(target) = follows {
29        Line::from(vec![
30            Span::raw(path),
31            Span::styled(" ยท ", Style::default().fg(Color::DarkGray)),
32            Span::styled(target, Style::default().fg(Color::DarkGray)),
33        ])
34    } else {
35        Line::raw(path)
36    }
37}
38
39/// Unified list widget for single and multi-select
40pub struct List<'a> {
41    state: &'a SelectionState,
42    prompt: &'a str,
43    context: &'a str,
44}
45
46impl<'a> List<'a> {
47    pub fn new(state: &'a SelectionState, prompt: &'a str, context: &'a str) -> Self {
48        Self {
49            state,
50            prompt,
51            context,
52        }
53    }
54}
55
56impl Widget for List<'_> {
57    fn render(self, area: Rect, buf: &mut Buffer) {
58        let (content_area, footer_area) = layouts::content_with_footer(area);
59
60        let mut list_state = ListState::default();
61        list_state.select(Some(self.state.cursor()));
62
63        let list_items: Vec<ListItem> = if self.state.multi_select() {
64            self.state
65                .visible_items()
66                .map(|(i, item)| ListItem::new(checkbox_line(item, self.state.is_selected(i))))
67                .collect()
68        } else {
69            self.state
70                .visible_items()
71                .map(|(_, item)| ListItem::new(styled_item_line(item)))
72                .collect()
73        };
74
75        let mut list = RatatuiList::new(list_items)
76            .block(
77                Block::default()
78                    .borders(Borders::TOP | Borders::BOTTOM)
79                    .border_style(BORDER_STYLE),
80            )
81            .highlight_symbol(HIGHLIGHT_SYMBOL);
82
83        // Single-select uses highlight style, multi-select doesn't
84        if !self.state.multi_select() {
85            list = list.highlight_style(HIGHLIGHT_STYLE);
86        }
87
88        StatefulWidget::render(list, content_area, buf, &mut list_state);
89
90        // Footer with optional selection count for multi-select
91        let count_info = if self.state.multi_select() && self.state.selected_count() > 0 {
92            format!(" ({} selected)", self.state.selected_count())
93        } else {
94            String::new()
95        };
96
97        let mode_span = match self.state.search_query() {
98            Some(query) => Span::styled(format!(" /{query}"), HIGHLIGHT_STYLE),
99            None => Span::raw(format!(" {}", self.prompt)),
100        };
101
102        let (diff_label, diff_style) = diff_toggle_style(self.state.show_diff());
103        Footer::new(
104            vec![context_span(self.context), mode_span, Span::raw(count_info)],
105            vec![Span::styled(format!(" {} ", diff_label), diff_style)],
106        )
107        .render(footer_area, buf);
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::tui::components::list::ListAction;
115    use ratatui::{Terminal, backend::TestBackend};
116
117    fn create_test_terminal(width: u16, height: u16) -> Terminal<TestBackend> {
118        let backend = TestBackend::new(width, height);
119        Terminal::new(backend).unwrap()
120    }
121
122    fn buffer_to_plain_text(terminal: &Terminal<TestBackend>) -> String {
123        let buffer = terminal.backend().buffer();
124        let mut lines = Vec::new();
125        for y in 0..buffer.area.height {
126            let mut line = String::new();
127            for x in 0..buffer.area.width {
128                line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
129            }
130            lines.push(line.trim_end().to_string());
131        }
132        while lines.last().is_some_and(|l| l.is_empty()) {
133            lines.pop();
134        }
135        lines.join("\n")
136    }
137
138    #[test]
139    fn test_render_single_select() {
140        let mut terminal = create_test_terminal(80, 8);
141        let items = vec![
142            "nixpkgs".to_string(),
143            "home-manager".to_string(),
144            "flake-utils".to_string(),
145        ];
146        let state = SelectionState::new(items, false, false);
147
148        terminal
149            .draw(|frame| {
150                List::new(&state, "Select input", "Change")
151                    .render(frame.area(), frame.buffer_mut());
152            })
153            .unwrap();
154
155        let output = buffer_to_plain_text(&terminal);
156        insta::assert_snapshot!(output);
157    }
158
159    #[test]
160    fn test_render_single_select_with_diff_on() {
161        let mut terminal = create_test_terminal(80, 8);
162        let items = vec!["nixpkgs".to_string(), "home-manager".to_string()];
163        let mut state = SelectionState::new(items, false, true);
164        state.handle(ListAction::Down);
165
166        terminal
167            .draw(|frame| {
168                List::new(&state, "Select input", "Change")
169                    .render(frame.area(), frame.buffer_mut());
170            })
171            .unwrap();
172
173        let output = buffer_to_plain_text(&terminal);
174        insta::assert_snapshot!(output);
175    }
176
177    #[test]
178    fn test_render_multi_select() {
179        let mut terminal = create_test_terminal(80, 8);
180        let items = vec![
181            "nixpkgs".to_string(),
182            "home-manager".to_string(),
183            "flake-utils".to_string(),
184        ];
185        let mut state = SelectionState::new(items, true, false);
186        state.handle(ListAction::Toggle);
187
188        terminal
189            .draw(|frame| {
190                List::new(&state, "Select inputs", "Update")
191                    .render(frame.area(), frame.buffer_mut());
192            })
193            .unwrap();
194
195        let output = buffer_to_plain_text(&terminal);
196        insta::assert_snapshot!(output);
197    }
198
199    fn search_items() -> Vec<String> {
200        [
201            "bookah",
202            "centerpiece",
203            "clan",
204            "crane",
205            "depot",
206            "direnv-instant",
207            "disko",
208            "distro",
209            "flake-fmt",
210        ]
211        .iter()
212        .map(|s| s.to_string())
213        .collect()
214    }
215
216    #[test]
217    fn test_render_single_select_search_filtered() {
218        let mut terminal = create_test_terminal(80, 8);
219        let mut state = SelectionState::new(search_items(), false, false);
220        state.handle(ListAction::SearchStart);
221        state.handle(ListAction::SearchInput('f'));
222        state.handle(ListAction::SearchInput('l'));
223
224        terminal
225            .draw(|frame| {
226                List::new(&state, "Select input", "Change")
227                    .render(frame.area(), frame.buffer_mut());
228            })
229            .unwrap();
230
231        let output = buffer_to_plain_text(&terminal);
232        insta::assert_snapshot!(output);
233    }
234
235    #[test]
236    fn test_render_single_select_search_no_match() {
237        let mut terminal = create_test_terminal(80, 8);
238        let mut state = SelectionState::new(search_items(), false, false);
239        state.handle(ListAction::SearchStart);
240        for c in "zzz".chars() {
241            state.handle(ListAction::SearchInput(c));
242        }
243
244        terminal
245            .draw(|frame| {
246                List::new(&state, "Select input", "Change")
247                    .render(frame.area(), frame.buffer_mut());
248            })
249            .unwrap();
250
251        let output = buffer_to_plain_text(&terminal);
252        insta::assert_snapshot!(output);
253    }
254
255    #[test]
256    fn test_render_multi_select_search_keeps_selection() {
257        let mut terminal = create_test_terminal(80, 8);
258        let mut state = SelectionState::new(search_items(), true, false);
259        // Select "bookah", then filter down to "flake-fmt" and select it too
260        state.handle(ListAction::Toggle);
261        state.handle(ListAction::SearchStart);
262        state.handle(ListAction::SearchInput('f'));
263        state.handle(ListAction::SearchInput('l'));
264        state.handle(ListAction::Toggle);
265
266        terminal
267            .draw(|frame| {
268                List::new(&state, "Select inputs", "Update")
269                    .render(frame.area(), frame.buffer_mut());
270            })
271            .unwrap();
272
273        let output = buffer_to_plain_text(&terminal);
274        insta::assert_snapshot!(output);
275    }
276
277    #[test]
278    fn test_parse_follows_item_with_target() {
279        let (path, follows) = parse_follows_item("crane.nixpkgs\tnixpkgs");
280        assert_eq!(path, "crane.nixpkgs");
281        assert_eq!(follows, Some("nixpkgs"));
282    }
283
284    #[test]
285    fn test_parse_follows_item_without_target() {
286        let (path, follows) = parse_follows_item("crane.nixpkgs");
287        assert_eq!(path, "crane.nixpkgs");
288        assert_eq!(follows, None);
289    }
290
291    #[test]
292    fn test_nested_input_display_roundtrip() {
293        use crate::follows::AttrPath;
294        use crate::lock::NestedInput;
295
296        // With follows target.
297        let input = NestedInput {
298            path: AttrPath::parse("crane.nixpkgs").unwrap(),
299            follows: Some(AttrPath::parse("nixpkgs").unwrap()),
300            url: None,
301        };
302        let display = input.to_display_string();
303        let (path, follows) = parse_follows_item(&display);
304        assert_eq!(path, "crane.nixpkgs");
305        assert_eq!(follows, Some("nixpkgs"));
306
307        // Without follows target.
308        let input = NestedInput {
309            path: AttrPath::parse("crane.nixpkgs").unwrap(),
310            follows: None,
311            url: None,
312        };
313        let display = input.to_display_string();
314        let (path, follows) = parse_follows_item(&display);
315        assert_eq!(path, "crane.nixpkgs");
316        assert_eq!(follows, None);
317    }
318}