duat_utils/modes/
prompt.rs

1use std::{io::Write, marker::PhantomData, sync::LazyLock};
2
3use duat_core::{prelude::*, text::Searcher};
4
5use super::IncSearcher;
6use crate::{
7    hooks::{SearchPerformed, SearchUpdated},
8    widgets::PromptLine,
9};
10
11static PROMPT_TAGGER: LazyLock<Tagger> = LazyLock::new(Tagger::new);
12static TAGGER: LazyLock<Tagger> = LazyLock::new(Tagger::new);
13
14/// A [`Mode`] for the [`PromptLine`]
15///
16/// This mode abstracts over what the inner [`PromptMode`] actually
17/// does, by letting them focus on just updating the [`Text`] and
18/// acting on user input, instead of having to worry about which keys
19/// do what, and when to update.
20///
21/// There are currently three [`PromptMode`]s:
22///
23/// - [`RunCommands`] is just your regular command runner, it can also
24///   detect if your [`Parameter`]s are correct and show that in real
25///   time.
26/// - [`PipeSelections`] pipes each [`Selection`]'s selection in the
27///   current [`File`] to an external application, replacing each
28///   selection with the returned value.
29/// - [`IncSearch`] has a further inner abstraction, [`IncSearcher`],
30///   which lets you abstract over what the incremental search will
31///   actually do. I.e. will it search for the next ocurrence, split
32///   selections by matches, things of the sort.
33///
34/// [`Parameter`]: cmd::Parameter
35/// [`Selection`]: mode::Selection
36#[derive(Clone)]
37pub struct Prompt<U: Ui, M: PromptMode<U> = RunCommands>(M, String, PhantomData<U>);
38
39impl<M: PromptMode<U>, U: Ui> Prompt<U, M> {
40    /// Returns a new [`Prompt`] from this [`PromptMode`]
41    ///
42    /// For convenience, you should make it so `new` methods in
43    /// [`PromptMode`] implementors return a [`Prompt<Self, U>`],
44    /// rather than the [`PromptMode`] itself.
45    pub fn new(mode: M) -> Self {
46        Self(mode, String::new(), PhantomData)
47    }
48
49	/// Returns a new [`Prompt`] with some initial text
50    pub fn new_with(mode: M, initial: impl ToString) -> Self {
51        Self(mode, initial.to_string(), PhantomData)
52    }
53}
54
55impl<M: PromptMode<U>, U: Ui> mode::Mode<U> for Prompt<U, M> {
56    type Widget = PromptLine<U>;
57
58    fn send_key(&mut self, pa: &mut Pass, key: KeyEvent, handle: Handle<Self::Widget, U>) {
59        let mut update = |pa: &mut Pass| {
60            let text = std::mem::take(handle.write(pa).text_mut());
61            let text = self.0.update(pa, text, handle.area(pa));
62            *handle.write(pa).text_mut() = text;
63        };
64
65        match key {
66            key!(KeyCode::Backspace) => {
67                if handle.read(pa).text().is_empty() {
68                    handle.write(pa).text_mut().selections_mut().clear();
69
70                    update(pa);
71
72                    if let Some(ret_handle) = self.0.return_handle() {
73                        mode::reset_to(ret_handle);
74                    } else {
75                        mode::reset::<M::ExitWidget, U>();
76                    }
77                } else {
78                    handle.edit_main(pa, |mut e| {
79                        e.move_hor(-1);
80                        e.set_anchor_if_needed();
81                        e.replace("");
82                        e.unset_anchor();
83                    });
84                    update(pa);
85                }
86            }
87            key!(KeyCode::Delete) => {
88                handle.edit_main(pa, |mut e| e.replace(""));
89                update(pa);
90            }
91
92            key!(KeyCode::Char(char)) => {
93                handle.edit_main(pa, |mut e| {
94                    e.insert(char);
95                    e.move_hor(1);
96                });
97                update(pa);
98            }
99            key!(KeyCode::Left) => {
100                handle.edit_main(pa, |mut e| e.move_hor(-1));
101                update(pa);
102            }
103            key!(KeyCode::Right) => {
104                handle.edit_main(pa, |mut e| e.move_hor(1));
105                update(pa);
106            }
107
108            key!(KeyCode::Esc) => {
109                let p = handle.read(pa).text().len();
110                handle.edit_main(pa, |mut e| {
111                    e.move_to_start();
112                    e.set_anchor();
113                    e.move_to(p);
114                    e.replace("");
115                });
116                handle.write(pa).text_mut().selections_mut().clear();
117                update(pa);
118
119                if let Some(ret_handle) = self.0.return_handle() {
120                    mode::reset_to(ret_handle);
121                } else {
122                    mode::reset::<M::ExitWidget, U>();
123                }
124            }
125            key!(KeyCode::Enter) => {
126                handle.write(pa).text_mut().selections_mut().clear();
127
128                update(pa);
129
130                if let Some(ret_handle) = self.0.return_handle() {
131                    mode::reset_to(ret_handle);
132                } else {
133                    mode::reset::<M::ExitWidget, U>();
134                }
135            }
136            _ => {}
137        }
138    }
139
140    fn on_switch(&mut self, pa: &mut Pass, handle: Handle<Self::Widget, U>) {
141        let text = {
142            let pl = handle.write(pa);
143            *pl.text_mut() = Text::new_with_selections();
144            pl.text_mut().replace_range(0..0, &self.1);
145            run_once::<M, U>();
146
147            let tag = Ghost(match pl.prompt_of::<M>() {
148                Some(text) => txt!("{text}[prompt.colon]:").build(),
149                None => txt!("{}[prompt.colon]:", self.0.prompt()).build(),
150            });
151            pl.text_mut().insert_tag(*PROMPT_TAGGER, 0, tag);
152
153            std::mem::take(pl.text_mut())
154        };
155
156        let text = self.0.on_switch(pa, text, handle.area(pa));
157
158        *handle.write(pa).text_mut() = text;
159    }
160
161    fn before_exit(&mut self, pa: &mut Pass, handle: Handle<Self::Widget, U>) {
162        let text = std::mem::take(handle.write(pa).text_mut());
163        self.0.before_exit(pa, text, handle.area(pa));
164    }
165}
166
167/// A mode to control the [`Prompt`], by acting on its [`Text`] and
168/// [`U::Area`]
169///
170/// Through the [`Pass`], one can act on the entirety of Duat's shared
171/// state:
172///
173/// ```rust
174/// use duat_core::prelude::*;
175/// use duat_utils::modes::PromptMode;
176///
177/// #[derive(Default, Clone)]
178/// struct RealTimeSwitch {
179///     initial: Option<String>,
180///     current: Option<String>,
181///     name_was_correct: bool,
182/// };
183///
184/// impl<U: Ui> PromptMode<U> for RealTimeSwitch {
185///     fn update(&mut self, pa: &mut Pass, text: Text, area: &U::Area) -> Text {
186///         let name = text.to_string();
187///
188///         self.name_was_correct = if name != *self.current.as_ref().unwrap() {
189///             if cmd::buffer(pa, &name).is_ok() {
190///                 self.current = Some(name);
191///                 true
192///             } else {
193///                 false
194///             }
195///         } else {
196///             true
197///         };
198///
199///         text
200///     }
201///
202///     fn on_switch(&mut self, pa: &mut Pass, text: Text, area: &U::Area) -> Text {
203///         self.initial = Some(context::fixed_file::<U>(pa).unwrap().read(pa).name());
204///         self.current = self.initial.clone();
205///
206///         text
207///     }
208///
209///     fn before_exit(&mut self, pa: &mut Pass, text: Text, area: &U::Area) {
210///         if !self.name_was_correct {
211///             cmd::buffer(pa, self.initial.take().unwrap());
212///         }
213///     }
214///
215///     fn prompt(&self) -> Text {
216///         txt!("[prompt]switch to").build()
217///     }
218/// }
219/// ```
220///
221/// The [`PromptMode`] above will switch to the file with the same
222/// name as the one in the [`PromptLine`], returning to the initial
223/// file if the match failed.
224///
225/// [`U::Area`]: Ui::Area
226#[allow(unused_variables)]
227pub trait PromptMode<U: Ui>: Clone + Send + 'static {
228    /// What [`Widget`] to exit to, upon pressing enter, esc, or
229    /// backspace in an empty [`PromptLine`]
230    type ExitWidget: Widget<U> = File<U>;
231
232    /// Updates the [`PromptLine`] and [`Text`] of the [`Prompt`]
233    ///
234    /// This function is triggered every time the user presses a key
235    /// in the [`Prompt`] mode.
236    fn update(&mut self, pa: &mut Pass, text: Text, area: &U::Area) -> Text;
237
238    /// What to do when switchin onto this [`PromptMode`]
239    ///
240    /// The initial [`Text`] is always empty, except for the [prompt]
241    /// [`Ghost`] at the beginning of the line.
242    ///
243    /// [prompt]: PromptMode::prompt
244    fn on_switch(&mut self, pa: &mut Pass, text: Text, area: &U::Area) -> Text {
245        text
246    }
247
248    /// What to do before exiting the [`PromptMode`]
249    ///
250    /// This usually involves some sor of "commitment" to the result,
251    /// e.g., [`RunCommands`] executes the call, [`IncSearch`]
252    /// finishes the search, etc.
253    fn before_exit(&mut self, pa: &mut Pass, text: Text, area: &U::Area) {}
254
255    /// Things to do when this [`PromptMode`] is first instantiated
256    fn once() {}
257
258    /// What text should be at the beginning of the [`PromptLine`], as
259    /// a [`Ghost`]
260    fn prompt(&self) -> Text;
261
262    /// An optional returning [`Handle`] for the [`ExitWidget`]
263    ///
264    /// [`ExitWidget`]: PromptMode::ExitWidget
265    fn return_handle(&self) -> Option<Handle<Self::ExitWidget, U>> {
266        None
267    }
268}
269
270/// Runs Duat commands, with syntax highlighting for correct
271/// [`Parameter`]s
272///
273/// [`Parameter`]: duat_core::cmd::Parameter
274#[derive(Default, Clone)]
275pub struct RunCommands;
276
277impl RunCommands {
278    /// Crates a new [`RunCommands`]
279    pub fn new<U: Ui>() -> Prompt<U, Self> {
280        Prompt::new(Self)
281    }
282
283    /// Opens a [`RunCommands`] with some initial text
284    pub fn new_with<U: Ui>(initial: impl ToString) -> Prompt<U, Self> {
285        Prompt::new_with(Self, initial)
286    }
287}
288
289impl<U: Ui> PromptMode<U> for RunCommands {
290    fn update(&mut self, pa: &mut Pass, mut text: Text, _: &<U as Ui>::Area) -> Text {
291        text.remove_tags(*TAGGER, ..);
292
293        let command = text.to_string();
294        let caller = command.split_whitespace().next();
295        if let Some(caller) = caller {
296            if let Some((ok_ranges, err_range)) = cmd::check_args(pa, &command) {
297                let id = form::id_of!("caller.info");
298                text.insert_tag(*TAGGER, 0..caller.len(), id.to_tag(0));
299
300                let default_id = form::id_of!("parameter.info");
301                for (range, id) in ok_ranges {
302                    text.insert_tag(*TAGGER, range, id.unwrap_or(default_id).to_tag(0));
303                }
304                if let Some((range, _)) = err_range {
305                    let id = form::id_of!("parameter.error");
306                    text.insert_tag(*TAGGER, range, id.to_tag(0));
307                }
308            } else {
309                let id = form::id_of!("caller.error");
310                text.insert_tag(*TAGGER, 0..caller.len(), id.to_tag(0));
311            }
312        }
313
314        text
315    }
316
317    fn before_exit(&mut self, _: &mut Pass, text: Text, _: &<U as Ui>::Area) {
318        let call = text.to_string();
319        if !call.is_empty() {
320            cmd::queue_notify(call);
321        }
322    }
323
324    fn once() {
325        form::set_weak("caller.info", "accent.info");
326        form::set_weak("caller.error", "accent.error");
327        form::set_weak("parameter.info", "default.info");
328        form::set_weak("parameter.error", "default.error");
329    }
330
331    fn prompt(&self) -> Text {
332        Text::default()
333    }
334}
335
336/// The [`PromptMode`] that makes use of [`IncSearcher`]s
337///
338/// In order to make use of incremental search, you'd do something
339/// like this:
340///
341/// ```rust
342/// use duat_core::prelude::*;
343/// use duat_utils::modes::{IncSearch, Regular, SearchFwd};
344///
345/// fn setup_generic_over_ui<U: Ui>() {
346///     mode::map::<Regular, U>("<C-s>", IncSearch::new(SearchFwd));
347/// }
348/// ```
349///
350/// This function returns a [`Prompt<IncSearch<SearchFwd, U>, U>`],
351#[derive(Clone)]
352pub struct IncSearch<I: IncSearcher<U>, U: Ui> {
353    inc: I,
354    orig: Option<(mode::Selections, <U::Area as Area>::PrintInfo)>,
355    ghost: PhantomData<U>,
356    prev: String,
357}
358
359impl<I: IncSearcher<U>, U: Ui> IncSearch<I, U> {
360    /// Returns a [`Prompt`] with [`IncSearch<I, U>`] as its
361    /// [`PromptMode`]
362    pub fn new(inc: I) -> Prompt<U, Self> {
363        Prompt::new(Self {
364            inc,
365            orig: None,
366            ghost: PhantomData,
367            prev: String::new(),
368        })
369    }
370}
371
372impl<I: IncSearcher<U>, U: Ui> PromptMode<U> for IncSearch<I, U> {
373    fn update(&mut self, pa: &mut Pass, mut text: Text, _: &<U as Ui>::Area) -> Text {
374        let (orig_selections, orig_print_info) = self.orig.as_ref().unwrap();
375        text.remove_tags(*TAGGER, ..);
376
377        let handle = context::fixed_file::<U>(pa).unwrap();
378
379        if text == self.prev {
380            return text;
381        } else {
382            let prev = std::mem::replace(&mut self.prev, text.to_string());
383            hook::queue(SearchUpdated((prev, self.prev.clone())));
384        }
385
386        match Searcher::new(text.to_string()) {
387            Ok(searcher) => {
388                let (file, area) = handle.write_with_area(pa);
389                area.set_print_info(orig_print_info.clone());
390                *file.selections_mut() = orig_selections.clone();
391
392                let ast = regex_syntax::ast::parse::Parser::new()
393                    .parse(&text.to_string())
394                    .unwrap();
395
396                crate::tag_from_ast(*TAGGER, &mut text, &ast);
397
398                self.inc.search(pa, handle.attach_searcher(searcher));
399            }
400            Err(err) => {
401                let regex_syntax::Error::Parse(err) = *err else {
402                    unreachable!("As far as I can tell, regex_syntax has goofed up");
403                };
404
405                let span = err.span();
406                let id = form::id_of!("regex.error");
407
408                text.insert_tag(*TAGGER, span.start.offset..span.end.offset, id.to_tag(0));
409            }
410        }
411
412        text
413    }
414
415    fn on_switch(&mut self, pa: &mut Pass, text: Text, _: &<U as Ui>::Area) -> Text {
416        let handle = context::fixed_file::<U>(pa).unwrap();
417
418        self.orig = Some((
419            handle.read(pa).selections().clone(),
420            handle.area(pa).print_info(),
421        ));
422
423        text
424    }
425
426    fn before_exit(&mut self, _: &mut Pass, text: Text, _: &<U as Ui>::Area) {
427        if !text.is_empty() {
428            if let Err(err) = Searcher::new(text.to_string()) {
429                let regex_syntax::Error::Parse(err) = *err else {
430                    unreachable!("As far as I can tell, regex_syntax has goofed up");
431                };
432
433                let range = err.span().start.offset..err.span().end.offset;
434                let err = txt!(
435                    "[a]{:?}, \"{}\"[prompt.colon]:[] {}",
436                    range,
437                    text.strs(range).unwrap(),
438                    err.kind()
439                );
440
441                context::error!(target: self.inc.prompt().to_string(), "{err}")
442            } else {
443                hook::queue(SearchPerformed(text.to_string()));
444            }
445        }
446    }
447
448    fn once() {
449        form::set_weak("regex.error", "accent.error");
450        form::set_weak("regex.operator", "operator");
451        form::set_weak("regex.class", "constant");
452        form::set_weak("regex.bracket", "punctuation.bracket");
453    }
454
455    fn prompt(&self) -> Text {
456        txt!("{}", self.inc.prompt()).build()
457    }
458}
459
460/// Pipes the selections of a [`File`] through an external command
461///
462/// This can be useful if you, for example, don't have access to a
463/// formatter, but want to format text, so you pass it to
464/// [`PipeSelections`] with `fold` as the command, or things of the
465/// sort.
466#[derive(Clone, Copy)]
467pub struct PipeSelections<U>(PhantomData<U>);
468
469impl<U: Ui> PipeSelections<U> {
470    /// Returns a [`Prompt`] with [`PipeSelections`] as its
471    /// [`PromptMode`]
472    pub fn new() -> Prompt<U, Self> {
473        Prompt::new(Self(PhantomData))
474    }
475}
476
477impl<U: Ui> PromptMode<U> for PipeSelections<U> {
478    fn update(&mut self, _: &mut Pass, mut text: Text, _: &<U as Ui>::Area) -> Text {
479        fn is_in_path(program: &str) -> bool {
480            if let Ok(path) = std::env::var("PATH") {
481                for p in path.split(":") {
482                    let p_str = format!("{p}/{program}");
483                    if let Ok(true) = std::fs::exists(p_str) {
484                        return true;
485                    }
486                }
487            }
488            false
489        }
490
491        text.remove_tags(*TAGGER, ..);
492
493        let command = text.to_string();
494        let Some(caller) = command.split_whitespace().next() else {
495            return text;
496        };
497
498        let args = cmd::args_iter(&command);
499
500        let (caller_id, args_id) = if is_in_path(caller) {
501            (form::id_of!("caller.info"), form::id_of!("parameter.indo"))
502        } else {
503            (
504                form::id_of!("caller.error"),
505                form::id_of!("parameter.error"),
506            )
507        };
508
509        let c_s = command.len() - command.trim_start().len();
510        text.insert_tag(*TAGGER, c_s..c_s + caller.len(), caller_id.to_tag(0));
511
512        for (_, range) in args {
513            text.insert_tag(*TAGGER, range, args_id.to_tag(0));
514        }
515
516        text
517    }
518
519    fn before_exit(&mut self, pa: &mut Pass, text: Text, _: &<U as Ui>::Area) {
520        use std::process::{Command, Stdio};
521
522        let command = text.to_string();
523        let Some(caller) = command.split_whitespace().next() else {
524            return;
525        };
526
527        let handle = context::fixed_file::<U>(pa).unwrap();
528        handle.edit_all(pa, |mut c| {
529            let Ok(mut child) = Command::new(caller)
530                .args(cmd::args_iter(&command).map(|(a, _)| a))
531                .stdin(Stdio::piped())
532                .stdout(Stdio::piped())
533                .spawn()
534            else {
535                return;
536            };
537
538            let input: String = c.selection().collect();
539            if let Some(mut stdin) = child.stdin.take() {
540                std::thread::spawn(move || {
541                    stdin.write_all(input.as_bytes()).unwrap();
542                });
543            }
544            if let Ok(out) = child.wait_with_output() {
545                let out = String::from_utf8_lossy(&out.stdout);
546                c.set_anchor_if_needed();
547                c.replace(out);
548            }
549        });
550    }
551
552    fn prompt(&self) -> Text {
553        txt!("[prompt]pipe").build()
554    }
555}
556
557/// Runs the [`once`] function of widgets.
558///
559/// [`once`]: Widget::once
560fn run_once<M: PromptMode<U>, U: Ui>() {
561    use std::{any::TypeId, sync::Mutex};
562
563    static LIST: LazyLock<Mutex<Vec<TypeId>>> = LazyLock::new(|| Mutex::new(Vec::new()));
564
565    let mut list = LIST.lock().unwrap();
566    if !list.contains(&TypeId::of::<M>()) {
567        M::once();
568        list.push(TypeId::of::<M>());
569    }
570}