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