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::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(if input.is_empty() { 3 } else { 0 })
108 .rev()
110 .chain(state.command_prompt.suggestions.iter())
111 .enumerate()
112 .collect_vec();
114
115 let append_suggestion = |input: &String| -> String {
117 let new_input = if state.command_prompt.suggestions.is_empty() {
118 input.to_string()
119 } else {
120 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 input.to_owned() + " " + selection
134 } else {
135 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 set_cursor_to_pos(input.chars().count(), ui);
173 run_fuzzy_parser(input, state, msgs);
175 }
176 }
177
178 response.request_focus();
179
180 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 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 input.to_owned() + " " + &suggestion.0
315 } else {
316 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(input, state, msgs);
340 }
341 }
342 }
343 });
344 });
345 });
346}
347
348#[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}