Skip to main content

flywheel/widget/
stream.rs

1//! Stream Widget: The core streaming text display widget.
2//!
3//! This widget provides optimistic append with automatic fallback to
4//! slow-path rendering when needed.
5//!
6//! # Usage
7//!
8//! The recommended API is [`StreamWidget::push`], which handles all
9//! rendering optimizations internally:
10//!
11//! ```ignore
12//! stream.push(&engine, "Hello world");
13//! ```
14//!
15//! The engine automatically chooses between:
16//! - **Fast Path**: Direct ANSI emission for simple appends (0ms latency)
17//! - **Slow Path**: Buffer update for wrapping/scrolling (next frame)
18
19use super::scroll_buffer::ScrollBuffer;
20use crate::actor::Engine;
21use crate::buffer::{Buffer, Cell, Rgb};
22use crate::layout::Rect;
23use std::io::Write;
24use unicode_segmentation::UnicodeSegmentation;
25use unicode_width::UnicodeWidthStr;
26
27/// Configuration for the stream widget.
28#[derive(Debug, Clone)]
29pub struct StreamConfig {
30    /// Maximum lines to keep in scrollback.
31    pub max_scrollback: usize,
32    /// Default foreground color.
33    pub default_fg: Rgb,
34    /// Default background color.
35    pub default_bg: Rgb,
36    /// Whether to auto-scroll when new content arrives.
37    pub auto_scroll: bool,
38    /// Whether to enable word wrapping.
39    pub word_wrap: bool,
40}
41
42impl Default for StreamConfig {
43    fn default() -> Self {
44        Self {
45            max_scrollback: 10000,
46            default_fg: Rgb::new(220, 220, 220),
47            default_bg: Rgb::DEFAULT_BG,
48            auto_scroll: true,
49            word_wrap: true,
50        }
51    }
52}
53
54/// Result of an append operation.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum AppendResult {
57    /// Content was appended using fast path (direct cursor write).
58    FastPath {
59        /// Number of characters appended.
60        chars: usize,
61        /// Starting column of the append.
62        start_col: u16,
63        /// Row of the append.
64        row: u16,
65    },
66    /// Content required slow path (dirty rect for diffing).
67    SlowPath {
68        /// The dirty rectangle that needs re-rendering.
69        dirty_rect: Rect,
70    },
71    /// No content was appended (empty string).
72    Empty,
73}
74
75/// A streaming text widget optimized for LLM token output.
76///
77/// This widget maintains its own content buffer and provides two
78/// rendering paths:
79///
80/// - **Fast path**: Direct cursor-based append for simple cases
81/// - **Slow path**: Full dirty-rect re-render for complex cases
82pub struct StreamWidget {
83    /// Widget bounds within the terminal.
84    bounds: Rect,
85    /// Configuration.
86    config: StreamConfig,
87    /// Content buffer.
88    content: ScrollBuffer,
89    /// Current cursor column within the visible area.
90    cursor_col: u16,
91    /// Current cursor row within the visible area.
92    cursor_row: u16,
93    /// Current foreground color.
94    current_fg: Rgb,
95    /// Current background color.
96    current_bg: Rgb,
97    /// Whether the widget needs a full redraw.
98    needs_full_redraw: bool,
99    /// Dirty rectangles accumulated since last render.
100    dirty_rects: Vec<Rect>,
101}
102
103impl StreamWidget {
104    /// Create a new stream widget with the given bounds.
105    pub fn new(bounds: Rect) -> Self {
106        Self::with_config(bounds, StreamConfig::default())
107    }
108
109    /// Create a new stream widget with custom configuration.
110    pub fn with_config(bounds: Rect, config: StreamConfig) -> Self {
111        Self {
112            bounds,
113            current_fg: config.default_fg,
114            current_bg: config.default_bg,
115            content: ScrollBuffer::new(config.max_scrollback),
116            config,
117            cursor_col: 0,
118            cursor_row: 0,
119            needs_full_redraw: true,
120            dirty_rects: Vec::new(),
121        }
122    }
123
124    /// Get the widget bounds.
125    pub const fn bounds(&self) -> Rect {
126        self.bounds
127    }
128
129    /// Set new bounds for the widget.
130    pub fn set_bounds(&mut self, bounds: Rect) {
131        if bounds != self.bounds {
132            self.bounds = bounds;
133            self.needs_full_redraw = true;
134        }
135    }
136
137    /// Set the foreground color for subsequent text.
138    pub const fn set_fg(&mut self, fg: Rgb) {
139        self.current_fg = fg;
140    }
141
142    /// Set the background color for subsequent text.
143    pub const fn set_bg(&mut self, bg: Rgb) {
144        self.current_bg = bg;
145    }
146
147    /// Reset colors to defaults.
148    pub const fn reset_colors(&mut self) {
149        self.current_fg = self.config.default_fg;
150        self.current_bg = self.config.default_bg;
151    }
152
153    /// Check if fast path append is possible for the given text.
154    ///
155    /// Fast path is possible when:
156    /// 1. We're at the bottom of the scroll buffer
157    /// 2. The text doesn't contain newlines
158    /// 3. The text fits on the current line without wrapping
159    /// 4. No scrolling is needed
160    fn can_fast_path(&self, text: &str) -> bool {
161        // Must be at bottom for fast path
162        if !self.content.at_bottom() {
163            return false;
164        }
165
166        // No newlines allowed in fast path
167        if text.contains('\n') {
168            return false;
169        }
170
171        // Check if text fits on current line
172        let text_width = UnicodeWidthStr::width(text);
173        let available = (self.bounds.width as usize).saturating_sub(self.cursor_col as usize);
174
175        text_width <= available
176    }
177
178    /// Append text using the fast path.
179    ///
180    /// This directly emits ANSI sequences without going through the diffing
181    /// engine. Only call this after checking `can_fast_path()`.
182    fn append_fast_path(&mut self, text: &str) -> AppendResult {
183        let start_col = self.cursor_col;
184        let row = self.cursor_row;
185        let mut char_count = 0;
186
187        // Append to content buffer
188        let cells = text.graphemes(true).filter_map(|g| {
189             Cell::from_grapheme(g).map(|mut c| {
190                 c.set_fg(self.current_fg);
191                 c.set_bg(self.current_bg);
192                 c
193             })
194        });
195        self.content.append(cells);
196
197        // Update cursor position
198        for grapheme in text.graphemes(true) {
199            let width = UnicodeWidthStr::width(grapheme);
200            // safe cast: can_fast_path ensures it fits in width
201            self.cursor_col += u16::try_from(width).unwrap_or(0);
202            char_count += 1;
203        }
204
205        AppendResult::FastPath {
206            chars: char_count,
207            start_col,
208            row,
209        }
210    }
211
212    /// Append text using the slow path.
213    ///
214    /// This processes the text, handling newlines and wrapping, and marks
215    /// the affected area as dirty for the diffing engine.
216    fn append_slow_path(&mut self, text: &str) -> AppendResult {
217        let initial_row = self.cursor_row;
218        let mut max_row = self.cursor_row;
219        let initial_col = self.cursor_col;
220        let mut min_touched_col = self.cursor_col;
221        let mut max_col = self.cursor_col;
222
223        for ch in text.chars() {
224            match ch {
225                '\n' => {
226                    // Hard newline
227                    let was_at_bottom = self.content.at_bottom();
228                    self.content.newline(false);
229                    if !was_at_bottom {
230                        self.content.scroll_up(1);
231                    }
232                    
233                    max_col = max_col.max(self.cursor_col);
234                    self.cursor_col = 0;
235                    min_touched_col = 0; // Newline starts at 0
236                    self.cursor_row += 1;
237
238                    // Check for scroll
239                    if self.cursor_row >= self.bounds.height {
240                        self.handle_scroll(was_at_bottom);
241                    }
242                }
243                '\r' => {
244                    // Carriage return
245                    self.cursor_col = 0;
246                    min_touched_col = 0;
247                }
248                '\t' => {
249                    // Tab - expand to spaces
250                    let spaces = 4 - (self.cursor_col % 4);
251                    for _ in 0..spaces {
252                        self.append_char(' ');
253                    }
254                }
255                _ => {
256                    self.append_char(ch);
257                }
258            }
259
260            max_row = max_row.max(self.cursor_row);
261            max_col = max_col.max(self.cursor_col);
262            
263            // If wrap happened in append_char, min_touched_col should be updated in a real implementation
264            if self.cursor_col < initial_col && self.cursor_row > initial_row {
265                 min_touched_col = 0;
266            }
267        }
268
269        // Calculate dirty rect
270        let dirty_rect = Rect {
271            x: self.bounds.x + min_touched_col,
272            y: self.bounds.y + initial_row,
273            width: self.bounds.width,
274            height: (max_row - initial_row + 1).max(1),
275        };
276
277        if !self.needs_full_redraw {
278             self.dirty_rects.push(dirty_rect);
279        }
280
281        AppendResult::SlowPath { dirty_rect }
282    }
283
284    /// Append a single character, handling wrapping.
285    #[allow(clippy::cast_possible_truncation)]
286    fn append_char(&mut self, ch: char) {
287        let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
288
289        // Check for wrap
290        if self.cursor_col + char_width > self.bounds.width {
291            if self.config.word_wrap {
292                let was_at_bottom = self.content.at_bottom();
293                self.content.newline(true);
294                if !was_at_bottom {
295                    self.content.scroll_up(1);
296                }
297                
298                self.cursor_col = 0;
299                self.cursor_row += 1;
300
301                if self.cursor_row >= self.bounds.height {
302                    self.handle_scroll(was_at_bottom);
303                }
304            } else {
305                // No wrap - just don't add the character
306                return;
307            }
308        }
309
310        // Add character to content
311        let mut cell = Cell::from_char(ch);
312        cell.set_fg(self.current_fg);
313        cell.set_bg(self.current_bg);
314        
315        self.content.append(std::iter::once(cell));
316        self.cursor_col += char_width;
317    }
318
319    /// Handle scrolling when cursor goes past bottom.
320    const fn handle_scroll(&mut self, was_at_bottom: bool) {
321        // Keep cursor at bottom row
322        self.cursor_row = self.bounds.height - 1;
323
324        // If we were at bottom and auto-scrolling is on, stick to bottom.
325        // Otherwise, stay detached (sticky scroll).
326        if self.config.auto_scroll && was_at_bottom {
327            self.content.scroll_to_bottom();
328        }
329
330        // Full redraw needed when scrolling
331        self.needs_full_redraw = true;
332    }
333
334    /// Append text to the widget.
335    ///
336    /// This automatically chooses between fast and slow path based on
337    /// the text content and current state.
338    pub fn append(&mut self, text: &str) -> AppendResult {
339        if text.is_empty() {
340            return AppendResult::Empty;
341        }
342
343        if self.can_fast_path(text) {
344            self.append_fast_path(text)
345        } else {
346            self.append_slow_path(text)
347        }
348    }
349
350    /// Render the widget to a buffer.
351    ///
352    /// This renders the visible content to the given buffer.
353    #[allow(clippy::cast_possible_truncation)]
354    pub fn render(&mut self, buffer: &mut Buffer) {
355        let viewport_height = self.bounds.height as usize;
356
357        // Get visible lines
358        let visible_lines: Vec<_> = self.content.visible_lines(viewport_height).collect();
359
360        // Render each line
361        for (row, line) in visible_lines.iter().enumerate() {
362            let y = self.bounds.y + row as u16;
363            if y >= self.bounds.y + self.bounds.height {
364                break;
365            }
366
367            let mut col = 0u16;
368            for cell in &line.content {
369                if col >= self.bounds.width {
370                    break;
371                }
372
373                let x = self.bounds.x + col;
374                // buffer.set(x, y, *cell); // Direct set since cell has grapheme and style
375                // But wait, buffer.set takes x, y, Cell.
376                buffer.set(x, y, *cell); 
377                
378                col += u16::from(cell.display_width());
379            }
380
381            // Clear rest of line
382            while col < self.bounds.width {
383                let x = self.bounds.x + col;
384                buffer.set(x, y, Cell::new(' ').with_fg(self.current_fg).with_bg(self.current_bg));
385                col += 1;
386            }
387        }
388
389        // Clear any remaining rows
390        for row in visible_lines.len()..viewport_height {
391            let y = self.bounds.y + row as u16;
392            for col in 0..self.bounds.width {
393                let x = self.bounds.x + col;
394                buffer.set(x, y, Cell::new(' ').with_fg(self.current_fg).with_bg(self.current_bg));
395            }
396        }
397
398        self.needs_full_redraw = false;
399        self.dirty_rects.clear();
400    }
401
402    /// Write fast-path output directly to an output buffer.
403    ///
404    /// This generates ANSI sequences for direct terminal output,
405    /// bypassing the buffer diffing.
406    pub fn write_fast_path(
407        &self,
408        result: AppendResult,
409        text: &str,
410        output: &mut Vec<u8>,
411    ) {
412        if let AppendResult::FastPath { start_col, row, .. } = result {
413            // Move cursor to position
414            let abs_x = self.bounds.x + start_col + 1; // 1-indexed
415            let abs_y = self.bounds.y + row + 1; // 1-indexed
416
417            let _ = write!(output, "\x1b[{abs_y};{abs_x}H");
418
419            // Set colors
420            let _ = write!(
421                output,
422                "\x1b[38;2;{};{};{}m\x1b[48;2;{};{};{}m",
423                self.current_fg.r, self.current_fg.g, self.current_fg.b,
424                self.current_bg.r, self.current_bg.g, self.current_bg.b
425            );
426
427            // Write text
428            output.extend_from_slice(text.as_bytes());
429        }
430    }
431
432    /// Append text and perform fast-path generation if possible.
433    ///
434    /// If the text was successfully appended via fast path (no wrap, no scroll),
435    /// the ANSI sequence is written to `output` and `true` is returned.
436    /// Otherwise returns `false` (caller should rely on standard cycle).
437    pub fn append_fast_into(&mut self, text: &str, output: &mut Vec<u8>) -> bool {
438        let result = self.append(text);
439        if let AppendResult::FastPath { .. } = result {
440            self.write_fast_path(result, text, output);
441            true
442        } else {
443            false
444        }
445    }
446
447    /// Push text to the stream with automatic optimization.
448    ///
449    /// This is the **recommended API** for appending content. It handles
450    /// all rendering decisions internally:
451    ///
452    /// - **Fast Path**: If the text fits on the current line without wrapping
453    ///   or scrolling, ANSI codes are emitted directly to the terminal for
454    ///   zero-latency display.
455    /// - **Slow Path**: If wrapping or scrolling is required, the internal
456    ///   buffer is updated and the widget is marked dirty for the next frame.
457    ///
458    /// # Example
459    ///
460    /// ```ignore
461    /// // Just push. The engine handles the rest.
462    /// stream.push(&engine, "Hello ");
463    /// stream.push(&engine, "world!");
464    /// ```
465    pub fn push(&mut self, engine: &Engine, text: &str) {
466        let result = self.append(text);
467        
468        if let AppendResult::FastPath { .. } = result {
469            // Zero-latency path: emit ANSI directly
470            let mut output = Vec::with_capacity(64);
471            self.write_fast_path(result, text, &mut output);
472            engine.write_raw(output);
473        }
474        // SlowPath/Empty: Buffer updated or nothing to do.
475        // The render cycle will pick up dirty state.
476    }
477
478    /// Check if a full redraw is needed.
479    pub const fn needs_redraw(&self) -> bool {
480        self.needs_full_redraw || !self.dirty_rects.is_empty()
481    }
482
483    /// Get the dirty rectangles.
484    pub fn dirty_rects(&self) -> &[Rect] {
485        &self.dirty_rects
486    }
487
488    /// Mark the widget for full redraw.
489    pub const fn invalidate(&mut self) {
490        self.needs_full_redraw = true;
491    }
492
493    /// Clear all content.
494    pub fn clear(&mut self) {
495        self.content.clear();
496        self.cursor_col = 0;
497        self.cursor_row = 0;
498        self.needs_full_redraw = true;
499    }
500
501    /// Scroll up by the given number of lines.
502    pub fn scroll_up(&mut self, lines: usize) {
503        self.content.scroll_up(lines);
504        self.needs_full_redraw = true;
505    }
506
507    /// Scroll down by the given number of lines.
508    pub const fn scroll_down(&mut self, lines: usize) {
509        self.content.scroll_down(lines);
510        self.needs_full_redraw = true;
511    }
512
513    /// Get the current cursor position within the widget.
514    pub const fn cursor_position(&self) -> (u16, u16) {
515        (self.cursor_col, self.cursor_row)
516    }
517
518    /// Get the number of lines in the buffer.
519    pub fn line_count(&self) -> usize {
520        self.content.len()
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_stream_widget_new() {
530        let widget = StreamWidget::new(Rect::new(0, 0, 80, 24));
531        assert_eq!(widget.bounds().width, 80);
532        assert_eq!(widget.bounds().height, 24);
533        assert_eq!(widget.cursor_position(), (0, 0));
534    }
535
536    #[test]
537    fn test_stream_widget_append_fast_path() {
538        let mut widget = StreamWidget::new(Rect::new(0, 0, 80, 24));
539        let result = widget.append("Hello");
540
541        match result {
542            AppendResult::FastPath { chars, start_col, row } => {
543                assert_eq!(chars, 5);
544                assert_eq!(start_col, 0);
545                assert_eq!(row, 0);
546            }
547            _ => panic!("Expected fast path"),
548        }
549
550        assert_eq!(widget.cursor_position(), (5, 0));
551    }
552
553    #[test]
554    fn test_stream_widget_append_slow_path_newline() {
555        let mut widget = StreamWidget::new(Rect::new(0, 0, 80, 24));
556        let result = widget.append("Hello\nWorld");
557
558        match result {
559            AppendResult::SlowPath { .. } => {}
560            _ => panic!("Expected slow path due to newline"),
561        }
562
563        assert_eq!(widget.cursor_position(), (5, 1));
564    }
565
566    #[test]
567    fn test_stream_widget_wrap() {
568        let mut widget = StreamWidget::new(Rect::new(0, 0, 10, 24));
569        
570        // Append text that will wrap
571        widget.append("12345678901234567890");
572        
573        // Should have wrapped to line 2
574        assert!(widget.cursor_row > 0);
575    }
576
577    #[test]
578    fn test_stream_widget_render() {
579        let mut widget = StreamWidget::new(Rect::new(0, 0, 10, 3));
580        widget.append("Line 1\nLine 2\nLine 3");
581
582        let mut buffer = Buffer::new(10, 3);
583        widget.render(&mut buffer);
584
585        // Check that content was rendered
586        let cell = buffer.get(0, 0).unwrap();
587        assert_eq!(cell.grapheme(), Some("L"));
588    }
589}