rusticity_term/ui/
ec2.rs

1use crate::common::{render_pagination_text, CyclicEnum, InputFocus, SortDirection};
2use crate::ec2::{Column, Instance};
3use crate::keymap::Mode;
4use crate::table::TableState;
5use ratatui::prelude::*;
6
7pub const FILTER_CONTROLS: [InputFocus; 3] = [
8    InputFocus::Filter,
9    InputFocus::Checkbox("state"),
10    InputFocus::Pagination,
11];
12
13pub const STATE_FILTER: InputFocus = InputFocus::Checkbox("state");
14
15#[derive(Debug, Clone, Copy, PartialEq, Default)]
16pub enum StateFilter {
17    #[default]
18    AllStates,
19    Running,
20    Stopped,
21    Terminated,
22    Pending,
23    ShuttingDown,
24    Stopping,
25}
26
27impl StateFilter {
28    pub fn name(&self) -> &'static str {
29        match self {
30            StateFilter::AllStates => "All states",
31            StateFilter::Running => "Running",
32            StateFilter::Stopped => "Stopped",
33            StateFilter::Terminated => "Terminated",
34            StateFilter::Pending => "Pending",
35            StateFilter::ShuttingDown => "Shutting down",
36            StateFilter::Stopping => "Stopping",
37        }
38    }
39
40    pub fn matches(&self, state: &str) -> bool {
41        match self {
42            StateFilter::AllStates => true,
43            StateFilter::Running => state == "running",
44            StateFilter::Stopped => state == "stopped",
45            StateFilter::Terminated => state == "terminated",
46            StateFilter::Pending => state == "pending",
47            StateFilter::ShuttingDown => state == "shutting-down",
48            StateFilter::Stopping => state == "stopping",
49        }
50    }
51}
52
53impl CyclicEnum for StateFilter {
54    const ALL: &'static [Self] = &[
55        StateFilter::AllStates,
56        StateFilter::Running,
57        StateFilter::Stopped,
58        StateFilter::Terminated,
59        StateFilter::Pending,
60        StateFilter::ShuttingDown,
61        StateFilter::Stopping,
62    ];
63
64    fn next(&self) -> Self {
65        match self {
66            StateFilter::AllStates => StateFilter::Running,
67            StateFilter::Running => StateFilter::Stopped,
68            StateFilter::Stopped => StateFilter::Terminated,
69            StateFilter::Terminated => StateFilter::Pending,
70            StateFilter::Pending => StateFilter::ShuttingDown,
71            StateFilter::ShuttingDown => StateFilter::Stopping,
72            StateFilter::Stopping => StateFilter::AllStates,
73        }
74    }
75
76    fn prev(&self) -> Self {
77        match self {
78            StateFilter::AllStates => StateFilter::Stopping,
79            StateFilter::Running => StateFilter::AllStates,
80            StateFilter::Stopped => StateFilter::Running,
81            StateFilter::Terminated => StateFilter::Stopped,
82            StateFilter::Pending => StateFilter::Terminated,
83            StateFilter::ShuttingDown => StateFilter::Pending,
84            StateFilter::Stopping => StateFilter::ShuttingDown,
85        }
86    }
87}
88
89pub struct State {
90    pub table: TableState<Instance>,
91    pub state_filter: StateFilter,
92    pub sort_column: Column,
93    pub sort_direction: SortDirection,
94    pub input_focus: InputFocus,
95}
96
97impl Default for State {
98    fn default() -> Self {
99        Self {
100            table: TableState::default(),
101            state_filter: StateFilter::default(),
102            sort_column: Column::LaunchTime,
103            sort_direction: SortDirection::Desc,
104            input_focus: InputFocus::Filter,
105        }
106    }
107}
108
109pub const FILTER_HINT: &str = "Find Instance by attribute or tag (case-sensitive)";
110
111pub fn render_instances(
112    frame: &mut Frame,
113    area: Rect,
114    state: &State,
115    visible_columns: &[&str],
116    mode: Mode,
117) {
118    use crate::common::render_dropdown;
119    use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
120    use crate::ui::table::render_table;
121
122    let chunks = Layout::default()
123        .direction(Direction::Vertical)
124        .constraints([Constraint::Length(3), Constraint::Min(0)])
125        .split(area);
126
127    let filtered_items: Vec<&Instance> = state
128        .table
129        .items
130        .iter()
131        .filter(|i| state.state_filter.matches(&i.state))
132        .filter(|i| {
133            if state.table.filter.is_empty() {
134                return true;
135            }
136            i.name.contains(&state.table.filter)
137                || i.instance_id.contains(&state.table.filter)
138                || i.state.contains(&state.table.filter)
139                || i.instance_type.contains(&state.table.filter)
140                || i.availability_zone.contains(&state.table.filter)
141                || i.security_groups.contains(&state.table.filter)
142                || i.key_name.contains(&state.table.filter)
143        })
144        .collect();
145
146    let page_size = state.table.page_size.value();
147    let filtered_count = filtered_items.len();
148    let total_pages = filtered_count.div_ceil(page_size);
149    let current_page = state.table.selected / page_size;
150    let pagination = render_pagination_text(current_page, total_pages);
151
152    render_filter_bar(
153        frame,
154        FilterConfig {
155            filter_text: &state.table.filter,
156            placeholder: FILTER_HINT,
157            mode,
158            is_input_focused: state.input_focus == InputFocus::Filter,
159            controls: vec![
160                FilterControl {
161                    text: state.state_filter.name().to_string(),
162                    is_focused: state.input_focus == STATE_FILTER,
163                },
164                FilterControl {
165                    text: pagination.clone(),
166                    is_focused: state.input_focus == InputFocus::Pagination,
167                },
168            ],
169            area: chunks[0],
170        },
171    );
172
173    let columns: Vec<_> = visible_columns
174        .iter()
175        .filter_map(|id| Column::from_id(id).map(|c| c.to_column()))
176        .collect();
177
178    let title = format!("Instances ({})", filtered_count);
179
180    use crate::ui::table::TableConfig;
181    render_table(
182        frame,
183        TableConfig {
184            items: filtered_items,
185            selected_index: state.table.selected,
186            expanded_index: state.table.expanded_item,
187            columns: &columns,
188            sort_column: "",
189            sort_direction: state.sort_direction,
190            title,
191            area: chunks[1],
192            get_expanded_content: Some(Box::new(|instance: &Instance| {
193                crate::ui::table::expanded_from_columns(&columns, instance)
194            })),
195            is_active: mode == Mode::Normal,
196        },
197    );
198
199    // Render dropdown for StateFilter when focused (after table so it appears on top)
200    if mode == Mode::FilterInput && state.input_focus == STATE_FILTER {
201        let filter_names: Vec<&str> = StateFilter::ALL.iter().map(|f| f.name()).collect();
202        let selected_idx = StateFilter::ALL
203            .iter()
204            .position(|f| *f == state.state_filter)
205            .unwrap_or(0);
206        let controls_after = pagination.len() as u16 + 3;
207        render_dropdown(
208            frame,
209            &filter_names,
210            selected_idx,
211            chunks[0],
212            controls_after,
213        );
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_state_filter_names() {
223        assert_eq!(StateFilter::AllStates.name(), "All states");
224        assert_eq!(StateFilter::Running.name(), "Running");
225        assert_eq!(StateFilter::Stopped.name(), "Stopped");
226        assert_eq!(StateFilter::Terminated.name(), "Terminated");
227        assert_eq!(StateFilter::Pending.name(), "Pending");
228        assert_eq!(StateFilter::ShuttingDown.name(), "Shutting down");
229        assert_eq!(StateFilter::Stopping.name(), "Stopping");
230    }
231
232    #[test]
233    fn test_state_filter_matches() {
234        assert!(StateFilter::AllStates.matches("running"));
235        assert!(StateFilter::AllStates.matches("stopped"));
236        assert!(StateFilter::Running.matches("running"));
237        assert!(!StateFilter::Running.matches("stopped"));
238        assert!(StateFilter::Stopped.matches("stopped"));
239        assert!(!StateFilter::Stopped.matches("running"));
240    }
241
242    #[test]
243    fn test_state_filter_next() {
244        assert_eq!(StateFilter::AllStates.next(), StateFilter::Running);
245        assert_eq!(StateFilter::Running.next(), StateFilter::Stopped);
246        assert_eq!(StateFilter::Stopping.next(), StateFilter::AllStates);
247    }
248
249    #[test]
250    fn test_state_filter_prev() {
251        assert_eq!(StateFilter::AllStates.prev(), StateFilter::Stopping);
252        assert_eq!(StateFilter::Running.prev(), StateFilter::AllStates);
253        assert_eq!(StateFilter::Stopped.prev(), StateFilter::Running);
254    }
255
256    #[test]
257    fn test_state_filter_all_constant() {
258        assert_eq!(StateFilter::ALL.len(), 7);
259        assert_eq!(StateFilter::ALL[0], StateFilter::AllStates);
260        assert_eq!(StateFilter::ALL[6], StateFilter::Stopping);
261    }
262
263    #[test]
264    fn test_state_default() {
265        let state = State::default();
266        assert_eq!(state.table.items.len(), 0);
267        assert_eq!(state.table.selected, 0);
268        assert!(!state.table.loading);
269        assert_eq!(state.table.filter, "");
270        assert_eq!(state.state_filter, StateFilter::AllStates);
271        assert_eq!(state.sort_column, Column::LaunchTime);
272        assert_eq!(state.sort_direction, SortDirection::Desc);
273    }
274
275    #[test]
276    fn test_state_filter_matches_all_states() {
277        let filter = StateFilter::AllStates;
278        assert!(filter.matches("running"));
279        assert!(filter.matches("stopped"));
280        assert!(filter.matches("terminated"));
281        assert!(filter.matches("pending"));
282        assert!(filter.matches("shutting-down"));
283        assert!(filter.matches("stopping"));
284    }
285
286    #[test]
287    fn test_state_filter_matches_specific_states() {
288        assert!(StateFilter::Running.matches("running"));
289        assert!(!StateFilter::Running.matches("stopped"));
290
291        assert!(StateFilter::Stopped.matches("stopped"));
292        assert!(!StateFilter::Stopped.matches("running"));
293
294        assert!(StateFilter::Terminated.matches("terminated"));
295        assert!(!StateFilter::Terminated.matches("running"));
296
297        assert!(StateFilter::Pending.matches("pending"));
298        assert!(!StateFilter::Pending.matches("running"));
299
300        assert!(StateFilter::ShuttingDown.matches("shutting-down"));
301        assert!(!StateFilter::ShuttingDown.matches("running"));
302
303        assert!(StateFilter::Stopping.matches("stopping"));
304        assert!(!StateFilter::Stopping.matches("running"));
305    }
306
307    #[test]
308    fn test_state_filter_cycle() {
309        let mut filter = StateFilter::AllStates;
310        filter = filter.next();
311        assert_eq!(filter, StateFilter::Running);
312        filter = filter.next();
313        assert_eq!(filter, StateFilter::Stopped);
314        filter = filter.next();
315        assert_eq!(filter, StateFilter::Terminated);
316        filter = filter.next();
317        assert_eq!(filter, StateFilter::Pending);
318        filter = filter.next();
319        assert_eq!(filter, StateFilter::ShuttingDown);
320        filter = filter.next();
321        assert_eq!(filter, StateFilter::Stopping);
322        filter = filter.next();
323        assert_eq!(filter, StateFilter::AllStates);
324    }
325
326    #[test]
327    fn test_filter_controls_constant() {
328        assert_eq!(FILTER_CONTROLS.len(), 3);
329        assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
330        assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
331        assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
332    }
333
334    #[test]
335    fn test_input_focus_cycling() {
336        let mut focus = InputFocus::Filter;
337        focus = focus.next(&FILTER_CONTROLS);
338        assert_eq!(focus, STATE_FILTER);
339        focus = focus.next(&FILTER_CONTROLS);
340        assert_eq!(focus, InputFocus::Pagination);
341        focus = focus.next(&FILTER_CONTROLS);
342        assert_eq!(focus, InputFocus::Filter);
343    }
344
345    #[test]
346    fn test_input_focus_reverse_cycling() {
347        let mut focus = InputFocus::Filter;
348        focus = focus.prev(&FILTER_CONTROLS);
349        assert_eq!(focus, InputFocus::Pagination);
350        focus = focus.prev(&FILTER_CONTROLS);
351        assert_eq!(focus, STATE_FILTER);
352        focus = focus.prev(&FILTER_CONTROLS);
353        assert_eq!(focus, InputFocus::Filter);
354    }
355
356    #[test]
357    fn test_state_default_input_focus() {
358        let state = State::default();
359        assert_eq!(state.input_focus, InputFocus::Filter);
360    }
361
362    #[test]
363    fn test_filter_controls_includes_state_filter() {
364        assert_eq!(FILTER_CONTROLS.len(), 3);
365        assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
366        assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
367        assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
368    }
369
370    #[test]
371    fn test_state_filter_constant() {
372        assert_eq!(STATE_FILTER, InputFocus::Checkbox("state"));
373    }
374
375    #[test]
376    fn test_state_filter_all_has_7_items() {
377        assert_eq!(StateFilter::ALL.len(), 7);
378    }
379
380    #[test]
381    fn test_dropdown_shows_when_state_filter_focused() {
382        // This is tested via integration - dropdown renders when input_focus == STATE_FILTER
383        // Verify the constant is accessible
384        let focus = STATE_FILTER;
385        assert_eq!(focus, InputFocus::Checkbox("state"));
386    }
387}