1use crate::buffer::ScreenBuffer;
8use crate::cell::Cell;
9use crate::event::{Event, KeyCode, KeyEvent};
10use crate::geometry::Rect;
11use crate::segment::Segment;
12use crate::style::Style;
13use crate::text::truncate_to_display_width;
14use unicode_width::UnicodeWidthStr;
15
16use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
17
18#[derive(Clone, Debug)]
23pub struct RichLog {
24 entries: Vec<Vec<Segment>>,
26 scroll_offset: usize,
28 style: Style,
30 auto_scroll: bool,
32 border: BorderStyle,
34}
35
36impl RichLog {
37 pub fn new() -> Self {
39 Self {
40 entries: Vec::new(),
41 scroll_offset: 0,
42 style: Style::default(),
43 auto_scroll: true,
44 border: BorderStyle::None,
45 }
46 }
47
48 #[must_use]
50 pub fn with_style(mut self, style: Style) -> Self {
51 self.style = style;
52 self
53 }
54
55 #[must_use]
57 pub fn with_border(mut self, border: BorderStyle) -> Self {
58 self.border = border;
59 self
60 }
61
62 #[must_use]
64 pub fn with_auto_scroll(mut self, enabled: bool) -> Self {
65 self.auto_scroll = enabled;
66 self
67 }
68
69 pub fn push(&mut self, entry: Vec<Segment>) {
71 self.entries.push(entry);
72 if self.auto_scroll {
73 self.scroll_offset = self.entries.len().saturating_sub(1);
79 }
80 }
81
82 pub fn push_text(&mut self, text: &str) {
84 self.entries.push(vec![Segment::new(text)]);
85 if self.auto_scroll {
86 self.scroll_offset = self.entries.len().saturating_sub(1);
87 }
88 }
89
90 pub fn clear(&mut self) {
92 self.entries.clear();
93 self.scroll_offset = 0;
94 }
95
96 pub fn len(&self) -> usize {
98 self.entries.len()
99 }
100
101 pub fn is_empty(&self) -> bool {
103 self.entries.is_empty()
104 }
105
106 pub fn scroll_to_bottom(&mut self) {
108 if !self.entries.is_empty() {
109 self.scroll_offset = self.entries.len().saturating_sub(1);
110 }
111 }
112
113 pub fn scroll_to_top(&mut self) {
115 self.scroll_offset = 0;
116 }
117
118 pub fn scroll_offset(&self) -> usize {
120 self.scroll_offset
121 }
122}
123
124impl Default for RichLog {
125 fn default() -> Self {
126 Self::new()
127 }
128}
129
130impl Widget for RichLog {
131 fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
132 if area.size.width == 0 || area.size.height == 0 {
133 return;
134 }
135
136 super::border::render_border(area, self.border, self.style.clone(), buf);
138
139 let inner = super::border::inner_area(area, self.border);
140 if inner.size.width == 0 || inner.size.height == 0 {
141 return;
142 }
143
144 let height = inner.size.height as usize;
145 let width = inner.size.width as usize;
146
147 let max_offset = self.entries.len().saturating_sub(height.max(1));
149 let scroll = self.scroll_offset.min(max_offset);
150
151 let visible_end = (scroll + height).min(self.entries.len());
152
153 for (row, entry_idx) in (scroll..visible_end).enumerate() {
154 let y = inner.position.y + row as u16;
155 if let Some(entry) = self.entries.get(entry_idx) {
156 let mut col: u16 = 0;
157 for segment in entry {
158 if col as usize >= width {
159 break;
160 }
161 let remaining = width.saturating_sub(col as usize);
162 let truncated = truncate_to_display_width(&segment.text, remaining);
163 for ch in truncated.chars() {
164 let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
165 if col as usize + char_w > width {
166 break;
167 }
168 let x = inner.position.x + col;
169 buf.set(x, y, Cell::new(ch.to_string(), segment.style.clone()));
170 col += char_w as u16;
171 }
172 }
173 }
174 }
175 }
176}
177
178impl InteractiveWidget for RichLog {
179 fn handle_event(&mut self, event: &Event) -> EventResult {
180 let Event::Key(KeyEvent { code, .. }) = event else {
181 return EventResult::Ignored;
182 };
183
184 match code {
185 KeyCode::Up => {
186 if self.scroll_offset > 0 {
187 self.scroll_offset -= 1;
188 self.auto_scroll = false;
189 }
190 EventResult::Consumed
191 }
192 KeyCode::Down => {
193 if !self.entries.is_empty()
194 && self.scroll_offset < self.entries.len().saturating_sub(1)
195 {
196 self.scroll_offset += 1;
197 self.auto_scroll = false;
198 }
199 EventResult::Consumed
200 }
201 KeyCode::PageUp => {
202 let page = 20;
204 self.scroll_offset = self.scroll_offset.saturating_sub(page);
205 self.auto_scroll = false;
206 EventResult::Consumed
207 }
208 KeyCode::PageDown => {
209 let page = 20;
210 if !self.entries.is_empty() {
211 self.scroll_offset =
212 (self.scroll_offset + page).min(self.entries.len().saturating_sub(1));
213 self.auto_scroll = false;
214 }
215 EventResult::Consumed
216 }
217 KeyCode::Home => {
218 self.scroll_to_top();
219 self.auto_scroll = false;
220 EventResult::Consumed
221 }
222 KeyCode::End => {
223 self.scroll_to_bottom();
224 EventResult::Consumed
226 }
227 _ => EventResult::Ignored,
228 }
229 }
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used)]
234mod tests {
235 use super::*;
236 use crate::geometry::Size;
237 use crate::style::Style;
238
239 fn make_segment(text: &str) -> Segment {
240 Segment::new(text)
241 }
242
243 fn styled_segment(text: &str, style: Style) -> Segment {
244 Segment::styled(text, style)
245 }
246
247 #[test]
248 fn new_log_is_empty() {
249 let log = RichLog::new();
250 assert!(log.is_empty());
251 assert_eq!(log.len(), 0);
252 assert_eq!(log.scroll_offset(), 0);
253 }
254
255 #[test]
256 fn default_matches_new() {
257 let log: RichLog = Default::default();
258 assert!(log.is_empty());
259 assert_eq!(log.len(), 0);
260 }
261
262 #[test]
263 fn push_adds_entries() {
264 let mut log = RichLog::new();
265 log.push(vec![make_segment("line 1")]);
266 log.push(vec![make_segment("line 2")]);
267 assert_eq!(log.len(), 2);
268 assert!(!log.is_empty());
269 }
270
271 #[test]
272 fn push_text_convenience() {
273 let mut log = RichLog::new();
274 log.push_text("hello");
275 assert_eq!(log.len(), 1);
276 }
277
278 #[test]
279 fn clear_resets() {
280 let mut log = RichLog::new();
281 log.push_text("a");
282 log.push_text("b");
283 log.clear();
284 assert!(log.is_empty());
285 assert_eq!(log.scroll_offset(), 0);
286 }
287
288 #[test]
289 fn render_empty_log() {
290 let log = RichLog::new();
291 let mut buf = ScreenBuffer::new(Size::new(20, 5));
292 log.render(Rect::new(0, 0, 20, 5), &mut buf);
293 assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some(" "));
295 }
296
297 #[test]
298 fn render_with_entries() {
299 let mut log = RichLog::new().with_auto_scroll(false);
300 log.push_text("hello");
301 log.push_text("world");
302
303 let mut buf = ScreenBuffer::new(Size::new(10, 5));
304 log.render(Rect::new(0, 0, 10, 5), &mut buf);
305
306 assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("h"));
307 assert_eq!(buf.get(1, 0).map(|c| c.grapheme.as_str()), Some("e"));
308 assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("w"));
309 }
310
311 #[test]
312 fn render_with_multi_segment_entries() {
313 let mut log = RichLog::new().with_auto_scroll(false);
314 let bold = Style::new().bold(true);
315 log.push(vec![styled_segment("bold", bold), make_segment(" normal")]);
316
317 let mut buf = ScreenBuffer::new(Size::new(20, 5));
318 log.render(Rect::new(0, 0, 20, 5), &mut buf);
319
320 let cell_b = buf.get(0, 0);
322 assert!(cell_b.is_some());
323 assert_eq!(cell_b.map(|c| c.grapheme.as_str()), Some("b"));
324 assert!(cell_b.map(|c| c.style.bold).unwrap_or(false));
325
326 let cell_space = buf.get(4, 0);
328 assert_eq!(cell_space.map(|c| c.grapheme.as_str()), Some(" "));
329 }
330
331 #[test]
332 fn render_with_border() {
333 let mut log = RichLog::new()
334 .with_border(BorderStyle::Single)
335 .with_auto_scroll(false);
336 log.push_text("hi");
337
338 let mut buf = ScreenBuffer::new(Size::new(10, 5));
339 log.render(Rect::new(0, 0, 10, 5), &mut buf);
340
341 let corner = buf.get(0, 0).map(|c| c.grapheme.as_str());
343 assert_eq!(corner, Some("\u{250c}"));
344
345 assert_eq!(buf.get(1, 1).map(|c| c.grapheme.as_str()), Some("h"));
347 }
348
349 #[test]
350 fn scroll_operations() {
351 let mut log = RichLog::new().with_auto_scroll(false);
352 for i in 0..20 {
353 log.push_text(&format!("line {i}"));
354 }
355
356 log.scroll_to_bottom();
357 assert_eq!(log.scroll_offset(), 19);
358
359 log.scroll_to_top();
360 assert_eq!(log.scroll_offset(), 0);
361 }
362
363 #[test]
364 fn auto_scroll_on_push() {
365 let mut log = RichLog::new().with_auto_scroll(true);
366 log.push_text("a");
367 assert_eq!(log.scroll_offset(), 0);
368 log.push_text("b");
369 assert_eq!(log.scroll_offset(), 1);
370 log.push_text("c");
371 assert_eq!(log.scroll_offset(), 2);
372 }
373
374 #[test]
375 fn manual_scroll_disables_auto_scroll() {
376 let mut log = RichLog::new().with_auto_scroll(true);
377 for _ in 0..10 {
378 log.push_text("line");
379 }
380
381 let event = Event::Key(KeyEvent::plain(KeyCode::Up));
383 let result = log.handle_event(&event);
384 assert_eq!(result, EventResult::Consumed);
385
386 let prev_offset = log.scroll_offset();
388 log.push_text("new line");
389 assert_eq!(log.scroll_offset(), prev_offset);
391 }
392
393 #[test]
394 fn keyboard_navigation() {
395 let mut log = RichLog::new().with_auto_scroll(false);
396 for i in 0..30 {
397 log.push_text(&format!("line {i}"));
398 }
399
400 let down = Event::Key(KeyEvent::plain(KeyCode::Down));
402 log.handle_event(&down);
403 assert_eq!(log.scroll_offset(), 1);
404
405 let up = Event::Key(KeyEvent::plain(KeyCode::Up));
407 log.handle_event(&up);
408 assert_eq!(log.scroll_offset(), 0);
409
410 log.handle_event(&up);
412 assert_eq!(log.scroll_offset(), 0);
413
414 let pgdn = Event::Key(KeyEvent::plain(KeyCode::PageDown));
416 log.handle_event(&pgdn);
417 assert_eq!(log.scroll_offset(), 20);
418
419 let pgup = Event::Key(KeyEvent::plain(KeyCode::PageUp));
421 log.handle_event(&pgup);
422 assert_eq!(log.scroll_offset(), 0);
423
424 let end = Event::Key(KeyEvent::plain(KeyCode::End));
426 log.handle_event(&end);
427 assert_eq!(log.scroll_offset(), 29);
428
429 let home = Event::Key(KeyEvent::plain(KeyCode::Home));
431 log.handle_event(&home);
432 assert_eq!(log.scroll_offset(), 0);
433 }
434
435 #[test]
436 fn empty_log_keyboard_events_graceful() {
437 let mut log = RichLog::new();
438 let down = Event::Key(KeyEvent::plain(KeyCode::Down));
439 let result = log.handle_event(&down);
440 assert_eq!(result, EventResult::Consumed);
441 assert_eq!(log.scroll_offset(), 0);
442 }
443
444 #[test]
445 fn utf8_safety_wide_chars() {
446 let mut log = RichLog::new().with_auto_scroll(false);
447 log.push_text("日本語テスト");
448 log.push_text("Hello 🎉 World");
449
450 let mut buf = ScreenBuffer::new(Size::new(10, 5));
451 log.render(Rect::new(0, 0, 10, 5), &mut buf);
452
453 let first_cell = buf.get(0, 0).map(|c| c.grapheme.as_str());
455 assert_eq!(first_cell, Some("日"));
456 }
457
458 #[test]
459 fn overflow_truncation() {
460 let mut log = RichLog::new().with_auto_scroll(false);
461 log.push_text("This is a very long line that should be truncated to fit");
462
463 let mut buf = ScreenBuffer::new(Size::new(10, 1));
464 log.render(Rect::new(0, 0, 10, 1), &mut buf);
465
466 assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("T"));
468 assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some(" "));
469 assert_eq!(buf.get(5, 0).map(|c| c.grapheme.as_str()), Some("i"));
470 }
471
472 #[test]
473 fn unhandled_event_returns_ignored() {
474 let mut log = RichLog::new();
475 let event = Event::Key(KeyEvent::plain(KeyCode::Char('a')));
476 assert_eq!(log.handle_event(&event), EventResult::Ignored);
477 }
478
479 #[test]
480 fn builder_pattern() {
481 let log = RichLog::new()
482 .with_style(Style::new().bold(true))
483 .with_border(BorderStyle::Rounded)
484 .with_auto_scroll(false);
485
486 assert!(!log.auto_scroll);
487 assert!(matches!(log.border, BorderStyle::Rounded));
488 }
489}