Skip to main content

lv_tui/
render.rs

1use crate::buffer::Buffer;
2use crate::geom::{Pos, Rect};
3use crate::node::NodeId;
4use crate::style::{Border, Style, TextAlign, TextTruncate, TextWrap};
5
6/// 渲染上下文——组件通过它输出内容到缓冲区
7pub struct RenderCx<'a> {
8    pub rect: Rect,
9    pub buffer: &'a mut Buffer,
10    pub cursor: Pos,
11    pub style: Style,
12    /// 当前焦点节点
13    pub focused_id: Option<NodeId>,
14    /// 裁剪区域(覆盖 rect 的默认裁剪,用于 Scroll 等)
15    pub clip_rect: Option<Rect>,
16    /// 文本换行模式
17    pub wrap: TextWrap,
18    /// 文本截断模式
19    pub truncate: TextTruncate,
20    /// 文本对齐
21    pub align: TextAlign,
22}
23
24impl<'a> RenderCx<'a> {
25    pub fn new(rect: Rect, buffer: &'a mut Buffer, style: Style) -> Self {
26        let cursor = Pos {
27            x: rect.x,
28            y: rect.y,
29        };
30        Self {
31            rect,
32            buffer,
33            cursor,
34            style,
35            focused_id: None,
36            clip_rect: None,
37            wrap: TextWrap::None,
38            truncate: TextTruncate::None,
39            align: TextAlign::Left,
40        }
41    }
42
43    /// 检查指定节点是否为当前焦点
44    pub fn is_focused(&self, id: NodeId) -> bool {
45        self.focused_id == Some(id)
46    }
47
48    /// 计算对齐偏移(根据当前 align 设置和有效裁剪区域)
49    pub fn align_offset(&self, text_width: u16) -> u16 {
50        let clip = self.effective_clip();
51        let available = clip.x.saturating_add(clip.width).saturating_sub(self.rect.x);
52        match self.align {
53            TextAlign::Left => 0,
54            TextAlign::Center => available.saturating_sub(text_width) / 2,
55            TextAlign::Right => available.saturating_sub(text_width),
56        }
57    }
58
59    /// 写入文本使用的实际裁剪区域
60    fn effective_clip(&self) -> Rect {
61        self.clip_rect.unwrap_or(self.rect)
62    }
63
64    /// 换行时使用的裁剪(放宽垂直限制)
65    fn wrap_clip(&self) -> Rect {
66        let mut clip = self.effective_clip();
67        if self.wrap != TextWrap::None {
68            clip.height = u16::MAX.saturating_sub(clip.y); // 垂直不裁剪
69        }
70        clip
71    }
72
73    /// 在 cursor 位置写入文本,推进 x(考虑宽字符和截断)
74    pub fn text(&mut self, text: impl AsRef<str>) {
75        let text = text.as_ref();
76        let clip = self.effective_clip();
77        let available = clip.x.saturating_add(clip.width).saturating_sub(self.cursor.x);
78
79        if self.wrap == TextWrap::None && self.truncate == TextTruncate::Ellipsis {
80            let total: u16 = text.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
81            if total > available && available >= 1 {
82                let mut used: u16 = 0;
83                let mut bytes = 0;
84                for ch in text.chars() {
85                    let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
86                    if used + w > available.saturating_sub(1) { break; }
87                    used += w;
88                    bytes += ch.len_utf8();
89                }
90                if bytes > 0 {
91                    self.buffer.write_text(self.cursor, clip, &text[..bytes], &self.style);
92                    self.cursor.x = self.cursor.x.saturating_add(used);
93                }
94                self.buffer.write_text(self.cursor, clip, "…", &self.style);
95                self.cursor.x = self.cursor.x.saturating_add(1);
96                return;
97            }
98        }
99
100        self.buffer.write_text(self.cursor, clip, text, &self.style);
101        let width: u16 = text.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
102        self.cursor.x = self.cursor.x.saturating_add(width);
103    }
104
105    /// 写入文本后将 cursor 移到下一行行首(支持换行和截断)
106    pub fn line(&mut self, text: impl AsRef<str>) {
107        let text = text.as_ref();
108        let clip = self.wrap_clip();
109        let available = if clip.width >= self.cursor.x.saturating_sub(clip.x) {
110            clip.width.saturating_sub(self.cursor.x.saturating_sub(clip.x))
111        } else {
112            0
113        };
114
115        let saved_clip = self.clip_rect;
116        if self.wrap != TextWrap::None {
117            self.clip_rect = Some(clip); // 换行时放宽垂直裁剪
118        }
119
120        match self.wrap {
121            TextWrap::None => {
122                let tw: u16 = text.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
123                self.cursor.x = self.rect.x.saturating_add(self.align_offset(tw));
124                match self.truncate {
125                    TextTruncate::None => {
126                        self.text(text);
127                    }
128                    TextTruncate::Ellipsis => {
129                        let total = tw;
130                        if total > available && available >= 1 {
131                            let mut used: u16 = 0;
132                            let mut bytes = 0;
133                            for ch in text.chars() {
134                                let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
135                                if used + w > available.saturating_sub(1) { break; }
136                                used += w;
137                                bytes += ch.len_utf8();
138                            }
139                            if bytes > 0 {
140                                self.text(&text[..bytes]);
141                            }
142                            self.text("…");
143                        } else {
144                            self.text(text);
145                        }
146                    }
147                }
148                self.cursor.y = self.cursor.y.saturating_add(1);
149                self.cursor.x = self.rect.x;
150            }
151            TextWrap::Char => {
152                let mut remaining = text;
153                loop {
154                    let line_widths: Vec<(usize, u16)> = remaining
155                        .char_indices()
156                        .map(|(i, c)| (i, unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16))
157                        .filter(|&(_, w)| w > 0)
158                        .collect();
159
160                    let mut used: u16 = 0;
161                    let mut bytes = 0;
162                    for &(byte_idx, w) in &line_widths {
163                        if used + w > available { break; }
164                        used += w;
165                        bytes = byte_idx + remaining[byte_idx..].chars().next().map(|c| c.len_utf8()).unwrap_or(0);
166                    }
167                    if bytes == 0 { break; }
168                    let line_text = &remaining[..bytes];
169                    let lw: u16 = line_text.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
170                    self.cursor.x = self.rect.x.saturating_add(self.align_offset(lw));
171                    self.text(line_text);
172                    remaining = &remaining[bytes..];
173                    if remaining.is_empty() { break; }
174                    self.cursor.y = self.cursor.y.saturating_add(1);
175                    self.cursor.x = self.rect.x;
176                }
177                if text.is_empty() {
178                    self.cursor.y = self.cursor.y.saturating_add(1);
179                    self.cursor.x = self.rect.x;
180                }
181            }
182            TextWrap::Word => {
183                let mut remaining = text;
184                loop {
185                    // Find the next word boundary: split at spaces for ASCII,
186                    // treat each CJK char as its own word
187                    let (word, rest) = next_word(remaining);
188                    if word.is_empty() { break; }
189
190                    let ww: u16 = word.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
191
192                    // If word doesn't fit on current line and we have content, start new line
193                    let cur_w: u16 = self.cursor.x.saturating_sub(self.rect.x);
194                    if cur_w + ww > available && cur_w > 0 {
195                        self.cursor.y = self.cursor.y.saturating_add(1);
196                        self.cursor.x = self.rect.x;
197                    }
198                    self.text(word);
199                    remaining = rest;
200                }
201                if text.is_empty() {
202                    self.cursor.y = self.cursor.y.saturating_add(1);
203                    self.cursor.x = self.rect.x;
204                }
205                // Always advance to next line after WordWrap rendering
206                self.cursor.y = self.cursor.y.saturating_add(1);
207                self.cursor.x = self.rect.x;
208            }
209        }
210
211        self.clip_rect = saved_clip;
212    }
213
214    /// 修改当前渲染样式
215    pub fn set_style(&mut self, style: Style) {
216        self.style = style;
217    }
218
219    /// 绘制边框(使用当前样式,遵循 clip_rect)
220    pub fn draw_border(&mut self, border: Border) {
221        let clip = self.effective_clip();
222        self.buffer.draw_border(clip, border, &self.style);
223    }
224}
225
226/// Split the next word from `text`. Words are space-delimited for ASCII;
227/// CJK characters (wide) are treated as individual words.
228fn next_word(text: &str) -> (&str, &str) {
229    let mut chars = text.char_indices().peekable();
230
231    // Skip leading spaces
232    while let Some(&(_i, c)) = chars.peek() {
233        if c == ' ' { chars.next(); } else { break; }
234    }
235
236    let start = chars.peek().map(|&(i, _)| i).unwrap_or(text.len());
237    if start >= text.len() { return ("", ""); }
238
239    // Check if first char is CJK (wide)
240    if let Some(&(_, c)) = chars.peek() {
241        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
242        if w > 1 {
243            let end = start + c.len_utf8();
244            return (&text[start..end], &text[end..]);
245        }
246    }
247
248    // Collect until space or CJK char, INCLUDING the trailing space
249    let mut end = text.len();
250    for (i, c) in text[start..].char_indices() {
251        if c == ' ' {
252            end = start + i + c.len_utf8(); // include the space
253            break;
254        }
255        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
256        if w > 1 {
257            if i == 0 {
258                end = start + c.len_utf8();
259            } else {
260                end = start + i;
261            }
262            break;
263        }
264    }
265    (&text[start..end], &text[end..])
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::buffer::Buffer;
272    use crate::geom::{Rect, Size};
273
274    #[test]
275    fn test_word_wrap_simple() {
276        let mut buf = Buffer::new(Size { width: 20, height: 3 });
277        let mut cx = RenderCx::new(Rect { x: 0, y: 0, width: 8, height: 3 }, &mut buf, Style::default());
278        cx.wrap = TextWrap::Word;
279        cx.line("hello world");
280        // "hello" width=5 fits on line 0, "world" width=5 doesn't fit → line 1
281        assert_eq!(&buf.cells[0].symbol, "h");
282        // "world" should be at line 1, col 0 → index 20
283        assert_eq!(&buf.cells[20].symbol, "w");
284    }
285
286    #[test]
287    fn test_next_word_english() {
288        let (w, r) = next_word("hello world");
289        assert_eq!(w, "hello ");
290        assert_eq!(r, "world");
291    }
292
293    #[test]
294    fn test_next_word_cjk() {
295        let (w, r) = next_word("你好世界");
296        assert_eq!(w, "你");
297        assert_eq!(r, "好世界");
298    }
299
300    #[test]
301    fn test_word_wrap_long() {
302        let mut buf = Buffer::new(Size { width: 20, height: 2 });
303        let mut cx = RenderCx::new(Rect { x: 0, y: 0, width: 5, height: 2 }, &mut buf, Style::default());
304        cx.wrap = TextWrap::Word;
305        cx.line("superlongword");
306        // Single long word wraps by character
307        assert_eq!(&buf.cells[0].symbol, "s");
308    }
309}