vtui_core/
canvas.rs

1use ratatui::{buffer::Buffer, layout::Rect, style::Style, widgets::Widget};
2use unicode_segmentation::UnicodeSegmentation;
3use unicode_width::UnicodeWidthStr;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub struct LogicalRect {
7    pub x: i32,
8    pub y: i32,
9    pub width: i32,
10    pub height: i32,
11}
12
13impl From<Rect> for LogicalRect {
14    fn from(value: Rect) -> Self {
15        Self::new(value.x as i32, value.y as i32, value.width, value.height)
16    }
17}
18
19impl LogicalRect {
20    pub fn new(x: i32, y: i32, width: u16, height: u16) -> Self {
21        Self {
22            x,
23            y,
24            width: width as i32,
25            height: height as i32,
26        }
27    }
28
29    pub fn intersection(self, other: Self) -> Self {
30        if !self.intersects(other) {
31            return Self {
32                x: 0,
33                y: 0,
34                width: 0,
35                height: 0,
36            };
37        }
38
39        let x1 = self.x.max(other.x);
40        let y1 = self.y.max(other.y);
41        let x2 = self.right().min(other.right());
42        let y2 = self.bottom().min(other.bottom());
43
44        if x2 <= x1 || y2 <= y1 {
45            LogicalRect {
46                x: x1,
47                y: y1,
48                width: 0,
49                height: 0,
50            }
51        } else {
52            LogicalRect {
53                x: x1,
54                y: y1,
55                width: x2 - x1,
56                height: y2 - y1,
57            }
58        }
59    }
60
61    #[inline(always)]
62    pub fn intersects(self, other: Self) -> bool {
63        self.y < other.y + other.height
64            && self.y + self.height > other.y
65            && self.x < other.x + other.width
66            && self.x + self.width > other.x
67    }
68
69    pub fn with_offset(mut self, offset_x: i32, offset_y: i32) -> Self {
70        self.x -= offset_x;
71        self.y -= offset_y;
72        self
73    }
74
75    pub const fn area(self) -> i64 {
76        (self.width as i64) * (self.height as i64)
77    }
78
79    pub const fn left(self) -> i32 {
80        self.x
81    }
82
83    pub const fn right(self) -> i32 {
84        self.x.saturating_add(self.width)
85    }
86
87    pub const fn top(self) -> i32 {
88        self.y
89    }
90
91    pub const fn bottom(self) -> i32 {
92        self.y.saturating_add(self.height)
93    }
94}
95
96pub struct Canvas<'a> {
97    buf: &'a mut Buffer,
98    rect: LogicalRect,
99    offset_x: i32,
100    offset_y: i32,
101}
102
103impl<'a> Canvas<'a> {
104    pub fn new(rect: LogicalRect, buf: &'a mut Buffer) -> Self {
105        Self {
106            rect,
107            buf,
108            offset_x: 0,
109            offset_y: 0,
110        }
111    }
112}
113
114impl Canvas<'_> {
115    fn set_stringn<T, S>(&mut self, x: i32, y: i32, text: T, max_width: usize, style: S)
116    where
117        T: AsRef<str>,
118        S: Into<Style>,
119    {
120        let buffer_area = {
121            let inner = LogicalRect::from(self.buf.area);
122
123            if self.clipped() {
124                inner.intersection(self.rect)
125            } else {
126                inner
127            }
128        };
129
130        let buf_x = self.get_buf_column(x);
131        let buf_y = self.get_buf_row(y);
132        let max_width = max_width.try_into().unwrap_or(i32::MAX);
133
134        if buf_y < buffer_area.top() || buf_y >= buffer_area.bottom() {
135            return;
136        }
137
138        let start = buf_x.max(buffer_area.left());
139
140        if start >= buffer_area.right() {
141            return;
142        }
143
144        let mut remaining = (buffer_area.right() - start).clamp(0, max_width) as u16;
145        let mut skip_width = (start - buf_x).max(0) as u16;
146        let mut cursor = start as u16;
147        let row = buf_y as u16;
148
149        let style = style.into();
150
151        for g in UnicodeSegmentation::graphemes(text.as_ref(), true) {
152            if g.contains(char::is_control) {
153                continue;
154            }
155
156            let width = g.width() as u16;
157
158            if width == 0 || skip_width > 0 {
159                skip_width = skip_width.saturating_sub(width);
160                continue;
161            }
162
163            if remaining < width {
164                break;
165            }
166
167            self.buf[(cursor, row)].set_symbol(g).set_style(style);
168
169            let end = cursor + width;
170            cursor += 1;
171
172            while cursor < end {
173                self.buf[(cursor, row)].reset();
174                cursor += 1;
175            }
176
177            remaining -= width;
178        }
179    }
180
181    fn get_buf_column(&self, x: i32) -> i32 {
182        self.rect.x + x - self.offset_x
183    }
184
185    fn get_buf_row(&self, y: i32) -> i32 {
186        self.rect.y + y - self.offset_y
187    }
188
189    fn clipped(&self) -> bool {
190        false
191    }
192}
193
194impl Canvas<'_> {
195    pub fn buffer_mut(&mut self) -> &mut Buffer {
196        self.buf
197    }
198
199    pub fn rect(&self) -> LogicalRect {
200        self.rect
201    }
202
203    pub fn area(&self) -> LogicalRect {
204        LogicalRect::new(0, 0, self.rect.width as u16, self.rect.height as u16)
205    }
206
207    pub fn set_offset(&mut self, offset_x: i32, offset_y: i32) {
208        self.offset_x = offset_x;
209        self.offset_y = offset_y;
210    }
211
212    pub fn text<T, S>(&mut self, x: i32, y: i32, text: T, style: S)
213    where
214        T: AsRef<str>,
215        S: Into<Style>,
216    {
217        self.set_stringn(x, y, text, usize::MAX, style)
218    }
219
220    pub fn render_widget(&mut self, rect: LogicalRect, widget: impl Widget) {
221        let rect = rect.with_offset(self.offset_x - self.rect.x, self.offset_y - self.rect.y);
222
223        if !rect.intersects(self.rect) {
224            return;
225        }
226
227        // Clip to canvas viewport
228        let clip = rect.intersection(self.rect);
229
230        // Clip to buffer bounds - this ensures non-negative coordinates
231        let buffer_bounds = LogicalRect::from(self.buf.area);
232        let final_clip = clip.intersection(buffer_bounds);
233
234        // Early exit if nothing visible in buffer
235        if final_clip.width <= 0 || final_clip.height <= 0 {
236            return;
237        }
238
239        // Now final_clip.{x,y} are guaranteed >= buffer_bounds.{x,y}
240        // For typical case where buffer starts at (0,0), they're guaranteed >= 0
241
242        // Create temporary buffer for widget rendering
243        let temp_rect = Rect {
244            x: 0,
245            y: 0,
246            width: rect.width.min(u16::MAX as i32) as u16,
247            height: rect.height.min(u16::MAX as i32) as u16,
248        };
249
250        let mut temp_buf = Buffer::empty(temp_rect);
251        widget.render(temp_rect, &mut temp_buf);
252
253        // Calculate source offset in temp buffer
254        // This tells us which part of the rendered widget to copy
255        let src_x0 = (final_clip.x - rect.x) as usize;
256        let src_y0 = (final_clip.y - rect.y) as usize;
257
258        // Calculate destination offset in canvas buffer
259        // Subtract buffer area offset to get array index
260        let dst_x0 = (final_clip.x - self.buf.area.x as i32) as usize;
261        let dst_y0 = (final_clip.y - self.buf.area.y as i32) as usize;
262
263        let src_stride = rect.width as usize;
264        let dst_stride = self.buf.area.width as usize;
265        let row_len = final_clip.width as usize;
266
267        // Copy visible portion from temp buffer to canvas buffer
268        for row in 0..final_clip.height as usize {
269            let src_row = (src_y0 + row) * src_stride + src_x0;
270            let dst_row = (dst_y0 + row) * dst_stride + dst_x0;
271
272            let src = &temp_buf.content[src_row..src_row + row_len];
273            let dst = &mut self.buf.content[dst_row..dst_row + row_len];
274            dst.clone_from_slice(src);
275        }
276    }
277}