Skip to main content

guise/ai/
streamingtext.rs

1//! `AIStreamingText` — markdown with a caret on the end.
2//!
3//! A reply that is still arriving reads as finished unless something says
4//! otherwise, and a spinner in the corner is the wrong signal — the text is
5//! already there, it is just not done. So this renders exactly what
6//! [`Markdown`] renders and puts a blinking block on the last line, the way a
7//! terminal shows a process still writing.
8//!
9//! It takes the whole text every frame rather than a delta, because that is
10//! what a `Render` pass has: the host appends to its own `String` and this
11//! draws it.
12//!
13//! ```ignore
14//! AIStreamingText::new(&partial_reply)
15//! ```
16
17use std::sync::OnceLock;
18use std::time::{Duration, Instant};
19
20use gpui::prelude::*;
21use gpui::{canvas, div, fill, px, App, Bounds, IntoElement, Pixels, SharedString, Window};
22
23use crate::devtools::Probed;
24use crate::frameclock::{request_frame, FrameKind};
25use crate::markdown::Markdown;
26use crate::theme::{theme, Size};
27
28/// How long the caret takes to go from solid to clear and back.
29const BLINK_MS: u64 = 900;
30
31fn animation_start() -> Instant {
32  static START: OnceLock<Instant> = OnceLock::new();
33  *START.get_or_init(Instant::now)
34}
35
36fn request_next_toggle(window: &mut Window, cx: &mut App, after_ms: u64) {
37  request_frame(
38    FrameKind::Caret,
39    Duration::from_millis(after_ms.max(1)),
40    window,
41    cx,
42  );
43}
44
45/// Streaming markdown with a trailing caret.
46#[derive(IntoElement)]
47pub struct AIStreamingText {
48  text: SharedString,
49  size: Size,
50  caret: bool,
51}
52
53impl AIStreamingText {
54  pub fn new(text: impl Into<SharedString>) -> Self {
55    AIStreamingText {
56      text: text.into(),
57      size: Size::Sm,
58      caret: true,
59    }
60  }
61
62  pub fn size(mut self, size: Size) -> Self {
63    self.size = size;
64    self
65  }
66
67  /// Drop the caret while keeping the same layout — for the frame a reply
68  /// finishes on, so the text doesn't jump.
69  pub fn caret(mut self, caret: bool) -> Self {
70    self.caret = caret;
71    self
72  }
73}
74
75impl RenderOnce for AIStreamingText {
76  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
77    let t = theme(cx);
78    let font = t.font_size(self.size);
79    let caret_color = t.text().hsla();
80
81    // The caret sits on its own row under the text rather than inline:
82    // the markdown body is a column of laid-out lines, and threading a
83    // caret into the last one would mean shaping the text twice.
84    let caret = canvas(
85      |_, _, _| (),
86      move |bounds: Bounds<Pixels>, _, window, cx| {
87        if !bounds.intersects(&window.content_mask().bounds) {
88          return;
89        }
90        let elapsed = animation_start().elapsed().as_millis() as u64 % BLINK_MS;
91        let half = BLINK_MS / 2;
92        if elapsed < half {
93          window.paint_quad(fill(bounds, caret_color));
94          request_next_toggle(window, cx, half - elapsed);
95        } else {
96          request_next_toggle(window, cx, BLINK_MS - elapsed);
97        }
98      },
99    )
100    .w(px(font * 0.5))
101    .h(px(font * 1.1));
102
103    div()
104      .flex()
105      .flex_col()
106      .w_full()
107      .child(Markdown::new(self.text).size(self.size))
108      .when(self.caret, |column| {
109        column.child(div().flex().items_center().h(px(font * 1.3)).child(caret))
110      })
111      .probe("AIStreamingText")
112  }
113}