1pub mod parse;
26
27use std::collections::{HashMap, HashSet};
28use std::ops::Range;
29use std::rc::Rc;
30
31use gpui::{
32 AnyElement, App, ClipboardItem, FontWeight, InteractiveElement, IntoElement, ParentElement,
33 RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
34 prelude::FluentBuilder, px,
35};
36use gpui_kit_semantics::{NodeSpec, Role, Semantic};
37use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, Theme, TypeScale};
38
39use crate::controls::button::Button;
40use crate::display::badge::Tone;
41use crate::foundation::{Ident, Sizable, StyledExt};
42use crate::motion::keyed;
43use crate::overlay::Tooltipped;
44use crate::strings::{ActiveStrings, StringKey};
45
46pub use parse::{Block, CellAlign, Document, Inline, ListEntry};
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum MarkdownEvent {
51 LinkClicked { href: SharedString },
53 ImageRequested {
59 src: SharedString,
60 alt: SharedString,
61 },
62 CodeCopied {
64 language: Option<SharedString>,
65 text: SharedString,
66 },
67 MoreRequested { lines: usize },
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ImageRequest {
74 pub src: SharedString,
75 pub alt: SharedString,
76 pub title: Option<SharedString>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CodeBlock {
82 pub language: Option<SharedString>,
84 pub text: SharedString,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CodeSpan {
94 pub range: Range<usize>,
95 pub tone: Tone,
96}
97
98type EventHandler = Rc<dyn Fn(&MarkdownEvent, &mut Window, &mut App)>;
99type ImageSource = Rc<dyn Fn(&ImageRequest, &mut Window, &mut App) -> Option<AnyElement>>;
100type Highlighter = Rc<dyn Fn(&CodeBlock) -> Vec<CodeSpan>>;
101
102#[derive(IntoElement)]
104pub struct Markdown {
105 ident: Ident,
106 source: SharedString,
107 max_lines: Option<usize>,
108 on_event: Option<EventHandler>,
109 image: Option<ImageSource>,
110 highlighter: Option<Highlighter>,
111}
112
113impl std::fmt::Debug for Markdown {
114 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 formatter
116 .debug_struct("Markdown")
117 .field("ident", &self.ident)
118 .field("bytes", &self.source.len())
119 .field("max_lines", &self.max_lines)
120 .field("has_images", &self.image.is_some())
121 .field("has_highlighter", &self.highlighter.is_some())
122 .finish()
123 }
124}
125
126impl Markdown {
127 pub fn new(ident: impl Into<Ident>, source: impl Into<SharedString>) -> Self {
128 Self {
129 ident: ident.into(),
130 source: source.into(),
131 max_lines: None,
132 on_event: None,
133 image: None,
134 highlighter: None,
135 }
136 }
137
138 pub fn max_lines(mut self, lines: usize) -> Self {
145 self.max_lines = Some(lines);
146 self
147 }
148
149 pub fn on_event(
150 mut self,
151 handler: impl Fn(&MarkdownEvent, &mut Window, &mut App) + 'static,
152 ) -> Self {
153 self.on_event = Some(Rc::new(handler));
154 self
155 }
156
157 pub fn image(
162 mut self,
163 source: impl Fn(&ImageRequest, &mut Window, &mut App) -> Option<AnyElement> + 'static,
164 ) -> Self {
165 self.image = Some(Rc::new(source));
166 self
167 }
168
169 pub fn highlight(
175 mut self,
176 highlighter: impl Fn(&CodeBlock) -> Vec<CodeSpan> + 'static,
177 ) -> Self {
178 self.highlighter = Some(Rc::new(highlighter));
179 self
180 }
181}
182
183impl RenderOnce for Markdown {
184 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
185 let theme = cx.theme().clone();
186 let ident = self.ident.clone();
187 let parsed = Document::parse(self.source.as_ref());
188 let (document, hidden) = match self.max_lines {
189 Some(max) => parsed.truncate(max),
190 None => (parsed, 0),
191 };
192
193 let mut painter = Painter {
194 ident: ident.clone(),
195 theme: theme.clone(),
196 on_event: self.on_event.clone(),
197 image: self.image.clone(),
198 highlighter: self.highlighter.clone(),
199 used: HashMap::new(),
200 requested: Vec::new(),
201 };
202
203 let mut column = div().column().w_full().gap_token(&theme, Space::Md);
204 for block in &document.blocks {
205 column = column.child(painter.block(block, window, cx));
206 }
207
208 if hidden > 0 {
209 column = column.child(painter.more(hidden, cx));
210 }
211
212 let requests = std::mem::take(&mut painter.requested);
213 report_images(&ident, requests, self.on_event.as_ref(), window, cx);
214
215 column.semantic_in(
216 cx,
217 NodeSpec::new(ident.semantic_id(), Role::Region)
218 .value(format!("{} blocks", document.blocks.len())),
219 )
220 }
221}
222
223#[derive(Debug, Default)]
225struct Requested(HashSet<SharedString>);
226
227fn report_images(
234 ident: &Ident,
235 requests: Vec<ImageRequest>,
236 handler: Option<&EventHandler>,
237 window: &mut Window,
238 cx: &mut App,
239) {
240 if requests.is_empty() {
241 return;
242 }
243 let Some(handler) = handler.cloned() else {
244 return;
245 };
246 let cell = keyed::slot::<Requested>(&ident.child("images").semantic_id(), cx);
247 let fresh: Vec<ImageRequest> = {
248 let mut seen = cell.borrow_mut();
249 requests
250 .into_iter()
251 .filter(|request| seen.0.insert(request.src.clone()))
252 .collect()
253 };
254 for request in fresh {
255 handler(
256 &MarkdownEvent::ImageRequested {
257 src: request.src,
258 alt: request.alt,
259 },
260 window,
261 cx,
262 );
263 }
264}
265
266#[derive(Debug, Clone, Copy, Default)]
268struct RunStyle {
269 strong: bool,
270 emphasis: bool,
271 struck: bool,
272}
273
274struct Painter {
276 ident: Ident,
277 theme: Theme,
278 on_event: Option<EventHandler>,
279 image: Option<ImageSource>,
280 highlighter: Option<Highlighter>,
281 used: HashMap<String, usize>,
284 requested: Vec<ImageRequest>,
285}
286
287impl Painter {
288 fn ident_for(&mut self, kind: &str, name: &str) -> Ident {
293 let stem = format!("{kind}-{}", slug(name));
294 let count = self.used.entry(stem.clone()).or_insert(0);
295 *count += 1;
296 match *count {
297 1 => self.ident.child(stem),
298 repeat => self.ident.child(format!("{stem}-{repeat}")),
299 }
300 }
301
302 fn report(&self, event: MarkdownEvent) -> Option<impl Fn(&mut Window, &mut App) + use<>> {
303 let handler = self.on_event.clone()?;
304 Some(move |window: &mut Window, cx: &mut App| handler(&event, window, cx))
305 }
306
307 fn block(&mut self, block: &Block, window: &mut Window, cx: &mut App) -> AnyElement {
308 match block {
309 Block::Heading { level, content } => self.heading(*level, content, window, cx),
310 Block::Paragraph(inlines) => self.paragraph(inlines, window, cx),
311 Block::Code { language, text } => self.code(language.clone(), text.clone(), cx),
312 Block::Quote(blocks) => self.quote(blocks, window, cx),
313 Block::List {
314 ordered,
315 start,
316 entries,
317 } => self.list(*ordered, *start, entries, window, cx),
318 Block::Rule => self.rule(cx),
319 Block::Table {
320 alignment,
321 head,
322 rows,
323 } => self.table(alignment, head, rows, window, cx),
324 Block::Html(text) => self.html_block(text.clone(), cx),
325 }
326 }
327
328 fn heading(
329 &mut self,
330 level: u8,
331 content: &[Inline],
332 window: &mut Window,
333 cx: &mut App,
334 ) -> AnyElement {
335 let text = flatten(content);
336 let ident = self.ident_for("heading", &text);
337 let theme = self.theme.clone();
338 let scale = match level {
342 1 => TypeScale::Title,
343 2 => TypeScale::Body,
344 _ => TypeScale::Label,
345 };
346 let runs = self.inline_row(
347 content,
348 RunStyle {
349 strong: true,
350 ..RunStyle::default()
351 },
352 window,
353 cx,
354 );
355
356 div()
357 .w_full()
358 .type_scale(&theme, scale)
359 .text_color(theme.colors.text)
360 .child(runs)
361 .semantic_in(
362 cx,
363 NodeSpec::new(ident.semantic_id(), Role::Heading)
364 .parent(self.ident.semantic_id())
365 .text(text)
366 .level(u32::from(level)),
370 )
371 .into_any_element()
372 }
373
374 fn paragraph(&mut self, inlines: &[Inline], window: &mut Window, cx: &mut App) -> AnyElement {
375 let theme = self.theme.clone();
376 div()
377 .w_full()
378 .type_scale(&theme, TypeScale::Body)
379 .text_color(theme.colors.text)
380 .child(self.inline_row(inlines, RunStyle::default(), window, cx))
381 .into_any_element()
382 }
383
384 fn inline_row(
391 &mut self,
392 inlines: &[Inline],
393 style: RunStyle,
394 window: &mut Window,
395 cx: &mut App,
396 ) -> AnyElement {
397 let mut row = div().flex().flex_row().flex_wrap().w_full();
398 for element in self.inlines(inlines, style, window, cx) {
399 row = row.child(element);
400 }
401 row.into_any_element()
402 }
403
404 fn inlines(
405 &mut self,
406 inlines: &[Inline],
407 style: RunStyle,
408 window: &mut Window,
409 cx: &mut App,
410 ) -> Vec<AnyElement> {
411 let theme = self.theme.clone();
412 let mut elements = Vec::new();
413 for inline in inlines {
414 match inline {
415 Inline::Text(text) => elements.push(run(&theme, text.clone(), style)),
416 Inline::Code(text) => elements.push(
417 div()
418 .px(px(theme.space(Space::Xs)))
419 .radius(&theme, Radius::Small)
420 .bg(theme.colors.raised)
421 .font_family(theme.typography.mono.clone())
422 .text_size(px(theme.typography.code.size))
423 .child(text.clone())
424 .into_any_element(),
425 ),
426 Inline::Emphasis(inner) => elements.extend(self.inlines(
427 inner,
428 RunStyle {
429 emphasis: true,
430 ..style
431 },
432 window,
433 cx,
434 )),
435 Inline::Strong(inner) => elements.extend(self.inlines(
436 inner,
437 RunStyle {
438 strong: true,
439 ..style
440 },
441 window,
442 cx,
443 )),
444 Inline::Struck(inner) => elements.extend(self.inlines(
445 inner,
446 RunStyle {
447 struck: true,
448 ..style
449 },
450 window,
451 cx,
452 )),
453 Inline::Link {
454 href,
455 title,
456 content,
457 } => elements.push(self.link(href, title.as_ref(), content, style, window, cx)),
458 Inline::Image { src, alt, title } => {
459 elements.push(self.image(src, alt, title.as_ref(), window, cx))
460 }
461 Inline::Html(html) => elements.push(self.html_inline(html.clone(), cx)),
462 Inline::SoftBreak => elements.push(run(&theme, " ".into(), style)),
463 Inline::HardBreak => {
464 elements.push(div().w_full().h(px(0.0)).flex_none().into_any_element())
465 }
466 }
467 }
468 elements
469 }
470
471 fn link(
472 &mut self,
473 href: &SharedString,
474 title: Option<&SharedString>,
475 content: &[Inline],
476 style: RunStyle,
477 window: &mut Window,
478 cx: &mut App,
479 ) -> AnyElement {
480 let theme = self.theme.clone();
481 let label = {
482 let text = flatten(content);
483 if text.trim().is_empty() {
484 href.clone()
485 } else {
486 text
487 }
488 };
489 let ident = self.ident_for("link", href.as_ref());
490 let help = match title {
493 Some(title) => SharedString::from(format!("{title} — {href}")),
494 None => href.clone(),
495 };
496 let runs = self.inlines(content, style, window, cx);
497 let taken = self.report(MarkdownEvent::LinkClicked { href: href.clone() });
498
499 div()
500 .id(ident.element_id())
501 .flex()
502 .flex_row()
503 .flex_wrap()
504 .cursor_pointer()
505 .text_color(theme.colors.accent)
506 .underline()
507 .tip(ident.clone(), help)
508 .children(if runs.is_empty() {
509 vec![run(&theme, label.clone(), style)]
510 } else {
511 runs
512 })
513 .when_some(taken, |element, taken| {
514 element.on_click(move |_, window, cx| taken(window, cx))
515 })
516 .semantic_in(
517 cx,
518 NodeSpec::new(ident.semantic_id(), Role::Link)
519 .parent(self.ident.semantic_id())
520 .text(label)
521 .value(href.clone()),
524 )
525 .into_any_element()
526 }
527
528 fn image(
529 &mut self,
530 src: &SharedString,
531 alt: &SharedString,
532 title: Option<&SharedString>,
533 window: &mut Window,
534 cx: &mut App,
535 ) -> AnyElement {
536 let theme = self.theme.clone();
537 let ident = self.ident_for("image", src.as_ref());
538 let request = ImageRequest {
539 src: src.clone(),
540 alt: alt.clone(),
541 title: title.cloned(),
542 };
543 let supplied = self
544 .image
545 .as_ref()
546 .and_then(|source| source(&request, window, cx));
547 let named = if alt.trim().is_empty() {
548 cx.strings().text(StringKey::MarkdownImageAlt)
549 } else {
550 alt.clone()
551 };
552
553 if supplied.is_none() {
554 self.requested.push(request);
555 }
556
557 let spec = NodeSpec::new(ident.semantic_id(), Role::Image)
558 .parent(self.ident.semantic_id())
559 .text(named.clone())
560 .value(if supplied.is_some() {
561 SharedString::new_static("supplied")
562 } else {
563 SharedString::new_static("not fetched")
564 });
565
566 match supplied {
567 Some(element) => div()
568 .child(element)
569 .semantic_in(cx, spec)
570 .into_any_element(),
571 None => div()
575 .column()
576 .gap(px(2.0))
577 .px_token(&theme, Space::Sm)
578 .py_token(&theme, Space::Xs)
579 .radius(&theme, Radius::Small)
580 .frame(&theme, Surface::Raised, Elevation::Raised)
581 .child(
582 div()
583 .type_scale(&theme, TypeScale::Label)
584 .text_color(theme.colors.text)
585 .child(named),
586 )
587 .child(
588 div()
589 .type_scale(&theme, TypeScale::Caption)
590 .text_color(theme.colors.text_faint)
591 .child(
592 cx.strings()
593 .format(StringKey::MarkdownImageNotFetched, &[src.as_ref()]),
594 ),
595 )
596 .semantic_in(cx, spec)
597 .into_any_element(),
598 }
599 }
600
601 fn code(
602 &mut self,
603 language: Option<SharedString>,
604 text: SharedString,
605 cx: &mut App,
606 ) -> AnyElement {
607 let theme = self.theme.clone();
608 let ident = self.ident_for("code", language.as_deref().unwrap_or(PLAIN_TEXT_ID));
611 let label = language
612 .clone()
613 .unwrap_or_else(|| cx.strings().text(StringKey::MarkdownPlainText));
614 let block = CodeBlock {
615 language: language.clone(),
616 text: text.clone(),
617 };
618 let spans = self
619 .highlighter
620 .as_ref()
621 .map(|highlight| highlight(&block))
622 .unwrap_or_default();
623 let lines = text.lines().count().max(1);
624
625 let copied = self.report(MarkdownEvent::CodeCopied {
626 language: language.clone(),
627 text: text.clone(),
628 });
629 let copy_ident = ident.child("copy");
630 let clipboard = text.clone();
631
632 let body = div()
633 .column()
634 .w_full()
635 .font_family(theme.typography.mono.clone())
636 .text_size(px(theme.typography.code.size))
637 .line_height(px(theme.typography.code.line_height))
638 .text_color(theme.colors.text)
639 .children(code_lines(&theme, text.as_ref(), &spans));
640
641 div()
642 .column()
643 .w_full()
644 .gap_token(&theme, Space::Xs)
645 .p_token(&theme, Space::Sm)
646 .radius(&theme, Radius::Card)
647 .frame(&theme, Surface::Raised, Elevation::Raised)
648 .child(
649 div()
650 .row()
651 .w_full()
652 .justify_between()
653 .type_scale(&theme, TypeScale::Caption)
654 .text_color(theme.colors.text_faint)
655 .child(label.clone())
656 .child(
657 Button::new(copy_ident)
658 .label(cx.strings().text(StringKey::Copy))
659 .ghost()
660 .control_size(gpui_kit_theme::ControlSize::Xs)
661 .on_click(move |window, cx| {
662 cx.write_to_clipboard(ClipboardItem::new_string(
663 clipboard.to_string(),
664 ));
665 if let Some(copied) = &copied {
666 copied(window, cx);
667 }
668 }),
669 ),
670 )
671 .child(body)
672 .semantic_in(
673 cx,
674 NodeSpec::new(ident.semantic_id(), Role::Text)
675 .parent(self.ident.semantic_id())
676 .text(label)
677 .value(format!("{lines} lines")),
678 )
679 .into_any_element()
680 }
681
682 fn quote(&mut self, blocks: &[Block], window: &mut Window, cx: &mut App) -> AnyElement {
683 let theme = self.theme.clone();
684 let mut column = div()
685 .column()
686 .flex_1()
687 .min_w_0()
688 .gap_token(&theme, Space::Sm);
689 for block in blocks {
690 column = column.child(self.block(block, window, cx));
691 }
692 div()
693 .row()
694 .items_stretch()
695 .w_full()
696 .gap_token(&theme, Space::Sm)
697 .child(
698 div()
699 .w(px(theme.borders.thick))
700 .flex_none()
701 .bg(theme.colors.hairline_strong),
702 )
703 .child(
704 div()
705 .flex_1()
706 .min_w_0()
707 .text_color(theme.colors.text_muted)
708 .child(column),
709 )
710 .into_any_element()
711 }
712
713 fn list(
714 &mut self,
715 ordered: bool,
716 start: u64,
717 entries: &[ListEntry],
718 window: &mut Window,
719 cx: &mut App,
720 ) -> AnyElement {
721 let theme = self.theme.clone();
722 let mut column = div().column().w_full().gap_token(&theme, Space::Xs);
723 for (offset, entry) in entries.iter().enumerate() {
724 column = column.child(self.entry(ordered, start, offset, entry, window, cx));
725 }
726 column.into_any_element()
727 }
728
729 fn entry(
730 &mut self,
731 ordered: bool,
732 start: u64,
733 offset: usize,
734 entry: &ListEntry,
735 window: &mut Window,
736 cx: &mut App,
737 ) -> AnyElement {
738 let theme = self.theme.clone();
739 let marker = match (entry.task, ordered) {
740 (Some(true), _) => SharedString::new_static("☑"),
741 (Some(false), _) => SharedString::new_static("☐"),
742 (None, true) => SharedString::from(format!("{}.", start + offset as u64)),
743 (None, false) => SharedString::new_static("•"),
744 };
745
746 let mut body = div()
747 .column()
748 .flex_1()
749 .min_w_0()
750 .gap_token(&theme, Space::Xs);
751 for block in &entry.blocks {
752 body = body.child(self.block(block, window, cx));
753 }
754
755 let row = div()
756 .row()
757 .items_start()
758 .w_full()
759 .gap_token(&theme, Space::Sm)
760 .child(
761 div()
762 .flex_none()
763 .min_w(px(18.0))
764 .type_scale(&theme, TypeScale::Body)
765 .text_color(theme.colors.text_faint)
766 .child(marker),
767 )
768 .child(body);
769
770 match entry.task {
774 Some(checked) => {
775 let stated = entry.blocks.iter().find_map(|block| match block {
776 Block::Paragraph(inlines) => Some(flatten(inlines)),
777 _ => None,
778 });
779 let ident = self.ident_for("task", stated.as_deref().unwrap_or(TASK_ID));
783 let text = stated.unwrap_or_else(|| cx.strings().text(StringKey::MarkdownTask));
784 row.semantic_in(
785 cx,
786 NodeSpec::new(ident.semantic_id(), Role::Checkbox)
787 .parent(self.ident.semantic_id())
788 .text(text)
789 .checked(checked)
790 .disabled(true),
791 )
792 .into_any_element()
793 }
794 None => row.into_any_element(),
795 }
796 }
797
798 fn rule(&mut self, cx: &mut App) -> AnyElement {
799 let theme = self.theme.clone();
800 let ident = self.ident_for("rule", "break");
801 div()
802 .w_full()
803 .h(px(theme.borders.hairline))
804 .bg(theme.colors.hairline)
805 .semantic_in(
806 cx,
807 NodeSpec::new(ident.semantic_id(), Role::Separator)
808 .parent(self.ident.semantic_id()),
809 )
810 .into_any_element()
811 }
812
813 fn table(
814 &mut self,
815 alignment: &[CellAlign],
816 head: &[Vec<Inline>],
817 rows: &[Vec<Vec<Inline>>],
818 window: &mut Window,
819 cx: &mut App,
820 ) -> AnyElement {
821 let theme = self.theme.clone();
822 let name = flatten(head.first().map(Vec::as_slice).unwrap_or_default());
823 let ident = self.ident_for("table", name.as_ref());
824
825 let mut frame = div()
826 .column()
827 .w_full()
828 .radius(&theme, Radius::Card)
829 .frame(&theme, Surface::Panel, Elevation::Raised)
830 .overflow_hidden();
831
832 if !head.is_empty() {
833 let mut header = div()
834 .row()
835 .items_stretch()
836 .w_full()
837 .bg(theme.colors.raised)
838 .type_scale(&theme, TypeScale::Caption)
839 .text_color(theme.colors.text_muted);
840 for (column, cell) in head.iter().enumerate() {
841 let content = self.inline_row(cell, RunStyle::default(), window, cx);
842 header =
843 header.child(cell_frame(&theme, aligned(alignment, column)).child(content));
844 }
845 frame = frame.child(header);
846 }
847
848 for row in rows {
849 let mut line = div()
850 .row()
851 .items_stretch()
852 .w_full()
853 .type_scale(&theme, TypeScale::Label)
854 .text_color(theme.colors.text);
855 for (column, cell) in row.iter().enumerate() {
856 let content = self.inline_row(cell, RunStyle::default(), window, cx);
857 line = line.child(cell_frame(&theme, aligned(alignment, column)).child(content));
858 }
859 frame = frame.child(line);
860 }
861
862 frame
863 .semantic_in(
864 cx,
865 NodeSpec::new(ident.semantic_id(), Role::Table)
866 .parent(self.ident.semantic_id())
867 .text(name)
868 .value(format!("{} rows", rows.len())),
869 )
870 .into_any_element()
871 }
872
873 fn html_block(&mut self, html: SharedString, cx: &mut App) -> AnyElement {
874 let theme = self.theme.clone();
875 let ident = self.ident_for("html", "block");
876 div()
877 .column()
878 .w_full()
879 .gap(px(2.0))
880 .px_token(&theme, Space::Sm)
881 .py_token(&theme, Space::Xs)
882 .radius(&theme, Radius::Small)
883 .border(px(theme.borders.hairline))
884 .border_color(theme.colors.warning.opacity(0.4))
885 .bg(theme.colors.warning.opacity(0.06))
886 .child(
887 div()
888 .type_scale(&theme, TypeScale::Caption)
889 .text_color(theme.colors.warning)
890 .child(cx.strings().text(StringKey::MarkdownUnrenderedHtml)),
891 )
892 .child(
893 div()
894 .font_family(theme.typography.mono.clone())
895 .text_size(px(theme.typography.code.size))
896 .line_height(px(theme.typography.code.line_height))
897 .text_color(theme.colors.text_muted)
898 .children(
899 html.lines()
900 .map(|line| div().child(SharedString::from(line.to_string()))),
901 ),
902 )
903 .semantic_in(
904 cx,
905 NodeSpec::new(ident.semantic_id(), Role::Text)
906 .parent(self.ident.semantic_id())
907 .text(html.clone())
908 .value(UNRENDERED),
909 )
910 .into_any_element()
911 }
912
913 fn html_inline(&mut self, html: SharedString, cx: &mut App) -> AnyElement {
914 let theme = self.theme.clone();
915 let ident = self.ident_for("html", "inline");
916 div()
917 .px(px(2.0))
918 .radius(&theme, Radius::Small)
919 .bg(theme.colors.warning.opacity(0.1))
920 .font_family(theme.typography.mono.clone())
921 .text_size(px(theme.typography.code.size))
922 .text_color(theme.colors.warning)
923 .child(html.clone())
924 .semantic_in(
925 cx,
926 NodeSpec::new(ident.semantic_id(), Role::Text)
927 .parent(self.ident.semantic_id())
928 .text(html)
929 .value(UNRENDERED),
930 )
931 .into_any_element()
932 }
933
934 fn more(&mut self, hidden: usize, cx: &mut App) -> AnyElement {
940 let theme = self.theme.clone();
941 let ident = self.ident.child("truncated");
942 let label = if hidden == 1 {
943 cx.strings().text(StringKey::MarkdownShowMoreOne)
944 } else {
945 cx.strings()
946 .format(StringKey::MarkdownShowMoreMany, &[&hidden.to_string()])
947 };
948 let asked = self.report(MarkdownEvent::MoreRequested { lines: hidden });
949
950 div()
951 .row()
952 .w_full()
953 .gap_token(&theme, Space::Sm)
954 .child(
955 Button::new(ident.child("more"))
956 .label(label.clone())
957 .link()
958 .on_click(move |window, cx| {
959 if let Some(asked) = &asked {
960 asked(window, cx);
961 }
962 }),
963 )
964 .semantic_in(
965 cx,
966 NodeSpec::new(ident.semantic_id(), Role::Status)
967 .parent(self.ident.semantic_id())
968 .text(label)
969 .value(hidden.to_string()),
970 )
971 .into_any_element()
972 }
973}
974
975const UNRENDERED: SharedString = SharedString::new_static("unrendered html");
980
981const PLAIN_TEXT_ID: &str = "plain text";
984const TASK_ID: &str = "task";
985
986fn run(theme: &Theme, text: SharedString, style: RunStyle) -> AnyElement {
987 div()
988 .when(style.strong, |element| {
989 element.font_weight(FontWeight::BOLD)
990 })
991 .when(style.emphasis, gpui::Styled::italic)
992 .when(style.struck, |element| {
993 element.line_through().text_color(theme.colors.text_muted)
994 })
995 .child(text)
996 .into_any_element()
997}
998
999fn cell_frame(theme: &Theme, align: CellAlign) -> gpui::Div {
1000 div()
1001 .flex_1()
1002 .min_w_0()
1003 .px_token(theme, Space::Sm)
1004 .py_token(theme, Space::Xs)
1005 .when(align == CellAlign::Center, |element| element.items_center())
1006 .when(align == CellAlign::End, |element| element.items_end())
1007}
1008
1009fn aligned(alignment: &[CellAlign], column: usize) -> CellAlign {
1010 alignment.get(column).copied().unwrap_or_default()
1011}
1012
1013fn code_lines(theme: &Theme, text: &str, spans: &[CodeSpan]) -> Vec<AnyElement> {
1015 let mut elements = Vec::new();
1016 let mut offset = 0;
1017 for line in text.lines() {
1018 let range = offset..offset + line.len();
1019 offset = range.end + 1;
1020 let mut row = div().flex().flex_row().flex_wrap().w_full();
1021 let mut cut = range.start;
1022 for span in spans
1023 .iter()
1024 .filter(|span| span.range.start < range.end && span.range.end > range.start)
1025 {
1026 let start = span.range.start.max(range.start);
1027 let end = span.range.end.min(range.end);
1028 if start > cut
1029 && let Some(before) = text.get(cut..start)
1030 {
1031 row = row.child(div().child(SharedString::from(before.to_string())));
1032 }
1033 if let Some(inside) = text.get(start..end) {
1034 row = row.child(
1035 div()
1036 .text_color(span.tone.color(theme))
1037 .child(SharedString::from(inside.to_string())),
1038 );
1039 }
1040 cut = end;
1041 }
1042 if let Some(tail) = text.get(cut..range.end) {
1043 row = row.child(div().child(SharedString::from(tail.to_string())));
1044 }
1045 elements.push(
1047 row.min_h(px(theme.typography.code.line_height))
1048 .into_any_element(),
1049 );
1050 }
1051 if elements.is_empty() {
1052 elements.push(
1053 div()
1054 .w_full()
1055 .min_h(px(theme.typography.code.line_height))
1056 .into_any_element(),
1057 );
1058 }
1059 elements
1060}
1061
1062fn flatten(inlines: &[Inline]) -> SharedString {
1064 fn walk(inlines: &[Inline], into: &mut String) {
1065 for inline in inlines {
1066 match inline {
1067 Inline::Text(text) | Inline::Code(text) | Inline::Html(text) => into.push_str(text),
1068 Inline::Emphasis(inner) | Inline::Strong(inner) | Inline::Struck(inner) => {
1069 walk(inner, into)
1070 }
1071 Inline::Link { content, .. } => walk(content, into),
1072 Inline::Image { alt, .. } => into.push_str(alt),
1073 Inline::SoftBreak | Inline::HardBreak => into.push(' '),
1074 }
1075 }
1076 }
1077 let mut text = String::new();
1078 walk(inlines, &mut text);
1079 SharedString::from(text.trim().to_string())
1080}
1081
1082fn slug(name: &str) -> String {
1085 let mut slug = String::new();
1086 for character in name.chars().take(64) {
1087 if character.is_ascii_alphanumeric() {
1088 slug.extend(character.to_lowercase());
1089 } else if !slug.ends_with('-') {
1090 slug.push('-');
1091 }
1092 }
1093 let slug = slug.trim_matches('-').to_string();
1094 if slug.is_empty() {
1095 "item".to_string()
1096 } else {
1097 slug
1098 }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104
1105 #[test]
1106 fn a_slug_never_carries_punctuation_or_an_empty_stem() {
1107 assert_eq!(
1108 slug("https://example.test/a?b=1"),
1109 "https-example-test-a-b-1"
1110 );
1111 assert_eq!(slug(" "), "item");
1112 assert_eq!(slug("Getting started!"), "getting-started");
1113 }
1114
1115 #[test]
1116 fn flattening_reads_a_line_the_way_it_is_spoken() {
1117 let document = Document::parse("**bold** and `code` and [a link](x)");
1118 let Some(Block::Paragraph(inlines)) = document.blocks.first() else {
1119 panic!("expected a paragraph");
1120 };
1121 assert_eq!(flatten(inlines).as_ref(), "bold and code and a link");
1122 }
1123}