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 style(mut self, style: TextViewStyle) -> Self {
91 self.text_style = Some(style);
92 self
93 }
94 pub fn selectable(mut self, value: bool) -> Self {
96 self.inner = self.inner.selectable(value);
97 self
98 }
99 pub fn selection_format(mut self, value: SelectionFormat) -> Self {
101 self.inner = self.inner.selection_format(value);
102 self
103 }
104 pub fn scrollable(mut self, value: bool) -> Self {
106 self.inner = self.inner.scrollable(value);
107 self
108 }
109 pub fn stream_fade(mut self, value: bool) -> Self {
116 self.stream_fade = Some(value);
117 self
118 }
119 pub fn motion(mut self, motion: TextViewMotion) -> Self {
122 self.motion = Some(motion);
123 self
124 }
125 pub fn max_lines(mut self, value: usize) -> Self {
127 self.inner = self.inner.max_lines(value);
128 self
129 }
130 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 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 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 pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self {
158 self.inner = self.inner.markdown_extensions(value);
159 self
160 }
161 pub fn markdown_mdx(mut self) -> Self {
163 self.inner = self.inner.markdown_mdx();
164 self
165 }
166
167 pub fn markdown_block_parser<F>(mut self, parser: F) -> Self
169 where
170 F: for<'a> Fn(&markdown::mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
171 + Send
172 + Sync
173 + 'static,
174 {
175 self.inner = self.inner.markdown_block_parser(parser);
176 self
177 }
178 pub fn markdown_block_renderer<F, E>(
180 mut self,
181 name: impl Into<SharedString>,
182 renderer: F,
183 ) -> Self
184 where
185 F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
186 E: IntoElement,
187 {
188 self.inner = self.inner.markdown_block_renderer(name, renderer);
189 self
190 }
191 pub fn plugin<P>(self, plugin: P) -> Self
193 where
194 P: TextViewPlugin,
195 {
196 plugin.setup(self)
197 }
198}
199
200impl IntoElement for TextView {
201 type Element = Self;
202
203 fn into_element(self) -> Self::Element {
204 self
205 }
206}
207
208pub struct TextViewLayoutState {
210 element: AnyElement,
211}
212
213pub struct TextViewPrepaintState;
215
216impl Element for TextView {
217 type RequestLayoutState = TextViewLayoutState;
218 type PrepaintState = TextViewPrepaintState;
219
220 fn id(&self) -> Option<ElementId> {
221 Some(self.id.clone())
222 }
223
224 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
225 None
226 }
227
228 fn request_layout(
229 &mut self,
230 _: Option<&GlobalElementId>,
231 _: Option<&InspectorElementId>,
232 window: &mut Window,
233 cx: &mut App,
234 ) -> (LayoutId, Self::RequestLayoutState) {
235 let mut inner = self.inner.clone();
236 if let Some(style) = self.text_style.clone() {
237 #[cfg(feature = "tree-sitter")]
242 if !std::sync::Arc::ptr_eq(
243 &style.highlight_theme,
244 &crate::highlighter::HighlightTheme::default_light(),
245 ) {
246 inner = inner.code_block_highlighter(super::component_code_block_highlighter(
247 style.highlight_theme.clone(),
248 ));
249 }
250 inner = inner.style(resolve_component_style(
251 crate::ActiveTheme::theme(cx),
252 style,
253 ));
254 }
255 let motion = self.motion.clone().or_else(|| {
256 self.stream_fade.map(|fade| {
257 if !fade {
258 return TextViewMotion::default();
259 }
260 TextViewMotion::default()
261 .with_stream_fade(STREAM_FADE)
262 .with_stream_fade_stagger(STREAM_FADE_STAGGER)
263 .with_stream_fade_easing(Easing::EaseOut)
264 })
265 });
266 if let Some(motion) = motion {
267 inner = inner.motion(motion);
268 }
269 let mut element = inner.into_any_element();
270 let layout_id = element.request_layout(window, cx);
271 (layout_id, TextViewLayoutState { element })
272 }
273
274 fn prepaint(
275 &mut self,
276 _: Option<&GlobalElementId>,
277 _: Option<&InspectorElementId>,
278 _: Bounds<Pixels>,
279 element: &mut Self::RequestLayoutState,
280 window: &mut Window,
281 cx: &mut App,
282 ) -> Self::PrepaintState {
283 element.element.prepaint(window, cx);
284 TextViewPrepaintState
285 }
286
287 fn paint(
288 &mut self,
289 _: Option<&GlobalElementId>,
290 _: Option<&InspectorElementId>,
291 _: Bounds<Pixels>,
292 element: &mut Self::RequestLayoutState,
293 _: &mut Self::PrepaintState,
294 window: &mut Window,
295 cx: &mut App,
296 ) {
297 element.element.paint(window, cx);
298 }
299}
300
301pub(super) fn resolve_component_style(
308 theme: &crate::Theme,
309 legacy: TextViewStyle,
310) -> gpui_base::TextViewStyle {
311 let themed = super::base_text_view_style(theme);
312
313 let refined = |mut base: gpui::StyleRefinement, overlay: &StyleRefinement| {
314 base.refine(overlay);
315 base
316 };
317 let code_block = refined(themed.code_block().clone(), &legacy.code_block);
318 let table = refined(themed.table().clone(), &legacy.table);
319 let table_head = refined(themed.table_head().clone(), &legacy.table_head);
320 let table_cell = refined(themed.table_cell().clone(), &legacy.table_cell);
321
322 let mut inline_code = themed.inline_code();
323 refine_highlight_style(&mut inline_code, legacy.inline_code);
324
325 let is_dark = themed.is_dark() || legacy.is_dark;
329
330 let mut style = themed
331 .with_paragraph_gap(legacy.paragraph_gap)
332 .with_heading_base_font_size(legacy.heading_base_font_size)
333 .with_code_block(code_block)
334 .with_table(table)
335 .with_table_head(table_head)
336 .with_table_cell(table_cell)
337 .with_inline_code(inline_code)
338 .with_dark(is_dark);
339 if let Some(heading_font_size) = legacy.heading_font_size {
340 style = style.with_heading_font_size(move |level, base| heading_font_size(level, base));
341 }
342 style
343}
344
345fn refine_highlight_style(style: &mut HighlightStyle, refinement: HighlightStyle) {
346 if refinement.color.is_some() {
347 style.color = refinement.color;
348 }
349 if refinement.font_weight.is_some() {
350 style.font_weight = refinement.font_weight;
351 }
352 if refinement.font_style.is_some() {
353 style.font_style = refinement.font_style;
354 }
355 if refinement.background_color.is_some() {
356 style.background_color = refinement.background_color;
357 }
358 if refinement.underline.is_some() {
359 style.underline = refinement.underline;
360 }
361 if refinement.strikethrough.is_some() {
362 style.strikethrough = refinement.strikethrough;
363 }
364 if refinement.fade_out.is_some() {
365 style.fade_out = refinement.fade_out;
366 }
367}
368
369pub trait TextViewPlugin {
371 fn setup(self, text_view: TextView) -> TextView;
373}
374impl<P> TextViewPlugin for P
375where
376 P: MarkdownPlugin,
377{
378 fn setup(self, mut text_view: TextView) -> TextView {
379 text_view.inner = text_view.inner.plugin(self);
380 text_view
381 }
382}
383
384#[derive(IntoElement, Clone)]
386pub enum Text {
387 String(SharedString),
388 TextView(Box<TextView>),
389}
390impl From<SharedString> for Text {
391 fn from(value: SharedString) -> Self {
392 Self::String(value)
393 }
394}
395impl From<String> for Text {
396 fn from(value: String) -> Self {
397 Self::String(value.into())
398 }
399}
400impl From<&str> for Text {
401 fn from(value: &str) -> Self {
402 Self::String(value.to_string().into())
403 }
404}
405impl From<TextView> for Text {
406 fn from(value: TextView) -> Self {
407 Self::TextView(Box::new(value))
408 }
409}
410impl Text {
411 pub fn style(self, style: TextViewStyle) -> Self {
413 match self {
414 Self::String(value) => Self::String(value),
415 Self::TextView(view) => Self::TextView(Box::new(view.style(style))),
416 }
417 }
418 pub(crate) fn get_text(&self, cx: &App) -> SharedString {
419 match self {
420 Self::String(value) => value.clone(),
421 Self::TextView(view) => gpui_base::Text::from(view.inner.clone()).get_text(cx),
422 }
423 }
424}
425impl RenderOnce for Text {
426 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
427 match self {
428 Self::String(value) => value.into_any_element(),
429 Self::TextView(view) => view.into_any_element(),
430 }
431 }
432}
433
434#[track_caller]
436pub fn markdown(source: impl Into<SharedString>) -> TextView {
437 TextView::markdown(
438 ElementId::CodeLocation(*std::panic::Location::caller()),
439 source,
440 )
441}
442#[track_caller]
444pub fn html(source: impl Into<SharedString>) -> TextView {
445 TextView::html(
446 ElementId::CodeLocation(*std::panic::Location::caller()),
447 source,
448 )
449}
450
451#[cfg(test)]
452mod tests {
453 use std::sync::{
454 Arc,
455 atomic::{AtomicUsize, Ordering},
456 };
457
458 use gpui::{
459 Context, IntoElement, ParentElement as _, Render, TestAppContext, VisualTestContext, div,
460 };
461
462 struct StatelessMarkdown {
463 renders: Arc<AtomicUsize>,
464 }
465
466 impl Render for StatelessMarkdown {
467 fn render(&mut self, _: &mut gpui::Window, _: &mut Context<Self>) -> impl IntoElement {
468 self.renders.fetch_add(1, Ordering::Relaxed);
469 div().child(
470 super::markdown(include_str!("../../../../examples/fixtures/test.md"))
471 .markdown_block_parser(|_, _| None),
472 )
473 }
474 }
475
476 #[gpui::test]
477 fn stateless_markdown_facade_settles_after_parsing(cx: &mut TestAppContext) {
478 cx.update(crate::init);
479 let renders = Arc::new(AtomicUsize::new(0));
480 let (_, cx) = cx.add_window_view({
481 let renders = renders.clone();
482 move |_, _| StatelessMarkdown { renders }
483 });
484 let cx: &mut VisualTestContext = cx;
485
486 cx.run_until_parked();
487 cx.update(|window, cx| window.draw(cx).clear(cx));
488 let renders_after_redraw = renders.load(Ordering::Relaxed);
489 cx.run_until_parked();
490 assert_eq!(
491 renders.load(Ordering::Relaxed),
492 renders_after_redraw,
493 "an unchanged compatibility TextView must not schedule another render after its parse",
494 );
495 }
496}