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    items: &'a [String],
43    prompt: &'a str,
44    context: &'a str,
45}
46
47impl<'a> List<'a> {
48    pub fn new(
49        state: &'a SelectionState,
50        items: &'a [String],
51        prompt: &'a str,
52        context: &'a str,
53    ) -> Self {
54        Self {
55            state,
56            items,
57            prompt,
58            context,
59        }
60    }
61}
62
63impl Widget for List<'_> {
64    fn render(self, area: Rect, buf: &mut Buffer) {
65        let (content_area, footer_area) = layouts::content_with_footer(area);
66
67        let mut list_state = ListState::default();
68        list_state.select(Some(self.state.cursor()));
69
70        let list_items: Vec<ListItem> = if self.state.multi_select() {
71            self.items
72                .iter()
73                .enumerate()
74                .map(|(i, item)| ListItem::new(checkbox_line(item, self.state.is_selected(i))))
75                .collect()
76        } else {
77            self.items
78                .iter()
79                .map(|item| ListItem::new(styled_item_line(item)))
80                .collect()
81        };
82
83        let mut list = RatatuiList::new(list_items)
84            .block(
85                Block::default()
86                    .borders(Borders::TOP | Borders::BOTTOM)
87                    .border_style(BORDER_STYLE),
88            )
89            .highlight_symbol(HIGHLIGHT_SYMBOL);
90
91        // Single-select uses highlight style, multi-select doesn't
92        if !self.state.multi_select() {
93            list = list.highlight_style(HIGHLIGHT_STYLE);
94        }
95
96        StatefulWidget::render(list, content_area, buf, &mut list_state);
97
98        // Footer with optional selection count for multi-select
99        let count_info = if self.state.multi_select() && self.state.selected_count() > 0 {
100            format!(" ({} selected)", self.state.selected_count())
101        } else {
102            String::new()
103        };
104
105        let (diff_label, diff_style) = diff_toggle_style(self.state.show_diff());
106        Footer::new(
107            vec![
108                context_span(self.context),
109                Span::raw(format!(" {}{}", self.prompt, count_info)),
110            ],
111            vec![Span::styled(format!(" {} ", diff_label), diff_style)],
112        )
113        .render(footer_area, buf);
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::tui::components::list::ListAction;
121    use ratatui::{Terminal, backend::TestBackend};
122
123    fn create_test_terminal(width: u16, height: u16) -> Terminal<TestBackend> {
124        let backend = TestBackend::new(width, height);
125        Terminal::new(backend).unwrap()
126    }
127
128    fn buffer_to_plain_text(terminal: &Terminal<TestBackend>) -> String {
129        let buffer = terminal.backend().buffer();
130        let mut lines = Vec::new();
131        for y in 0..buffer.area.height {
132            let mut line = String::new();
133            for x in 0..buffer.area.width {
134                line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
135            }
136            lines.push(line.trim_end().to_string());
137        }
138        while lines.last().is_some_and(|l| l.is_empty()) {
139            lines.pop();
140        }
141        lines.join("\n")
142    }
143
144    #[test]
145    fn test_render_single_select() {
146        let mut terminal = create_test_terminal(80, 8);
147        let items = vec![
148            "nixpkgs".to_string(),
149            "home-manager".to_string(),
150            "flake-utils".to_string(),
151        ];
152        let state = SelectionState::new(items.len(), false, false);
153
154        terminal
155            .draw(|frame| {
156                List::new(&state, &items, "Select input", "Change")
157                    .render(frame.area(), frame.buffer_mut());
158            })
159            .unwrap();
160
161        let output = buffer_to_plain_text(&terminal);
162        insta::assert_snapshot!(output);
163    }
164
165    #[test]
166    fn test_render_single_select_with_diff_on() {
167        let mut terminal = create_test_terminal(80, 8);
168        let items = vec!["nixpkgs".to_string(), "home-manager".to_string()];
169        let mut state = SelectionState::new(items.len(), false, true);
170        state.handle(ListAction::Down);
171
172        terminal
173            .draw(|frame| {
174                List::new(&state, &items, "Select input", "Change")
175                    .render(frame.area(), frame.buffer_mut());
176            })
177            .unwrap();
178
179        let output = buffer_to_plain_text(&terminal);
180        insta::assert_snapshot!(output);
181    }
182
183    #[test]
184    fn test_render_multi_select() {
185        let mut terminal = create_test_terminal(80, 8);
186        let items = vec![
187            "nixpkgs".to_string(),
188            "home-manager".to_string(),
189            "flake-utils".to_string(),
190        ];
191        let mut state = SelectionState::new(items.len(), true, false);
192        state.handle(ListAction::Toggle);
193
194        terminal
195            .draw(|frame| {
196                List::new(&state, &items, "Select inputs", "Update")
197                    .render(frame.area(), frame.buffer_mut());
198            })
199            .unwrap();
200
201        let output = buffer_to_plain_text(&terminal);
202        insta::assert_snapshot!(output);
203    }
204
205    #[test]
206    fn test_parse_follows_item_with_target() {
207        let (path, follows) = parse_follows_item("crane.nixpkgs\tnixpkgs");
208        assert_eq!(path, "crane.nixpkgs");
209        assert_eq!(follows, Some("nixpkgs"));
210    }
211
212    #[test]
213    fn test_parse_follows_item_without_target() {
214        let (path, follows) = parse_follows_item("crane.nixpkgs");
215        assert_eq!(path, "crane.nixpkgs");
216        assert_eq!(follows, None);
217    }
218
219    #[test]
220    fn test_nested_input_display_roundtrip() {
221        use crate::lock::NestedInput;
222
223        // With follows target
224        let input = NestedInput {
225            path: "crane.nixpkgs".into(),
226            follows: Some("nixpkgs".into()),
227        };
228        let display = input.to_display_string();
229        let (path, follows) = parse_follows_item(&display);
230        assert_eq!(path, "crane.nixpkgs");
231        assert_eq!(follows, Some("nixpkgs"));
232
233        // Without follows target
234        let input = NestedInput {
235            path: "crane.nixpkgs".into(),
236            follows: None,
237        };
238        let display = input.to_display_string();
239        let (path, follows) = parse_follows_item(&display);
240        assert_eq!(path, "crane.nixpkgs");
241        assert_eq!(follows, None);
242    }
243}