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
15const STREAM_FADE: Duration = Duration::from_millis(350);
20
21#[derive(Clone)]
28pub struct TextView {
29 id: ElementId,
30 inner: gpui_base::TextView,
31 text_style: Option<TextViewStyle>,
32 motion: Option<TextViewMotion>,
33 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 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 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 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 pub fn style(mut self, style: TextViewStyle) -> Self {
79 self.text_style = Some(style);
80 self
81 }
82 pub fn selectable(mut self, value: bool) -> Self {
84 self.inner = self.inner.selectable(value);
85 self
86 }
87 pub fn selection_format(mut self, value: SelectionFormat) -> Self {
89 self.inner = self.inner.selection_format(value);
90 self
91 }
92 pub fn scrollable(mut self, value: bool) -> Self {
94 self.inner = self.inner.scrollable(value);
95 self
96 }
97 pub fn stream_fade(mut self, value: bool) -> Self {
103 self.stream_fade = Some(value);
104 self
105 }
106 pub fn motion(mut self, motion: TextViewMotion) -> Self {
109 self.motion = Some(motion);
110 self
111 }
112 pub fn max_lines(mut self, value: usize) -> Self {
114 self.inner = self.inner.max_lines(value);
115 self
116 }
117 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 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 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 pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self {
145 self.inner = self.inner.markdown_extensions(value);
146 self
147 }
148 pub fn markdown_mdx(mut self) -> Self {
150 self.inner = self.inner.markdown_mdx();
151 self
152 }
153
154 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 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 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
195pub struct TextViewLayoutState {
197 element: AnyElement,
198}
199
200pub 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 #[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
287pub(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 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
355pub trait TextViewPlugin {
357 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#[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 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#[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#[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}