1use gpui::{
2 AnyElement, AnyView, 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, TaskToggleEvent, 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 link_tooltip<F>(mut self, builder: F) -> Self
191 where
192 F: Fn(&SharedString, &mut Window, &mut App) -> AnyView + Send + Sync + 'static,
193 {
194 self.inner = self.inner.link_tooltip(builder);
195 self
196 }
197 pub fn on_task_toggle<F>(mut self, f: F) -> Self
200 where
201 F: Fn(&TaskToggleEvent, &mut Window, &mut App) + Send + Sync + 'static,
202 {
203 self.inner = self.inner.on_task_toggle(f);
204 self
205 }
206 pub fn on_reveal<F>(mut self, f: F) -> Self
209 where
210 F: Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
211 {
212 self.inner = self.inner.on_reveal(f);
213 self
214 }
215 pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self {
217 self.inner = self.inner.markdown_extensions(value);
218 self
219 }
220 pub fn markdown_mdx(mut self) -> Self {
222 self.inner = self.inner.markdown_mdx();
223 self
224 }
225
226 pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
228 where
229 F: for<'a> Fn(&markdown::mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
230 + Send
231 + Sync
232 + 'static,
233 {
234 self.inner = self.inner.markdown_block_parser(parser);
235 self
236 }
237 pub fn markdown_block_renderer<F, E>(
239 mut self,
240 name: impl Into<SharedString>,
241 renderer: F,
242 ) -> Self
243 where
244 F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
245 E: IntoElement,
246 {
247 self.inner = self.inner.markdown_block_renderer(name, renderer);
248 self
249 }
250 pub fn plugin<P>(self, plugin: P) -> Self
252 where
253 P: TextViewPlugin,
254 {
255 plugin.setup(self)
256 }
257}
258
259impl IntoElement for TextView {
260 type Element = Self;
261
262 fn into_element(self) -> Self::Element {
263 self
264 }
265}
266
267pub struct TextViewLayoutState {
269 element: AnyElement,
270}
271
272pub struct TextViewPrepaintState;
274
275impl Element for TextView {
276 type RequestLayoutState = TextViewLayoutState;
277 type PrepaintState = TextViewPrepaintState;
278
279 fn id(&self) -> Option<ElementId> {
280 Some(self.id.clone())
281 }
282
283 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
284 None
285 }
286
287 fn request_layout(
288 &mut self,
289 _: Option<&GlobalElementId>,
290 _: Option<&InspectorElementId>,
291 window: &mut Window,
292 cx: &mut App,
293 ) -> (LayoutId, Self::RequestLayoutState) {
294 let mut inner = self.inner.clone();
295 if let Some(style) = self.text_style.clone() {
296 #[cfg(any(feature = "tree-sitter", feature = "syntect"))]
301 if !std::sync::Arc::ptr_eq(
302 &style.highlight_theme,
303 &crate::highlighter::HighlightTheme::default_light(),
304 ) {
305 inner = inner.shared_code_block_highlighter(super::shared_code_block_highlighter(
306 &style.highlight_theme,
307 ));
308 }
309 inner = inner.style(resolve_component_style(
310 crate::ActiveTheme::theme(cx),
311 style,
312 ));
313 }
314 let motion = self.motion.clone().or_else(|| {
315 self.stream_fade.map(|fade| {
316 if !fade {
317 return TextViewMotion::default();
318 }
319 TextViewMotion::default()
320 .with_stream_fade(STREAM_FADE)
321 .with_stream_fade_stagger(STREAM_FADE_STAGGER)
322 .with_stream_fade_easing(Easing::EaseOut)
323 })
324 });
325 if let Some(motion) = motion {
326 inner = inner.motion(motion);
327 }
328 let mut element = inner.into_any_element();
329 let layout_id = element.request_layout(window, cx);
330 (layout_id, TextViewLayoutState { element })
331 }
332
333 fn prepaint(
334 &mut self,
335 _: Option<&GlobalElementId>,
336 _: Option<&InspectorElementId>,
337 _: Bounds<Pixels>,
338 element: &mut Self::RequestLayoutState,
339 window: &mut Window,
340 cx: &mut App,
341 ) -> Self::PrepaintState {
342 element.element.prepaint(window, cx);
343 TextViewPrepaintState
344 }
345
346 fn paint(
347 &mut self,
348 _: Option<&GlobalElementId>,
349 _: Option<&InspectorElementId>,
350 _: Bounds<Pixels>,
351 element: &mut Self::RequestLayoutState,
352 _: &mut Self::PrepaintState,
353 window: &mut Window,
354 cx: &mut App,
355 ) {
356 element.element.paint(window, cx);
357 }
358}
359
360pub(super) fn resolve_component_style(
367 theme: &crate::Theme,
368 legacy: TextViewStyle,
369) -> gpui_base::TextViewStyle {
370 let themed = super::base_text_view_style(theme);
371
372 let refined = |mut base: gpui::StyleRefinement, overlay: &StyleRefinement| {
373 base.refine(overlay);
374 base
375 };
376 let code_block = refined(themed.code_block().clone(), &legacy.code_block);
377 let table = refined(themed.table().clone(), &legacy.table);
378 let table_head = refined(themed.table_head().clone(), &legacy.table_head);
379 let table_cell = refined(themed.table_cell().clone(), &legacy.table_cell);
380
381 let mut inline_code = themed.inline_code();
382 refine_highlight_style(&mut inline_code, legacy.inline_code);
383
384 let is_dark = themed.is_dark() || legacy.is_dark;
388
389 let heading_base_font_size = legacy.heading_base_font_size;
390 let heading_font_size = legacy.heading_font_size;
391 let pins_heading_size = heading_font_size.is_some()
395 || heading_base_font_size != TextViewStyle::default().heading_base_font_size;
396 let style = themed
397 .with_paragraph_gap(legacy.paragraph_gap)
398 .with_heading(move |level| {
399 if !pins_heading_size {
400 return StyleRefinement::default();
401 }
402 let default_size = match level {
403 1 => gpui::rems(2.),
404 2 => gpui::rems(1.5),
405 3 => gpui::rems(1.25),
406 4 => gpui::rems(1.125),
407 _ => gpui::rems(1.),
408 }
409 .to_pixels(heading_base_font_size);
410 let text_size = heading_font_size.as_ref().map_or(default_size, |resolve| {
411 resolve(level, heading_base_font_size)
412 });
413 StyleRefinement::default().text_size(text_size)
414 })
415 .with_code_block(code_block)
416 .with_table(table)
417 .with_table_head(table_head)
418 .with_table_cell(table_cell)
419 .with_inline_code(inline_code)
420 .with_dark(is_dark);
421 style
422}
423
424fn refine_highlight_style(style: &mut HighlightStyle, refinement: HighlightStyle) {
425 if refinement.color.is_some() {
426 style.color = refinement.color;
427 }
428 if refinement.font_weight.is_some() {
429 style.font_weight = refinement.font_weight;
430 }
431 if refinement.font_style.is_some() {
432 style.font_style = refinement.font_style;
433 }
434 if refinement.background_color.is_some() {
435 style.background_color = refinement.background_color;
436 }
437 if refinement.underline.is_some() {
438 style.underline = refinement.underline;
439 }
440 if refinement.strikethrough.is_some() {
441 style.strikethrough = refinement.strikethrough;
442 }
443 if refinement.fade_out.is_some() {
444 style.fade_out = refinement.fade_out;
445 }
446}
447
448pub trait TextViewPlugin {
450 fn setup(self, text_view: TextView) -> TextView;
452}
453impl<P> TextViewPlugin for P
454where
455 P: MarkdownPlugin,
456{
457 fn setup(self, mut text_view: TextView) -> TextView {
458 text_view.inner = text_view.inner.plugin(self);
459 text_view
460 }
461}
462
463#[derive(IntoElement, Clone)]
465pub enum Text {
466 String(SharedString),
467 TextView(Box<TextView>),
468}
469impl From<SharedString> for Text {
470 fn from(value: SharedString) -> Self {
471 Self::String(value)
472 }
473}
474impl From<String> for Text {
475 fn from(value: String) -> Self {
476 Self::String(value.into())
477 }
478}
479impl From<&str> for Text {
480 fn from(value: &str) -> Self {
481 Self::String(value.to_string().into())
482 }
483}
484impl From<TextView> for Text {
485 fn from(value: TextView) -> Self {
486 Self::TextView(Box::new(value))
487 }
488}
489impl Text {
490 pub fn style(self, style: TextViewStyle) -> Self {
492 match self {
493 Self::String(value) => Self::String(value),
494 Self::TextView(view) => Self::TextView(Box::new(view.style(style))),
495 }
496 }
497 pub(crate) fn get_text(&self, cx: &App) -> SharedString {
498 match self {
499 Self::String(value) => value.clone(),
500 Self::TextView(view) => gpui_base::Text::from(view.inner.clone()).get_text(cx),
501 }
502 }
503}
504impl RenderOnce for Text {
505 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
506 match self {
507 Self::String(value) => value.into_any_element(),
508 Self::TextView(view) => view.into_any_element(),
509 }
510 }
511}
512
513#[track_caller]
515pub fn markdown(source: impl Into<SharedString>) -> TextView {
516 TextView::markdown(
517 ElementId::CodeLocation(*std::panic::Location::caller()),
518 source,
519 )
520}
521#[track_caller]
523pub fn html(source: impl Into<SharedString>) -> TextView {
524 TextView::html(
525 ElementId::CodeLocation(*std::panic::Location::caller()),
526 source,
527 )
528}
529
530#[cfg(test)]
531mod tests {
532 use std::sync::{
533 Arc,
534 atomic::{AtomicUsize, Ordering},
535 };
536
537 use gpui::{
538 Context, IntoElement, ParentElement as _, Render, TestAppContext, VisualTestContext, div,
539 };
540
541 struct StatelessMarkdown {
542 renders: Arc<AtomicUsize>,
543 }
544
545 impl Render for StatelessMarkdown {
546 fn render(&mut self, _: &mut gpui::Window, _: &mut Context<Self>) -> impl IntoElement {
547 self.renders.fetch_add(1, Ordering::Relaxed);
548 div().child(
549 super::markdown(include_str!("../../../../examples/fixtures/test.md"))
550 .markdown_block_parser(|_, _| None),
551 )
552 }
553 }
554
555 #[gpui::test]
556 fn stateless_markdown_facade_settles_after_parsing(cx: &mut TestAppContext) {
557 cx.update(crate::init);
558 let renders = Arc::new(AtomicUsize::new(0));
559 let (_, cx) = cx.add_window_view({
560 let renders = renders.clone();
561 move |_, _| StatelessMarkdown { renders }
562 });
563 let cx: &mut VisualTestContext = cx;
564
565 cx.run_until_parked();
566 cx.update(|window, cx| window.draw(cx).clear(cx));
567 let renders_after_redraw = renders.load(Ordering::Relaxed);
568 cx.run_until_parked();
569 assert_eq!(
570 renders.load(Ordering::Relaxed),
571 renders_after_redraw,
572 "an unchanged compatibility TextView must not schedule another render after its parse",
573 );
574 }
575}