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    /// Scrolls a container that ignores scroll requests to the line of
157    /// `TextViewState::reveal_range`, with the line's window bounds.
158    pub fn on_reveal<F>(mut self, f: F) -> Self
159    where
160        F: Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
161    {
162        self.inner = self.inner.on_reveal(f);
163        self
164    }
165    /// Sets which Markdown extensions the parser accepts.
166    pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self {
167        self.inner = self.inner.markdown_extensions(value);
168        self
169    }
170    /// Enables the MDX Markdown extensions.
171    pub fn markdown_mdx(mut self) -> Self {
172        self.inner = self.inner.markdown_mdx();
173        self
174    }
175
176    /// Parses custom block nodes out of the Markdown AST.
177    pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
178    where
179        F: for<'a> Fn(&markdown::mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
180            + Send
181            + Sync
182            + 'static,
183    {
184        self.inner = self.inner.markdown_block_parser(parser);
185        self
186    }
187    /// Renders the custom block nodes named `name`.
188    pub fn markdown_block_renderer<F, E>(
189        mut self,
190        name: impl Into<SharedString>,
191        renderer: F,
192    ) -> Self
193    where
194        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
195        E: IntoElement,
196    {
197        self.inner = self.inner.markdown_block_renderer(name, renderer);
198        self
199    }
200    /// Applies a plugin, which may install any of the hooks above.
201    pub fn plugin<P>(self, plugin: P) -> Self
202    where
203        P: TextViewPlugin,
204    {
205        plugin.setup(self)
206    }
207}
208
209impl IntoElement for TextView {
210    type Element = Self;
211
212    fn into_element(self) -> Self::Element {
213        self
214    }
215}
216
217/// Layout state retained for source compatibility with the original component TextView.
218pub struct TextViewLayoutState {
219    element: AnyElement,
220}
221
222/// Prepaint state retained for source compatibility with the original component TextView.
223pub struct TextViewPrepaintState;
224
225impl Element for TextView {
226    type RequestLayoutState = TextViewLayoutState;
227    type PrepaintState = TextViewPrepaintState;
228
229    fn id(&self) -> Option<ElementId> {
230        Some(self.id.clone())
231    }
232
233    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
234        None
235    }
236
237    fn request_layout(
238        &mut self,
239        _: Option<&GlobalElementId>,
240        _: Option<&InspectorElementId>,
241        window: &mut Window,
242        cx: &mut App,
243    ) -> (LayoutId, Self::RequestLayoutState) {
244        let mut inner = self.inner.clone();
245        if let Some(style) = self.text_style.clone() {
246            // `request_layout` runs every frame, so this asks whether the
247            // caller ever replaced the theme -- a pointer comparison against
248            // the shared default -- rather than comparing two whole themes
249            // field by field.
250            #[cfg(feature = "tree-sitter")]
251            if !std::sync::Arc::ptr_eq(
252                &style.highlight_theme,
253                &crate::highlighter::HighlightTheme::default_light(),
254            ) {
255                inner = inner.shared_code_block_highlighter(super::shared_code_block_highlighter(
256                    &style.highlight_theme,
257                ));
258            }
259            inner = inner.style(resolve_component_style(
260                crate::ActiveTheme::theme(cx),
261                style,
262            ));
263        }
264        let motion = self.motion.clone().or_else(|| {
265            self.stream_fade.map(|fade| {
266                if !fade {
267                    return TextViewMotion::default();
268                }
269                TextViewMotion::default()
270                    .with_stream_fade(STREAM_FADE)
271                    .with_stream_fade_stagger(STREAM_FADE_STAGGER)
272                    .with_stream_fade_easing(Easing::EaseOut)
273            })
274        });
275        if let Some(motion) = motion {
276            inner = inner.motion(motion);
277        }
278        let mut element = inner.into_any_element();
279        let layout_id = element.request_layout(window, cx);
280        (layout_id, TextViewLayoutState { element })
281    }
282
283    fn prepaint(
284        &mut self,
285        _: Option<&GlobalElementId>,
286        _: Option<&InspectorElementId>,
287        _: Bounds<Pixels>,
288        element: &mut Self::RequestLayoutState,
289        window: &mut Window,
290        cx: &mut App,
291    ) -> Self::PrepaintState {
292        element.element.prepaint(window, cx);
293        TextViewPrepaintState
294    }
295
296    fn paint(
297        &mut self,
298        _: Option<&GlobalElementId>,
299        _: Option<&InspectorElementId>,
300        _: Bounds<Pixels>,
301        element: &mut Self::RequestLayoutState,
302        _: &mut Self::PrepaintState,
303        window: &mut Window,
304        cx: &mut App,
305    ) {
306        element.element.paint(window, cx);
307    }
308}
309
310/// Folds a component [`TextViewStyle`] onto the one the theme already derived.
311///
312/// The legacy type carries `StyleRefinement`s that callers filled in
313/// partially, so each one is refined onto the themed value rather than
314/// replacing it -- a caller who set only `white_space` keeps the themed
315/// padding and colors.
316pub(super) fn resolve_component_style(
317    theme: &crate::Theme,
318    legacy: TextViewStyle,
319) -> gpui_base::TextViewStyle {
320    let themed = super::base_text_view_style(theme);
321
322    let refined = |mut base: gpui::StyleRefinement, overlay: &StyleRefinement| {
323        base.refine(overlay);
324        base
325    };
326    let code_block = refined(themed.code_block().clone(), &legacy.code_block);
327    let table = refined(themed.table().clone(), &legacy.table);
328    let table_head = refined(themed.table_head().clone(), &legacy.table_head);
329    let table_cell = refined(themed.table_cell().clone(), &legacy.table_cell);
330
331    let mut inline_code = themed.inline_code();
332    refine_highlight_style(&mut inline_code, legacy.inline_code);
333
334    // `is_dark` only ever turns on: the component theme already answered the
335    // question, and a legacy style left at its `false` default must not undo
336    // a dark theme.
337    let is_dark = themed.is_dark() || legacy.is_dark;
338
339    let heading_base_font_size = legacy.heading_base_font_size;
340    let heading_font_size = legacy.heading_font_size;
341    let style = themed
342        .with_paragraph_gap(legacy.paragraph_gap)
343        .with_heading(move |level| {
344            let default_size = match level {
345                1 => gpui::rems(2.),
346                2 => gpui::rems(1.5),
347                3 => gpui::rems(1.25),
348                4 => gpui::rems(1.125),
349                _ => gpui::rems(1.),
350            }
351            .to_pixels(heading_base_font_size);
352            let text_size = heading_font_size.as_ref().map_or(default_size, |resolve| {
353                resolve(level, heading_base_font_size)
354            });
355            StyleRefinement::default().text_size(text_size)
356        })
357        .with_code_block(code_block)
358        .with_table(table)
359        .with_table_head(table_head)
360        .with_table_cell(table_cell)
361        .with_inline_code(inline_code)
362        .with_dark(is_dark);
363    style
364}
365
366fn refine_highlight_style(style: &mut HighlightStyle, refinement: HighlightStyle) {
367    if refinement.color.is_some() {
368        style.color = refinement.color;
369    }
370    if refinement.font_weight.is_some() {
371        style.font_weight = refinement.font_weight;
372    }
373    if refinement.font_style.is_some() {
374        style.font_style = refinement.font_style;
375    }
376    if refinement.background_color.is_some() {
377        style.background_color = refinement.background_color;
378    }
379    if refinement.underline.is_some() {
380        style.underline = refinement.underline;
381    }
382    if refinement.strikethrough.is_some() {
383        style.strikethrough = refinement.strikethrough;
384    }
385    if refinement.fade_out.is_some() {
386        style.fade_out = refinement.fade_out;
387    }
388}
389
390/// A bundle of [`TextView`] configuration that can be applied in one call.
391pub trait TextViewPlugin {
392    /// Applies this plugin's configuration to `text_view`.
393    fn setup(self, text_view: TextView) -> TextView;
394}
395impl<P> TextViewPlugin for P
396where
397    P: MarkdownPlugin,
398{
399    fn setup(self, mut text_view: TextView) -> TextView {
400        text_view.inner = text_view.inner.plugin(self);
401        text_view
402    }
403}
404
405/// Either a plain string or a rich [`TextView`].
406#[derive(IntoElement, Clone)]
407pub enum Text {
408    String(SharedString),
409    TextView(Box<TextView>),
410}
411impl From<SharedString> for Text {
412    fn from(value: SharedString) -> Self {
413        Self::String(value)
414    }
415}
416impl From<String> for Text {
417    fn from(value: String) -> Self {
418        Self::String(value.into())
419    }
420}
421impl From<&str> for Text {
422    fn from(value: &str) -> Self {
423        Self::String(value.to_string().into())
424    }
425}
426impl From<TextView> for Text {
427    fn from(value: TextView) -> Self {
428        Self::TextView(Box::new(value))
429    }
430}
431impl Text {
432    /// Sets the style for the [`TextView`]. Does nothing for a plain string.
433    pub fn style(self, style: TextViewStyle) -> Self {
434        match self {
435            Self::String(value) => Self::String(value),
436            Self::TextView(view) => Self::TextView(Box::new(view.style(style))),
437        }
438    }
439    pub(crate) fn get_text(&self, cx: &App) -> SharedString {
440        match self {
441            Self::String(value) => value.clone(),
442            Self::TextView(view) => gpui_base::Text::from(view.inner.clone()).get_text(cx),
443        }
444    }
445}
446impl RenderOnce for Text {
447    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
448        match self {
449            Self::String(value) => value.into_any_element(),
450            Self::TextView(view) => view.into_any_element(),
451        }
452    }
453}
454
455/// Creates a Markdown text view identified by the caller's code location.
456#[track_caller]
457pub fn markdown(source: impl Into<SharedString>) -> TextView {
458    TextView::markdown(
459        ElementId::CodeLocation(*std::panic::Location::caller()),
460        source,
461    )
462}
463/// Creates an HTML text view identified by the caller's code location.
464#[track_caller]
465pub fn html(source: impl Into<SharedString>) -> TextView {
466    TextView::html(
467        ElementId::CodeLocation(*std::panic::Location::caller()),
468        source,
469    )
470}
471
472#[cfg(test)]
473mod tests {
474    use std::sync::{
475        Arc,
476        atomic::{AtomicUsize, Ordering},
477    };
478
479    use gpui::{
480        Context, IntoElement, ParentElement as _, Render, TestAppContext, VisualTestContext, div,
481    };
482
483    struct StatelessMarkdown {
484        renders: Arc<AtomicUsize>,
485    }
486
487    impl Render for StatelessMarkdown {
488        fn render(&mut self, _: &mut gpui::Window, _: &mut Context<Self>) -> impl IntoElement {
489            self.renders.fetch_add(1, Ordering::Relaxed);
490            div().child(
491                super::markdown(include_str!("../../../../examples/fixtures/test.md"))
492                    .markdown_block_parser(|_, _| None),
493            )
494        }
495    }
496
497    #[gpui::test]
498    fn stateless_markdown_facade_settles_after_parsing(cx: &mut TestAppContext) {
499        cx.update(crate::init);
500        let renders = Arc::new(AtomicUsize::new(0));
501        let (_, cx) = cx.add_window_view({
502            let renders = renders.clone();
503            move |_, _| StatelessMarkdown { renders }
504        });
505        let cx: &mut VisualTestContext = cx;
506
507        cx.run_until_parked();
508        cx.update(|window, cx| window.draw(cx).clear(cx));
509        let renders_after_redraw = renders.load(Ordering::Relaxed);
510        cx.run_until_parked();
511        assert_eq!(
512            renders.load(Ordering::Relaxed),
513            renders_after_redraw,
514            "an unchanged compatibility TextView must not schedule another render after its parse",
515        );
516    }
517}