Skip to main content

datui_lib/widgets/textarea/
render.rs

1//! Drawing a [`TextArea`] into a ratatui buffer.
2//!
3//! The widget is implemented on `&TextArea` so callers keep ownership of their
4//! editor state across frames. Scroll position is the one thing rendering needs
5//! to write back, and it lives in a [`std::cell::Cell`] for that reason: the
6//! area is only known at draw time, so that is the only place the viewport can
7//! be reconciled with the cursor.
8
9use ratatui::{
10    buffer::Buffer,
11    layout::Rect,
12    style::Style,
13    widgets::{StatefulWidget, Widget},
14};
15use unicode_width::UnicodeWidthChar;
16
17use super::{TextArea, Viewport};
18
19/// One drawable cell: the symbol, its display width and its style.
20struct DisplayCell {
21    symbol: String,
22    width: usize,
23    style: Style,
24}
25
26impl TextArea {
27    /// Display column of a character position, with tabs expanded.
28    pub fn display_col(&self, row: usize, col: usize) -> usize {
29        let Some(line) = self.lines.get(row) else {
30            return 0;
31        };
32        let mut width = 0;
33        for c in line.chars().take(col) {
34            width += self.char_width(c, width);
35        }
36        width
37    }
38
39    /// Display width of one character starting at display column `at`.
40    pub(super) fn char_width(&self, c: char, at: usize) -> usize {
41        if c == '\t' {
42            self.tab_len - (at % self.tab_len)
43        } else {
44            UnicodeWidthChar::width(c).unwrap_or(0)
45        }
46    }
47
48    /// Scroll position that keeps the cursor on screen inside `area`, starting
49    /// from wherever the previous frame left it.
50    fn viewport_for(&self, area: Rect) -> Viewport {
51        let prev = self.viewport.get();
52        let (cursor_row, cursor_col) = self.cursor;
53
54        let height = area.height as usize;
55        let mut row = prev.row.min(self.lines.len().saturating_sub(1));
56        if cursor_row < row {
57            row = cursor_row;
58        } else if height > 0 && cursor_row >= row + height {
59            row = cursor_row + 1 - height;
60        }
61
62        let width = area.width as usize;
63        let cursor_x = self.display_col(cursor_row, cursor_col);
64        let mut col = prev.col;
65        if cursor_x < col {
66            col = cursor_x;
67        } else if width > 0 && cursor_x >= col + width {
68            col = cursor_x + 1 - width;
69        }
70
71        Viewport {
72            row,
73            col,
74            height: area.height,
75            width: area.width,
76        }
77    }
78
79    /// Style for the character at `(row, col)`, layering selection and cursor
80    /// highlights over the base style.
81    pub(super) fn style_at(&self, row: usize, col: usize) -> Style {
82        let mut style = self.style;
83        if let Some((start, end)) = self.selection()
84            && (row, col) >= start
85            && (row, col) < end
86        {
87            style = style.patch(self.selection_style);
88        }
89        if self.cursor_visible && self.cursor == (row, col) {
90            style = style.patch(self.cursor_style);
91        }
92        style
93    }
94
95    /// Expand one line into drawable cells, including the cursor cell when it
96    /// sits past the end of the line.
97    fn cells_for_line(&self, row: usize) -> Vec<DisplayCell> {
98        let line = &self.lines[row];
99        let mut cells = Vec::with_capacity(line.len() + 1);
100        let mut width = 0;
101
102        for (col, c) in line.chars().enumerate() {
103            let style = self.style_at(row, col);
104            if c == '\t' {
105                let spaces = self.tab_len - (width % self.tab_len);
106                for _ in 0..spaces {
107                    cells.push(DisplayCell {
108                        symbol: " ".to_string(),
109                        width: 1,
110                        style,
111                    });
112                }
113                width += spaces;
114                continue;
115            }
116            let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
117            if char_width == 0 {
118                continue;
119            }
120            cells.push(DisplayCell {
121                symbol: c.to_string(),
122                width: char_width,
123                style,
124            });
125            width += char_width;
126        }
127
128        let line_len = line.chars().count();
129        if self.cursor == (row, line_len) {
130            cells.push(DisplayCell {
131                symbol: " ".to_string(),
132                width: 1,
133                style: self.style_at(row, line_len),
134            });
135        }
136        cells
137    }
138
139    /// Draw one line of cells at `y`, skipping the first `scroll` display
140    /// columns.
141    fn render_line(cells: &[DisplayCell], area: Rect, y: u16, scroll: usize, buf: &mut Buffer) {
142        let mut x = area.x;
143        let mut column = 0;
144        for cell in cells {
145            let end = column + cell.width;
146            if end <= scroll {
147                column = end;
148                continue;
149            }
150            if x >= area.right() {
151                return;
152            }
153            // A wide character straddling either edge of the viewport is drawn
154            // as blanks: half a glyph would corrupt the line.
155            let clipped_left = column < scroll;
156            let overflows_right = x as usize + cell.width > area.right() as usize;
157            if clipped_left || overflows_right {
158                let visible = if clipped_left {
159                    end - scroll
160                } else {
161                    cell.width
162                };
163                for _ in 0..visible {
164                    if x >= area.right() {
165                        break;
166                    }
167                    buf[(x, y)].set_symbol(" ").set_style(cell.style);
168                    x += 1;
169                }
170                column = end;
171                continue;
172            }
173            buf[(x, y)].set_symbol(&cell.symbol).set_style(cell.style);
174            for offset in 1..cell.width as u16 {
175                buf[(x + offset, y)].set_symbol(" ").set_style(cell.style);
176            }
177            x += cell.width as u16;
178            column = end;
179        }
180    }
181}
182
183impl Widget for &TextArea {
184    fn render(self, area: Rect, buf: &mut Buffer) {
185        if area.width == 0 || area.height == 0 {
186            return;
187        }
188        if self.wrap {
189            self.render_wrapped(area, buf);
190            return;
191        }
192        let viewport = self.viewport_for(area);
193        self.viewport.set(viewport);
194
195        buf.set_style(area, self.style);
196
197        for offset in 0..area.height {
198            let row = viewport.row + offset as usize;
199            if row >= self.lines.len() {
200                break;
201            }
202            let cells = self.cells_for_line(row);
203            TextArea::render_line(&cells, area, area.y + offset, viewport.col, buf);
204        }
205    }
206}
207
208impl StatefulWidget for &TextArea {
209    /// Receives the scroll position settled on by this frame, as
210    /// `(first visible row, first visible column)`.
211    type State = (usize, usize);
212
213    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
214        Widget::render(self, area, buf);
215        *state = self.scroll_offsets();
216    }
217}