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