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(280);
31const STREAM_FADE_STAGGER: Duration = Duration::from_millis(10);
32
33#[derive(Clone)]
40pub struct TextView {
41 id: ElementId,
42 inner: gpui_base::TextView,
43 text_style: Option<TextViewStyle>,
44 motion: Option<TextViewMotion>,
45 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 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 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 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 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 pub fn style(mut self, style: TextViewStyle) -> Self {
103 self.text_style = Some(style);
104 self
105 }
106 pub fn selectable(mut self, value: bool) -> Self {
108 self.inner = self.inner.selectable(value);
109 self
110 }
111 pub fn inline_code_style(mut self, style: StyleRefinement) -> Self {
115 self.inner = self.inner.inline_code_style(style);
116 self
117 }
118 pub fn list_style(mut self, style: StyleRefinement) -> Self {
122 self.inner = self.inner.list_style(style);
123 self
124 }
125 pub fn blockquote_style(mut self, style: StyleRefinement) -> Self {
128 self.inner = self.inner.blockquote_style(style);
129 self
130 }
131 pub fn selection_format(mut self, value: SelectionFormat) -> Self {
133 self.inner = self.inner.selection_format(value);
134 self
135 }
136 pub fn scrollable(mut self, value: bool) -> Self {
138 self.inner = self.inner.scrollable(value);
139 self
140 }
141 pub fn stream_fade(mut self, value: bool) -> Self {
148 self.stream_fade = Some(value);
149 self
150 }
151 pub fn motion(mut self, motion: TextViewMotion) -> Self {
154 self.motion = Some(motion);
155 self
156 }
157 pub fn max_lines(mut self, value: usize) -> Self {
159 self.inner = self.inner.max_lines(value);
160 self
161 }
162 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 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 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 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 pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self {
199 self.inner = self.inner.markdown_extensions(value);
200 self
201 }
202 pub fn markdown_mdx(mut self) -> Self {
204 self.inner = self.inner.markdown_mdx();
205 self
206 }
207
208 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 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 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
249pub struct TextViewLayoutState {
251 element: AnyElement,
252}
253
254pub 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 #[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
342pub(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 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 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
430pub trait TextViewPlugin {
432 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#[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 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#[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#[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}