libsurfer/
command_prompt.rs

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