repl_block/
repl.rs

1//!
2
3use crate::{
4    cmd::{Cmd, Line},
5    error::ReplBlockResult,
6    history::{History, HistIdx},
7    macros::key,
8};
9use camino::{Utf8Path, Utf8PathBuf};
10use crossterm::{
11    cursor, execute, queue, style, terminal,
12    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
13    style::{Stylize, StyledContent},
14    terminal::ClearType,
15};
16use std::io::{Stdout, Write};
17use unicode_segmentation::UnicodeSegmentation;
18
19
20type Evaluator<'eval> =
21    dyn for<'src> FnMut(&'src str) -> ReplBlockResult<()> + 'eval;
22
23pub struct ReplBuilder<'eval, W: Write> {
24    sink: W,
25    default_prompt: Vec<StyledContent<char>>,
26    continue_prompt: Vec<StyledContent<char>>,
27    reverse_search_prompt: Vec<StyledContent<char>>,
28    history_filepath: Utf8PathBuf,
29    evaluator: Box<Evaluator<'eval>>,
30    hello_msg: String,
31    goodbye_msg: String,
32}
33
34impl<'eval> Default for ReplBuilder<'eval, Stdout> {
35    fn default() -> ReplBuilder<'eval, Stdout> {
36        #[inline(always)]
37        fn nop<'eval>() -> Box<Evaluator<'eval>> {
38            Box::new(|_| Ok(()))
39        }
40        ReplBuilder {
41            sink: std::io::stdout(),
42            default_prompt:  vec!['■'.yellow(), '>'.green().bold(), ' '.reset()],
43            continue_prompt: vec!['.'.yellow(), '.'.yellow(),       ' '.reset()],
44            reverse_search_prompt: vec![
45                'r'.yellow().italic(),
46                'e'.yellow().italic(),
47                'v'.yellow().italic(),
48                'e'.yellow().italic(),
49                'r'.yellow().italic(),
50                's'.yellow().italic(),
51                'e'.yellow().italic(),
52                ' '.reset(),
53                's'.yellow().italic(),
54                'e'.yellow().italic(),
55                'a'.yellow().italic(),
56                'r'.yellow().italic(),
57                'c'.yellow().italic(),
58                'h'.yellow().italic(),
59                ':'.blue().italic(),
60                ' '.reset(),
61            ],
62            history_filepath: Utf8PathBuf::from(".repl.history"),
63            evaluator: nop(),
64            hello_msg: format!("🖐 Press {} to exit.",  "Ctrl-D".magenta()),
65            goodbye_msg: "👋".to_string(),
66        }
67    }
68}
69
70impl<'eval, W: Write> ReplBuilder<'eval, W> {
71    pub fn sink<S: Write>(self, sink: S) -> ReplBuilder<'eval, S> {
72        ReplBuilder {
73            sink,
74            default_prompt: self.default_prompt,
75            continue_prompt: self.continue_prompt,
76            reverse_search_prompt: self.reverse_search_prompt,
77            history_filepath: self.history_filepath,
78            evaluator: self.evaluator,
79            hello_msg: self.hello_msg,
80            goodbye_msg: self.goodbye_msg,
81        }
82    }
83
84    pub fn default_prompt(mut self, prompt: Vec<StyledContent<char>>) -> Self {
85        self.default_prompt = prompt;
86        self
87    }
88
89    pub fn continue_prompt(mut self, prompt: Vec<StyledContent<char>>) -> Self {
90        self.continue_prompt = prompt;
91        self
92    }
93
94    pub fn reverse_search_prompt(mut self, prompt: Vec<StyledContent<char>>) -> Self {
95        self.reverse_search_prompt = prompt;
96        self
97    }
98
99    pub fn history_filepath(mut self, filepath: impl AsRef<Utf8Path>) -> Self {
100        self.history_filepath = filepath.as_ref().to_path_buf();
101        self
102    }
103
104    pub fn evaluator<E>(mut self, evaluator: E) -> Self
105    where
106        E: for<'src> FnMut(&'src str) -> ReplBlockResult<()> + 'eval
107    {
108        self.evaluator = Box::new(evaluator);
109        self
110    }
111
112    pub fn hello(mut self, hello_msg: impl Into<String>) -> Self {
113        self.hello_msg = hello_msg.into();
114        self
115    }
116
117    pub fn goodbye(mut self, goodbye_msg: impl Into<String>) -> Self {
118        self.goodbye_msg = goodbye_msg.into();
119        self
120    }
121
122    pub fn build(self) -> ReplBlockResult<Repl<'eval, W>> {
123        assert_eq!(
124            self.default_prompt.len(), self.continue_prompt.len(),
125            "default_prompt.len() != continue_prompt.len()"
126        );
127        let mut repl = Repl::new(
128            self.sink,
129            self.history_filepath,
130            self.evaluator,
131            self.default_prompt,
132            self.continue_prompt,
133            self.reverse_search_prompt,
134            self.hello_msg,
135            self.goodbye_msg,
136        )?;
137        repl.render_default_prompt()?;
138        repl.sink.flush()?;
139        Ok(repl)
140    }
141}
142
143
144
145pub struct Repl<'eval, W: Write> {
146    sink: W,
147    state: State,
148    /// The height of the input area, in lines
149    height: u16,
150    /// The history of cmds
151    history: History,
152    /// The filepath of the history file
153    history_filepath: Utf8PathBuf,
154    /// The fn used to perform the Evaluate step of the REPL
155    evaluator: Box<Evaluator<'eval>>,
156    /// The default command prompt
157    default_prompt: Vec<StyledContent<char>>,
158    /// The command prompt used for command continuations
159    continue_prompt: Vec<StyledContent<char>>,
160    /// The prompt used for reverse history search
161    reverse_search_prompt: Vec<StyledContent<char>>,
162    hello_msg: String,
163    goodbye_msg: String,
164}
165
166impl<'eval, W: Write> Repl<'eval, W> {
167    fn new(
168        mut sink: W,
169        history_filepath: impl AsRef<Utf8Path>,
170        evaluator: Box<Evaluator<'eval>>,
171        default_prompt: Vec<StyledContent<char>>,
172        continue_prompt: Vec<StyledContent<char>>,
173        reverse_search_prompt: Vec<StyledContent<char>>,
174        hello_msg: String,
175        goodbye_msg: String,
176    ) -> ReplBlockResult<Repl<'eval, W>> {
177        sink.flush()?;
178        let mut repl = Self {
179            sink,
180            state: State::Edit(EditState {
181                buffer: Cmd::default(),
182                cursor: ORIGIN,
183            }),
184            height: 1,
185            history: History::read_from_file(history_filepath.as_ref())?,
186            history_filepath: history_filepath.as_ref().to_path_buf(),
187            evaluator,
188            default_prompt,
189            continue_prompt,
190            reverse_search_prompt,
191            hello_msg,
192            goodbye_msg,
193        };
194        execute!(
195            repl.sink,
196            cursor::SetCursorStyle::BlinkingBar,
197            cursor::MoveToColumn(0),
198            style::Print(&repl.hello_msg),
199            style::Print("\n"),
200        )?;
201        Ok(repl)
202    }
203}
204
205impl<'eval, W: Write> Repl<'eval, W> {
206    pub fn start(&mut self) -> ReplBlockResult<()> {
207        loop {
208            let old_height = self.height;
209            self.dispatch_key_event()?; // This might alter `self.height`
210            self.render_ui(old_height)?;
211        }
212    }
213
214    fn dispatch_key_event(&mut self) -> ReplBlockResult<()> {
215        terminal::enable_raw_mode()?;
216        let event = event::read()?;
217        terminal::disable_raw_mode()?;
218        match event {
219            Event::Key(key!(CONTROL-'c')) => self.cmd_nop()?,
220
221            // Control application lifecycle:
222            Event::Key(key!(CONTROL-'d')) => self.cmd_exit_repl()?,
223            Event::Key(key!(CONTROL-'g')) => self.cmd_cancel_nav()?,
224            Event::Key(key!(@name Enter)) => self.cmd_eval()?,
225
226            // Navigation:
227            Event::Key(key!(CONTROL-'p'))    => self.cmd_nav_up()?,
228            Event::Key(key!(@name Up))       => self.cmd_nav_up()?,
229            Event::Key(key!(CONTROL-'n'))    => self.cmd_nav_down()?,
230            Event::Key(key!(@name Down))     => self.cmd_nav_down()?,
231            Event::Key(key!(CONTROL-'b'))    => self.cmd_nav_cmd_left()?,
232            Event::Key(key!(@name Left))     => self.cmd_nav_cmd_left()?,
233            Event::Key(key!(CONTROL-'f'))    => self.cmd_nav_cmd_right()?,
234            Event::Key(key!(@name Right))    => self.cmd_nav_cmd_right()?,
235            Event::Key(key!(CONTROL-'a'))    => self.cmd_nav_to_start_of_cmd()?,
236            Event::Key(key!(@name Home))     => self.cmd_nav_to_start_of_cmd()?,
237            Event::Key(key!(CONTROL-'e'))    => self.cmd_nav_to_end_of_cmd()?,
238            Event::Key(key!(@name End))      => self.cmd_nav_to_end_of_cmd()?,
239            Event::Key(key!(CONTROL-'r'))    => self.cmd_reverse_search_history()?,
240            Event::Key(key!(@name PageUp))   => self.cmd_nav_history_up()?,
241            Event::Key(key!(@name PageDown)) => self.cmd_nav_history_down()?,
242
243            // Editing;
244            Event::Key(key!(@c))                => self.cmd_insert_char(c)?,
245            Event::Key(key!(SHIFT-@c))          => self.cmd_insert_char(c)?,
246            // FIXME `SHIFT+Enter` doesn't work for...reasons(??),
247            //       yet `CONTROL-o` works as expected:
248            Event::Key(key!(@name SHIFT-Enter)) => self.cmd_insert_newline()?,
249            Event::Key(key!(CONTROL-'o'))       => self.cmd_insert_newline()?,
250            Event::Key(key!(@name Backspace))   => self.cmd_rm_grapheme_before_cursor()?,
251            Event::Key(key!(@name Delete))      => self.cmd_rm_grapheme_at_cursor()?,
252
253            _event => {/* ignore the event */},
254        }
255        Ok(())
256    }
257
258    fn render_ui(&mut self, old_input_area_height: u16) -> ReplBlockResult<()> {
259        let dims = self.input_area_dims()?;
260        let prompt_len = self.prompt_len();
261
262        let calculate_uncursor = |cmd: &Cmd, uncompressed: &Cmd, cursor: Coords| {
263            let prev_unlines: Vec<Vec<Line>> = (0..cursor.y)
264                .map(|y| cmd[y].uncompress(dims.width, prompt_len))
265                .collect();
266            let mut uncursor = Coords {
267                x: cursor.x,
268                y: prev_unlines.iter()
269                    .map(|unline| unline.len())
270                    .sum::<usize>() as u16,
271            };
272            let line = &cmd[cursor.y];
273            let unlines_for_line = line.uncompress(dims.width, prompt_len);
274            for unline in unlines_for_line.iter() {
275                let unline_len = unline.count_graphemes();
276                let width = std::cmp::min(dims.width, unline_len);
277                if uncursor.x > width {
278                    uncursor.x -= width;
279                    uncursor.y += 1;
280                } else {
281                    break;
282                }
283            }
284            if uncompressed[uncursor.y].is_start() {
285                uncursor.x += prompt_len;
286            }
287            uncursor
288        };
289
290        macro_rules! render {
291            ($cmd:expr, $cursor:expr) => {{
292                let (cmd, cursor): (&Cmd, Coords) = ($cmd, $cursor);
293                let uncompressed = cmd.uncompress(dims.width, prompt_len);
294
295                // Adjust the height of the input area
296                let num_unlines = uncompressed.count_lines() as u16;
297                let content_height = num_unlines;
298                self.height = std::cmp::max(self.height, content_height);
299
300                // Obtain an `uncompressed` version of `cursor`
301                let uncursor = calculate_uncursor(cmd, &uncompressed, cursor);
302
303                // Scroll up the old output *BEFORE* clearing the input area
304                for _ in old_input_area_height..content_height {
305                    queue!(self.sink, terminal::ScrollUp(1))?;
306                }
307
308                // execute!(
309                //     self.sink,
310                //     cursor::MoveUp(terminal::size().unwrap().1),
311                //     cursor::MoveToColumn(0),
312                //     terminal::Clear(ClearType::All),
313                //     style::Print(format!("CMD: {cmd:#?}\n")),
314                //     style::Print(format!("UNCOMPRESSED: {uncompressed:#?}\n")),
315                //     style::Print(format!("CURSOR: {cursor}\n")),
316                //     style::Print(format!("UNCURSOR: {uncursor}\n")),
317                //     style::Print(format!("TERM DIMS: {:?}\n", terminal::size()?)),
318                //     style::Print(format!("INPUT AREA DIMS: {dims:?}\n")),
319                //     cursor::MoveDown(terminal::size().unwrap().1),
320                // )?;
321
322                self.clear_input_area()?;
323                self.move_cursor_to_origin()?;
324                self.render_cmd(&uncompressed)?;
325
326                // Render the uncursor
327                let o = self.origin()?;
328                queue!(self.sink, cursor::MoveToColumn(o.x + uncursor.x))?;
329                queue!(self.sink, cursor::MoveToRow(o.y + uncursor.y))?;
330
331                ReplBlockResult::Ok(())
332            }};
333        }
334
335        match &self.state {
336            State::Edit(EditState { buffer, cursor }) => {
337                render!(buffer, *cursor)?;
338            }
339            State::Navigate(NavigateState { preview, cursor, .. }) => {
340                render!(preview, *cursor)?;
341            }
342            State::Search(SearchState { regex, preview, cursor, .. }) => {
343                let (cmd, cursor): (&Cmd, Coords) = (preview, *cursor);
344                let uncompressed = cmd.uncompress(dims.width, prompt_len);
345                let regex = regex.clone();
346
347                // Adjust the height of the input area
348                let num_unlines = uncompressed.count_lines() as u16;
349                const SEARCH_PROMPT_LINE: u16 = 1;
350                let content_height = num_unlines + SEARCH_PROMPT_LINE;
351                self.height = std::cmp::max(self.height, content_height);
352
353                // Scroll up the old output *BEFORE* clearing the input area
354                for _ in old_input_area_height..content_height {
355                    queue!(self.sink, terminal::ScrollUp(1))?;
356                }
357
358                self.clear_input_area()?;
359                self.move_cursor_to_origin()?;
360                self.render_cmd(&uncompressed)?;
361                self.render_reverse_search_prompt()?;
362
363                // Render the reverse search topic
364                queue!(self.sink, style::Print(regex))?;
365
366                let o = self.origin()?;
367                // Render the search prompt cursor
368                queue!(self.sink, cursor::MoveToRow(o.y + cursor.y + self.height))?;
369                queue!(self.sink, cursor::MoveToColumn(o.x + cursor.x))?;
370            }
371        }
372
373        self.sink.flush()?;
374        Ok(())
375    }
376
377    fn render_cmd(&mut self, uncompressed: &Cmd, ) -> ReplBlockResult<()> {
378        for (ulidx, unline) in uncompressed.lines().iter().enumerate() {
379            if ulidx == 0 {
380                self.render_default_prompt()?;
381                queue!(self.sink, style::Print(unline))?;
382                queue!(self.sink, cursor::MoveDown(1))?;
383                queue!(self.sink, cursor::MoveToColumn(0))?;
384            } else if unline.is_start() {
385                self.render_continue_prompt()?;
386                queue!(self.sink, style::Print(unline))?;
387                queue!(self.sink, cursor::MoveDown(1))?;
388                // queue!(self.sink, cursor::MoveToColumn(0))?;
389            } else {
390                queue!(self.sink, style::Print(unline))?;
391                queue!(self.sink, cursor::MoveDown(1))?;
392                queue!(self.sink, cursor::MoveToColumn(0))?;
393            }
394        }
395        Ok(())
396    }
397
398    fn render_default_prompt(
399        &mut self,
400    ) -> ReplBlockResult<&mut Self> {
401        queue!(self.sink, cursor::MoveToColumn(0))?;
402        for &c in &self.default_prompt {
403            queue!(self.sink, style::Print(c))?;
404        }
405        Ok(self)
406    }
407
408    fn render_continue_prompt(
409        &mut self,
410    ) -> ReplBlockResult<()> {
411        queue!(self.sink, cursor::MoveToColumn(0))?;
412        for &c in &self.continue_prompt {
413            queue!(self.sink, style::Print(c))?;
414        }
415        Ok(())
416    }
417
418    fn render_reverse_search_prompt(
419        &mut self,
420    ) -> ReplBlockResult<()> {
421        let origin = self.origin()?;
422        // Position the cursor to write the reverse search prompt
423        queue!(self.sink, cursor::MoveTo(origin.x, origin.y + self.height))?;
424        // Render the reverse search prompt
425        for c in &self.reverse_search_prompt {
426            queue!(self.sink, style::Print(c))?;
427        }
428        Ok(())
429    }
430
431    fn move_cursor_to_origin(
432        &mut self,
433    ) -> ReplBlockResult<()> {
434        let origin = self.origin()?;
435        queue!(self.sink, cursor::MoveTo(origin.x, origin.y))?;
436        Ok(())
437    }
438
439    fn clear_input_area(
440        &mut self,
441    ) -> ReplBlockResult<()> {
442        self.move_cursor_to_origin()?;
443        for _ in 0..self.height {
444            queue!(self.sink, terminal::Clear(ClearType::CurrentLine))?;
445            queue!(self.sink, cursor::MoveDown(1))?;
446        }
447        self.move_cursor_to_origin()?;
448        Ok(())
449    }
450
451
452    /// Return the global (col, row)-coordinates of the top-left corner of `self`.
453    fn origin(&self) -> ReplBlockResult<Coords> {
454        let (_term_width, term_height) = terminal::size()?;
455        Ok(Coords { x: 0, y: term_height - self.height })
456    }
457
458    /// Return the (width, height) dimensions of `self`.
459    /// The top left cell is represented `(1, 1)`.
460    fn input_area_dims(&self) -> ReplBlockResult<Dims> {
461        let (term_width, _term_height) = terminal::size()?;
462        Ok(Dims { width: term_width, height: self.height })
463    }
464
465    fn prompt_len(&self) -> u16 {
466        assert_eq!(
467            self.default_prompt.len(), self.continue_prompt.len(),
468            "default_prompt.len() != continue_prompt.len()"
469        );
470        self.default_prompt.len() as u16
471    }
472
473
474
475    fn cmd_nop(&mut self) -> ReplBlockResult<()> {
476        Ok(()) // NOP
477    }
478
479    /// Exit the REPL
480    fn cmd_exit_repl(&mut self) -> ReplBlockResult<()> {
481        execute!(
482            self.sink,
483            cursor::SetCursorStyle::DefaultUserShape,
484            cursor::MoveToColumn(0),
485            style::Print(&self.goodbye_msg),
486            terminal::Clear(ClearType::FromCursorDown),
487        )?;
488        self.sink.flush()?;
489        std::process::exit(0);
490    }
491
492    fn cmd_cancel_nav(&mut self) -> ReplBlockResult<()> {
493        match &mut self.state {
494            State::Edit(EditState { .. }) => {
495                // NOP
496            }
497            State::Navigate(NavigateState { backup, .. }) => {
498                self.state = State::Edit(EditState {
499                    cursor: backup.end_of_cmd(),
500                    buffer: std::mem::take(backup),
501                });
502            }
503            State::Search(SearchState { backup, .. }) => {
504                self.state = State::Edit(EditState {
505                    cursor: backup.end_of_cmd(),
506                    buffer: std::mem::take(backup),
507                });
508            }
509        }
510        Ok(())
511    }
512
513    fn cmd_nav_up(&mut self) -> ReplBlockResult<()> {
514        let is_at_top_line = |cursor: Coords| cursor.y == ORIGIN.x;
515        match &mut self.state {
516            State::Edit(EditState { buffer, cursor }) => {
517                if is_at_top_line(*cursor) {
518                    self.cmd_nav_history_up()?;
519                } else {
520                    cursor.y -= 1;
521                    let line_len = buffer[cursor.y].count_graphemes();
522                    cursor.x = std::cmp::min(cursor.x, line_len);
523                }
524            }
525            State::Navigate(NavigateState { preview, cursor, .. }) => {
526                if is_at_top_line(*cursor) {
527                    self.cmd_nav_history_up()?;
528                } else {
529                    cursor.y -= 1;
530                    let line_len = preview[cursor.y].count_graphemes();
531                    cursor.x = std::cmp::min(cursor.x, line_len);
532                }
533            }
534            State::Search(SearchState { .. }) => {
535                self.cmd_nav_history_up()?;
536            }
537        }
538        Ok(())
539    }
540
541    fn cmd_nav_down(&mut self) -> ReplBlockResult<()> {
542        let is_at_bottom_line = |cursor: Coords, cmd: &Cmd| cursor.y == cmd.count_lines() - 1;
543        match &mut self.state {
544            State::Edit(EditState { buffer, cursor }) => {
545                if is_at_bottom_line(*cursor, buffer) {
546                    self.cmd_nav_history_down()?;
547                } else {
548                    cursor.y += 1;
549                    let line_len = buffer[cursor.y].count_graphemes();
550                    cursor.x = std::cmp::min(cursor.x, line_len);
551                }
552            }
553            State::Navigate(NavigateState { preview, cursor, .. }) => {
554                if is_at_bottom_line(*cursor, preview) {
555                    self.cmd_nav_history_down()?;
556                } else {
557                    cursor.y += 1;
558                    let line_len = preview[cursor.y].count_graphemes();
559                    cursor.x = std::cmp::min(cursor.x, line_len);
560                }
561            }
562            State::Search(SearchState { .. }) => {
563                self.cmd_nav_history_down()?;
564            }
565        }
566        Ok(())
567    }
568
569    fn cmd_nav_history_up(&mut self) -> ReplBlockResult<()> {
570        match &mut self.state {
571            State::Edit(EditState { buffer, cursor: _ }) => {
572                let Some(max_hidx) = self.history.max_idx() else {
573                    return Ok(()); // NOP: no history to navigate
574                };
575                self.state = State::Navigate(NavigateState {
576                    hidx: max_hidx,
577                    backup: std::mem::take(buffer),
578                    preview: self.history[max_hidx].clone(),
579                    cursor: self.history[max_hidx].end_of_cmd(),
580                });
581            }
582            State::Navigate(NavigateState { hidx, preview, cursor, .. }) => {
583                let min_hidx = HistIdx(0);
584                if *hidx == min_hidx {
585                    // NOP, at the top of the History
586                } else {
587                    *hidx -= 1;
588                    *preview = self.history[*hidx].clone(); // update
589                    *cursor = preview.end_of_cmd();
590                }
591            }
592            State::Search(SearchState { preview, matches, current, .. }) => {
593                if *current >= matches.len() - 1 {
594                    // NOP
595                } else {
596                    *current += 1;
597                    *preview = if matches.is_empty() {
598                        Cmd::default()
599                    } else {
600                        let hidx = matches[*current];
601                        self.history[hidx].clone()
602                    };
603                }
604            }
605        }
606        Ok(())
607    }
608
609    fn cmd_nav_history_down(&mut self) -> ReplBlockResult<()> {
610        match &mut self.state {
611            State::Edit(EditState { .. }) => {/* NOP */}
612            State::Navigate(NavigateState { hidx, backup, preview, cursor }) => {
613                let max_hidx = self.history.max_idx();
614                if Some(*hidx) == max_hidx { // bottom-of-history
615                    self.state = State::Edit(EditState {
616                        cursor: backup.end_of_cmd(),
617                        buffer: std::mem::take(backup),
618                    });
619                } else {
620                    *hidx += 1;
621                    *preview = self.history[*hidx].clone(); // update
622                    *cursor = preview.end_of_cmd();
623                }
624            }
625            State::Search(SearchState { preview, matches, current, .. }) => {
626                if *current == 0 {
627                    // NOP
628                } else {
629                    *current -= 1;
630                    *preview = if matches.is_empty() {
631                        Cmd::default()
632                    } else {
633                        let hidx = matches[*current];
634                        self.history[hidx].clone()
635                    };
636                }
637            }
638        }
639        Ok(())
640    }
641
642    fn cmd_nav_cmd_left(&mut self) -> ReplBlockResult<()> {
643        let update_cursor = |cmd: &Cmd, cursor: &mut Coords| {
644            if *cursor == ORIGIN {
645                // NOP
646            } else {
647                let is_start_of_cursor_line = cursor.x == ORIGIN.x;
648                let has_prev_line = cursor.y >= 1;
649                if is_start_of_cursor_line && has_prev_line {
650                    *cursor = Coords {
651                        x: cmd[cursor.y - 1].count_graphemes(),
652                        y: cursor.y - 1,
653                    };
654                } else if is_start_of_cursor_line && !has_prev_line {
655                    // NOP
656                } else { // not at the start of a line
657                    cursor.x -= 1;
658                }
659            }
660        };
661        match &mut self.state {
662            State::Edit(EditState { buffer, cursor }) => {
663                update_cursor(buffer, cursor);
664            },
665            State::Navigate(NavigateState { preview, cursor, .. }) => {
666                update_cursor(preview, cursor);
667            },
668            State::Search(SearchState { cursor, .. }) => {
669                let prompt_len = self.reverse_search_prompt.len() as u16;
670                if cursor.x <= prompt_len {
671                    cursor.x = prompt_len; // bound here
672                } else {
673                    cursor.x -= 1;
674                }
675            },
676        }
677        Ok(())
678    }
679
680    fn cmd_nav_cmd_right(&mut self) -> ReplBlockResult<()> {
681        let update_cursor = |cmd: &Cmd, cursor: &mut Coords| {
682            if *cursor == cmd.end_of_cmd() {
683                // NOP
684            } else {
685                let is_end_of_cursor_line =
686                    cursor.x == cmd[cursor.y].count_graphemes();
687                let has_next_line = cursor.y + 1 < cmd.count_lines();
688                if is_end_of_cursor_line && has_next_line {
689                    *cursor = Coords {
690                        x: ORIGIN.x,
691                        y: cursor.y + 1,
692                    };
693                } else if is_end_of_cursor_line && !has_next_line {
694                    // NOP
695                } else { // not the end of the line
696                    cursor.x += 1;
697                }
698            }
699        };
700        match &mut self.state {
701            State::Edit(EditState { buffer, cursor }) => {
702                update_cursor(buffer, cursor);
703            },
704            State::Navigate(NavigateState { preview, cursor, .. }) => {
705                update_cursor(preview, cursor);
706            },
707            State::Search(SearchState { regex, cursor, .. }) => {
708                let prompt_len = self.reverse_search_prompt.len() as u16;
709                let regex_line_len = regex.graphemes(true).count() as u16;
710                if cursor.x >= prompt_len + regex_line_len {
711                    cursor.x = prompt_len + regex_line_len; // bound here
712                } else {
713                    cursor.x += 1;
714                }
715            },
716        }
717        Ok(())
718    }
719
720    /// Navigate to the start of the current Cmd
721    fn cmd_nav_to_start_of_cmd(&mut self) -> ReplBlockResult<()> {
722        match &mut self.state {
723            State::Edit(EditState { cursor, .. }) => {
724                *cursor = ORIGIN;
725            },
726            State::Navigate(NavigateState { cursor, .. }) => {
727                *cursor = ORIGIN;
728            },
729            State::Search(SearchState { cursor, .. }) => {
730                let prompt_len = self.reverse_search_prompt.len() as u16;
731                cursor.x = prompt_len;
732            },
733        }
734        Ok(())
735    }
736
737    /// Navigate to the end of the current Cmd
738    fn cmd_nav_to_end_of_cmd(&mut self) -> ReplBlockResult<()> {
739        match &mut self.state {
740            State::Edit(EditState { buffer, cursor }) => {
741                *cursor = buffer.end_of_cmd();
742            },
743            State::Navigate(NavigateState { preview, cursor, .. }) => {
744                *cursor = preview.end_of_cmd();
745            },
746            State::Search(SearchState { regex, cursor, .. }) => {
747                let prompt_len = self.reverse_search_prompt.len() as u16;
748                let regex_line_len = regex.graphemes(true).count() as u16;
749                cursor.x = prompt_len + regex_line_len;
750            },
751        }
752        Ok(())
753    }
754
755    fn cmd_reverse_search_history(&mut self) -> ReplBlockResult<()> {
756        match &mut self.state {
757            State::Edit(EditState { buffer, cursor }) => {
758                self.state = State::Search(SearchState {
759                    regex: String::new(),
760                    backup: std::mem::take(buffer),
761                    preview: Cmd::default(),
762                    cursor: *cursor,
763                    matches: vec![],
764                    current: 0,
765                });
766                self.cmd_reverse_search_history()?;
767            }
768            State::Navigate(NavigateState { hidx: _, backup, preview, cursor }) => {
769                self.state = State::Search(SearchState {
770                    regex: String::new(),
771                    backup: std::mem::take(backup),
772                    preview: std::mem::take(preview),
773                    cursor: *cursor,
774                    matches: vec![],
775                    current: 0,
776                });
777                self.cmd_reverse_search_history()?;
778            }
779            State::Search(SearchState {
780                regex,
781                backup: _,
782                preview,
783                cursor,
784                matches,
785                current,
786            }) => {
787                *matches = self.history.reverse_search(regex);
788                *current = 0;
789                *preview = if matches.is_empty() {
790                    Cmd::default()
791                } else {
792                    self.history[matches[*current]].clone()
793                };
794                let prompt_len = self.reverse_search_prompt.len() as u16;
795                *cursor = Coords { x: prompt_len, y: ORIGIN.y };
796            }
797        }
798        Ok(())
799    }
800
801    /// Insert a char into the current cmd at cursor position.
802    fn cmd_insert_char(&mut self, c: char) -> ReplBlockResult<()> {
803        let dims = self.input_area_dims()?;
804        match &mut self.state {
805            State::Edit(EditState { buffer, cursor }) => {
806                buffer.insert_char(*cursor, c);
807                cursor.x += 1;
808            }
809            State::Navigate(NavigateState { preview, cursor, .. }) => {
810                self.state = State::Edit(EditState {
811                    buffer: std::mem::take(preview),
812                    cursor: *cursor,
813                });
814                self.cmd_insert_char(c)?;
815            }
816            State::Search(SearchState {
817                regex,
818                backup: _,
819                preview,
820                cursor,
821                matches,
822                current,
823            }) => {
824                let prompt_len = self.reverse_search_prompt.len();
825                if regex.len() >= dims.width as usize - prompt_len - 1 {
826                    return Ok(()); // NOP
827                }
828                let mut re: Vec<&str> = regex.graphemes(true).collect();
829                let c = c.to_string();
830                re.insert(cursor.x as usize - prompt_len, &c);
831                *regex = re.into_iter().collect::<String>();
832                cursor.x += 1;
833                *matches = self.history.reverse_search(regex);
834                *current = 0;
835                *preview = if matches.is_empty() {
836                    Cmd::default()
837                } else {
838                    let hidx = matches[*current];
839                    self.history[hidx].clone()
840                };
841            }
842        }
843        Ok(())
844    }
845
846    /// Add a newline to the current cmd
847    fn cmd_insert_newline(&mut self) -> ReplBlockResult<()> {
848        match &mut self.state {
849            State::Edit(EditState { buffer, cursor }) => {
850                buffer.insert_empty_line(*cursor);
851                *cursor = Coords {
852                    x: ORIGIN.x,
853                    y: cursor.y + 1
854                };
855            }
856            State::Navigate(NavigateState { preview, cursor, .. }) => {
857                self.state = State::Edit(EditState {
858                    buffer: std::mem::take(preview),
859                    cursor: *cursor,
860                });
861                self.cmd_insert_newline()?;
862            }
863            State::Search(SearchState { .. }) => {
864                // NOP
865            }
866        }
867        Ok(())
868    }
869
870    /// Delete the grapheme before the cursor in of the current cmd
871    /// Do nothing if there if there is no grapheme before the cursor.
872    fn cmd_rm_grapheme_before_cursor(&mut self) -> ReplBlockResult<()> {
873        match &mut self.state {
874            State::Edit(EditState { buffer, cursor }) => {
875                if cursor.y == 0 && cursor.x == 0 {
876                    // NOP
877                } else if cursor.y == 0 && cursor.x > 0 {
878                    buffer.rm_grapheme_before(*cursor);
879                    cursor.x -= 1;
880                } else if cursor.y > 0 && cursor.x == 0 {
881                    let old_len = buffer[cursor.y - 1].count_graphemes();
882                    buffer.rm_grapheme_before(*cursor);
883                    *cursor = Coords { x: old_len, y: cursor.y - 1 };
884                } else if cursor.y > 0 && cursor.x > 0 {
885                    buffer.rm_grapheme_before(*cursor);
886                    cursor.x -= 1;
887                } else {
888                    let tag = "cmd_rm_grapheme_before_cursor";
889                    unreachable!("[{tag}] cursor={cursor:?}");
890                }
891            }
892            State::Navigate(NavigateState { preview, cursor, .. }) => {
893                self.state = State::Edit(EditState {
894                    buffer: std::mem::take(preview),
895                    cursor: *cursor,
896                });
897                self.cmd_rm_grapheme_before_cursor()?;
898            }
899            State::Search(SearchState {
900                regex,
901                backup: _,
902                preview,
903                cursor,
904                matches,
905                current,
906            }) => {
907                let prompt_len = self.reverse_search_prompt.len();
908                let rmidx = cursor.x as usize - prompt_len;
909                if regex.len() == 0 || rmidx == 0 {
910                    return Ok(()); // NOP
911                }
912                let mut re: Vec<&str> = regex.graphemes(true).collect();
913                re.remove(cursor.x as usize - prompt_len - 1);
914                *regex = re.into_iter().collect::<String>();
915                cursor.x -= 1;
916                *matches = self.history.reverse_search(regex);
917                *preview = if matches.is_empty() {
918                    Cmd::default()
919                } else {
920                    let hidx = matches[*current];
921                    self.history[hidx].clone()
922                };
923            },
924        }
925        Ok(())
926    }
927
928    /// Delete the grapheme at the position of the cursor in of the current cmd.
929    /// Do nothing if there if there is no grapheme at the cursor.
930    fn cmd_rm_grapheme_at_cursor(&mut self) -> ReplBlockResult<()> {
931        match &mut self.state {
932            State::Edit(EditState { buffer, cursor }) => {
933                let is_end_of_line = cursor.x == buffer[cursor.y].count_graphemes();
934                let has_next_line = cursor.y + 1 < buffer.count_lines();
935                if is_end_of_line && has_next_line {
936                    buffer.rm_grapheme_at(*cursor);
937                } else if is_end_of_line && !has_next_line {
938                    // NOP
939                } else if !is_end_of_line {
940                    buffer.rm_grapheme_at(*cursor);
941                } else {
942                    let tag = "cmd_rm_grapheme_at_cursor";
943                    unreachable!("[{tag}] cursor={cursor:?}");
944                }
945            }
946            State::Navigate(NavigateState { preview, cursor, .. }) => {
947                self.state = State::Edit(EditState {
948                    buffer: std::mem::take(preview),
949                    cursor: *cursor,
950                });
951                self.cmd_rm_grapheme_at_cursor()?;
952            }
953            State::Search(SearchState {
954                regex,
955                backup: _,
956                preview,
957                cursor,
958                matches,
959                current,
960            }) => {
961                let prompt_len = self.reverse_search_prompt.len();
962                let rmidx = cursor.x as usize - prompt_len;
963                let is_end_of_regex_line = rmidx == regex.graphemes(true).count();
964                if regex.len() == 0 || is_end_of_regex_line {
965                    return Ok(()); // NOP
966                }
967                let mut re: Vec<&str> = regex.graphemes(true).collect();
968                re.remove(cursor.x as usize - prompt_len);
969                *regex = re.into_iter().collect::<String>();
970                *matches = self.history.reverse_search(regex);
971                *preview = if matches.is_empty() {
972                    Cmd::default()
973                } else {
974                    let hidx = matches[*current];
975                    self.history[hidx].clone()
976                };
977            }
978        }
979        Ok(())
980    }
981
982    /// Execute the current cmd
983    fn cmd_eval(&mut self) -> ReplBlockResult<()> {
984        match &mut self.state {
985            State::Edit(EditState { buffer, cursor }) => {
986                let source_code = buffer.to_source_code();
987                if source_code.is_empty() {
988                    return Ok(());
989                }
990                { // Ensure output is written on a new line
991                    writeln!(self.sink)?;
992                    self.sink.flush()?;
993                }
994                let cmd = std::mem::take(buffer);
995                let _hidx = self.history.add_cmd(cmd);
996                self.history.write_to_file(&self.history_filepath)?;
997                (*self.evaluator)(source_code.as_str())?;
998                self.height = 1; // reset
999                *cursor = ORIGIN;
1000            }
1001            State::Navigate(NavigateState { preview, cursor, .. }) => {
1002                self.state = State::Edit(EditState {
1003                    buffer: std::mem::take(preview),
1004                    cursor: *cursor,
1005                });
1006                self.cmd_eval()?;
1007            }
1008            State::Search(SearchState { preview, cursor, .. }) => {
1009                self.state = State::Edit(EditState {
1010                    buffer: std::mem::take(preview),
1011                    cursor: *cursor,
1012                });
1013                self.cmd_eval()?;
1014            }
1015        }
1016        Ok(())
1017    }
1018}
1019
1020#[derive(Clone, Copy,  Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
1021pub struct Dims { pub width: u16, pub height: u16 }
1022
1023#[derive(Clone, Copy,  Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
1024pub struct Coords { pub x: u16, pub y: u16 }
1025
1026pub(crate) const ORIGIN: Coords = Coords { x: 0, y: 0 };
1027
1028impl std::fmt::Display for Coords {
1029    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1030        write!(f, "({}, {})", self.x, self.y)
1031    }
1032}
1033
1034
1035#[derive(Debug)]
1036enum State {
1037    Edit(EditState),
1038    Navigate(NavigateState),
1039    Search(SearchState),
1040}
1041
1042/// Editing a `Cmd`
1043#[derive(Debug)]
1044struct EditState {
1045    /// A buffer containing the cmd being edited
1046    buffer: Cmd,
1047    /// The cursor position within the Cmd buffer
1048    cursor: Coords,
1049}
1050
1051/// Navigating through the `History`
1052#[derive(Debug)]
1053struct NavigateState {
1054    /// Points to the History cmd being previewed
1055    hidx: HistIdx,
1056    /// A buffer containing the cmd that was last edited
1057    backup: Cmd,
1058    /// The `History` entry being previewed
1059    preview: Cmd,
1060    /// The cursor position within the Cmd preview buffer
1061    cursor: Coords,
1062}
1063
1064/// Searching backwards through the History for entries that match a regex
1065#[derive(Debug)]
1066struct SearchState {
1067    /// The regex being searched for
1068    regex: String,
1069    /// A buffer containing the Cmd that was last edited
1070    backup: Cmd,
1071    /// The `History` entry being previewed
1072    preview: Cmd,
1073    /// The cursor position within the Cmd buffer
1074    cursor: Coords,
1075    /// The `History` entries that match `regex`
1076    matches: Vec<HistIdx>,
1077    /// The current entry in `self.matches`
1078    current: usize,
1079}