cartographer_rs/menu/
interact.rs

1use crate::Menu;
2use crate::MenuItem;
3use crate::MenuOptions;
4use console::Key;
5use console::Term;
6use rust_fuzzy_search::fuzzy_compare;
7use std::io::Write;
8
9struct MenuItemKeepTrack {
10    menu_item: MenuItem,
11    is_visible: bool,
12    is_selected: bool,
13}
14
15/// Keeps track of the state of the menu
16struct MenuState {
17    // Stored user input
18    prompt: String,
19    inputed: String,
20    cursor_row: usize,
21
22    // Live updated info on data rows
23    rows: Vec<MenuItemKeepTrack>,
24
25    // stored data that is only read
26    term: Term,
27
28    // data about the displayed menu
29    lines_written: usize,
30}
31
32impl MenuState {
33    /// goes through the [`MenuState`], comparing each [`MenuItem`](crate::MenuItem) comparing the
34    /// visible_name and alternative_matches to the user's input
35    fn search_from_inputed(&mut self, opts: &MenuOptions) {
36        // keep a count of how many rows for later use
37        let mut num_results = 0;
38
39        // For each row, fuzzy compare, average out the fuzzy score, and if it is greater than the
40        // [`MenuOptions'](crate::MenuOptions) configured min_search_threshold. If it is greater,
41        // set its visibility to true. (The visibility of the row is what decides if something is
42        // shown
43        for i in 0..self.rows.len() {
44            // The score of this row
45            let mut score = 0.0;
46
47            // If the row is already selected, and it is configured to display selected items in
48            // search results, then mark all the selected rows as visible
49            if self.rows[i].is_selected && opts.show_select_in_search {
50                self.rows[i].is_visible = true;
51                num_results += 1;
52            } else {
53                score += fuzzy_compare(
54                    &self.rows[i].menu_item.visible_name.to_lowercase(),
55                    &self.inputed.to_lowercase(),
56                );
57                if self.rows[i].menu_item.alternative_matches.is_some() {
58                    for i in self.rows[i].menu_item.alternative_matches.clone().unwrap() {
59                        score += fuzzy_compare(&i.to_lowercase(), &self.inputed.to_lowercase());
60                        score /= 2.0;
61                    }
62                }
63                if score > opts.min_search_threshold {
64                    num_results += 1;
65                    self.rows[i].is_visible = true;
66                } else {
67                    self.rows[i].is_visible = false;
68                }
69            }
70        }
71
72        // If there are no search results, default to showing the original menu
73        if num_results == 0 {
74            for i in 0..self.rows.len() {
75                if self.rows[i].menu_item.visible_at_rest {
76                    self.rows[i].is_visible = true;
77                }
78                // Assume that the there were no items shown at some point, and the cursor has been
79                // "smooshed to the ceiling"
80                self.cursor_row = 0;
81            }
82        } else {
83            // Have the cursor stay in the same percentage zone of the menu (25% down before the
84            // search, keep it 25% from the top, after the search)
85            if (self.lines_written as i32 - 3) <= 0 {
86                self.cursor_row = 0;
87            } else {
88                self.cursor_row = (self.cursor_row / (self.lines_written - 3)) * num_results;
89            }
90        }
91    }
92
93    /// Edit the current row's indicator to be visible on user input
94    fn mark_selected(&mut self) {
95        // Poor man's "filter by visible" for loop
96        // counter keeps track of current "visible" row, and if that is the line that the user's
97        // cursor is on, sets it to selected, because that is the only row that the user could be
98        // trying to select
99        let mut counter = 0;
100        for i in 0..self.rows.len() {
101            if self.rows[i].is_visible {
102                if counter == self.cursor_row {
103                    self.rows[i].is_selected = !self.rows[i].is_selected;
104                }
105                counter += 1;
106            }
107        }
108    }
109
110    /// Get the visible string for visible row at item index `item_index`
111    fn get_row(
112        &self,
113        item: &MenuItemKeepTrack,
114        cur_redraw_row: usize,
115        opts: &MenuOptions,
116    ) -> String {
117        // If the row we are making a string for, and if the user's cursor is set to that row, set
118        // the cursor character, else it is a space
119        let cursor = if self.cursor_row == cur_redraw_row {
120            opts.cursor.to_string()
121        } else {
122            " ".repeat(opts.cursor_width)
123        };
124
125        // If the line is selected, add the selected character to the string.
126        let sel_indicator = match item.is_selected {
127            true => opts.selected_indicator.to_string() + " ",
128            false => "  ".repeat(opts.selected_indicator_width),
129        };
130
131        return cursor + sel_indicator.as_str() + item.menu_item.visible_name.as_str();
132    }
133
134    fn get_menu_string(&mut self, opts: &MenuOptions) -> Result<String, std::io::Error> {
135        // Keep the number of lines the next draw will write
136        let mut next_screen_num_lines = 0;
137
138        // Make a multiline string that represents the next screen
139        let next_screen = {
140            let mut output = String::new();
141
142            // for every item we are keeping track of,
143            // if it is "visible", get_row the visible string for it and add it to the next draw
144            for i in 0..self.rows.len() {
145                let item = self.rows.get(i).unwrap();
146
147                // If adding another line would make it taller than the configured max screen,
148                // break early
149                if next_screen_num_lines + 1 > opts.max_lines_visible {
150                    break;
151                }
152
153                if item.is_visible {
154                    let x = self.get_row(item, next_screen_num_lines, opts) + "\n";
155                    output += x.as_str();
156
157                    next_screen_num_lines += 1;
158                }
159            }
160            output
161        };
162        Ok(next_screen)
163    }
164
165    /// Redraw the menu based on the info in MenuState
166    fn redraw(&mut self, opts: &MenuOptions) -> Result<(), std::io::Error> {
167        let mut next_screen: String;
168        let mut next_screen_num_lines: usize;
169
170        loop {
171            next_screen = self.get_menu_string(opts)?;
172
173            next_screen_num_lines = next_screen.matches('\n').count();
174            if (next_screen_num_lines as i32 - 1) < self.cursor_row as i32 {
175                self.cursor_row -= 1;
176                continue;
177            } else {
178                // Add the prompt and the user's input to the redraw String
179                next_screen += self.prompt.as_str();
180                next_screen += self.inputed.as_str();
181                next_screen_num_lines = next_screen.matches('\n').count() + 1;
182                break;
183            }
184        }
185
186        // Clear last menu draw, but ignore this section if it is the first draw
187        if self.lines_written != 0 {
188            // This line fixes some strange bugs that included prompt lines not being deleted
189            // it does cause some flickering in generated video files however
190            self.term.clear_line()?;
191            self.term.clear_last_lines(self.lines_written - 1)?;
192            self.lines_written = 0;
193        }
194        // Draw the next menu
195        self.term.write_all(next_screen.as_bytes())?;
196        self.term.flush()?;
197        self.lines_written = next_screen_num_lines;
198
199        Ok(())
200    }
201}
202
203impl Menu {
204    /// Serve a menu. This function is locking and requires a terminal.
205    /// It returns a Vec of Strings from the items the user selected
206    pub fn serve(&self) -> Result<Option<Vec<String>>, std::io::Error> {
207        let term = Term::stdout();
208
209        let mut state = MenuState {
210            prompt: self.prompt.clone(),
211            lines_written: 0,
212            cursor_row: 1,
213            inputed: String::new(),
214            rows: Vec::<MenuItemKeepTrack>::new(),
215            term,
216        };
217
218        // Load the MenuItems into the MenuState
219        for i in 0..self.items.len() {
220            let item = self.items[i].clone();
221
222            let mut is_visible = false;
223            if self.items[i].visible_at_rest {
224                is_visible = true;
225            }
226
227            state.rows.push(MenuItemKeepTrack {
228                menu_item: item.clone(),
229                is_visible,
230                is_selected: false,
231            });
232        }
233
234        loop {
235            state.redraw(&self.configuration)?;
236            let usr_key = state.term.read_key()?;
237
238            match usr_key {
239                Key::Char(c) => {
240                    if Key::Char(c) == self.configuration.select_key {
241                        state.mark_selected();
242                    } else {
243                        state.inputed.push(c);
244                        state.search_from_inputed(&self.configuration);
245                    }
246                }
247                Key::Backspace => {
248                    state.inputed.pop();
249                    state.search_from_inputed(&self.configuration);
250                }
251                Key::ArrowUp | Key::ArrowLeft => {
252                    if state.cursor_row != 0 {
253                        state.cursor_row -= 1;
254                    }
255                }
256                Key::Tab => {
257                    if (state.cursor_row as i32) <= (state.lines_written as i32) - 3 {
258                        state.cursor_row += 1;
259                    } else {
260                        state.cursor_row = 0;
261                    }
262                }
263                Key::ArrowDown | Key::ArrowRight => {
264                    if (state.cursor_row as i32) <= (state.lines_written as i32) - 3 {
265                        state.cursor_row += 1;
266                    }
267                }
268                Key::Enter => {
269                    break;
270                }
271                _ => {
272                    // Ignore any other keypresses
273                    //println!("Heya fella, that key hasn't been implemented yet");
274                    //std::thread::sleep(Duration::from_millis(2000));
275                    //state.lines_written += 1;
276                    continue;
277                }
278            }
279        }
280
281        let mut output: Vec<String> = Vec::new();
282        for i in state.rows {
283            if i.is_selected {
284                output.push(i.menu_item.visible_name);
285            }
286        }
287
288        if output.is_empty() {
289            Ok(None)
290        } else {
291            Ok(Some(output))
292        }
293    }
294}