1use 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: 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(if input.is_empty() { 3 } else { 0 })
89 .rev()
91 .chain(state.command_prompt.suggestions.iter())
92 .enumerate()
93 .collect_vec();
95
96 let append_suggestion = |input: &String| -> String {
98 let new_input = if !state.command_prompt.suggestions.is_empty() {
99 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 input.to_owned() + " " + selection
113 } else {
114 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 set_cursor_to_pos(input.chars().count(), ui);
154 run_fuzzy_parser(input, state, msgs);
156 }
157 }
158
159 response.request_focus();
160
161 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 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 input.to_owned() + " " + &suggestion.0
296 } else {
297 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(input, state, msgs);
321 }
322 }
323 }
324 });
325 });
326 });
327}
328
329#[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}