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