Skip to main content

libsurfer/
command_prompt.rs

1//! Command prompt handling.
2use crate::command_parser::get_parser;
3use crate::fzcmd::{FuzzyOutput, ParseError, expand_command, parse_command};
4use crate::{SystemState, message::Message};
5use egui::scroll_area::ScrollBarVisibility;
6use egui::text::{CCursor, CCursorRange, LayoutJob, TextFormat};
7use egui::{Key, RichText, TextEdit, TextStyle};
8use emath::{Align, Align2, NumExt, Vec2};
9use epaint::{FontFamily, FontId};
10use itertools::Itertools;
11use std::iter::zip;
12
13pub fn run_fuzzy_parser(input: &str, state: &SystemState, msgs: &mut Vec<Message>) {
14    let FuzzyOutput {
15        expanded: _,
16        suggestions,
17    } = expand_command(input, get_parser(state));
18
19    msgs.push(Message::CommandPromptUpdate {
20        suggestions: suggestions.unwrap_or_else(|_| vec![]),
21    });
22}
23
24#[derive(Default)]
25pub struct CommandPrompt {
26    pub visible: bool,
27    pub suggestions: Vec<(String, Vec<bool>)>,
28    pub selected: usize,
29    pub new_selection: Option<usize>,
30    pub new_text: Option<(String, String)>,
31    pub previous_commands: Vec<(String, Vec<bool>)>,
32}
33
34pub fn show_command_prompt(
35    state: &mut SystemState,
36    ctx: &egui::Context,
37    // Window size if known. If unknown defaults to a width of 200pts
38    window_size: Option<Vec2>,
39    msgs: &mut Vec<Message>,
40) {
41    egui::Window::new("Commands")
42        .anchor(Align2::CENTER_TOP, Vec2::ZERO)
43        .title_bar(false)
44        .min_width(window_size.map_or(200., |s| s.x * 0.3))
45        .resizable(true)
46        .show(ctx, |ui| {
47            egui::Frame::NONE.show(ui, |ui| {
48                let text_update = state.command_prompt.new_text.take();
49                let input = &mut *state.command_prompt_text.borrow_mut();
50                if let Some(c) = state.char_to_add_to_prompt.take() {
51                    input.push(c);
52                }
53                if let Some((normal, selected)) = &text_update {
54                    *input = normal.clone() + selected;
55                }
56                let response = ui.add(
57                    TextEdit::singleline(input)
58                        .desired_width(f32::INFINITY)
59                        .lock_focus(true),
60                );
61
62                if response.changed() || state.command_prompt.suggestions.is_empty() {
63                    run_fuzzy_parser(input, state, msgs);
64                }
65
66                let set_cursor_to_pos = |pos, ui: &mut egui::Ui| {
67                    if let Some(mut state) = TextEdit::load_state(ui.ctx(), response.id) {
68                        let ccursor = CCursor::new(pos);
69                        state
70                            .cursor
71                            .set_char_range(Some(CCursorRange::one(ccursor)));
72                        state.store(ui.ctx(), response.id);
73                        ui.ctx().memory_mut(|m| m.request_focus(response.id));
74                    }
75                };
76
77                let select_range = |start, end, ui: &mut egui::Ui| {
78                    if let Some(mut state) = TextEdit::load_state(ui.ctx(), response.id) {
79                        let start = CCursor::new(start);
80                        let end = CCursor::new(end);
81                        state
82                            .cursor
83                            .set_char_range(Some(CCursorRange::two(start, end)));
84                        state.store(ui.ctx(), response.id);
85                        ui.ctx().memory_mut(|m| m.request_focus(response.id));
86                    }
87                };
88
89                /*
90                if response.ctx.input(|i| i.key_pressed(Key::ArrowUp)) {
91                    set_cursor_to_pos(input.chars().count(), ui);
92                } */
93
94                if response.ctx.input(|i| i.key_pressed(Key::Escape)) {
95                    msgs.push(Message::HideCommandPrompt);
96                }
97
98                if response.ctx.input(|i| i.key_pressed(Key::ArrowUp)) {
99                    msgs.push(Message::SelectPrevCommand);
100                }
101
102                if response.ctx.input(|i| i.key_pressed(Key::ArrowDown)) {
103                    msgs.push(Message::SelectNextCommand);
104                }
105
106                if response.ctx.input(|i| i.key_pressed(Key::N)) {
107                    msgs.push(Message::SelectNextCommand);
108                }
109
110                if response.ctx.input(|i| i.key_pressed(Key::P)) {
111                    msgs.push(Message::SelectPrevCommand);
112                }
113
114                if let Some((normal, selected)) = text_update {
115                    let normal_cnt = normal.chars().count();
116                    if selected.is_empty() {
117                        set_cursor_to_pos(normal_cnt, ui);
118                    } else {
119                        let selected_cnt = selected.chars().count();
120                        select_range(normal_cnt, normal_cnt + selected_cnt, ui);
121                    }
122                }
123
124                let suggestions = state
125                    .command_prompt
126                    .previous_commands
127                    .iter()
128                    // take up to 3 previous commands
129                    .take(if input.is_empty() { 3 } else { 0 })
130                    // reverse them so that the most recent one is at the bottom
131                    .rev()
132                    .chain(state.command_prompt.suggestions.iter())
133                    .enumerate()
134                    // allow scrolling down the suggestions
135                    .collect_vec();
136
137                // Expand the current input to full command and append the suggestion that is selected in the ui.
138                let append_suggestion = |input: &String| -> String {
139                    let new_input = if state.command_prompt.suggestions.is_empty() {
140                        input.clone()
141                    } else {
142                        // if no suggestions exist we use the last argument in the input (e.g., for divider_add)
143                        let default = input
144                            .split_ascii_whitespace()
145                            .last()
146                            .unwrap_or("")
147                            .to_string();
148
149                        let selection = suggestions
150                            .get(state.command_prompt.selected)
151                            .map_or(&default, |s| &s.1.0);
152
153                        if input.chars().last().is_some_and(char::is_whitespace) {
154                            // if no input exists for current argument just append
155                            input.to_owned() + " " + selection
156                        } else {
157                            // if something was already typed for this argument removed then append
158                            let parts = input.split_ascii_whitespace().collect_vec();
159                            parts.iter().take(parts.len().saturating_sub(1)).join(" ")
160                                + " "
161                                + selection
162                        }
163                    };
164                    expand_command(&new_input, get_parser(state)).expanded
165                };
166
167                if response.ctx.input(|i| i.key_pressed(Key::Tab)) {
168                    let mut new_input = append_suggestion(input);
169                    let parsed = parse_command(&new_input, get_parser(state));
170                    if let Err(ParseError::MissingParameters) = parsed {
171                        new_input += " ";
172                    }
173                    *input = new_input;
174                    set_cursor_to_pos(input.chars().count(), ui);
175                    run_fuzzy_parser(input, state, msgs);
176                }
177
178                if response.lost_focus() && response.ctx.input(|i| i.key_pressed(Key::Enter)) {
179                    let expanded = append_suggestion(input);
180                    let parsed = (
181                        expanded.clone(),
182                        parse_command(&expanded, get_parser(state)),
183                    );
184
185                    if let Ok(cmd) = parsed.1 {
186                        msgs.push(Message::HideCommandPrompt);
187                        msgs.push(Message::CommandPromptClear);
188                        msgs.push(Message::CommandPromptPushPrevious(parsed.0));
189                        msgs.push(cmd);
190                        run_fuzzy_parser("", state, msgs);
191                    } else {
192                        *input = parsed.0 + " ";
193                        // move cursor to end of input
194                        set_cursor_to_pos(input.chars().count(), ui);
195                        // run fuzzy parser since setting the cursor swallows the `changed` flag
196                        run_fuzzy_parser(input, state, msgs);
197                    }
198                }
199
200                response.request_focus();
201
202                // draw current expansion of input and selected suggestions
203                let expanded = expand_command(input, get_parser(state)).expanded;
204                if !expanded.is_empty() {
205                    ui.horizontal(|ui| {
206                        let label = ui.label(
207                            RichText::new("Expansion").color(
208                                state
209                                    .user
210                                    .config
211                                    .theme
212                                    .primary_ui_color
213                                    .foreground
214                                    .gamma_multiply(0.5),
215                            ),
216                        );
217                        ui.vertical(|ui| {
218                            ui.add_space(label.rect.height() / 2.0);
219                            ui.separator()
220                        });
221                    });
222
223                    ui.allocate_ui_with_layout(
224                        ui.available_size(),
225                        egui::Layout::top_down(Align::LEFT).with_cross_justify(true),
226                        |ui| {
227                            ui.add(SuggestionLabel::new(
228                                RichText::new(expanded.clone())
229                                    .size(14.0)
230                                    .family(FontFamily::Monospace)
231                                    .color(
232                                        state
233                                            .user
234                                            .config
235                                            .theme
236                                            .accent_info
237                                            .background
238                                            .gamma_multiply(0.75),
239                                    ),
240                                false,
241                            ))
242                        },
243                    );
244                }
245
246                let text_style = TextStyle::Button;
247                let row_height = ui.text_style_height(&text_style);
248                egui::ScrollArea::vertical()
249                    .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible)
250                    .show_rows(ui, row_height, suggestions.len(), |ui, row_range| {
251                        for (idx, suggestion) in &suggestions[row_range] {
252                            let idx = *idx;
253                            let mut job = LayoutJob::default();
254                            let selected = state.command_prompt.selected == idx;
255
256                            let previous_cmds_len = state.command_prompt.previous_commands.len();
257                            if idx == 0 && previous_cmds_len != 0 && input.is_empty() {
258                                ui.horizontal(|ui| {
259                                    let label = ui.label(
260                                        RichText::new("Recently used").color(
261                                            state
262                                                .user
263                                                .config
264                                                .theme
265                                                .primary_ui_color
266                                                .foreground
267                                                .gamma_multiply(0.5),
268                                        ),
269                                    );
270                                    ui.vertical(|ui| {
271                                        ui.add_space(label.rect.height() / 2.0);
272                                        ui.separator()
273                                    });
274                                });
275                            }
276
277                            if (idx == previous_cmds_len.clamp(0, 3) && input.is_empty())
278                                || (idx == 0 && !input.is_empty())
279                            {
280                                ui.horizontal(|ui| {
281                                    let label = ui.label(
282                                        RichText::new("Suggestions").color(
283                                            state
284                                                .user
285                                                .config
286                                                .theme
287                                                .primary_ui_color
288                                                .foreground
289                                                .gamma_multiply(0.5),
290                                        ),
291                                    );
292                                    ui.vertical(|ui| {
293                                        ui.add_space(label.rect.height() / 2.0);
294                                        ui.separator()
295                                    });
296                                });
297                            }
298
299                            for (c, highlight) in zip(suggestion.0.chars(), &suggestion.1) {
300                                let mut tmp = [0u8; 4];
301                                let sub_string = c.encode_utf8(&mut tmp);
302                                job.append(
303                                    sub_string,
304                                    0.0,
305                                    TextFormat {
306                                        font_id: FontId::new(14.0, FontFamily::Monospace),
307                                        color: if selected || *highlight {
308                                            state.user.config.theme.accent_info.background
309                                        } else {
310                                            state.user.config.theme.primary_ui_color.foreground
311                                        },
312                                        ..Default::default()
313                                    },
314                                );
315                            }
316
317                            // make label full width of the palette
318                            let resp = ui.allocate_ui_with_layout(
319                                ui.available_size(),
320                                egui::Layout::top_down(Align::LEFT).with_cross_justify(true),
321                                |ui| ui.add(SuggestionLabel::new(job, selected)),
322                            );
323
324                            if state
325                                .command_prompt
326                                .new_selection
327                                .is_some_and(|new_idx| idx == new_idx)
328                            {
329                                resp.response.scroll_to_me(Some(Align::Center));
330                            }
331
332                            if resp.inner.clicked() {
333                                let new_input =
334                                    if input.chars().last().is_some_and(char::is_whitespace) {
335                                        // if no input exists for current argument just append
336                                        input.to_owned() + " " + &suggestion.0
337                                    } else {
338                                        // if something was already typed for this argument removed then append
339                                        let parts = input.split_ascii_whitespace().collect_vec();
340                                        parts.iter().take(parts.len().saturating_sub(1)).join(" ")
341                                            + " "
342                                            + &suggestion.0
343                                    };
344                                let expanded =
345                                    expand_command(&new_input, get_parser(state)).expanded;
346                                let result = (
347                                    expanded.clone(),
348                                    parse_command(&expanded, get_parser(state)),
349                                );
350
351                                if let Ok(cmd) = result.1 {
352                                    msgs.push(Message::HideCommandPrompt);
353                                    msgs.push(Message::CommandPromptClear);
354                                    msgs.push(Message::CommandPromptPushPrevious(expanded));
355                                    msgs.push(cmd);
356                                    run_fuzzy_parser("", state, msgs);
357                                } else {
358                                    *input = result.0 + " ";
359                                    set_cursor_to_pos(input.chars().count(), ui);
360                                    // run fuzzy parser since setting the cursor swallows the `changed` flag
361                                    run_fuzzy_parser(input, state, msgs);
362                                }
363                            }
364                        }
365                    });
366            });
367        });
368}
369
370// This SuggestionLabel is based on egui's SelectableLabel
371#[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
372pub struct SuggestionLabel {
373    text: egui::WidgetText,
374    selected: bool,
375}
376
377impl SuggestionLabel {
378    pub fn new(text: impl Into<egui::WidgetText>, selected: bool) -> Self {
379        Self {
380            text: text.into(),
381            selected,
382        }
383    }
384}
385
386impl egui::Widget for SuggestionLabel {
387    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
388        let Self { text, selected: _ } = self;
389
390        let button_padding = ui.spacing().button_padding;
391        let total_extra = button_padding + button_padding;
392
393        let wrap_width = ui.available_width() - total_extra.x;
394        let text = text.into_galley(ui, None, wrap_width, TextStyle::Button);
395
396        let mut desired_size = total_extra + text.size();
397        desired_size.y = desired_size.y.at_least(ui.spacing().interact_size.y);
398        let (rect, response) = ui.allocate_at_least(desired_size, egui::Sense::click());
399
400        if ui.is_rect_visible(response.rect) {
401            let text_pos = ui
402                .layout()
403                .align_size_within_rect(text.size(), rect.shrink2(button_padding))
404                .min;
405
406            let visuals = ui.style().interact_selectable(&response, false);
407
408            if response.hovered() || self.selected {
409                let rect = rect.expand(visuals.expansion);
410
411                ui.painter().rect(
412                    rect,
413                    visuals.corner_radius,
414                    visuals.weak_bg_fill,
415                    epaint::Stroke::NONE,
416                    epaint::StrokeKind::Middle,
417                );
418            }
419
420            ui.painter().galley(text_pos, text, visuals.text_color());
421        }
422
423        response
424    }
425}