Skip to main content

gpui_component/text/
compat.rs

1use gpui::{
2    AnyElement, App, Bounds, ClickEvent, Element, ElementId, Entity, GlobalElementId,
3    HighlightStyle, InspectorElementId, IntoElement, LayoutId, Pixels, Refineable as _, RenderOnce,
4    SharedString, StyleRefinement, Styled, Window,
5};
6
7use std::time::Duration;
8
9use super::{
10    MarkdownExtensions, MarkdownNode, MarkdownParseContext, MarkdownPlugin, SelectionFormat,
11    TableData, TextViewMotion, TextViewState, TextViewStyle,
12};
13use gpui_base::{Easing, text::CodeBlock};
14
15/// How long a word of streamed text takes to reach full color, and how much later each further
16/// word of the same chunk starts. Measured frame by frame from claude.ai: a chunk lands as
17/// ~6 words every ~100 ms and goes from transparent to solid in ~250-300 ms, its words lighting
18/// up a few milliseconds apart rather than all at once.
19///
20/// The two pull in opposite directions and both matter.
21///
22/// `stagger × words` is how long a chunk takes to light up end to end, and it has to stay well
23/// under the interval between chunks: let it reach that and chunks overlap into one continuous
24/// drip of single words, which is what staggering was meant to avoid. At 10 ms a 6-word chunk
25/// lights up in 60 ms -- one gesture, with a visible gradient inside it.
26///
27/// The fade has to outlast that interval instead. How many words are visibly mid-fade at any
28/// moment is the reveal rate times the fade, so a short fade leaves a couple of grey glyphs on
29/// the tail and no gradient to speak of.
30const STREAM_FADE: Duration = Duration::from_millis(280);
31const STREAM_FADE_STAGGER: Duration = Duration::from_millis(10);
32
33/// The component-level rich text element.
34///
35/// The rendering, parsing and selection all live in [`gpui_base::TextView`];
36/// this wrapper exists so that the component API -- `TextViewStyle`, the
37/// component `HighlightTheme`, and the element's associated types -- keeps
38/// working unchanged.
39#[derive(Clone)]
40pub struct TextView {
41    id: ElementId,
42    inner: gpui_base::TextView,
43    text_style: Option<TextViewStyle>,
44    motion: Option<TextViewMotion>,
45    /// `None` leaves the state's own policy alone; `Some(false)` turns a
46    /// fade off that an earlier frame turned on.
47    stream_fade: Option<bool>,
48}
49
50impl Styled for TextView {
51    fn style(&mut self) -> &mut StyleRefinement {
52        gpui::Styled::style(&mut self.inner)
53    }
54}
55
56impl TextView {
57    /// Creates a text view rendering an existing [`TextViewState`].
58    pub fn new(state: &Entity<TextViewState>) -> Self {
59        Self {
60            id: ElementId::Name(state.entity_id().to_string().into()),
61            inner: gpui_base::TextView::new(state),
62            text_style: None,
63            motion: None,
64            stream_fade: None,
65        }
66    }
67    /// Creates a text view that parses `text` as Markdown.
68    pub fn markdown(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
69        let id = id.into();
70        Self {
71            id: id.clone(),
72            inner: gpui_base::TextView::markdown(id, text),
73            text_style: None,
74            motion: None,
75            stream_fade: None,
76        }
77    }
78    /// Creates a text view that parses `text` as HTML.
79    pub fn html(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
80        let id = id.into();
81        Self {
82            id: id.clone(),
83            inner: gpui_base::TextView::html(id, text),
84            text_style: None,
85            motion: None,
86            stream_fade: None,
87        }
88    }
89    /// Sets the style, folded onto the one derived from the active theme.
90    pub fn style(mut self, style: TextViewStyle) -> Self {
91        self.text_style = Some(style);
92        self
93    }
94    /// Sets whether the text can be selected with the mouse.
95    pub fn selectable(mut self, value: bool) -> Self {
96        self.inner = self.inner.selectable(value);
97        self
98    }
99    /// Sets whether a copied selection carries Markdown source or plain text.
100    pub fn selection_format(mut self, value: SelectionFormat) -> Self {
101        self.inner = self.inner.selection_format(value);
102        self
103    }
104    /// Sets whether the view scrolls its own content.
105    pub fn scrollable(mut self, value: bool) -> Self {
106        self.inner = self.inner.scrollable(value);
107        self
108    }
109    /// Fades streamed text in the way Claude reveals a reply: the words a `set_text` or
110    /// `push_str` adds start transparent and light up one after another, each reaching full
111    /// color over 280 ms. A chunk far larger than one keystroke burst -- a backfill, a replay --
112    /// fades as a whole instead, since nobody typed it. Text that replaces rather than extends
113    /// the current content shows at once, and reduced motion disables the fade. Use
114    /// [`Self::motion`] for other timing.
115    pub fn stream_fade(mut self, value: bool) -> Self {
116        self.stream_fade = Some(value);
117        self
118    }
119    /// Sets the motion policy explicitly, overriding [`Self::stream_fade`]'s
120    /// theme timing.
121    pub fn motion(mut self, motion: TextViewMotion) -> Self {
122        self.motion = Some(motion);
123        self
124    }
125    /// Clamps the rendered content to `value` lines.
126    pub fn max_lines(mut self, value: usize) -> Self {
127        self.inner = self.inner.max_lines(value);
128        self
129    }
130    /// Renders an element in the corner of every fenced code block.
131    pub fn code_block_actions<F, E>(mut self, f: F) -> Self
132    where
133        F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
134        E: IntoElement,
135    {
136        self.inner = self.inner.code_block_actions(f);
137        self
138    }
139    /// Renders an element in the corner of every table.
140    pub fn table_actions<F, E>(mut self, f: F) -> Self
141    where
142        F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
143        E: IntoElement,
144    {
145        self.inner = self.inner.table_actions(f);
146        self
147    }
148    /// Handles link clicks instead of opening the URL.
149    pub fn on_link_click<F>(mut self, f: F) -> Self
150    where
151        F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
152    {
153        self.inner = self.inner.on_link_click(f);
154        self
155    }
156    /// Sets which Markdown extensions the parser accepts.
157    pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self {
158        self.inner = self.inner.markdown_extensions(value);
159        self
160    }
161    /// Enables the MDX Markdown extensions.
162    pub fn markdown_mdx(mut self) -> Self {
163        self.inner = self.inner.markdown_mdx();
164        self
165    }
166
167    /// Parses custom block nodes out of the Markdown AST.
168    pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
169    where
170        F: for<'a> Fn(&markdown::mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
171            + Send
172            + Sync
173            + 'static,
174    {
175        self.inner = self.inner.markdown_block_parser(parser);
176        self
177    }
178    /// Renders the custom block nodes named `name`.
179    pub fn markdown_block_renderer<F, E>(
180        mut self,
181        name: impl Into<SharedString>,
182        renderer: F,
183    ) -> Self
184    where
185        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
186        E: IntoElement,
187    {
188        self.inner = self.inner.markdown_block_renderer(name, renderer);
189        self
190    }
191    /// Applies a plugin, which may install any of the hooks above.
192    pub fn plugin<P>(self, plugin: P) -> Self
193    where
194        P: TextViewPlugin,
195    {
196        plugin.setup(self)
197    }
198}
199
200impl IntoElement for TextView {
201    type Element = Self;
202
203    fn into_element(self) -> Self::Element {
204        self
205    }
206}
207
208/// Layout state retained for source compatibility with the original component TextView.
209pub struct TextViewLayoutState {
210    element: AnyElement,
211}
212
213/// Prepaint state retained for source compatibility with the original component TextView.
214pub struct TextViewPrepaintState;
215
216impl Element for TextView {
217    type RequestLayoutState = TextViewLayoutState;
218    type PrepaintState = TextViewPrepaintState;
219
220    fn id(&self) -> Option<ElementId> {
221        Some(self.id.clone())
222    }
223
224    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
225        None
226    }
227
228    fn request_layout(
229        &mut self,
230        _: Option<&GlobalElementId>,
231        _: Option<&InspectorElementId>,
232        window: &mut Window,
233        cx: &mut App,
234    ) -> (LayoutId, Self::RequestLayoutState) {
235        let mut inner = self.inner.clone();
236        if let Some(style) = self.text_style.clone() {
237            // `request_layout` runs every frame, so this asks whether the
238            // caller ever replaced the theme -- a pointer comparison against
239            // the shared default -- rather than comparing two whole themes
240            // field by field.
241            #[cfg(feature = "tree-sitter")]
242            if !std::sync::Arc::ptr_eq(
243                &style.highlight_theme,
244                &crate::highlighter::HighlightTheme::default_light(),
245            ) {
246                inner = inner.code_block_highlighter(super::component_code_block_highlighter(
247                    style.highlight_theme.clone(),
248                ));
249            }
250            inner = inner.style(resolve_component_style(
251                crate::ActiveTheme::theme(cx),
252                style,
253            ));
254        }
255        let motion = self.motion.clone().or_else(|| {
256            self.stream_fade.map(|fade| {
257                if !fade {
258                    return TextViewMotion::default();
259                }
260                TextViewMotion::default()
261                    .with_stream_fade(STREAM_FADE)
262                    .with_stream_fade_stagger(STREAM_FADE_STAGGER)
263                    .with_stream_fade_easing(Easing::EaseOut)
264            })
265        });
266        if let Some(motion) = motion {
267            inner = inner.motion(motion);
268        }
269        let mut element = inner.into_any_element();
270        let layout_id = element.request_layout(window, cx);
271        (layout_id, TextViewLayoutState { element })
272    }
273
274    fn prepaint(
275        &mut self,
276        _: Option<&GlobalElementId>,
277        _: Option<&InspectorElementId>,
278        _: Bounds<Pixels>,
279        element: &mut Self::RequestLayoutState,
280        window: &mut Window,
281        cx: &mut App,
282    ) -> Self::PrepaintState {
283        element.element.prepaint(window, cx);
284        TextViewPrepaintState
285    }
286
287    fn paint(
288        &mut self,
289        _: Option<&GlobalElementId>,
290        _: Option<&InspectorElementId>,
291        _: Bounds<Pixels>,
292        element: &mut Self::RequestLayoutState,
293        _: &mut Self::PrepaintState,
294        window: &mut Window,
295        cx: &mut App,
296    ) {
297        element.element.paint(window, cx);
298    }
299}
300
301/// Folds a component [`TextViewStyle`] onto the one the theme already derived.
302///
303/// The legacy type carries `StyleRefinement`s that callers filled in
304/// partially, so each one is refined onto the themed value rather than
305/// replacing it -- a caller who set only `white_space` keeps the themed
306/// padding and colors.
307pub(super) fn resolve_component_style(
308    theme: &crate::Theme,
309    legacy: TextViewStyle,
310) -> gpui_base::TextViewStyle {
311    let themed = super::base_text_view_style(theme);
312
313    let refined = |mut base: gpui::StyleRefinement, overlay: &StyleRefinement| {
314        base.refine(overlay);
315        base
316    };
317    let code_block = refined(themed.code_block().clone(), &legacy.code_block);
318    let table = refined(themed.table().clone(), &legacy.table);
319    let table_head = refined(themed.table_head().clone(), &legacy.table_head);
320    let table_cell = refined(themed.table_cell().clone(), &legacy.table_cell);
321
322    let mut inline_code = themed.inline_code();
323    refine_highlight_style(&mut inline_code, legacy.inline_code);
324
325    // `is_dark` only ever turns on: the component theme already answered the
326    // question, and a legacy style left at its `false` default must not undo
327    // a dark theme.
328    let is_dark = themed.is_dark() || legacy.is_dark;
329
330    let mut style = themed
331        .with_paragraph_gap(legacy.paragraph_gap)
332        .with_heading_base_font_size(legacy.heading_base_font_size)
333        .with_code_block(code_block)
334        .with_table(table)
335        .with_table_head(table_head)
336        .with_table_cell(table_cell)
337        .with_inline_code(inline_code)
338        .with_dark(is_dark);
339    if let Some(heading_font_size) = legacy.heading_font_size {
340        style = style.with_heading_font_size(move |level, base| heading_font_size(level, base));
341    }
342    style
343}
344
345fn refine_highlight_style(style: &mut HighlightStyle, refinement: HighlightStyle) {
346    if refinement.color.is_some() {
347        style.color = refinement.color;
348    }
349    if refinement.font_weight.is_some() {
350        style.font_weight = refinement.font_weight;
351    }
352    if refinement.font_style.is_some() {
353        style.font_style = refinement.font_style;
354    }
355    if refinement.background_color.is_some() {
356        style.background_color = refinement.background_color;
357    }
358    if refinement.underline.is_some() {
359        style.underline = refinement.underline;
360    }
361    if refinement.strikethrough.is_some() {
362        style.strikethrough = refinement.strikethrough;
363    }
364    if refinement.fade_out.is_some() {
365        style.fade_out = refinement.fade_out;
366    }
367}
368
369/// A bundle of [`TextView`] configuration that can be applied in one call.
370pub trait TextViewPlugin {
371    /// Applies this plugin's configuration to `text_view`.
372    fn setup(self, text_view: TextView) -> TextView;
373}
374impl<P> TextViewPlugin for P
375where
376    P: MarkdownPlugin,
377{
378    fn setup(self, mut text_view: TextView) -> TextView {
379        text_view.inner = text_view.inner.plugin(self);
380        text_view
381    }
382}
383
384/// Either a plain string or a rich [`TextView`].
385#[derive(IntoElement, Clone)]
386pub enum Text {
387    String(SharedString),
388    TextView(Box<TextView>),
389}
390impl From<SharedString> for Text {
391    fn from(value: SharedString) -> Self {
392        Self::String(value)
393    }
394}
395impl From<String> for Text {
396    fn from(value: String) -> Self {
397        Self::String(value.into())
398    }
399}
400impl From<&str> for Text {
401    fn from(value: &str) -> Self {
402        Self::String(value.to_string().into())
403    }
404}
405impl From<TextView> for Text {
406    fn from(value: TextView) -> Self {
407        Self::TextView(Box::new(value))
408    }
409}
410impl Text {
411    /// Sets the style for the [`TextView`]. Does nothing for a plain string.
412    pub fn style(self, style: TextViewStyle) -> Self {
413        match self {
414            Self::String(value) => Self::String(value),
415            Self::TextView(view) => Self::TextView(Box::new(view.style(style))),
416        }
417    }
418    pub(crate) fn get_text(&self, cx: &App) -> SharedString {
419        match self {
420            Self::String(value) => value.clone(),
421            Self::TextView(view) => gpui_base::Text::from(view.inner.clone()).get_text(cx),
422        }
423    }
424}
425impl RenderOnce for Text {
426    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
427        match self {
428            Self::String(value) => value.into_any_element(),
429            Self::TextView(view) => view.into_any_element(),
430        }
431    }
432}
433
434/// Creates a Markdown text view identified by the caller's code location.
435#[track_caller]
436pub fn markdown(source: impl Into<SharedString>) -> TextView {
437    TextView::markdown(
438        ElementId::CodeLocation(*std::panic::Location::caller()),
439        source,
440    )
441}
442/// Creates an HTML text view identified by the caller's code location.
443#[track_caller]
444pub fn html(source: impl Into<SharedString>) -> TextView {
445    TextView::html(
446        ElementId::CodeLocation(*std::panic::Location::caller()),
447        source,
448    )
449}
450
451#[cfg(test)]
452mod tests {
453    use std::sync::{
454        Arc,
455        atomic::{AtomicUsize, Ordering},
456    };
457
458    use gpui::{
459        Context, IntoElement, ParentElement as _, Render, TestAppContext, VisualTestContext, div,
460    };
461
462    struct StatelessMarkdown {
463        renders: Arc<AtomicUsize>,
464    }
465
466    impl Render for StatelessMarkdown {
467        fn render(&mut self, _: &mut gpui::Window, _: &mut Context<Self>) -> impl IntoElement {
468            self.renders.fetch_add(1, Ordering::Relaxed);
469            div().child(
470                super::markdown(include_str!("../../../../examples/fixtures/test.md"))
471                    .markdown_block_parser(|_, _| None),
472            )
473        }
474    }
475
476    #[gpui::test]
477    fn stateless_markdown_facade_settles_after_parsing(cx: &mut TestAppContext) {
478        cx.update(crate::init);
479        let renders = Arc::new(AtomicUsize::new(0));
480        let (_, cx) = cx.add_window_view({
481            let renders = renders.clone();
482            move |_, _| StatelessMarkdown { renders }
483        });
484        let cx: &mut VisualTestContext = cx;
485
486        cx.run_until_parked();
487        cx.update(|window, cx| window.draw(cx).clear(cx));
488        let renders_after_redraw = renders.load(Ordering::Relaxed);
489        cx.run_until_parked();
490        assert_eq!(
491            renders.load(Ordering::Relaxed),
492            renders_after_redraw,
493            "an unchanged compatibility TextView must not schedule another render after its parse",
494        );
495    }
496}