scud/commands/spawn/tui/components/
streaming_view.rs1use ratatui::{
7 buffer::Buffer,
8 layout::Rect,
9 style::{Style, Stylize},
10 text::{Line, Span},
11 widgets::{
12 Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
13 ScrollbarState, StatefulWidget, Widget, Wrap,
14 },
15};
16
17use super::super::theme::*;
18
19#[derive(Debug, Clone)]
21pub struct OutputLine {
22 pub text: String,
24 pub line_type: OutputLineType,
26 pub timestamp: Option<u64>,
28}
29
30impl OutputLine {
31 pub fn new(text: impl Into<String>) -> Self {
33 Self {
34 text: text.into(),
35 line_type: OutputLineType::Normal,
36 timestamp: None,
37 }
38 }
39
40 pub fn with_type(mut self, line_type: OutputLineType) -> Self {
42 self.line_type = line_type;
43 self
44 }
45
46 pub fn with_timestamp(mut self, ts: u64) -> Self {
48 self.timestamp = Some(ts);
49 self
50 }
51}
52
53impl From<String> for OutputLine {
54 fn from(s: String) -> Self {
55 Self::new(s)
56 }
57}
58
59impl From<&str> for OutputLine {
60 fn from(s: &str) -> Self {
61 Self::new(s)
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum OutputLineType {
68 #[default]
70 Normal,
71 Error,
73 Success,
75 System,
77 Input,
79 Prompt,
81}
82
83impl OutputLineType {
84 pub fn color(&self) -> ratatui::style::Color {
86 match self {
87 Self::Normal => TEXT_TERMINAL,
88 Self::Error => ERROR,
89 Self::Success => SUCCESS,
90 Self::System => TEXT_MUTED,
91 Self::Input => ACCENT,
92 Self::Prompt => TEXT_MUTED,
93 }
94 }
95}
96
97#[derive(Debug, Default)]
99pub struct StreamingViewState {
100 pub scroll_offset: usize,
102 pub auto_scroll: bool,
104 total_lines: usize,
106}
107
108impl StreamingViewState {
109 pub fn new() -> Self {
111 Self {
112 scroll_offset: 0,
113 auto_scroll: true,
114 total_lines: 0,
115 }
116 }
117
118 pub fn scroll_up(&mut self, lines: usize) {
120 let max_scroll = self.total_lines.saturating_sub(1);
121 self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
122 self.auto_scroll = false;
123 }
124
125 pub fn scroll_down(&mut self, lines: usize) {
127 self.scroll_offset = self.scroll_offset.saturating_sub(lines);
128 if self.scroll_offset == 0 {
129 self.auto_scroll = true;
130 }
131 }
132
133 pub fn scroll_to_bottom(&mut self) {
135 self.scroll_offset = 0;
136 self.auto_scroll = true;
137 }
138
139 pub fn scroll_to_top(&mut self) {
141 self.scroll_offset = self.total_lines.saturating_sub(1);
142 self.auto_scroll = false;
143 }
144
145 pub fn set_total_lines(&mut self, total: usize) {
147 self.total_lines = total;
148 if self.scroll_offset > 0 && self.scroll_offset >= total {
150 self.scroll_offset = total.saturating_sub(1);
151 }
152 }
153
154 pub fn is_at_bottom(&self) -> bool {
156 self.scroll_offset == 0
157 }
158}
159
160pub struct StreamingView<'a> {
165 lines: &'a [OutputLine],
167 focused: bool,
169 title: Option<String>,
171 show_scrollbar: bool,
173 fullscreen: bool,
175}
176
177impl<'a> StreamingView<'a> {
178 pub fn new(lines: &'a [OutputLine]) -> Self {
180 Self {
181 lines,
182 focused: false,
183 title: None,
184 show_scrollbar: true,
185 fullscreen: false,
186 }
187 }
188
189 pub fn from_strings(lines: &'a [String]) -> StreamingViewStrings<'a> {
191 StreamingViewStrings {
192 lines,
193 focused: false,
194 title: None,
195 show_scrollbar: true,
196 fullscreen: false,
197 }
198 }
199
200 pub fn focused(mut self, focused: bool) -> Self {
202 self.focused = focused;
203 self
204 }
205
206 pub fn title(mut self, title: impl Into<String>) -> Self {
208 self.title = Some(title.into());
209 self
210 }
211
212 pub fn show_scrollbar(mut self, show: bool) -> Self {
214 self.show_scrollbar = show;
215 self
216 }
217
218 pub fn fullscreen(mut self, fullscreen: bool) -> Self {
220 self.fullscreen = fullscreen;
221 self
222 }
223}
224
225impl StatefulWidget for StreamingView<'_> {
226 type State = StreamingViewState;
227
228 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
229 state.set_total_lines(self.lines.len());
231
232 let border_color = if self.focused {
233 BORDER_ACTIVE
234 } else {
235 BORDER_DEFAULT
236 };
237 let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
238
239 let title = if self.fullscreen {
240 self.title
241 .unwrap_or_else(|| " Output (Esc to exit) ".to_string())
242 } else {
243 self.title.unwrap_or_else(|| " Live Output ".to_string())
244 };
245
246 let block = Block::default()
247 .borders(Borders::ALL)
248 .border_type(BorderType::Rounded)
249 .border_style(Style::default().fg(border_color))
250 .title(Line::from(title).fg(title_color))
251 .style(Style::default().bg(BG_TERMINAL))
252 .padding(Padding::new(1, 0, 0, 0));
253
254 let inner = block.inner(area);
255 Widget::render(block, area, buf);
256
257 let visible_height = inner.height as usize;
258 let total_lines = self.lines.len();
259
260 let end_idx = total_lines.saturating_sub(state.scroll_offset);
262 let start_idx = end_idx.saturating_sub(visible_height);
263
264 let text_width = if self.show_scrollbar && total_lines > visible_height {
266 inner.width.saturating_sub(2)
267 } else {
268 inner.width
269 };
270 let text_area = Rect::new(inner.x, inner.y, text_width, inner.height);
271
272 let visible_lines: Vec<Line> = self
274 .lines
275 .iter()
276 .skip(start_idx)
277 .take(visible_height)
278 .map(|line| {
279 Line::from(Span::styled(
280 line.text.as_str(),
281 Style::default().fg(line.line_type.color()),
282 ))
283 })
284 .collect();
285
286 let paragraph = Paragraph::new(visible_lines);
287 Widget::render(paragraph, text_area, buf);
288
289 if self.show_scrollbar && total_lines > visible_height {
291 let scrollbar_area = Rect::new(
292 inner.x + inner.width.saturating_sub(1),
293 inner.y,
294 1,
295 inner.height,
296 );
297
298 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
299 .begin_symbol(None)
300 .end_symbol(None)
301 .track_symbol(Some(" "))
302 .thumb_symbol("▐");
303
304 let mut scrollbar_state = ScrollbarState::new(total_lines).position(start_idx);
305 StatefulWidget::render(scrollbar, scrollbar_area, buf, &mut scrollbar_state);
306 }
307 }
308}
309
310pub struct StreamingViewStrings<'a> {
312 lines: &'a [String],
313 focused: bool,
314 title: Option<String>,
315 show_scrollbar: bool,
316 fullscreen: bool,
317}
318
319impl<'a> StreamingViewStrings<'a> {
320 pub fn focused(mut self, focused: bool) -> Self {
322 self.focused = focused;
323 self
324 }
325
326 pub fn title(mut self, title: impl Into<String>) -> Self {
328 self.title = Some(title.into());
329 self
330 }
331
332 pub fn show_scrollbar(mut self, show: bool) -> Self {
334 self.show_scrollbar = show;
335 self
336 }
337
338 pub fn fullscreen(mut self, fullscreen: bool) -> Self {
340 self.fullscreen = fullscreen;
341 self
342 }
343}
344
345impl StatefulWidget for StreamingViewStrings<'_> {
346 type State = StreamingViewState;
347
348 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
349 state.set_total_lines(self.lines.len());
351
352 let border_color = if self.focused {
353 BORDER_ACTIVE
354 } else {
355 BORDER_DEFAULT
356 };
357 let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
358
359 let title = if self.fullscreen {
360 self.title
361 .unwrap_or_else(|| " Output (Esc to exit) ".to_string())
362 } else {
363 self.title.unwrap_or_else(|| " Live Output ".to_string())
364 };
365
366 let block = Block::default()
367 .borders(Borders::ALL)
368 .border_type(BorderType::Rounded)
369 .border_style(Style::default().fg(border_color))
370 .title(Line::from(title).fg(title_color))
371 .style(Style::default().bg(BG_TERMINAL))
372 .padding(Padding::new(1, 0, 0, 0));
373
374 let inner = block.inner(area);
375 Widget::render(block, area, buf);
376
377 let visible_height = inner.height as usize;
378 let total_lines = self.lines.len();
379
380 let end_idx = total_lines.saturating_sub(state.scroll_offset);
382 let start_idx = end_idx.saturating_sub(visible_height);
383
384 let text_width = if self.show_scrollbar && total_lines > visible_height {
386 inner.width.saturating_sub(2)
387 } else {
388 inner.width
389 };
390 let text_area = Rect::new(inner.x, inner.y, text_width, inner.height);
391
392 let visible_lines: Vec<Line> = self
394 .lines
395 .iter()
396 .skip(start_idx)
397 .take(visible_height)
398 .map(|line| {
399 Line::from(Span::styled(
400 line.as_str(),
401 Style::default().fg(TEXT_TERMINAL),
402 ))
403 })
404 .collect();
405
406 let paragraph = Paragraph::new(visible_lines);
407 Widget::render(paragraph, text_area, buf);
408
409 if self.show_scrollbar && total_lines > visible_height {
411 let scrollbar_area = Rect::new(
412 inner.x + inner.width.saturating_sub(1),
413 inner.y,
414 1,
415 inner.height,
416 );
417
418 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
419 .begin_symbol(None)
420 .end_symbol(None)
421 .track_symbol(Some(" "))
422 .thumb_symbol("▐");
423
424 let mut scrollbar_state = ScrollbarState::new(total_lines).position(start_idx);
425 StatefulWidget::render(scrollbar, scrollbar_area, buf, &mut scrollbar_state);
426 }
427 }
428}
429
430pub struct OutputDisplay<'a> {
432 text: &'a str,
433 focused: bool,
434 title: Option<String>,
435 wrap: bool,
436}
437
438impl<'a> OutputDisplay<'a> {
439 pub fn new(text: &'a str) -> Self {
441 Self {
442 text,
443 focused: false,
444 title: None,
445 wrap: true,
446 }
447 }
448
449 pub fn focused(mut self, focused: bool) -> Self {
451 self.focused = focused;
452 self
453 }
454
455 pub fn title(mut self, title: impl Into<String>) -> Self {
457 self.title = Some(title.into());
458 self
459 }
460
461 pub fn wrap(mut self, wrap: bool) -> Self {
463 self.wrap = wrap;
464 self
465 }
466}
467
468impl Widget for OutputDisplay<'_> {
469 fn render(self, area: Rect, buf: &mut Buffer) {
470 let border_color = if self.focused {
471 BORDER_ACTIVE
472 } else {
473 BORDER_DEFAULT
474 };
475 let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
476
477 let title = self.title.unwrap_or_else(|| " Output ".to_string());
478
479 let block = Block::default()
480 .borders(Borders::ALL)
481 .border_type(BorderType::Rounded)
482 .border_style(Style::default().fg(border_color))
483 .title(Line::from(title).fg(title_color))
484 .style(Style::default().bg(BG_TERMINAL))
485 .padding(Padding::horizontal(1));
486
487 let mut paragraph = Paragraph::new(self.text)
488 .style(Style::default().fg(TEXT_TERMINAL))
489 .block(block);
490
491 if self.wrap {
492 paragraph = paragraph.wrap(Wrap { trim: false });
493 }
494
495 Widget::render(paragraph, area, buf);
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn test_output_line_creation() {
505 let line = OutputLine::new("Hello world")
506 .with_type(OutputLineType::Success)
507 .with_timestamp(12345);
508
509 assert_eq!(line.text, "Hello world");
510 assert_eq!(line.line_type, OutputLineType::Success);
511 assert_eq!(line.timestamp, Some(12345));
512 }
513
514 #[test]
515 fn test_output_line_from_string() {
516 let line: OutputLine = "Test line".into();
517 assert_eq!(line.text, "Test line");
518 assert_eq!(line.line_type, OutputLineType::Normal);
519 }
520
521 #[test]
522 fn test_streaming_view_state_scrolling() {
523 let mut state = StreamingViewState::new();
524 state.set_total_lines(100);
525
526 assert!(state.is_at_bottom());
528 assert!(state.auto_scroll);
529
530 state.scroll_up(10);
532 assert_eq!(state.scroll_offset, 10);
533 assert!(!state.auto_scroll);
534
535 state.scroll_down(5);
537 assert_eq!(state.scroll_offset, 5);
538 assert!(!state.auto_scroll);
539
540 state.scroll_to_bottom();
542 assert!(state.is_at_bottom());
543 assert!(state.auto_scroll);
544 }
545
546 #[test]
547 fn test_scroll_clamping() {
548 let mut state = StreamingViewState::new();
549 state.set_total_lines(50);
550
551 state.scroll_up(100);
553 assert_eq!(state.scroll_offset, 49); state.set_total_lines(30);
557 assert_eq!(state.scroll_offset, 29);
558 }
559
560 #[test]
561 fn test_line_type_colors() {
562 let _ = OutputLineType::Normal.color();
564 let _ = OutputLineType::Error.color();
565 let _ = OutputLineType::Success.color();
566 let _ = OutputLineType::System.color();
567 let _ = OutputLineType::Input.color();
568 let _ = OutputLineType::Prompt.color();
569 }
570}