kiro_editor/
screen.rs

1use crate::error::{Error, Result};
2use crate::highlight::Highlighting;
3use crate::input::{InputSeq, KeySeq};
4use crate::row::Row;
5use crate::signal::SigwinchWatcher;
6use crate::status_bar::StatusBar;
7use crate::term_color::{Color, TermColor};
8use crate::text_buffer::TextBuffer;
9use std::cmp;
10use std::io::Write;
11use std::time::SystemTime;
12use unicode_width::UnicodeWidthChar;
13
14pub const VERSION: &str = env!("CARGO_PKG_VERSION");
15pub const HELP: &str = "\
16    Ctrl-Q                        : Quit
17    Ctrl-S                        : Save to file
18    Ctrl-O                        : Open text buffer
19    Ctrl-X                        : Next text buffer
20    Alt-X                         : Previous text buffer
21    Ctrl-P or UP                  : Move cursor up
22    Ctrl-N or DOWN                : Move cursor down
23    Ctrl-F or RIGHT               : Move cursor right
24    Ctrl-B or LEFT                : Move cursor left
25    Ctrl-A or Alt-LEFT or HOME    : Move cursor to head of line
26    Ctrl-E or Alt-RIGHT or END    : Move cursor to end of line
27    Ctrl-[ or Ctrl-V or PAGE DOWN : Next page
28    Ctrl-] or Alt-V or PAGE UP    : Previous page
29    Alt-F or Ctrl-RIGHT           : Move cursor to next word
30    Alt-B or Ctrl-LEFT            : Move cursor to previous word
31    Alt-N or Ctrl-DOWN            : Move cursor to next paragraph
32    Alt-P or Ctrl-UP              : Move cursor to previous paragraph
33    Alt-<                         : Move cursor to top of file
34    Alt->                         : Move cursor to bottom of file
35    Ctrl-H or BACKSPACE           : Delete character
36    Ctrl-D or DELETE              : Delete next character
37    Ctrl-W                        : Delete a word
38    Ctrl-J                        : Delete until head of line
39    Ctrl-K                        : Delete until end of line
40    Ctrl-U                        : Undo last change
41    Ctrl-R                        : Redo last undo change
42    Ctrl-G                        : Search text
43    Ctrl-M                        : New line
44    Ctrl-L                        : Refresh screen
45    Ctrl-?                        : Show this help";
46
47#[derive(PartialEq)]
48enum StatusMessageKind {
49    Info,
50    Error,
51}
52
53struct StatusMessage {
54    text: String,
55    timestamp: SystemTime,
56    kind: StatusMessageKind,
57}
58
59impl StatusMessage {
60    fn new<S: Into<String>>(message: S, kind: StatusMessageKind) -> StatusMessage {
61        StatusMessage {
62            text: message.into(),
63            timestamp: SystemTime::now(),
64            kind,
65        }
66    }
67}
68
69fn get_window_size<I, W>(input: I, mut output: W) -> Result<(usize, usize)>
70where
71    I: Iterator<Item = Result<InputSeq>>,
72    W: Write,
73{
74    if let Some(s) = term_size::dimensions_stdout() {
75        return Ok(s);
76    }
77
78    // By moving cursor at the bottom-right corner by 'B' and 'C' commands, get the size of
79    // current screen. \x1b[9999;9999H is not available since it does not guarantee cursor
80    // stops on the corner. Finally command 'n' queries cursor position.
81    output.write(b"\x1b[9999C\x1b[9999B\x1b[6n")?;
82    output.flush()?;
83
84    // Wait for response from terminal discarding other sequences
85    for seq in input {
86        if let KeySeq::Cursor(r, c) = seq?.key {
87            return Ok((c, r));
88        }
89    }
90
91    Err(Error::UnknownWindowSize) // Give up
92}
93
94fn too_small_window(width: usize, height: usize) -> bool {
95    width < 1 || height < 3
96}
97
98#[derive(PartialEq, Clone, Copy, Debug)]
99enum DrawMessage {
100    Open,
101    Close,
102    Update,
103    DoNothing,
104}
105
106impl DrawMessage {
107    fn fold(self, rhs: Self) -> Self {
108        // Folding new state into current state. For example, when current state is Open and new state
109        // is Close, applying Open then Close means DoNothing.
110        use DrawMessage::*;
111        match (self, rhs) {
112            (Open, Open) => unreachable!(),
113            (Open, Close) => DoNothing,
114            (Open, Update) => Open,
115            (Close, Open) => Update,
116            (Close, Close) => unreachable!(),
117            (Close, Update) => unreachable!(),
118            (Update, Open) => unreachable!(),
119            (Update, Close) => Close,
120            (Update, Update) => Update,
121            (lhs, DoNothing) => lhs,
122            (DoNothing, rhs) => rhs,
123        }
124    }
125}
126
127pub struct Screen<W: Write> {
128    output: W,
129    // X coordinate in `render` text of rows
130    rx: usize,
131    // Screen size
132    num_cols: usize,
133    num_rows: usize,
134    message: Option<StatusMessage>,
135    draw_message: DrawMessage,
136    // Dirty line which requires rendering update. After this line must be updated since
137    // updating line may affect highlights of succeeding lines
138    dirty_start: Option<usize>,
139    // Watch resize signal
140    sigwinch: SigwinchWatcher,
141    term_color: TermColor,
142    pub cursor_moved: bool,
143    pub rowoff: usize, // Row scroll offset
144    pub coloff: usize, // Column scroll offset
145}
146
147impl<W: Write> Screen<W> {
148    pub fn new<I>(size: Option<(usize, usize)>, input: I, mut output: W) -> Result<Self>
149    where
150        I: Iterator<Item = Result<InputSeq>>,
151    {
152        let (w, h) = if let Some(s) = size {
153            s
154        } else {
155            get_window_size(input, &mut output)?
156        };
157
158        if too_small_window(w, h) {
159            return Err(Error::TooSmallWindow(w, h));
160        }
161
162        // Enter alternate screen buffer to restore previous screen on quit
163        // Note that 'CSI ? 47 h' does not work on WSL environment (#11)
164        // https://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer
165        // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html (CSI ? Pm h)
166        output.write(b"\x1b[?1049h")?;
167
168        Ok(Self {
169            output,
170            rx: 0,
171            num_cols: w,
172            // Screen height is 1 line less than window height due to status bar
173            num_rows: h.saturating_sub(2),
174            message: Some(StatusMessage::new(
175                "Ctrl-? for help",
176                StatusMessageKind::Info,
177            )),
178            draw_message: DrawMessage::Open,
179            dirty_start: Some(0), // Render entire screen at first paint
180            sigwinch: SigwinchWatcher::new()?,
181            term_color: TermColor::from_env(),
182            cursor_moved: true,
183            rowoff: 0,
184            coloff: 0,
185        })
186    }
187
188    fn write_flush(&mut self, bytes: &[u8]) -> Result<()> {
189        self.output.write(bytes)?;
190        self.output.flush()?;
191        Ok(())
192    }
193
194    fn trim_line<S: AsRef<str>>(&self, line: &S) -> String {
195        let line = line.as_ref();
196        if line.len() <= self.coloff {
197            return "".to_string();
198        }
199        line.chars().skip(self.coloff).take(self.num_cols).collect()
200    }
201
202    fn draw_status_bar<B: Write>(&self, mut buf: B, status_bar: &StatusBar) -> Result<()> {
203        write!(buf, "\x1b[{}H", self.rows() + 1)?;
204
205        buf.write(self.term_color.sequence(Color::Invert))?;
206
207        let left = status_bar.left();
208        // TODO: Handle multi-byte chars correctly
209        let left = &left[..cmp::min(left.len(), self.num_cols)];
210        buf.write(left.as_bytes())?; // Left of status bar
211
212        let rest_len = self.num_cols - left.len();
213        if rest_len == 0 {
214            buf.write(self.term_color.sequence(Color::Reset))?;
215            return Ok(());
216        }
217
218        let right = status_bar.right();
219        if right.len() > rest_len {
220            for _ in 0..rest_len {
221                buf.write(b" ")?;
222            }
223            buf.write(self.term_color.sequence(Color::Reset))?;
224            return Ok(());
225        }
226
227        for _ in 0..rest_len - right.len() {
228            buf.write(b" ")?; // Add spaces at center of status bar
229        }
230        buf.write(right.as_bytes())?;
231
232        buf.write(self.term_color.sequence(Color::Reset))?;
233        Ok(())
234    }
235
236    fn draw_message_bar<B: Write>(&self, mut buf: B, message: &StatusMessage) -> Result<()> {
237        // TODO: Handle multi-byte chars correctly
238        let text = &message.text[..cmp::min(message.text.len(), self.num_cols)];
239
240        write!(buf, "\x1b[{}H", self.num_rows + 2)?;
241
242        if message.kind == StatusMessageKind::Error {
243            buf.write(self.term_color.sequence(Color::RedBG))?;
244        }
245
246        buf.write(text.as_bytes())?;
247
248        if message.kind != StatusMessageKind::Info {
249            buf.write(self.term_color.sequence(Color::Reset))?;
250        }
251
252        buf.write(b"\x1b[K")?;
253        Ok(())
254    }
255
256    pub fn render_welcome(&mut self, status_bar: &StatusBar) -> Result<()> {
257        self.write_flush(b"\x1b[?25l")?; // Hide cursor
258
259        let mut buf = Vec::with_capacity((self.rows() + 2 + self.num_cols) * 3);
260        buf.write(self.term_color.sequence(Color::Reset))?;
261
262        for y in 0..self.rows() {
263            write!(buf, "\x1b[{}H", y + 1)?;
264
265            if y == self.rows() / 3 {
266                let msg_buf = format!("Kiro editor -- version {}", VERSION);
267                let welcome = self.trim_line(&msg_buf);
268                let padding = (self.num_cols - welcome.len()) / 2;
269                if padding > 0 {
270                    buf.write(self.term_color.sequence(Color::NonText))?;
271                    buf.write(b"~")?;
272                    buf.write(self.term_color.sequence(Color::Reset))?;
273                    for _ in 0..padding - 1 {
274                        buf.write(b" ")?;
275                    }
276                }
277                buf.write(welcome.as_bytes())?;
278            } else {
279                buf.write(self.term_color.sequence(Color::NonText))?;
280                buf.write(b"~")?;
281            }
282
283            buf.write(b"\x1b[K")?;
284        }
285
286        buf.write(self.term_color.sequence(Color::Reset))?;
287        self.draw_status_bar(&mut buf, status_bar)?;
288        if let Some(message) = &self.message {
289            self.draw_message_bar(&mut buf, message)?;
290        }
291
292        write!(buf, "\x1b[H")?; // Set cursor to left-top
293        buf.write(b"\x1b[?25h")?; // Show cursor
294        self.write_flush(&buf)?;
295
296        self.after_render();
297        Ok(())
298    }
299
300    fn draw_rows<B: Write>(
301        &self,
302        mut buf: B,
303        dirty_start: usize,
304        rows: &[Row],
305        hl: &Highlighting,
306    ) -> Result<()> {
307        let row_len = rows.len();
308
309        buf.write(self.term_color.sequence(Color::Reset))?;
310
311        for y in 0..self.rows() {
312            let file_row = y + self.rowoff;
313
314            if file_row < dirty_start {
315                continue;
316            }
317
318            // H: Command to move cursor. Here \x1b[H is the same as \x1b[1;1H
319            write!(buf, "\x1b[{}H", y + 1)?;
320
321            if file_row >= row_len {
322                buf.write(self.term_color.sequence(Color::NonText))?;
323                buf.write(b"~")?;
324            } else {
325                let row = &rows[file_row];
326
327                let mut col = 0;
328                let mut prev_color = Color::Reset;
329                for (c, hl) in row.render_text().chars().zip(hl.lines[file_row].iter()) {
330                    col += c.width_cjk().unwrap_or(1);
331                    if col <= self.coloff {
332                        continue;
333                    } else if col > self.num_cols + self.coloff {
334                        break;
335                    }
336
337                    let color = hl.color();
338                    if color != prev_color {
339                        if prev_color.has_bg_color() {
340                            buf.write(self.term_color.sequence(Color::Reset))?;
341                        }
342                        buf.write(self.term_color.sequence(color))?;
343                        prev_color = color;
344                    }
345
346                    write!(buf, "{}", c)?;
347                }
348            }
349
350            // Ensure to end with reset color sequence. Otherwise, when background color is highlighted
351            // at the end of line, highlight will continue to the end of last column in terminal window.
352            buf.write(self.term_color.sequence(Color::Reset))?;
353
354            // Erases the part of the line to the right of the cursor. http://vt100.net/docs/vt100-ug/chapter3.html#EL
355            buf.write(b"\x1b[K")?;
356        }
357
358        Ok(())
359    }
360
361    fn redraw(
362        &mut self,
363        text_buf: &TextBuffer,
364        hl: &Highlighting,
365        status_bar: &StatusBar,
366    ) -> Result<()> {
367        let cursor_row = text_buf.cy() - self.rowoff + 1;
368        let cursor_col = self.rx - self.coloff + 1;
369        let draw_message = self.draw_message;
370
371        if self.dirty_start.is_none()
372            && !status_bar.redraw
373            && draw_message == DrawMessage::DoNothing
374        {
375            if self.cursor_moved {
376                write!(self.output, "\x1b[{};{}H", cursor_row, cursor_col)?;
377                self.output.flush()?;
378            }
379            return Ok(());
380        }
381
382        // \x1b[: Escape sequence header
383        // Hide cursor while updating screen. 'l' is command to set mode http://vt100.net/docs/vt100-ug/chapter3.html#SM
384        // This command must be flushed at first otherwise cursor may move before being hidden
385        self.write_flush(b"\x1b[?25l")?;
386
387        let mut buf = Vec::with_capacity((self.rows() + 2) * self.num_cols);
388        if let Some(s) = self.dirty_start {
389            self.draw_rows(&mut buf, s, text_buf.rows(), hl)?;
390        }
391
392        // When message bar opens/closes, position of status bar is changed
393        if status_bar.redraw
394            || draw_message == DrawMessage::Open
395            || draw_message == DrawMessage::Close
396        {
397            self.draw_status_bar(&mut buf, status_bar)?;
398        }
399
400        // When closing message bar, nothing to do since status bar will overwrite old message bar
401        if draw_message == DrawMessage::Update || draw_message == DrawMessage::Open {
402            if let Some(message) = &self.message {
403                self.draw_message_bar(&mut buf, message)?;
404            }
405        }
406
407        // Move cursor even if cursor_moved is false since cursor is moved by draw_* methods
408        write!(buf, "\x1b[{};{}H", cursor_row, cursor_col)?;
409
410        // Reveal cursor again. 'h' is command to reset mode https://vt100.net/docs/vt100-ug/chapter3.html#RM
411        buf.write(b"\x1b[?25h")?;
412
413        self.write_flush(&buf)?;
414
415        Ok(())
416    }
417
418    fn next_coloff(&self, want_stop: usize, row: &Row) -> usize {
419        let mut coloff = 0;
420        for c in row.render_text().chars() {
421            coloff += c.width_cjk().unwrap_or(1);
422            if coloff >= want_stop {
423                // Screen cannot start from at the middle of double-width character
424                break;
425            }
426        }
427        coloff
428    }
429
430    fn do_scroll(&mut self, rows: &[Row], (cx, cy): (usize, usize)) {
431        let prev_rowoff = self.rowoff;
432        let prev_coloff = self.coloff;
433
434        // Calculate X coordinate to render considering tab stop
435        if cy < rows.len() {
436            self.rx = rows[cy].rx_from_cx(cx);
437        } else {
438            self.rx = 0;
439        }
440
441        // Adjust scroll position when cursor is outside screen
442        if cy < self.rowoff {
443            // Scroll up when cursor is above the top of window
444            self.rowoff = cy;
445        }
446        if cy >= self.rowoff + self.rows() {
447            // Scroll down when cursor is below the bottom of screen
448            self.rowoff = cy - self.rows() + 1;
449        }
450        if self.rx < self.coloff {
451            self.coloff = self.rx;
452        }
453        if self.rx >= self.coloff + self.num_cols {
454            self.coloff = self.next_coloff(self.rx - self.num_cols + 1, &rows[cy]);
455        }
456
457        if prev_rowoff != self.rowoff || prev_coloff != self.coloff {
458            // If scroll happens, all rows on screen must be updated
459            // TODO: Improve rendering on scrolling up/down using scroll region commands \x1b[M/\x1b[D.
460            // But scroll down region command was implemented in tmux recently and not included in
461            // stable release: https://github.com/tmux/tmux/commit/45f4ff54850ff9b448070a96b33e63451f973e33
462            self.set_dirty_start(self.rowoff);
463        }
464    }
465
466    fn update_message_bar(&mut self) -> Result<()> {
467        if let Some(m) = &self.message {
468            if SystemTime::now().duration_since(m.timestamp)?.as_secs() > 5 {
469                self.unset_message();
470            }
471        }
472        if self.draw_message == DrawMessage::Close {
473            self.set_dirty_start(self.num_rows); // Closing message bar reveals one more line
474        }
475        Ok(())
476    }
477
478    fn after_render(&mut self) {
479        // Clear state
480        self.dirty_start = None;
481        self.cursor_moved = false;
482        self.draw_message = DrawMessage::DoNothing;
483    }
484
485    pub fn render(
486        &mut self,
487        buf: &TextBuffer,
488        hl: &mut Highlighting,
489        status_bar: &StatusBar,
490    ) -> Result<()> {
491        self.do_scroll(buf.rows(), buf.cursor());
492        self.update_message_bar()?; // This must be updated here since it affects area of highlighting
493        hl.update(buf.rows(), self.rowoff + self.rows());
494        self.redraw(buf, hl, status_bar)?;
495        self.after_render();
496        Ok(())
497    }
498
499    pub fn render_help(&mut self) -> Result<()> {
500        let help: Vec<_> = HELP
501            .split('\n')
502            .skip_while(|s| !s.contains(':'))
503            .map(str::trim_start)
504            .collect();
505        let rows = self.rows();
506
507        let vertical_margin = if help.len() < rows {
508            (rows - help.len()) / 2
509        } else {
510            0
511        };
512        let help_max_width = help.iter().map(|l| l.len()).max().unwrap();
513        let left_margin = if help_max_width < self.num_cols {
514            (self.num_cols - help_max_width) / 2
515        } else {
516            0
517        };
518
519        let mut buf = Vec::with_capacity(rows * self.num_cols);
520
521        for y in 0..vertical_margin {
522            write!(buf, "\x1b[{}H", y + 1)?;
523            buf.write(b"\x1b[K")?;
524        }
525
526        let left_pad = " ".repeat(left_margin);
527        let help_height = cmp::min(vertical_margin + help.len(), rows);
528        for y in vertical_margin..help_height {
529            let idx = y - vertical_margin;
530            write!(buf, "\x1b[{}H", y + 1)?;
531            buf.write(left_pad.as_bytes())?;
532
533            let help = &help[idx][..cmp::min(help[idx].len(), self.num_cols)];
534            buf.write(self.term_color.sequence(Color::Cyan))?;
535            let mut cols = help.split(':');
536            if let Some(col) = cols.next() {
537                buf.write(col.as_bytes())?;
538            }
539            buf.write(self.term_color.sequence(Color::Reset))?;
540            if let Some(col) = cols.next() {
541                write!(buf, ":{}", col)?;
542            }
543
544            buf.write(b"\x1b[K")?;
545        }
546
547        for y in help_height..rows {
548            write!(buf, "\x1b[{}H", y + 1)?;
549            buf.write(b"\x1b[K")?;
550        }
551
552        self.write_flush(&buf)
553    }
554
555    pub fn set_dirty_start(&mut self, start: usize) {
556        if let Some(s) = self.dirty_start {
557            if s < start {
558                return;
559            }
560        }
561        self.dirty_start = Some(start);
562    }
563
564    pub fn maybe_resize<I>(&mut self, input: I) -> Result<bool>
565    where
566        I: Iterator<Item = Result<InputSeq>>,
567    {
568        if !self.sigwinch.notified() {
569            return Ok(false); // Did not receive signal
570        }
571
572        let (w, h) = get_window_size(input, &mut self.output)?;
573        if too_small_window(w, h) {
574            return Err(Error::TooSmallWindow(w, h));
575        }
576
577        self.num_rows = h.saturating_sub(2);
578        self.num_cols = w;
579        self.dirty_start = Some(0);
580
581        Ok(true)
582    }
583
584    fn set_message(&mut self, m: Option<StatusMessage>) {
585        let op = match (&self.message, &m) {
586            (Some(p), Some(n)) if p.text == n.text => DrawMessage::DoNothing,
587            (Some(_), Some(_)) => DrawMessage::Update,
588            (None, Some(_)) => DrawMessage::Open,
589            (Some(_), None) => DrawMessage::Close,
590            (None, None) => DrawMessage::DoNothing,
591        };
592        // Folding is necessary to consider that message is set multiple times within one tick
593        self.draw_message = self.draw_message.fold(op);
594        self.message = m;
595    }
596
597    pub fn set_info_message<S: Into<String>>(&mut self, message: S) {
598        self.set_message(Some(StatusMessage::new(message, StatusMessageKind::Info)));
599    }
600
601    pub fn set_error_message<S: Into<String>>(&mut self, message: S) {
602        self.set_message(Some(StatusMessage::new(message, StatusMessageKind::Error)));
603    }
604
605    pub fn unset_message(&mut self) {
606        self.set_message(None);
607    }
608
609    pub fn rows(&self) -> usize {
610        if self.message.is_some() {
611            self.num_rows
612        } else {
613            self.num_rows + 1
614        }
615    }
616
617    pub fn cols(&self) -> usize {
618        self.num_cols
619    }
620
621    pub fn message_text(&self) -> &'_ str {
622        self.message.as_ref().map(|m| m.text.as_str()).unwrap_or("")
623    }
624
625    pub fn force_set_cursor(&mut self, row: usize, col: usize) -> Result<()> {
626        write!(self.output, "\x1b[{};{}H", row, col)?;
627        self.output.flush()?;
628        Ok(())
629    }
630}
631
632impl<W: Write> Drop for Screen<W> {
633    fn drop(&mut self) {
634        // Back to normal screen buffer from alternate screen buffer
635        // https://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer
636        //
637        // Note that we used \x1b[2J\x1b[H previously but it did not erase screen and
638        // \x1b[?47l\x1b[H worked but not in the WSL terminal environment (#11).
639        //
640        // CSI ? 1049 h
641        // > Save cursor as in DECSC, xterm. After saving the cursor, switch to the Alternate
642        // > Screen Buffer, clearing it first.  This may be disabled by the titeInhibit resource.
643        // > This control combines the effects of the 1 0 4 7 and 1 0 4 8  modes. Use this with
644        // > terminfo-based applications rather than the 4 7  mode.
645        // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
646        self.write_flush(b"\x1b[?1049l\x1b[H")
647            .expect("Back to normal screen buffer");
648    }
649}