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