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