1use 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: 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::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(if input.is_empty() { 3 } else { 0 })
130 .rev()
132 .chain(state.command_prompt.suggestions.iter())
133 .enumerate()
134 .collect_vec();
136
137 let append_suggestion = |input: &String| -> String {
139 let new_input = if state.command_prompt.suggestions.is_empty() {
140 input.clone()
141 } else {
142 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 input.to_owned() + " " + selection
156 } else {
157 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 set_cursor_to_pos(input.chars().count(), ui);
195 run_fuzzy_parser(input, state, msgs);
197 }
198 }
199
200 response.request_focus();
201
202 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 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 input.to_owned() + " " + &suggestion.0
337 } else {
338 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(input, state, msgs);
362 }
363 }
364 }
365 });
366 });
367 });
368}
369
370#[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}