reedline/painting/
painter.rs

1use crate::{CursorConfig, PromptEditMode, PromptViMode};
2
3use {
4    super::utils::{coerce_crlf, line_width},
5    crate::{
6        menu::{Menu, ReedlineMenu},
7        painting::PromptLines,
8        Prompt,
9    },
10    crossterm::{
11        cursor::{self, MoveTo, RestorePosition, SavePosition},
12        style::{Attribute, Print, ResetColor, SetAttribute, SetForegroundColor},
13        terminal::{self, Clear, ClearType},
14        QueueableCommand,
15    },
16    std::io::{Result, Write},
17};
18#[cfg(feature = "external_printer")]
19use {crate::LineBuffer, crossterm::cursor::MoveUp};
20
21// Returns a string that skips N number of lines with the next offset of lines
22// An offset of 0 would return only one line after skipping the required lines
23fn skip_buffer_lines(string: &str, skip: usize, offset: Option<usize>) -> &str {
24    let mut matches = string.match_indices('\n');
25    let index = if skip == 0 {
26        0
27    } else {
28        matches
29            .clone()
30            .nth(skip - 1)
31            .map(|(index, _)| index + 1)
32            .unwrap_or(string.len())
33    };
34
35    let limit = match offset {
36        Some(offset) => {
37            let offset = skip + offset;
38            matches
39                .nth(offset)
40                .map(|(index, _)| index)
41                .unwrap_or(string.len())
42        }
43        None => string.len(),
44    };
45
46    string[index..limit].trim_end_matches('\n')
47}
48
49/// the type used by crossterm operations
50pub type W = std::io::BufWriter<std::io::Stderr>;
51
52/// Implementation of the output to the terminal
53pub struct Painter {
54    // Stdout
55    stdout: W,
56    prompt_start_row: u16,
57    terminal_size: (u16, u16),
58    last_required_lines: u16,
59    large_buffer: bool,
60}
61
62impl Painter {
63    pub(crate) fn new(stdout: W) -> Self {
64        Painter {
65            stdout,
66            prompt_start_row: 0,
67            terminal_size: (0, 0),
68            last_required_lines: 0,
69            large_buffer: false,
70        }
71    }
72
73    /// Height of the current terminal window
74    pub fn screen_height(&self) -> u16 {
75        self.terminal_size.1
76    }
77
78    /// Width of the current terminal window
79    pub fn screen_width(&self) -> u16 {
80        self.terminal_size.0
81    }
82
83    /// Returns the available lines from the prompt down
84    pub fn remaining_lines(&self) -> u16 {
85        self.screen_height().saturating_sub(self.prompt_start_row)
86    }
87
88    /// Sets the prompt origin position and screen size for a new line editor
89    /// invocation
90    ///
91    /// Not to be used for resizes during a running line editor, use
92    /// [`Painter::handle_resize()`] instead
93    pub(crate) fn initialize_prompt_position(&mut self) -> Result<()> {
94        // Update the terminal size
95        self.terminal_size = {
96            let size = terminal::size()?;
97            // if reported size is 0, 0 -
98            // use a default size to avoid divide by 0 panics
99            if size == (0, 0) {
100                (80, 24)
101            } else {
102                size
103            }
104        };
105        // Cursor positions are 0 based here.
106        let (column, row) = cursor::position()?;
107        // Assumption: if the cursor is not on the zeroth column,
108        // there is content we want to leave intact, thus advance to the next row
109        let new_row = if column > 0 { row + 1 } else { row };
110        //  If we are on the last line and would move beyond the last line due to
111        //  the condition above, we need to make room for the prompt.
112        //  Otherwise printing the prompt would scroll of the stored prompt
113        //  origin, causing issues after repaints.
114        let new_row = if new_row == self.screen_height() {
115            self.print_crlf()?;
116            new_row.saturating_sub(1)
117        } else {
118            new_row
119        };
120        self.prompt_start_row = new_row;
121        Ok(())
122    }
123
124    /// Main pain painter for the prompt and buffer
125    /// It queues all the actions required to print the prompt together with
126    /// lines that make the buffer.
127    /// Using the prompt lines object in this function it is estimated how the
128    /// prompt should scroll up and how much space is required to print all the
129    /// lines for the buffer
130    ///
131    /// Note. The `ScrollUp` operation in `crossterm` deletes lines from the top of
132    /// the screen.
133    pub(crate) fn repaint_buffer(
134        &mut self,
135        prompt: &dyn Prompt,
136        lines: &PromptLines,
137        prompt_mode: PromptEditMode,
138        menu: Option<&ReedlineMenu>,
139        use_ansi_coloring: bool,
140        cursor_config: &Option<CursorConfig>,
141    ) -> Result<()> {
142        self.stdout.queue(cursor::Hide)?;
143
144        let screen_width = self.screen_width();
145        let screen_height = self.screen_height();
146
147        // Lines and distance parameters
148        let remaining_lines = self.remaining_lines();
149        let required_lines = lines.required_lines(screen_width, menu);
150
151        // Marking the painter state as larger buffer to avoid animations
152        self.large_buffer = required_lines >= screen_height;
153
154        // This might not be terribly performant. Testing it out
155        let is_reset = || match cursor::position() {
156            // when output something without newline, the cursor position is at current line.
157            // but the prompt_start_row is next line.
158            // in this case we don't want to reset, need to `add 1` to handle for such case.
159            Ok(position) => position.1 + 1 < self.prompt_start_row,
160            Err(_) => false,
161        };
162
163        // Moving the start position of the cursor based on the size of the required lines
164        if self.large_buffer || is_reset() {
165            self.prompt_start_row = 0;
166        } else if required_lines >= remaining_lines {
167            let extra = required_lines.saturating_sub(remaining_lines);
168            self.queue_universal_scroll(extra)?;
169            self.prompt_start_row = self.prompt_start_row.saturating_sub(extra);
170        }
171
172        // Moving the cursor to the start of the prompt
173        // from this position everything will be printed
174        self.stdout
175            .queue(cursor::MoveTo(0, self.prompt_start_row))?
176            .queue(Clear(ClearType::FromCursorDown))?;
177
178        if self.large_buffer {
179            self.print_large_buffer(prompt, lines, menu, use_ansi_coloring)?;
180        } else {
181            self.print_small_buffer(prompt, lines, menu, use_ansi_coloring)?;
182        }
183
184        // The last_required_lines is used to move the cursor at the end where stdout
185        // can print without overwriting the things written during the painting
186        self.last_required_lines = required_lines;
187
188        self.stdout.queue(RestorePosition)?;
189
190        if let Some(shapes) = cursor_config {
191            let shape = match &prompt_mode {
192                PromptEditMode::Emacs => shapes.emacs,
193                PromptEditMode::Vi(PromptViMode::Insert) => shapes.vi_insert,
194                PromptEditMode::Vi(PromptViMode::Normal) => shapes.vi_normal,
195                _ => None,
196            };
197            if let Some(shape) = shape {
198                self.stdout.queue(shape)?;
199            }
200        }
201        self.stdout.queue(cursor::Show)?;
202
203        self.stdout.flush()
204    }
205
206    fn print_right_prompt(&mut self, lines: &PromptLines) -> Result<()> {
207        let prompt_length_right = line_width(&lines.prompt_str_right);
208        let start_position = self
209            .screen_width()
210            .saturating_sub(prompt_length_right as u16);
211        let screen_width = self.screen_width();
212        let input_width = lines.estimate_right_prompt_line_width(screen_width);
213
214        let mut row = self.prompt_start_row;
215        if lines.right_prompt_on_last_line {
216            row += lines.prompt_lines_with_wrap(screen_width);
217        }
218
219        if input_width <= start_position {
220            self.stdout
221                .queue(SavePosition)?
222                .queue(cursor::MoveTo(start_position, row))?
223                .queue(Print(&coerce_crlf(&lines.prompt_str_right)))?
224                .queue(RestorePosition)?;
225        }
226
227        Ok(())
228    }
229
230    fn print_menu(
231        &mut self,
232        menu: &dyn Menu,
233        lines: &PromptLines,
234        use_ansi_coloring: bool,
235    ) -> Result<()> {
236        let screen_width = self.screen_width();
237        let screen_height = self.screen_height();
238        let cursor_distance = lines.distance_from_prompt(screen_width);
239
240        // If there is not enough space to print the menu, then the starting
241        // drawing point for the menu will overwrite the last rows in the buffer
242        let starting_row = if cursor_distance >= screen_height.saturating_sub(1) {
243            screen_height.saturating_sub(menu.min_rows())
244        } else {
245            self.prompt_start_row + cursor_distance + 1
246        };
247
248        let remaining_lines = screen_height.saturating_sub(starting_row);
249        let menu_string = menu.menu_string(remaining_lines, use_ansi_coloring);
250        self.stdout
251            .queue(cursor::MoveTo(0, starting_row))?
252            .queue(Clear(ClearType::FromCursorDown))?
253            .queue(Print(menu_string.trim_end_matches('\n')))?;
254
255        Ok(())
256    }
257
258    fn print_small_buffer(
259        &mut self,
260        prompt: &dyn Prompt,
261        lines: &PromptLines,
262        menu: Option<&ReedlineMenu>,
263        use_ansi_coloring: bool,
264    ) -> Result<()> {
265        // print our prompt with color
266        if use_ansi_coloring {
267            self.stdout
268                .queue(SetForegroundColor(prompt.get_prompt_color()))?;
269        }
270
271        self.stdout
272            .queue(Print(&coerce_crlf(&lines.prompt_str_left)))?;
273
274        let prompt_indicator = match menu {
275            Some(menu) => menu.indicator(),
276            None => &lines.prompt_indicator,
277        };
278
279        if use_ansi_coloring {
280            self.stdout
281                .queue(SetForegroundColor(prompt.get_indicator_color()))?;
282        }
283
284        self.stdout.queue(Print(&coerce_crlf(prompt_indicator)))?;
285
286        if use_ansi_coloring {
287            self.stdout
288                .queue(SetForegroundColor(prompt.get_prompt_right_color()))?;
289        }
290
291        self.print_right_prompt(lines)?;
292
293        if use_ansi_coloring {
294            self.stdout
295                .queue(SetAttribute(Attribute::Reset))?
296                .queue(ResetColor)?;
297        }
298
299        self.stdout
300            .queue(Print(&lines.before_cursor))?
301            .queue(SavePosition)?
302            .queue(Print(&lines.after_cursor))?;
303
304        if let Some(menu) = menu {
305            self.print_menu(menu, lines, use_ansi_coloring)?;
306        } else {
307            self.stdout.queue(Print(&lines.hint))?;
308        }
309
310        Ok(())
311    }
312
313    fn print_large_buffer(
314        &mut self,
315        prompt: &dyn Prompt,
316        lines: &PromptLines,
317        menu: Option<&ReedlineMenu>,
318        use_ansi_coloring: bool,
319    ) -> Result<()> {
320        let screen_width = self.screen_width();
321        let screen_height = self.screen_height();
322        let cursor_distance = lines.distance_from_prompt(screen_width);
323        let remaining_lines = screen_height.saturating_sub(cursor_distance);
324
325        // Calculating the total lines before the cursor
326        // The -1 in the total_lines_before is there because the at least one line of the prompt
327        // indicator is printed in the same line as the first line of the buffer
328        let prompt_lines = lines.prompt_lines_with_wrap(screen_width) as usize;
329
330        let prompt_indicator = match menu {
331            Some(menu) => menu.indicator(),
332            None => &lines.prompt_indicator,
333        };
334
335        let prompt_indicator_lines = prompt_indicator.lines().count();
336        let before_cursor_lines = lines.before_cursor.lines().count();
337        let total_lines_before = prompt_lines + prompt_indicator_lines + before_cursor_lines - 1;
338
339        // Extra rows represent how many rows are "above" the visible area in the terminal
340        let extra_rows = (total_lines_before).saturating_sub(screen_height as usize);
341
342        // print our prompt with color
343        if use_ansi_coloring {
344            self.stdout
345                .queue(SetForegroundColor(prompt.get_prompt_color()))?;
346        }
347
348        // In case the prompt is made out of multiple lines, the prompt is split by
349        // lines and only the required ones are printed
350        let prompt_skipped = skip_buffer_lines(&lines.prompt_str_left, extra_rows, None);
351        self.stdout.queue(Print(&coerce_crlf(prompt_skipped)))?;
352
353        if extra_rows == 0 {
354            self.print_right_prompt(lines)?;
355        }
356
357        // Adjusting extra_rows base on the calculated prompt line size
358        let extra_rows = extra_rows.saturating_sub(prompt_lines);
359
360        let indicator_skipped = skip_buffer_lines(prompt_indicator, extra_rows, None);
361        self.stdout.queue(Print(&coerce_crlf(indicator_skipped)))?;
362
363        if use_ansi_coloring {
364            self.stdout.queue(ResetColor)?;
365        }
366
367        // The minimum number of lines from the menu are removed from the buffer if there is no more
368        // space to print the menu. This will only happen if the cursor is at the last line and
369        // it is a large buffer
370        let offset = menu.and_then(|menu| {
371            if cursor_distance >= screen_height.saturating_sub(1) {
372                let rows = lines
373                    .before_cursor
374                    .lines()
375                    .count()
376                    .saturating_sub(extra_rows)
377                    .saturating_sub(menu.min_rows() as usize);
378                Some(rows)
379            } else {
380                None
381            }
382        });
383
384        // Selecting the lines before the cursor that will be printed
385        let before_cursor_skipped = skip_buffer_lines(&lines.before_cursor, extra_rows, offset);
386        self.stdout.queue(Print(before_cursor_skipped))?;
387        self.stdout.queue(SavePosition)?;
388
389        if let Some(menu) = menu {
390            // TODO: Also solve the difficult problem of displaying (parts of)
391            // the content after the cursor with the completion menu
392            self.print_menu(menu, lines, use_ansi_coloring)?;
393        } else {
394            // Selecting lines for the hint
395            // The -1 subtraction is done because the remaining lines consider the line where the
396            // cursor is located as a remaining line. That has to be removed to get the correct offset
397            // for the after-cursor and hint lines
398            let offset = remaining_lines.saturating_sub(1) as usize;
399            // Selecting lines after the cursor
400            let after_cursor_skipped = skip_buffer_lines(&lines.after_cursor, 0, Some(offset));
401            self.stdout.queue(Print(after_cursor_skipped))?;
402            // Hint lines
403            let hint_skipped = skip_buffer_lines(&lines.hint, 0, Some(offset));
404            self.stdout.queue(Print(hint_skipped))?;
405        }
406
407        Ok(())
408    }
409
410    /// Updates prompt origin and offset to handle a screen resize event
411    pub(crate) fn handle_resize(&mut self, width: u16, height: u16) {
412        self.terminal_size = (width, height);
413
414        // `cursor::position() is blocking and can timeout.
415        // The question is whether we can afford it. If not, perhaps we should use it in some scenarios but not others
416        // The problem is trying to calculate this internally doesn't seem to be reliable because terminals might
417        // have additional text in their buffer that messes with the offset on scroll.
418        // It seems like it _should_ be ok because it only happens on resize.
419
420        // Known bug: on iterm2 and kitty, clearing the screen via CMD-K doesn't reset
421        // the position. Might need to special-case this.
422        //
423        // I assume this is a bug with the position() call but haven't figured that
424        // out yet.
425        if let Ok(position) = cursor::position() {
426            self.prompt_start_row = position.1;
427        }
428    }
429
430    /// Writes `line` to the terminal with a following carriage return and newline
431    pub(crate) fn paint_line(&mut self, line: &str) -> Result<()> {
432        self.stdout.queue(Print(line))?.queue(Print("\r\n"))?;
433
434        self.stdout.flush()
435    }
436
437    /// Goes to the beginning of the next line
438    ///
439    /// Also works in raw mode
440    pub(crate) fn print_crlf(&mut self) -> Result<()> {
441        self.stdout.queue(Print("\r\n"))?;
442
443        self.stdout.flush()
444    }
445
446    /// Clear the screen by printing enough whitespace to start the prompt or
447    /// other output back at the first line of the terminal.
448    pub(crate) fn clear_screen(&mut self) -> Result<()> {
449        self.stdout.queue(cursor::Hide)?;
450        let (_, num_lines) = terminal::size()?;
451        for _ in 0..2 * num_lines {
452            self.stdout.queue(Print("\n"))?;
453        }
454        self.stdout.queue(MoveTo(0, 0))?;
455        self.stdout.queue(cursor::Show)?;
456
457        self.stdout.flush()?;
458        self.initialize_prompt_position()
459    }
460
461    pub(crate) fn clear_scrollback(&mut self) -> Result<()> {
462        self.stdout
463            .queue(crossterm::terminal::Clear(ClearType::All))?
464            .queue(crossterm::terminal::Clear(ClearType::Purge))?
465            .queue(cursor::MoveTo(0, 0))?
466            .flush()?;
467        self.initialize_prompt_position()
468    }
469
470    // The prompt is moved to the end of the buffer after the event was handled
471    // If the prompt is in the middle of a multiline buffer, then the output to stdout
472    // could overwrite the buffer writing
473    pub(crate) fn move_cursor_to_end(&mut self) -> Result<()> {
474        let final_row = self.prompt_start_row + self.last_required_lines;
475        let scroll = final_row.saturating_sub(self.screen_height() - 1);
476        if scroll != 0 {
477            self.queue_universal_scroll(scroll)?;
478        }
479        self.stdout
480            .queue(MoveTo(0, final_row.min(self.screen_height() - 1)))?;
481
482        self.stdout.flush()
483    }
484
485    /// Prints an external message
486    ///
487    /// This function doesn't flush the buffer. So buffer should be flushed
488    /// afterwards perhaps by repainting the prompt via `repaint_buffer()`.
489    #[cfg(feature = "external_printer")]
490    pub(crate) fn print_external_message(
491        &mut self,
492        messages: Vec<String>,
493        line_buffer: &LineBuffer,
494        prompt: &dyn Prompt,
495    ) -> Result<()> {
496        // adding 3 seems to be right for first line-wrap
497        let prompt_len = prompt.render_prompt_right().len() + 3;
498        let mut buffer_num_lines = 0_u16;
499        for (i, line) in line_buffer.get_buffer().lines().enumerate() {
500            let screen_lines = match i {
501                0 => {
502                    // the first line has to deal with the prompt
503                    let first_line_len = line.len() + prompt_len;
504                    // at least, it is one line
505                    ((first_line_len as u16) / (self.screen_width())) + 1
506                }
507                _ => {
508                    // the n-th line, no prompt, at least, it is one line
509                    ((line.len() as u16) / self.screen_width()) + 1
510                }
511            };
512            // count up screen-lines
513            buffer_num_lines = buffer_num_lines.saturating_add(screen_lines);
514        }
515        // move upward to start print if the line-buffer is more than one screen-line
516        if buffer_num_lines > 1 {
517            self.stdout.queue(MoveUp(buffer_num_lines - 1))?;
518        }
519        let erase_line = format!("\r{}\r", " ".repeat(self.screen_width().into()));
520        for line in messages {
521            self.stdout.queue(Print(&erase_line))?;
522            // Note: we don't use `print_line` here because we don't want to
523            // flush right now. The subsequent repaint of the prompt will cause
524            // immediate flush anyways. And if we flush here, every external
525            // print causes visible flicker.
526            self.stdout.queue(Print(line))?.queue(Print("\r\n"))?;
527            let new_start = self.prompt_start_row.saturating_add(1);
528            let height = self.screen_height();
529            if new_start >= height {
530                self.prompt_start_row = height - 1;
531            } else {
532                self.prompt_start_row = new_start;
533            }
534        }
535        Ok(())
536    }
537
538    /// Queue scroll of `num` lines to `self.stdout`.
539    ///
540    /// On some platforms and terminals (e.g. windows terminal, alacritty on windows and linux)
541    /// using special escape sequence '\[e<num>S' (provided by [`ScrollUp`]) does not put lines
542    /// that go offscreen in scrollback history. This method prints newlines near the edge of screen
543    /// (which always works) instead. See [here](https://github.com/nushell/nushell/issues/9166)
544    /// for more info on subject.
545    ///
546    /// ## Note
547    /// This method does not return cursor to the original position and leaves it at the first
548    /// column of last line. **Be sure to use [`MoveTo`] afterwards if this is not the desired
549    /// location**
550    fn queue_universal_scroll(&mut self, num: u16) -> Result<()> {
551        // If cursor is not near end of screen printing new will not scroll terminal.
552        // Move it to the last line to ensure that every newline results in scroll
553        self.stdout.queue(MoveTo(0, self.screen_height() - 1))?;
554        for _ in 0..num {
555            self.stdout.queue(Print(&coerce_crlf("\n")))?;
556        }
557        Ok(())
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use pretty_assertions::assert_eq;
565
566    #[test]
567    fn test_skip_lines() {
568        let string = "sentence1\nsentence2\nsentence3\n";
569
570        assert_eq!(skip_buffer_lines(string, 1, None), "sentence2\nsentence3");
571        assert_eq!(skip_buffer_lines(string, 2, None), "sentence3");
572        assert_eq!(skip_buffer_lines(string, 3, None), "");
573        assert_eq!(skip_buffer_lines(string, 4, None), "");
574    }
575
576    #[test]
577    fn test_skip_lines_no_newline() {
578        let string = "sentence1";
579
580        assert_eq!(skip_buffer_lines(string, 0, None), "sentence1");
581        assert_eq!(skip_buffer_lines(string, 1, None), "");
582    }
583
584    #[test]
585    fn test_skip_lines_with_limit() {
586        let string = "sentence1\nsentence2\nsentence3\nsentence4\nsentence5";
587
588        assert_eq!(
589            skip_buffer_lines(string, 1, Some(1)),
590            "sentence2\nsentence3",
591        );
592
593        assert_eq!(
594            skip_buffer_lines(string, 1, Some(2)),
595            "sentence2\nsentence3\nsentence4",
596        );
597
598        assert_eq!(
599            skip_buffer_lines(string, 2, Some(1)),
600            "sentence3\nsentence4",
601        );
602
603        assert_eq!(
604            skip_buffer_lines(string, 1, Some(10)),
605            "sentence2\nsentence3\nsentence4\nsentence5",
606        );
607
608        assert_eq!(
609            skip_buffer_lines(string, 0, Some(1)),
610            "sentence1\nsentence2",
611        );
612
613        assert_eq!(skip_buffer_lines(string, 0, Some(0)), "sentence1",);
614        assert_eq!(skip_buffer_lines(string, 1, Some(0)), "sentence2",);
615    }
616}