Skip to main content

carch_core/ui/
state.rs

1use std::collections::{HashMap, VecDeque};
2use std::path::PathBuf;
3
4use fuzzy_matcher::skim::SkimMatcherV2;
5use ratatui::layout::Rect;
6use ratatui::text::Text;
7
8use super::popups::run_script::RunScriptPopup;
9use super::theme::Theme;
10
11#[derive(PartialEq, Eq, Debug, Clone, Copy)]
12pub enum AppMode {
13    Normal,
14    Search,
15    Confirm,
16    Help,
17    Preview,
18    RunScript,
19    Description,
20    RootWarning,
21}
22
23#[derive(PartialEq, Eq, Debug, Clone, Copy)]
24pub enum FocusedPanel {
25    Categories,
26    Scripts,
27}
28
29pub struct StatefulList<T> {
30    pub state: ratatui::widgets::ListState,
31    pub items: Vec<T>,
32}
33
34impl<T> Default for StatefulList<T> {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl<T> StatefulList<T> {
41    pub fn new() -> StatefulList<T> {
42        StatefulList { state: ratatui::widgets::ListState::default(), items: Vec::new() }
43    }
44
45    pub fn with_items(items: Vec<T>) -> StatefulList<T> {
46        let mut list = StatefulList::new();
47        list.items = items;
48        if !list.items.is_empty() {
49            list.state.select(Some(0));
50        }
51        list
52    }
53
54    pub fn next(&mut self) {
55        if self.items.is_empty() {
56            self.state.select(None);
57            return;
58        }
59        let i = match self.state.selected() {
60            Some(i) if i + 1 < self.items.len() => i + 1,
61            _ => 0,
62        };
63        self.state.select(Some(i));
64    }
65
66    pub fn previous(&mut self) {
67        if self.items.is_empty() {
68            self.state.select(None);
69            return;
70        }
71        let last = self.items.len() - 1;
72        let i = match self.state.selected() {
73            Some(0) | None => last,
74            Some(i) => i - 1,
75        };
76        self.state.select(Some(i));
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn next_on_empty_does_not_panic() {
86        let mut list: StatefulList<u32> = StatefulList::new();
87        list.next();
88        list.next();
89        list.next();
90        assert_eq!(list.state.selected(), None);
91    }
92
93    #[test]
94    fn previous_on_empty_does_not_panic() {
95        let mut list: StatefulList<u32> = StatefulList::new();
96        list.previous();
97        list.previous();
98        assert_eq!(list.state.selected(), None);
99    }
100
101    #[test]
102    fn next_wraps_to_start() {
103        let mut list = StatefulList::with_items(vec!["a", "b", "c"]);
104        assert_eq!(list.state.selected(), Some(0));
105        list.next();
106        assert_eq!(list.state.selected(), Some(1));
107        list.next();
108        assert_eq!(list.state.selected(), Some(2));
109        list.next();
110        assert_eq!(list.state.selected(), Some(0));
111    }
112
113    #[test]
114    fn previous_wraps_to_end() {
115        let mut list = StatefulList::with_items(vec!["a", "b", "c"]);
116        list.previous();
117        assert_eq!(list.state.selected(), Some(2));
118        list.previous();
119        assert_eq!(list.state.selected(), Some(1));
120    }
121
122    #[test]
123    fn single_item_next_keeps_selection() {
124        let mut list = StatefulList::with_items(vec!["only"]);
125        list.next();
126        assert_eq!(list.state.selected(), Some(0));
127        list.previous();
128        assert_eq!(list.state.selected(), Some(0));
129    }
130}
131
132#[derive(Clone, Debug)]
133pub struct ScriptItem {
134    pub category: String,
135    pub name:     String,
136    pub path:     PathBuf,
137}
138
139#[derive(Default, Clone)]
140pub struct UiOptions {
141    pub log_mode:     bool,
142    pub theme:        String,
143    pub theme_locked: bool,
144    pub is_root:      bool,
145}
146
147#[derive(Default)]
148pub struct PreviewState {
149    pub content:    String,
150    pub scroll:     u16,
151    pub max_scroll: u16,
152    pub cache:      HashMap<PathBuf, Text<'static>>,
153}
154
155#[derive(Clone, Debug)]
156pub struct SearchResult {
157    pub item:    ScriptItem,
158    pub score:   i64,
159    pub indices: Vec<usize>,
160}
161
162#[derive(Default)]
163pub struct SearchState {
164    pub input:           String,
165    pub results:         Vec<SearchResult>,
166    pub cursor_position: usize,
167    pub selected_idx:    usize,
168    pub autocomplete:    Option<String>,
169    pub matcher:         SkimMatcherV2,
170}
171
172#[derive(Default)]
173pub struct MultiSelectState {
174    pub enabled: bool,
175    pub scripts: Vec<PathBuf>,
176}
177
178#[derive(Default)]
179pub struct HelpState {
180    pub scroll:     u16,
181    pub max_scroll: u16,
182}
183
184#[derive(Default)]
185pub struct DescriptionState {
186    pub content:    Option<String>,
187    pub scroll:     u16,
188    pub max_scroll: u16,
189}
190
191pub struct App {
192    pub mode:          AppMode,
193    pub quit:          bool,
194    pub focused_panel: FocusedPanel,
195    pub log_mode:      bool,
196    pub modules_dir:   PathBuf,
197    pub theme:         Theme,
198    pub theme_locked:  bool,
199
200    pub scripts:     StatefulList<ScriptItem>,
201    pub categories:  StatefulList<String>,
202    pub all_scripts: HashMap<String, Vec<ScriptItem>>,
203
204    pub script_panel_area:      Rect,
205    pub preview:                PreviewState,
206    pub search:                 SearchState,
207    pub multi_select:           MultiSelectState,
208    pub help:                   HelpState,
209    pub description:            DescriptionState,
210    pub run_script_popup:       Option<RunScriptPopup>,
211    pub script_execution_queue: VecDeque<PathBuf>,
212
213    pub needs_redraw: bool,
214    pub last_size:    Rect,
215}