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    /// Overrides the source of every document image, including embedded data URLs.
90    ///
91    /// Used for both rendering and intrinsic-size measurement. The returned source
92    /// is authoritative: pending or failed loads never fall back to the document URL.
93    /// Without this override, images use Base's default URI and data URL handling.
94    pub fn image_source<F>(mut self, resolver: F) -> Self
95    where
96        F: Fn(&gpui::SharedUri) -> gpui::ImageSource + Send + Sync + 'static,
97    {
98        self.inner = self.inner.image_source(resolver);
99        self
100    }
101    /// Sets the style, folded onto the one derived from the active theme.
102    pub fn style(mut self, style: TextViewStyle) -> Self {
103        self.text_style = Some(style);
104        self
105    }
106    /// Sets whether the text can be selected with the mouse.
107    pub fn selectable(mut self, value: bool) -> Self {
108        self.inner = self.inner.selectable(value);
109        self
110    }
111    /// Refines inline code typography, background, border, radius, and padding
112    /// over the resolved theme or explicit style. Other layout properties are
113    /// ignored. Unspecified properties are inherited.
114    pub fn inline_code_style(mut self, style: StyleRefinement) -> Self {
115        self.inner = self.inner.inline_code_style(style);
116        self
117    }
118    /// Refines bullet and number list markers over the resolved theme or
119    /// explicit style. The text color also outlines unchecked task-list
120    /// checkboxes. Unspecified properties are inherited.
121    pub fn list_style(mut self, style: StyleRefinement) -> Self {
122        self.inner = self.inner.list_style(style);
123        self
124    }
125    /// Refines the blockquote container, including its left border, over the
126    /// resolved theme or explicit style. Unspecified properties are inherited.
127    pub fn blockquote_style(mut self, style: StyleRefinement) -> Self {
128        self.inner = self.inner.blockquote_style(style);
129        self
130    }
131    /// Sets whether a copied selection carries Markdown source or plain text.
132    pub fn selection_format(mut self, value: SelectionFormat) -> Self {
133        self.inner = self.inner.selection_format(value);
134        self
135    }
136    /// Sets whether the view scrolls its own content.
137    pub fn scrollable(mut self, value: bool) -> Self {
138        self.inner = self.inner.scrollable(value);
139        self
140    }
141    /// Fades streamed text in the way Claude reveals a reply: the words a `set_text` or
142    /// `push_str` adds start transparent and light up one after another, each reaching full
143    /// color over 280 ms. A chunk far larger than one keystroke burst -- a backfill, a replay --
144    /// fades as a whole instead, since nobody typed it. Text that replaces rather than extends
145    /// the current content shows at once, and reduced motion disables the fade. Use
146    /// [`Self::motion`] for other timing.
147    pub fn stream_fade(mut self, value: bool) -> Self {
148        self.stream_fade = Some(value);
149        self
150    }
151    /// Sets the motion policy explicitly, overriding [`Self::stream_fade`]'s
152    /// theme timing.
153    pub fn motion(mut self, motion: TextViewMotion) -> Self {
154        self.motion = Some(motion);
155        self
156    }
157    /// Clamps the rendered content to `value` lines.
158    pub fn max_lines(mut self, value: usize) -> Self {
159        self.inner = self.inner.max_lines(value);
160        self
161    }
162    /// Renders an element in the corner of every fenced code block.
163    pub fn code_block_actions<F, E>(mut self, f: F) -> Self
164    where
165        F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
166        E: IntoElement,
167    {
168        self.inner = self.inner.code_block_actions(f);
169        self
170    }
171    /// Renders an element in the corner of every table.
172    pub fn table_actions<F, E>(mut self, f: F) -> Self
173    where
174        F: Fn(&TableData, &mut Window, &mut App) -> E + Send + Sync + 'static,
175        E: IntoElement,
176    {
177        self.inner = self.inner.table_actions(f);
178        self
179    }
180    /// Handles link clicks instead of opening the URL.
181    pub fn on_link_click<F>(mut self, f: F) -> Self
182    where
183        F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
184    {
185        self.inner = self.inner.on_link_click(f);
186        self
187    }
188    /// Scrolls a container that ignores scroll requests to the line of
189    /// `TextViewState::reveal_range`, with the line's window bounds.
190    pub fn on_reveal<F>(mut self, f: F) -> Self
191    where
192        F: Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
193    {
194        self.inner = self.inner.on_reveal(f);
195        self
196    }
197    /// Sets which Markdown extensions the parser accepts.
198    pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self {
199        self.inner = self.inner.markdown_extensions(value);
200        self
201    }
202    /// Enables the MDX Markdown extensions.
203    pub fn markdown_mdx(mut self) -> Self {
204        self.inner = self.inner.markdown_mdx();
205        self
206    }
207
208    /// Parses custom block nodes out of the Markdown AST.
209    pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
210    where
211        F: for<'a> Fn(&markdown::mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
212            + Send
213            + Sync
214            + 'static,
215    {
216        self.inner = self.inner.markdown_block_parser(parser);
217        self
218    }
219    /// Renders the custom block nodes named `name`.
220    pub fn markdown_block_renderer<F, E>(
221        mut self,
222        name: impl Into<SharedString>,
223        renderer: F,
224    ) -> Self
225    where
226        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
227        E: IntoElement,
228    {
229        self.inner = self.inner.markdown_block_renderer(name, renderer);
230        self
231    }
232    /// Applies a plugin, which may install any of the hooks above.
233    pub fn plugin<P>(self, plugin: P) -> Self
234    where
235        P: TextViewPlugin,
236    {
237        plugin.setup(self)
238    }
239}
240
241impl IntoElement for TextView {
242    type Element = Self;
243
244    fn into_element(self) -> Self::Element {
245        self
246    }
247}
248
249/// Layout state retained for source compatibility with the original component TextView.
250pub struct TextViewLayoutState {
251    element: AnyElement,
252}
253
254/// Prepaint state retained for source compatibility with the original component TextView.
255pub struct TextViewPrepaintState;
256
257impl Element for TextView {
258    type RequestLayoutState = TextViewLayoutState;
259    type PrepaintState = TextViewPrepaintState;
260
261    fn id(&self) -> Option<ElementId> {
262        Some(self.id.clone())
263    }
264
265    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
266        None
267    }
268
269    fn request_layout(
270        &mut self,
271        _: Option<&GlobalElementId>,
272        _: Option<&InspectorElementId>,
273        window: &mut Window,
274        cx: &mut App,
275    ) -> (LayoutId, Self::RequestLayoutState) {
276        let mut inner = self.inner.clone();
277        if let Some(style) = self.text_style.clone() {
278            // `request_layout` runs every frame, so this asks whether the
279            // caller ever replaced the theme -- a pointer comparison against
280            // the shared default -- rather than comparing two whole themes
281            // field by field.
282            #[cfg(any(feature = "tree-sitter", feature = "syntect"))]
283            if !std::sync::Arc::ptr_eq(
284                &style.highlight_theme,
285                &crate::highlighter::HighlightTheme::default_light(),
286            ) {
287                inner = inner.shared_code_block_highlighter(super::shared_code_block_highlighter(
288                    &style.highlight_theme,
289                ));
290            }
291            inner = inner.style(resolve_component_style(
292                crate::ActiveTheme::theme(cx),
293                style,
294            ));
295        }
296        let motion = self.motion.clone().or_else(|| {
297            self.stream_fade.map(|fade| {
298                if !fade {
299                    return TextViewMotion::default();
300                }
301                TextViewMotion::default()
302                    .with_stream_fade(STREAM_FADE)
303                    .with_stream_fade_stagger(STREAM_FADE_STAGGER)
304                    .with_stream_fade_easing(Easing::EaseOut)
305            })
306        });
307        if let Some(motion) = motion {
308            inner = inner.motion(motion);
309        }
310        let mut element = inner.into_any_element();
311        let layout_id = element.request_layout(window, cx);
312        (layout_id, TextViewLayoutState { element })
313    }
314
315    fn prepaint(
316        &mut self,
317        _: Option<&GlobalElementId>,
318        _: Option<&InspectorElementId>,
319        _: Bounds<Pixels>,
320        element: &mut Self::RequestLayoutState,
321        window: &mut Window,
322        cx: &mut App,
323    ) -> Self::PrepaintState {
324        element.element.prepaint(window, cx);
325        TextViewPrepaintState
326    }
327
328    fn paint(
329        &mut self,
330        _: Option<&GlobalElementId>,
331        _: Option<&InspectorElementId>,
332        _: Bounds<Pixels>,
333        element: &mut Self::RequestLayoutState,
334        _: &mut Self::PrepaintState,
335        window: &mut Window,
336        cx: &mut App,
337    ) {
338        element.element.paint(window, cx);
339    }
340}
341
342/// Folds a component [`TextViewStyle`] onto the one the theme already derived.
343///
344/// The legacy type carries `StyleRefinement`s that callers filled in
345/// partially, so each one is refined onto the themed value rather than
346/// replacing it -- a caller who set only `white_space` keeps the themed
347/// padding and colors.
348pub(super) fn resolve_component_style(
349    theme: &crate::Theme,
350    legacy: TextViewStyle,
351) -> gpui_base::TextViewStyle {
352    let themed = super::base_text_view_style(theme);
353
354    let refined = |mut base: gpui::StyleRefinement, overlay: &StyleRefinement| {
355        base.refine(overlay);
356        base
357    };
358    let code_block = refined(themed.code_block().clone(), &legacy.code_block);
359    let table = refined(themed.table().clone(), &legacy.table);
360    let table_head = refined(themed.table_head().clone(), &legacy.table_head);
361    let table_cell = refined(themed.table_cell().clone(), &legacy.table_cell);
362
363    let mut inline_code = themed.inline_code();
364    refine_highlight_style(&mut inline_code, legacy.inline_code);
365
366    // `is_dark` only ever turns on: the component theme already answered the
367    // question, and a legacy style left at its `false` default must not undo
368    // a dark theme.
369    let is_dark = themed.is_dark() || legacy.is_dark;
370
371    let heading_base_font_size = legacy.heading_base_font_size;
372    let heading_font_size = legacy.heading_font_size;
373    // A style that never touched heading sizes keeps the Base default, which
374    // scales headings with the body text; only an explicit base size or
375    // resolver pins them to pixels.
376    let pins_heading_size = heading_font_size.is_some()
377        || heading_base_font_size != TextViewStyle::default().heading_base_font_size;
378    let style = themed
379        .with_paragraph_gap(legacy.paragraph_gap)
380        .with_heading(move |level| {
381            if !pins_heading_size {
382                return StyleRefinement::default();
383            }
384            let default_size = match level {
385                1 => gpui::rems(2.),
386                2 => gpui::rems(1.5),
387                3 => gpui::rems(1.25),
388                4 => gpui::rems(1.125),
389                _ => gpui::rems(1.),
390            }
391            .to_pixels(heading_base_font_size);
392            let text_size = heading_font_size.as_ref().map_or(default_size, |resolve| {
393                resolve(level, heading_base_font_size)
394            });
395            StyleRefinement::default().text_size(text_size)
396        })
397        .with_code_block(code_block)
398        .with_table(table)
399        .with_table_head(table_head)
400        .with_table_cell(table_cell)
401        .with_inline_code(inline_code)
402        .with_dark(is_dark);
403    style
404}
405
406fn refine_highlight_style(style: &mut HighlightStyle, refinement: HighlightStyle) {
407    if refinement.color.is_some() {
408        style.color = refinement.color;
409    }
410    if refinement.font_weight.is_some() {
411        style.font_weight = refinement.font_weight;
412    }
413    if refinement.font_style.is_some() {
414        style.font_style = refinement.font_style;
415    }
416    if refinement.background_color.is_some() {
417        style.background_color = refinement.background_color;
418    }
419    if refinement.underline.is_some() {
420        style.underline = refinement.underline;
421    }
422    if refinement.strikethrough.is_some() {
423        style.strikethrough = refinement.strikethrough;
424    }
425    if refinement.fade_out.is_some() {
426        style.fade_out = refinement.fade_out;
427    }
428}
429
430/// A bundle of [`TextView`] configuration that can be applied in one call.
431pub trait TextViewPlugin {
432    /// Applies this plugin's configuration to `text_view`.
433    fn setup(self, text_view: TextView) -> TextView;
434}
435impl<P> TextViewPlugin for P
436where
437    P: MarkdownPlugin,
438{
439    fn setup(self, mut text_view: TextView) -> TextView {
440        text_view.inner = text_view.inner.plugin(self);
441        text_view
442    }
443}
444
445/// Either a plain string or a rich [`TextView`].
446#[derive(IntoElement, Clone)]
447pub enum Text {
448    String(SharedString),
449    TextView(Box<TextView>),
450}
451impl From<SharedString> for Text {
452    fn from(value: SharedString) -> Self {
453        Self::String(value)
454    }
455}
456impl From<String> for Text {
457    fn from(value: String) -> Self {
458        Self::String(value.into())
459    }
460}
461impl From<&str> for Text {
462    fn from(value: &str) -> Self {
463        Self::String(value.to_string().into())
464    }
465}
466impl From<TextView> for Text {
467    fn from(value: TextView) -> Self {
468        Self::TextView(Box::new(value))
469    }
470}
471impl Text {
472    /// Sets the style for the [`TextView`]. Does nothing for a plain string.
473    pub fn style(self, style: TextViewStyle) -> Self {
474        match self {
475            Self::String(value) => Self::String(value),
476            Self::TextView(view) => Self::TextView(Box::new(view.style(style))),
477        }
478    }
479    pub(crate) fn get_text(&self, cx: &App) -> SharedString {
480        match self {
481            Self::String(value) => value.clone(),
482            Self::TextView(view) => gpui_base::Text::from(view.inner.clone()).get_text(cx),
483        }
484    }
485}
486impl RenderOnce for Text {
487    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
488        match self {
489            Self::String(value) => value.into_any_element(),
490            Self::TextView(view) => view.into_any_element(),
491        }
492    }
493}
494
495/// Creates a Markdown text view identified by the caller's code location.
496#[track_caller]
497pub fn markdown(source: impl Into<SharedString>) -> TextView {
498    TextView::markdown(
499        ElementId::CodeLocation(*std::panic::Location::caller()),
500        source,
501    )
502}
503/// Creates an HTML text view identified by the caller's code location.
504#[track_caller]
505pub fn html(source: impl Into<SharedString>) -> TextView {
506    TextView::html(
507        ElementId::CodeLocation(*std::panic::Location::caller()),
508        source,
509    )
510}
511
512#[cfg(test)]
513mod tests {
514    use std::sync::{
515        Arc,
516        atomic::{AtomicUsize, Ordering},
517    };
518
519    use gpui::{
520        Context, IntoElement, ParentElement as _, Render, TestAppContext, VisualTestContext, div,
521    };
522
523    struct StatelessMarkdown {
524        renders: Arc<AtomicUsize>,
525    }
526
527    impl Render for StatelessMarkdown {
528        fn render(&mut self, _: &mut gpui::Window, _: &mut Context<Self>) -> impl IntoElement {
529            self.renders.fetch_add(1, Ordering::Relaxed);
530            div().child(
531                super::markdown(include_str!("../../../../examples/fixtures/test.md"))
532                    .markdown_block_parser(|_, _| None),
533            )
534        }
535    }
536
537    #[gpui::test]
538    fn stateless_markdown_facade_settles_after_parsing(cx: &mut TestAppContext) {
539        cx.update(crate::init);
540        let renders = Arc::new(AtomicUsize::new(0));
541        let (_, cx) = cx.add_window_view({
542            let renders = renders.clone();
543            move |_, _| StatelessMarkdown { renders }
544        });
545        let cx: &mut VisualTestContext = cx;
546
547        cx.run_until_parked();
548        cx.update(|window, cx| window.draw(cx).clear(cx));
549        let renders_after_redraw = renders.load(Ordering::Relaxed);
550        cx.run_until_parked();
551        assert_eq!(
552            renders.load(Ordering::Relaxed),
553            renders_after_redraw,
554            "an unchanged compatibility TextView must not schedule another render after its parse",
555        );
556    }
557}