Skip to main content

intuituive/
command_line.rs

1//! A command line: [`command_line`], a bar of one or two rows that opens
2//! in a [`Mode`] (vim's `:` command, a `/` search, a filter that follows
3//! each key), asks yes or no in place ([`CommandLine::confirm`]), and
4//! says what went wrong in red. Its input completes and keeps a history
5//! as any [`input`](crate::input)'s. Generic: taskwarrior-tui's command
6//! bar, Yazi's and lazygit's prompts are modes of it.
7
8use std::cell::RefCell;
9use std::rc::Rc;
10
11use rich_interact::{Key, KeyCode};
12
13use crate::app::Ctx;
14use crate::complete::{CompleteOptions, Completer, Completion, InputHistory};
15use crate::node::{Axis, Node};
16use crate::reactive::{NodeId, Runtime, Signal, signal};
17use crate::screen::Rect;
18use crate::text_area::{Options, TextArea};
19use crate::widget::{Canvas, DrawCx, EventCx, MeasureCx, Used, Widget, WidgetEvent, widget};
20
21/// What a mode runs on Enter: `Ok` closes the bar, `Err(message)` keeps
22/// it open with the message in red.
23type OnSubmit = Box<dyn FnMut(&str, &mut Ctx) -> Result<(), String>>;
24type OnChange = Box<dyn FnMut(&str, &mut Ctx)>;
25type OnCancel = Box<dyn FnMut(&mut Ctx)>;
26
27/// A way the command line opens: its prefix (`:`, `/`), its title, the
28/// text it starts with, its completion and history, and what hears
29/// Enter, each edit and Esc.
30///
31/// ```
32/// use intuituive::command_line::Mode;
33/// use intuituive::complete_words;
34///
35/// let filter = Mode::new("/")
36///     .title("Filter Tasks")
37///     .complete(complete_words(["project:", "status:"]))
38///     .on_change(|text, _| println!("filter: {text}"))
39///     .on_submit(|_, _| Ok(()));
40/// # let _ = filter;
41/// ```
42#[derive(Default)]
43#[must_use = "builder methods return a new value; it does nothing until it is used"]
44pub struct Mode {
45    prefix: String,
46    title: Option<String>,
47    initial: String,
48    complete: Option<(Completer, CompleteOptions)>,
49    history: Option<InputHistory>,
50    submit: Option<OnSubmit>,
51    change: Option<OnChange>,
52    cancel: Option<OnCancel>,
53}
54
55impl Mode {
56    /// A mode whose line starts with `prefix` (`":"`, `"/"`, or `""`).
57    pub fn new(prefix: impl Into<String>) -> Mode {
58        Mode {
59            prefix: prefix.into(),
60            ..Mode::default()
61        }
62    }
63
64    /// The bar's title (console markup): above the line in a two-row bar,
65    /// before it in a one-row one.
66    pub fn title(mut self, title: impl Into<String>) -> Mode {
67        self.title = Some(title.into());
68        self
69    }
70
71    /// The text the line starts with, the caret at its end.
72    pub fn initial(mut self, text: impl Into<String>) -> Mode {
73        self.initial = text.into();
74        self
75    }
76
77    /// Complete the line, as [`Node::complete`](crate::Node::complete).
78    pub fn complete(self, complete: impl Fn(&str, usize) -> Vec<Completion> + 'static) -> Mode {
79        self.complete_with(complete, CompleteOptions::default())
80    }
81
82    /// [`complete`](Self::complete) as `options` say.
83    pub fn complete_with(
84        mut self,
85        complete: impl Fn(&str, usize) -> Vec<Completion> + 'static,
86        options: CompleteOptions,
87    ) -> Mode {
88        self.complete = Some((Rc::new(complete), options));
89        self
90    }
91
92    /// Go through `history` with ↑ and ↓, and add each line entered to it,
93    /// as [`Node::history`](crate::Node::history).
94    pub fn history(mut self, history: impl Into<InputHistory>) -> Mode {
95        self.history = Some(history.into());
96        self
97    }
98
99    /// Run `submit` with the line on Enter: `Ok(())` closes the bar,
100    /// `Err(message)` keeps it open and shows the message in red.
101    pub fn on_submit(
102        mut self,
103        submit: impl FnMut(&str, &mut Ctx) -> Result<(), String> + 'static,
104    ) -> Mode {
105        self.submit = Some(Box::new(submit));
106        self
107    }
108
109    /// Run `change` with the line after each edit: a filter as you type.
110    pub fn on_change(mut self, change: impl FnMut(&str, &mut Ctx) + 'static) -> Mode {
111        self.change = Some(Box::new(change));
112        self
113    }
114
115    /// Run `cancel` when Esc closes the bar (a filter put back).
116    pub fn on_cancel(mut self, cancel: impl FnMut(&mut Ctx) + 'static) -> Mode {
117        self.cancel = Some(Box::new(cancel));
118        self
119    }
120}
121
122/// A yes-or-no question's answer.
123type OnYes = Box<dyn FnOnce(&mut Ctx)>;
124
125/// What the bar shows.
126#[derive(Clone, Default)]
127enum Bar {
128    /// Closed, with a message (markup) or nothing.
129    #[default]
130    Idle,
131    Message(String),
132    Error(String),
133    /// Typing in a mode, with the error its last Enter gave.
134    Typing(Rc<RefCell<Mode>>, Option<String>),
135    /// Asking, with the keys that answer yes.
136    Confirm(String, Vec<Key>, Rc<RefCell<Option<OnYes>>>),
137}
138
139impl PartialEq for Bar {
140    /// The same mode or question (not one alike), and the same texts.
141    fn eq(&self, other: &Bar) -> bool {
142        match (self, other) {
143            (Bar::Idle, Bar::Idle) => true,
144            (Bar::Message(a), Bar::Message(b)) | (Bar::Error(a), Bar::Error(b)) => a == b,
145            (Bar::Typing(a, x), Bar::Typing(b, y)) => Rc::ptr_eq(a, b) && x == y,
146            (Bar::Confirm(_, _, a), Bar::Confirm(_, _, b)) => Rc::ptr_eq(a, b),
147            _ => false,
148        }
149    }
150}
151
152/// A command line's state: open it in a [`Mode`], ask a question, show an
153/// error. Made by [`CommandLine::new`] while the app is built and given
154/// to [`command_line`]; `Copy`, so key bindings take it.
155#[derive(Clone, Copy)]
156pub struct CommandLine {
157    bar: Signal<Bar>,
158    /// Bumped on each open, so the bar sets its input up again.
159    opened: Signal<u64>,
160    value: Signal<String>,
161    id: Signal<Option<NodeId>>,
162    /// Where the focus was when it opened, to give it back.
163    before: Signal<Option<NodeId>>,
164}
165
166impl Default for CommandLine {
167    fn default() -> CommandLine {
168        CommandLine::new()
169    }
170}
171
172impl CommandLine {
173    /// A closed command line.
174    pub fn new() -> CommandLine {
175        CommandLine {
176            bar: signal(Bar::Idle),
177            opened: signal(0),
178            value: signal(String::new()),
179            id: signal(None),
180            before: signal(None),
181        }
182    }
183
184    /// Open the line in `mode`, taking the focus (given back when it
185    /// closes).
186    pub fn open(&self, cx: &mut Ctx, mode: Mode) {
187        self.value.set(mode.initial.clone());
188        self.bar.set(Bar::Typing(Rc::new(RefCell::new(mode)), None));
189        self.opened.update(|n| *n += 1);
190        self.take_focus(cx);
191    }
192
193    /// Ask `question` (markup) in the bar: `y` runs `on_yes`, `n` or Esc
194    /// does not, and either closes it; other keys are ignored.
195    pub fn confirm(
196        &self,
197        cx: &mut Ctx,
198        question: impl Into<String>,
199        on_yes: impl FnOnce(&mut Ctx) + 'static,
200    ) {
201        self.confirm_keys(cx, question, "y", on_yes);
202    }
203
204    /// [`confirm`](Self::confirm) answered yes by `keys` (`"x"`: press `x`
205    /// again to confirm a delete, as taskwarrior-tui asks); `n`, `q` and
206    /// Esc answer no.
207    pub fn confirm_keys(
208        &self,
209        cx: &mut Ctx,
210        question: impl Into<String>,
211        keys: &str,
212        on_yes: impl FnOnce(&mut Ctx) + 'static,
213    ) {
214        let keys = keys.split_whitespace().filter_map(Key::parse).collect();
215        let on_yes: OnYes = Box::new(on_yes);
216        self.bar.set(Bar::Confirm(
217            question.into(),
218            keys,
219            Rc::new(RefCell::new(Some(on_yes))),
220        ));
221        self.take_focus(cx);
222    }
223
224    /// Show `message` (plain text) in red until the next open or message;
225    /// while typing, over the line's title, the line kept open.
226    pub fn error(&self, message: impl Into<String>) {
227        let message = message.into();
228        let typing = self.bar.with_untracked(|bar| match bar {
229            Bar::Typing(mode, _) => Some(mode.clone()),
230            _ => None,
231        });
232        match typing {
233            Some(mode) => self.bar.set(Bar::Typing(mode, Some(message))),
234            None => self.bar.set(Bar::Error(message)),
235        }
236    }
237
238    /// Show `markup` in the closed bar (a hint, a count).
239    pub fn message(&self, markup: impl Into<String>) {
240        self.bar.set(Bar::Message(markup.into()));
241    }
242
243    /// Close it (an error or message goes too), giving the focus back.
244    pub fn close(&self, cx: &mut Ctx) {
245        let open = self.is_open_untracked();
246        self.bar.set(Bar::Idle);
247        if open && let Some(before) = self.before.get_untracked() {
248            cx.focus(before);
249        }
250    }
251
252    /// Whether it is open (typing or asking), subscribing.
253    pub fn is_open(&self) -> bool {
254        self.bar
255            .with(|bar| matches!(bar, Bar::Typing(..) | Bar::Confirm(..)))
256    }
257
258    fn is_open_untracked(&self) -> bool {
259        self.bar
260            .with_untracked(|bar| matches!(bar, Bar::Typing(..) | Bar::Confirm(..)))
261    }
262
263    /// The line as typed, subscribing.
264    pub fn text(&self) -> String {
265        self.value.get()
266    }
267
268    fn take_focus(&self, cx: &mut Ctx) {
269        let Some(id) = self.id.get_untracked() else {
270            return;
271        };
272        let focused = Runtime::current()
273            .focus_path()
274            .and_then(|path| path.last().copied());
275        if focused != Some(id) {
276            self.before.set(focused);
277        }
278        cx.focus(id);
279    }
280}
281
282/// How a [`command_line_with`] looks: one row (the default) or two (the
283/// title on its own row above the line, taskwarrior-tui's).
284#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
285#[must_use = "options do nothing until they are passed to what takes them"]
286pub struct CommandLineOptions {
287    pub(crate) two_rows: bool,
288}
289
290impl CommandLineOptions {
291    pub fn new() -> CommandLineOptions {
292        CommandLineOptions::default()
293    }
294
295    /// The title on a row of its own above the line.
296    pub fn two_rows(mut self) -> CommandLineOptions {
297        self.two_rows = true;
298        self
299    }
300}
301
302/// The bar of command line `state`, one row tall: closed, its message
303/// (or an error, in the theme's `error` style); open, the title (in
304/// `command.title`, reversed), the mode's prefix and the line; asking,
305/// the question (`command.question`, bold). Enter runs the mode's
306/// `on_submit`, Esc its `on_cancel`, and both close it (Esc closes the
307/// completion pop-up first); the focus goes back where it was.
308///
309/// ```
310/// use intuituive::prelude::*;
311/// use intuituive::command_line::{command_line, CommandLine, Mode};
312///
313/// let app = App::new(|| {
314///     let cmd = CommandLine::new();
315///     let ran = signal(String::new());
316///     column([
317///         text!("ran {ran}").flex(1),
318///         command_line(cmd),
319///     ])
320///     .on_key(":", move |cx| {
321///         cmd.open(cx, Mode::new(":").on_submit(move |text, _| match text {
322///             "quit" | "write" => Ok(ran.set(text.to_string())),
323///             _ => Err(format!("Not an editor command: {text}")),
324///         }))
325///     })
326///     .on_key("d", move |cx| cmd.confirm(cx, "Delete it? (y/n)", move |_| ran.set("delete".into())))
327/// });
328/// let mut driver = app.driver(40, 3);
329/// driver.press(":");
330/// driver.type_text("wq");
331/// driver.press("enter");
332/// // One row: the error before the line, which stays open.
333/// assert_eq!(driver.screen().plain()[2].trim_end(), "Not an editor command: wq :wq");
334/// driver.press("backspace");
335/// driver.type_text("rite");
336/// driver.press("enter");
337/// assert_eq!(driver.screen().plain()[0].trim_end(), "ran write");
338/// driver.press("d");
339/// assert_eq!(driver.screen().plain()[2].trim_end(), "Delete it? (y/n)");
340/// driver.press("y");
341/// assert_eq!(driver.screen().plain()[0].trim_end(), "ran delete");
342/// ```
343pub fn command_line(state: CommandLine) -> Node {
344    command_line_with(state, CommandLineOptions::default())
345}
346
347/// A [`command_line`] that looks as `options` say.
348pub fn command_line_with(state: CommandLine, options: CommandLineOptions) -> Node {
349    let area = TextArea::new(
350        state.value,
351        Options {
352            single_line: true,
353            ..Options::default()
354        },
355    );
356    let node = widget(CommandBar {
357        state,
358        area,
359        options,
360        opened: u64::MAX,
361        input: Rect::default(),
362    })
363    .what("command_line");
364    state.id.set(Some(node.id()));
365    node
366}
367
368struct CommandBar {
369    state: CommandLine,
370    area: TextArea,
371    options: CommandLineOptions,
372    /// The open the input was last set up for.
373    opened: u64,
374    /// Where the line's input was drawn, in the bar.
375    input: Rect,
376}
377
378impl CommandBar {
379    /// Set the input up for the mode it opened in.
380    fn set_up(&mut self) {
381        let opened = self.state.opened.get_untracked();
382        if opened == self.opened {
383            return;
384        }
385        self.opened = opened;
386        let (complete, history) = self.state.bar.with_untracked(|bar| match bar {
387            Bar::Typing(mode, _) => {
388                let mode = mode.borrow();
389                (mode.complete.clone(), mode.history)
390            }
391            _ => (None, None),
392        });
393        let (completer, options) = match complete {
394            Some((completer, options)) => (Some(completer), options),
395            None => (None, CompleteOptions::default()),
396        };
397        self.area.assist.completer = completer;
398        self.area.assist.options = options;
399        self.area.assist.history = history.map(|h| Rc::new(move || Some(h)) as _);
400        self.area.assist.popup = None;
401        self.area.assist.browse = None;
402    }
403
404    fn mode(&self) -> Option<Rc<RefCell<Mode>>> {
405        self.state.bar.with_untracked(|bar| match bar {
406            Bar::Typing(mode, _) => Some(mode.clone()),
407            _ => None,
408        })
409    }
410
411    /// A key while typing.
412    fn typing(&mut self, cx: &mut EventCx, key: Key, mode: Rc<RefCell<Mode>>) -> Used {
413        let plain = !key.modifiers.ctrl && !key.modifiers.alt;
414        let popup = self.area.assist.popup.is_some();
415        match key.code {
416            KeyCode::Enter if plain && !popup => {
417                let text = self.state.value.get_untracked();
418                if let Some(history) = self.area.assist.history() {
419                    history.push(&text);
420                }
421                let result = match mode.borrow_mut().submit.as_mut() {
422                    Some(submit) => submit(&text, cx.app()),
423                    None => Ok(()),
424                };
425                // The handler may have opened another mode or asked.
426                let still = self.mode().is_some_and(|m| Rc::ptr_eq(&m, &mode));
427                match result {
428                    Ok(()) if still => self.state.close(cx.app()),
429                    Ok(()) => {}
430                    Err(message) => self.state.error(message),
431                }
432                cx.redraw();
433                Used::Yes
434            }
435            KeyCode::Escape if !popup && self.area.buffer_selection().is_none() => {
436                self.state.close(cx.app());
437                if let Some(cancel) = mode.borrow_mut().cancel.as_mut() {
438                    cancel(cx.app());
439                }
440                cx.redraw();
441                Used::Yes
442            }
443            // Tab with nothing to complete stays in the line.
444            KeyCode::Tab | KeyCode::BackTab => {
445                self.area.event(cx, &WidgetEvent::Key(key));
446                Used::Yes
447            }
448            _ => {
449                let before = self.state.value.get_untracked();
450                let used = self.area.event(cx, &WidgetEvent::Key(key));
451                let after = self.state.value.get_untracked();
452                if after != before
453                    && let Some(change) = mode.borrow_mut().change.as_mut()
454                {
455                    change(&after, cx.app());
456                }
457                used
458            }
459        }
460    }
461}
462
463impl Widget for CommandBar {
464    fn name(&self) -> &'static str {
465        "command_line"
466    }
467
468    fn role(&self) -> crate::a11y::Role {
469        crate::a11y::Role::TextBox
470    }
471
472    fn focusable(&self) -> bool {
473        self.state.is_open_untracked()
474    }
475
476    fn measure(&mut self, _cx: &MeasureCx, axis: Axis, width: u16, _height: u16) -> u16 {
477        match axis {
478            Axis::Vertical => 1 + self.options.two_rows as u16,
479            Axis::Horizontal => width.max(20),
480        }
481    }
482
483    fn caret(&self) -> Option<(u16, u16)> {
484        self.mode()?;
485        let (x, y) = self.area.caret()?;
486        Some((self.input.x + x, self.input.y + y))
487    }
488
489    fn draw(&mut self, cx: &mut DrawCx, canvas: &mut Canvas) {
490        let bar = self.state.bar.get();
491        self.state.opened.get();
492        let (width, height) = (canvas.width(), canvas.height());
493        let error = cx.theme().error_style("error");
494        let title_style = cx.style("command.title", "reverse");
495        let question = cx.style("command.question", "bold");
496        // The row the line goes on, and the one above it for a title.
497        let line = height.saturating_sub(1);
498        let mut input = None;
499        match &bar {
500            Bar::Idle => {}
501            Bar::Message(markup) => {
502                canvas.markup(cx.console(), 0, line, width, markup, None);
503            }
504            Bar::Error(message) => {
505                canvas.print(0, line, message, Some(&error));
506            }
507            Bar::Confirm(text, _, _) => {
508                canvas.markup(cx.console(), 0, line, width, text, Some(&question));
509            }
510            Bar::Typing(mode, failed) => {
511                let mode = mode.borrow();
512                let mut x = 0;
513                match (&mode.title, failed, self.options.two_rows) {
514                    (_, Some(message), true) => {
515                        canvas.fill(0, 0, width, 1, Some(&error));
516                        canvas.print(0, 0, message, Some(&error));
517                    }
518                    (Some(title), None, true) => {
519                        canvas.fill(0, 0, width, 1, Some(&title_style));
520                        canvas.markup(cx.console(), 0, 0, width, title, Some(&title_style));
521                    }
522                    (_, Some(message), false) => {
523                        x = canvas.markup(
524                            cx.console(),
525                            0,
526                            line,
527                            width,
528                            &crate::rich::markup::escape(message),
529                            Some(&error),
530                        );
531                        x = (x + 1).min(width);
532                    }
533                    (Some(title), None, false) => {
534                        x = canvas.markup(cx.console(), 0, line, width, title, Some(&title_style));
535                        x = (x + 1).min(width);
536                    }
537                    (None, None, _) => {}
538                }
539                if !mode.prefix.is_empty() {
540                    canvas.print(x, line, &mode.prefix, None);
541                    x = (x + crate::rich::cells::cell_len(&mode.prefix) as u16).min(width);
542                }
543                input = Some(Rect::new(x, line, width - x, 1));
544            }
545        }
546        let Some(at) = input else {
547            self.input = Rect::default();
548            // A closed bar has no pop-up.
549            if std::mem::take(&mut self.area.assist.floated) {
550                cx.float(None);
551            }
552            return;
553        };
554        self.set_up();
555        self.input = at;
556        let rect = Rect::new(
557            canvas.rect.x + at.x,
558            canvas.rect.y + at.y,
559            at.width,
560            at.height,
561        );
562        let mut part = Canvas {
563            screen: &mut *canvas.screen,
564            rect,
565            written: &mut *canvas.written,
566        };
567        let mut inner = DrawCx {
568            console: cx.console,
569            theme: cx.theme,
570            id: cx.id,
571            rect: Rect::new(cx.rect.x + at.x, cx.rect.y + at.y, at.width, at.height),
572            repaint: true,
573            focus_path: cx.focus_path,
574            hover_path: cx.hover_path,
575            pointer: cx.pointer,
576            shift: cx.shift,
577            wants_hover: cx.wants_hover,
578            watchers: cx.watchers,
579            disabled: cx.disabled,
580        };
581        self.area.draw(&mut inner, &mut part);
582    }
583
584    fn event(&mut self, cx: &mut EventCx, event: &WidgetEvent) -> Used {
585        let bar = self.state.bar.get_untracked();
586        match (event, bar) {
587            (WidgetEvent::Key(key), Bar::Confirm(_, yes, on_yes)) => {
588                let key = *key;
589                let no = ["n", "q", "esc"]
590                    .iter()
591                    .any(|k| Key::parse(k).is_some_and(|k| k == key));
592                if yes.contains(&key) {
593                    self.state.close(cx.app());
594                    if let Some(on_yes) = on_yes.borrow_mut().take() {
595                        on_yes(cx.app());
596                    }
597                } else if no {
598                    self.state.close(cx.app());
599                } else if key.code == KeyCode::Char('c') && key.modifiers.ctrl {
600                    return Used::No;
601                }
602                cx.redraw();
603                Used::Yes
604            }
605            (WidgetEvent::Key(key), Bar::Typing(mode, failed)) => {
606                self.set_up();
607                // A key after an error clears it.
608                if failed.is_some() {
609                    self.state.bar.set(Bar::Typing(mode.clone(), None));
610                }
611                self.typing(cx, *key, mode)
612            }
613            (WidgetEvent::Paste(_), Bar::Typing(mode, _)) => {
614                self.set_up();
615                let before = self.state.value.get_untracked();
616                let used = self.area.event(cx, event);
617                let after = self.state.value.get_untracked();
618                if after != before
619                    && let Some(change) = mode.borrow_mut().change.as_mut()
620                {
621                    change(&after, cx.app());
622                }
623                used
624            }
625            (WidgetEvent::Focus(_), Bar::Typing(..)) => self.area.event(cx, event),
626            _ => Used::No,
627        }
628    }
629
630    fn claims_key(&self, key: Key) -> bool {
631        self.state.is_open_untracked() && self.area.claims_key(key)
632    }
633}